Skip to content
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
merged 8 commits into from
Mar 30, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions docs/rules/no-cycle.md
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
Copy link
Member

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?

Copy link
Member Author

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 requires that are either alone or in AssignmentExpressions but everything gets dicier once requires 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 requires, though.

Copy link
Member

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?

Copy link
Member Author

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 lint requires.

It would be a substantial improvement, and I think I am probably overstating the complexity. I'll try to take a second look.

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
4 changes: 4 additions & 0 deletions import.sublime-project
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@
},
"eslint_d":
{
"disable": true,
"chdir": "${project}"
}
},
"paths": {
"osx": ["${project}/node_modules/.bin"]
}
}
}
36 changes: 28 additions & 8 deletions src/ExportMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,16 @@ export default class ExportMap {
this.namespace = new Map()
// todo: restructure to key on path, value is resolver + map of names
this.reexports = new Map()
this.dependencies = new Map()
/**
* star-exports
* @type {Set} of () => ExportMap
*/
this.dependencies = new Set()
/**
* dependencies of this module that are not explicitly re-exported
* @type {Map} from path = () => ExportMap
*/
this.imports = new Map()
this.errors = []
}

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

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

// todo: report as unresolved?
Expand Down Expand Up @@ -88,7 +97,7 @@ export default class ExportMap {

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

// default exports must be explicitly re-exported (#328)
if (name !== 'default') {
for (let dep of this.dependencies.values()) {
for (let dep of this.dependencies) {
let innerMap = dep()
// todo: report as unresolved?
if (!innerMap) continue
Expand Down Expand Up @@ -373,6 +382,17 @@ ExportMap.parse = function (path, content, context) {
return object
}

function captureDependency(declaration) {
if (declaration.source == null) return null

const p = remotePath(declaration)
if (p == null || m.imports.has(p)) return p

const getter = () => ExportMap.for(p, context)
m.imports.set(p, { getter, source: declaration.source })
return p
}


ast.body.forEach(function (n) {

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

if (n.type === 'ExportAllDeclaration') {
let remoteMap = remotePath(n)
if (remoteMap == null) return
m.dependencies.set(remoteMap, () => ExportMap.for(remoteMap, context))
const p = captureDependency(n)
if (p) m.dependencies.add(m.imports.get(p).getter)
return
}

// capture namespaces in case of later export
if (n.type === 'ImportDeclaration') {
captureDependency(n)
let ns
if (n.specifiers.some(s => s.type === 'ImportNamespaceSpecifier' && (ns = s))) {
namespaces.set(ns.local.name, n)
}
return
}

if (n.type === 'ExportNamedDeclaration'){
if (n.type === 'ExportNamedDeclaration') {
// capture declaration
if (n.declaration != null) {
switch (n.declaration.type) {
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const rules = {
'group-exports': require('./rules/group-exports'),

'no-self-import': require('./rules/no-self-import'),
'no-cycle': require('./rules/no-cycle'),
'no-named-default': require('./rules/no-named-default'),
'no-named-as-default': require('./rules/no-named-as-default'),
'no-named-as-default-member': require('./rules/no-named-as-default-member'),
Expand Down
79 changes: 79 additions & 0 deletions src/rules/no-cycle.js
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('=>')
}
2 changes: 2 additions & 0 deletions tests/files/cycles/depth-one.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import foo from "./depth-zero"
export { foo }
5 changes: 5 additions & 0 deletions tests/files/cycles/depth-three-indirect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import './depth-two'

export function bar() {
return "side effects???"
}
2 changes: 2 additions & 0 deletions tests/files/cycles/depth-three-star.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import * as two from "./depth-two"
export { two }
2 changes: 2 additions & 0 deletions tests/files/cycles/depth-two.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { foo } from "./depth-one"
export { foo }
1 change: 1 addition & 0 deletions tests/files/cycles/depth-zero.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// export function foo() {}
1 change: 1 addition & 0 deletions tests/files/export-all.js
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'
15 changes: 15 additions & 0 deletions tests/src/rules/named.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,3 +329,18 @@ if (!CASE_SENSITIVE_FS) {
],
})
}

// export-all
ruleTester.run('named (export *)', rule, {
valid: [
test({
code: 'import { foo } from "./export-all"',
}),
],
invalid: [
test({
code: 'import { bar } from "./export-all"',
errors: [`bar not found in './export-all'`],
}),
],
})
89 changes: 89 additions & 0 deletions tests/src/rules/no-cycle.js
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`)],
}),
],
})
// })
Loading