Skip to content

Commit ed719a3

Browse files
committed
Merge branch 'no-cycles'
2 parents b34d9ff + ab44320 commit ed719a3

17 files changed

+309
-18
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
44
This change log adheres to standards from [Keep a CHANGELOG](http://keepachangelog.com).
55

66
## [Unreleased]
7+
### Added
78
- Autofixer for [`order`] rule ([#711], thanks [@tihonove])
9+
- Add [`no-cycle`] rule: reports import cycles.
810

911
## [2.9.0] - 2018-02-21
1012
### Added
@@ -443,6 +445,7 @@ for info on changes for earlier releases.
443445
[`no-self-import`]: ./docs/rules/no-self-import.md
444446
[`no-default-export`]: ./docs/rules/no-default-export.md
445447
[`no-useless-path-segments`]: ./docs/rules/no-useless-path-segments.md
448+
[`no-cycle`]: ./docs/rules/no-cycle.md
446449

447450
[`memo-parser`]: ./memo-parser/README.md
448451

docs/rules/no-cycle.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# import/no-cycle
2+
3+
Ensures that there is no resolvable path back to this module via its dependencies.
4+
5+
This includes cycles of depth 1 (imported module imports me) to `Infinity`, if the
6+
[`maxDepth`](#maxdepth) option is not set.
7+
8+
```js
9+
// dep-b.js
10+
import './dep-a.js'
11+
12+
export function b() { /* ... */ }
13+
14+
// dep-a.js
15+
import { b } from './dep-b.js' // reported: Dependency cycle detected.
16+
```
17+
18+
This rule does _not_ detect imports that resolve directly to the linted module;
19+
for that, see [`no-self-import`].
20+
21+
22+
## Rule Details
23+
24+
### Options
25+
26+
By default, this rule only detects cycles for ES6 imports, but see the [`no-unresolved` options](./no-unresolved.md#options) as this rule also supports the same `commonjs` and `amd` flags. However, these flags only impact which import types are _linted_; the
27+
import/export infrastructure only registers `import` statements in dependencies, so
28+
cycles created by `require` within imported modules may not be detected.
29+
30+
#### `maxDepth`
31+
32+
There is a `maxDepth` option available to prevent full expansion of very deep dependency trees:
33+
34+
```js
35+
/*eslint import/no-unresolved: [2, { maxDepth: 1 }]*/
36+
37+
// dep-c.js
38+
import './dep-a.js'
39+
40+
// dep-b.js
41+
import './dep-c.js'
42+
43+
export function b() { /* ... */ }
44+
45+
// dep-a.js
46+
import { b } from './dep-b.js' // not reported as the cycle is at depth 2
47+
```
48+
49+
This is not necessarily recommended, but available as a cost/benefit tradeoff mechanism
50+
for reducing total project lint time, if needed.
51+
52+
## When Not To Use It
53+
54+
This rule is comparatively computationally expensive. If you are pressed for lint
55+
time, or don't think you have an issue with dependency cycles, you may not want
56+
this rule enabled.
57+
58+
## Further Reading
59+
60+
- [Original inspiring issue](https://github.com/benmosher/eslint-plugin-import/issues/941)
61+
- Rule to detect that module imports itself: [`no-self-import`]
62+
63+
[`no-self-import`]: ./no-self-import.md

import.sublime-project

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
},
1717
"eslint_d":
1818
{
19+
"disable": true,
1920
"chdir": "${project}"
2021
}
22+
},
23+
"paths": {
24+
"osx": ["${project}/node_modules/.bin"]
2125
}
2226
}
2327
}

src/ExportMap.js

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,16 @@ export default class ExportMap {
2121
this.namespace = new Map()
2222
// todo: restructure to key on path, value is resolver + map of names
2323
this.reexports = new Map()
24-
this.dependencies = new Map()
24+
/**
25+
* star-exports
26+
* @type {Set} of () => ExportMap
27+
*/
28+
this.dependencies = new Set()
29+
/**
30+
* dependencies of this module that are not explicitly re-exported
31+
* @type {Map} from path = () => ExportMap
32+
*/
33+
this.imports = new Map()
2534
this.errors = []
2635
}
2736

@@ -46,7 +55,7 @@ export default class ExportMap {
4655

4756
// default exports must be explicitly re-exported (#328)
4857
if (name !== 'default') {
49-
for (let dep of this.dependencies.values()) {
58+
for (let dep of this.dependencies) {
5059
let innerMap = dep()
5160

5261
// todo: report as unresolved?
@@ -88,7 +97,7 @@ export default class ExportMap {
8897

8998
// default exports must be explicitly re-exported (#328)
9099
if (name !== 'default') {
91-
for (let dep of this.dependencies.values()) {
100+
for (let dep of this.dependencies) {
92101
let innerMap = dep()
93102
// todo: report as unresolved?
94103
if (!innerMap) continue
@@ -125,7 +134,7 @@ export default class ExportMap {
125134

126135
// default exports must be explicitly re-exported (#328)
127136
if (name !== 'default') {
128-
for (let dep of this.dependencies.values()) {
137+
for (let dep of this.dependencies) {
129138
let innerMap = dep()
130139
// todo: report as unresolved?
131140
if (!innerMap) continue
@@ -373,6 +382,17 @@ ExportMap.parse = function (path, content, context) {
373382
return object
374383
}
375384

385+
function captureDependency(declaration) {
386+
if (declaration.source == null) return null
387+
388+
const p = remotePath(declaration)
389+
if (p == null || m.imports.has(p)) return p
390+
391+
const getter = () => ExportMap.for(p, context)
392+
m.imports.set(p, { getter, source: declaration.source })
393+
return p
394+
}
395+
376396

377397
ast.body.forEach(function (n) {
378398

@@ -386,22 +406,22 @@ ExportMap.parse = function (path, content, context) {
386406
}
387407

388408
if (n.type === 'ExportAllDeclaration') {
389-
let remoteMap = remotePath(n)
390-
if (remoteMap == null) return
391-
m.dependencies.set(remoteMap, () => ExportMap.for(remoteMap, context))
409+
const p = captureDependency(n)
410+
if (p) m.dependencies.add(m.imports.get(p).getter)
392411
return
393412
}
394413

395414
// capture namespaces in case of later export
396415
if (n.type === 'ImportDeclaration') {
416+
captureDependency(n)
397417
let ns
398418
if (n.specifiers.some(s => s.type === 'ImportNamespaceSpecifier' && (ns = s))) {
399419
namespaces.set(ns.local.name, n)
400420
}
401421
return
402422
}
403423

404-
if (n.type === 'ExportNamedDeclaration'){
424+
if (n.type === 'ExportNamedDeclaration') {
405425
// capture declaration
406426
if (n.declaration != null) {
407427
switch (n.declaration.type) {

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const rules = {
1212
'group-exports': require('./rules/group-exports'),
1313

1414
'no-self-import': require('./rules/no-self-import'),
15+
'no-cycle': require('./rules/no-cycle'),
1516
'no-named-default': require('./rules/no-named-default'),
1617
'no-named-as-default': require('./rules/no-named-as-default'),
1718
'no-named-as-default-member': require('./rules/no-named-as-default-member'),

src/rules/no-cycle.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* @fileOverview Ensures that no imported module imports the linted module.
3+
* @author Ben Mosher
4+
*/
5+
6+
import Exports from '../ExportMap'
7+
import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor'
8+
import docsUrl from '../docsUrl'
9+
10+
// todo: cache cycles / deep relationships for faster repeat evaluation
11+
module.exports = {
12+
meta: {
13+
docs: { url: docsUrl('no-cycle') },
14+
schema: [makeOptionsSchema({
15+
maxDepth:{
16+
description: 'maximum dependency depth to traverse',
17+
type: 'integer',
18+
minimum: 1,
19+
},
20+
})],
21+
},
22+
23+
create: function (context) {
24+
const myPath = context.getFilename()
25+
if (myPath === '<text>') return // can't cycle-check a non-file
26+
27+
const options = context.options[0] || {}
28+
const maxDepth = options.maxDepth || Infinity
29+
30+
function checkSourceValue(sourceNode, importer) {
31+
const imported = Exports.get(sourceNode.value, context)
32+
33+
if (imported == null) {
34+
return // no-unresolved territory
35+
}
36+
37+
if (imported.path === myPath) {
38+
return // no-self-import territory
39+
}
40+
41+
const untraversed = [{mget: () => imported, route:[]}]
42+
const traversed = new Set()
43+
function detectCycle({mget, route}) {
44+
const m = mget()
45+
if (m == null) return
46+
if (traversed.has(m.path)) return
47+
traversed.add(m.path)
48+
49+
for (let [path, { getter, source }] of m.imports) {
50+
if (path === myPath) return true
51+
if (traversed.has(path)) continue
52+
if (route.length + 1 < maxDepth) {
53+
untraversed.push({
54+
mget: getter,
55+
route: route.concat(source),
56+
})
57+
}
58+
}
59+
}
60+
61+
while (untraversed.length > 0) {
62+
const next = untraversed.shift() // bfs!
63+
if (detectCycle(next)) {
64+
const message = (next.route.length > 0
65+
? `Dependency cycle via ${routeString(next.route)}`
66+
: 'Dependency cycle detected.')
67+
context.report(importer, message)
68+
return
69+
}
70+
}
71+
}
72+
73+
return moduleVisitor(checkSourceValue, context.options[0])
74+
},
75+
}
76+
77+
function routeString(route) {
78+
return route.map(s => `${s.value}:${s.loc.start.line}`).join('=>')
79+
}

tests/files/cycles/depth-one.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import foo from "./depth-zero"
2+
export { foo }
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import './depth-two'
2+
3+
export function bar() {
4+
return "side effects???"
5+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import * as two from "./depth-two"
2+
export { two }

tests/files/cycles/depth-two.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import { foo } from "./depth-one"
2+
export { foo }

tests/files/cycles/depth-zero.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// export function foo() {}

tests/files/export-all.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
import { foo } from './sibling-with-names' // ensure importing exported name doesn't block
12
export * from './sibling-with-names'

tests/src/rules/named.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,18 @@ if (!CASE_SENSITIVE_FS) {
329329
],
330330
})
331331
}
332+
333+
// export-all
334+
ruleTester.run('named (export *)', rule, {
335+
valid: [
336+
test({
337+
code: 'import { foo } from "./export-all"',
338+
}),
339+
],
340+
invalid: [
341+
test({
342+
code: 'import { bar } from "./export-all"',
343+
errors: [`bar not found in './export-all'`],
344+
}),
345+
],
346+
})

0 commit comments

Comments
 (0)