-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
rule: no-cycle #1052
Merged
rule: no-cycle #1052
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
5fa2851
wip: no-cycle support with general dependency "imports" map in ExportMap
benmosher 0c21c4e
sublime-linter project tweaks
benmosher f7c48b5
no-cycle: real rule! first draft, perf is likely atrocious
benmosher 314c0b7
fix issue (and add conspicuously absent test) with 'export *'
benmosher 864dbcf
no-cycle: explicit CJS/AMD tests
benmosher 6933fa4
no-cycle: initial docs + maxDepth option
benmosher d81f48a
no-cycle: maxDepth tests + docs
benmosher ad66aea
smh.
benmosher File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
# import/no-cycle | ||
|
||
Ensures that there is no resolvable path back to this module via its dependencies. | ||
|
||
This includes cycles of depth 1 (imported module imports me) to `Infinity`, if the | ||
[`maxDepth`](#maxdepth) option is not set. | ||
|
||
```js | ||
// dep-b.js | ||
import './dep-a.js' | ||
|
||
export function b() { /* ... */ } | ||
|
||
// dep-a.js | ||
import { b } from './dep-b.js' // reported: Dependency cycle detected. | ||
``` | ||
|
||
This rule does _not_ detect imports that resolve directly to the linted module; | ||
for that, see [`no-self-import`]. | ||
|
||
|
||
## Rule Details | ||
|
||
### Options | ||
|
||
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 | ||
import/export infrastructure only registers `import` statements in dependencies, so | ||
cycles created by `require` within imported modules may not be detected. | ||
|
||
#### `maxDepth` | ||
|
||
There is a `maxDepth` option available to prevent full expansion of very deep dependency trees: | ||
|
||
```js | ||
/*eslint import/no-unresolved: [2, { maxDepth: 1 }]*/ | ||
|
||
// dep-c.js | ||
import './dep-a.js' | ||
|
||
// dep-b.js | ||
import './dep-c.js' | ||
|
||
export function b() { /* ... */ } | ||
|
||
// dep-a.js | ||
import { b } from './dep-b.js' // not reported as the cycle is at depth 2 | ||
``` | ||
|
||
This is not necessarily recommended, but available as a cost/benefit tradeoff mechanism | ||
for reducing total project lint time, if needed. | ||
|
||
## When Not To Use It | ||
|
||
This rule is comparatively computationally expensive. If you are pressed for lint | ||
time, or don't think you have an issue with dependency cycles, you may not want | ||
this rule enabled. | ||
|
||
## Further Reading | ||
|
||
- [Original inspiring issue](https://github.com/benmosher/eslint-plugin-import/issues/941) | ||
- Rule to detect that module imports itself: [`no-self-import`] | ||
|
||
[`no-self-import`]: ./no-self-import.md |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
/** | ||
* @fileOverview Ensures that no imported module imports the linted module. | ||
* @author Ben Mosher | ||
*/ | ||
|
||
import Exports from '../ExportMap' | ||
import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor' | ||
import docsUrl from '../docsUrl' | ||
|
||
// todo: cache cycles / deep relationships for faster repeat evaluation | ||
module.exports = { | ||
meta: { | ||
docs: { url: docsUrl('no-cycle') }, | ||
schema: [makeOptionsSchema({ | ||
maxDepth:{ | ||
description: 'maximum dependency depth to traverse', | ||
type: 'integer', | ||
minimum: 1, | ||
}, | ||
})], | ||
}, | ||
|
||
create: function (context) { | ||
const myPath = context.getFilename() | ||
if (myPath === '<text>') return // can't cycle-check a non-file | ||
|
||
const options = context.options[0] || {} | ||
const maxDepth = options.maxDepth || Infinity | ||
|
||
function checkSourceValue(sourceNode, importer) { | ||
const imported = Exports.get(sourceNode.value, context) | ||
|
||
if (imported == null) { | ||
return // no-unresolved territory | ||
} | ||
|
||
if (imported.path === myPath) { | ||
return // no-self-import territory | ||
} | ||
|
||
const untraversed = [{mget: () => imported, route:[]}] | ||
const traversed = new Set() | ||
function detectCycle({mget, route}) { | ||
const m = mget() | ||
if (m == null) return | ||
if (traversed.has(m.path)) return | ||
traversed.add(m.path) | ||
|
||
for (let [path, { getter, source }] of m.imports) { | ||
if (path === myPath) return true | ||
if (traversed.has(path)) continue | ||
if (route.length + 1 < maxDepth) { | ||
untraversed.push({ | ||
mget: getter, | ||
route: route.concat(source), | ||
}) | ||
} | ||
} | ||
} | ||
|
||
while (untraversed.length > 0) { | ||
const next = untraversed.shift() // bfs! | ||
if (detectCycle(next)) { | ||
const message = (next.route.length > 0 | ||
? `Dependency cycle via ${routeString(next.route)}` | ||
: 'Dependency cycle detected.') | ||
context.report(importer, message) | ||
return | ||
} | ||
} | ||
} | ||
|
||
return moduleVisitor(checkSourceValue, context.options[0]) | ||
}, | ||
} | ||
|
||
function routeString(route) { | ||
return route.map(s => `${s.value}:${s.loc.start.line}`).join('=>') | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
import foo from "./depth-zero" | ||
export { foo } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import './depth-two' | ||
|
||
export function bar() { | ||
return "side effects???" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
import * as two from "./depth-two" | ||
export { two } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
import { foo } from "./depth-one" | ||
export { foo } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
// export function foo() {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
import { foo } from './sibling-with-names' // ensure importing exported name doesn't block | ||
export * from './sibling-with-names' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import { test as _test, testFilePath } from '../utils' | ||
|
||
import { RuleTester } from 'eslint' | ||
|
||
const ruleTester = new RuleTester() | ||
, rule = require('rules/no-cycle') | ||
|
||
const error = message => ({ ruleId: 'no-cycle', message }) | ||
|
||
const test = def => _test(Object.assign(def, { | ||
filename: testFilePath('./cycles/depth-zero.js'), | ||
})) | ||
|
||
// describe.only("no-cycle", () => { | ||
ruleTester.run('no-cycle', rule, { | ||
valid: [ | ||
// this rule doesn't care if the cycle length is 0 | ||
test({ code: 'import foo from "./foo.js"'}), | ||
|
||
test({ code: 'import _ from "lodash"' }), | ||
test({ code: 'import foo from "@scope/foo"' }), | ||
test({ code: 'var _ = require("lodash")' }), | ||
test({ code: 'var find = require("lodash.find")' }), | ||
test({ code: 'var foo = require("./foo")' }), | ||
test({ code: 'var foo = require("../foo")' }), | ||
test({ code: 'var foo = require("foo")' }), | ||
test({ code: 'var foo = require("./")' }), | ||
test({ code: 'var foo = require("@scope/foo")' }), | ||
test({ code: 'var bar = require("./bar/index")' }), | ||
test({ code: 'var bar = require("./bar")' }), | ||
test({ | ||
code: 'var bar = require("./bar")', | ||
filename: '<text>', | ||
}), | ||
test({ | ||
code: 'import { foo } from "./depth-two"', | ||
options: [{ maxDepth: 1 }], | ||
}), | ||
], | ||
invalid: [ | ||
test({ | ||
code: 'import { foo } from "./depth-one"', | ||
errors: [error(`Dependency cycle detected.`)], | ||
}), | ||
test({ | ||
code: 'import { foo } from "./depth-one"', | ||
options: [{ maxDepth: 1 }], | ||
errors: [error(`Dependency cycle detected.`)], | ||
}), | ||
test({ | ||
code: 'const { foo } = require("./depth-one")', | ||
errors: [error(`Dependency cycle detected.`)], | ||
options: [{ commonjs: true }], | ||
}), | ||
test({ | ||
code: 'require(["./depth-one"], d1 => {})', | ||
errors: [error(`Dependency cycle detected.`)], | ||
options: [{ amd: true }], | ||
}), | ||
test({ | ||
code: 'define(["./depth-one"], d1 => {})', | ||
errors: [error(`Dependency cycle detected.`)], | ||
options: [{ amd: true }], | ||
}), | ||
test({ | ||
code: 'import { foo } from "./depth-two"', | ||
errors: [error(`Dependency cycle via ./depth-one:1`)], | ||
}), | ||
test({ | ||
code: 'import { foo } from "./depth-two"', | ||
options: [{ maxDepth: 2 }], | ||
errors: [error(`Dependency cycle via ./depth-one:1`)], | ||
}), | ||
test({ | ||
code: 'const { foo } = require("./depth-two")', | ||
errors: [error(`Dependency cycle via ./depth-one:1`)], | ||
options: [{ commonjs: true }], | ||
}), | ||
test({ | ||
code: 'import { two } from "./depth-three-star"', | ||
errors: [error(`Dependency cycle via ./depth-two:1=>./depth-one:1`)], | ||
}), | ||
test({ | ||
code: 'import { bar } from "./depth-three-indirect"', | ||
errors: [error(`Dependency cycle via ./depth-two:1=>./depth-one:1`)], | ||
}), | ||
], | ||
}) | ||
// }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems super important that this can detect requires as well as imports. What’s involved in making that happen?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I knew you'd pick up on that as I realized it and wrote it out. 😅
Right now, all the normal static analysis inspection of dependencies only picks up imports/exports from the topmost scope of the module.
Could also potentially nab any single-argument
require
s that are either alone or inAssignmentExpression
s but everything gets dicier oncerequire
s are in the mix, since they could be present in any scope in the module, technically.I want to avoid blocking merging and shipping this on supporting deep registration of CJS
require
s, though.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you use an ast selector like
CallExpression[callee.name=“require”]
for the visitor?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For sure. That's certainly what the
moduleVisitor
implementation does to find and lintrequire
s.It would be a substantial improvement, and I think I am probably overstating the complexity. I'll try to take a second look.