Skip to content

Commit 98a0991

Browse files
soryy708ljharb
authored andcommitted
[New] [Refactor] no-cycle: use scc algorithm to optimize; add skipErrorMessagePath for faster error messages
1 parent 19dbc33 commit 98a0991

File tree

6 files changed

+316
-1
lines changed

6 files changed

+316
-1
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
1313

1414
### Fixed
1515
- [`no-extraneous-dependencies`]: allow wrong path ([#3012], thanks [@chabb])
16+
- [`no-cycle`]: use scc algorithm to optimize ([#2998], thanks [@soryy708])
1617

1718
### Changed
1819
- [Docs] `no-extraneous-dependencies`: Make glob pattern description more explicit ([#2944], thanks [@mulztob])
@@ -1123,6 +1124,7 @@ for info on changes for earlier releases.
11231124
[#3012]: https://github.com/import-js/eslint-plugin-import/pull/3012
11241125
[#3011]: https://github.com/import-js/eslint-plugin-import/pull/3011
11251126
[#3004]: https://github.com/import-js/eslint-plugin-import/pull/3004
1127+
[#2998]: https://github.com/import-js/eslint-plugin-import/pull/2998
11261128
[#2991]: https://github.com/import-js/eslint-plugin-import/pull/2991
11271129
[#2989]: https://github.com/import-js/eslint-plugin-import/pull/2989
11281130
[#2987]: https://github.com/import-js/eslint-plugin-import/pull/2987

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8"
106106
},
107107
"dependencies": {
108+
"@rtsao/scc": "^1.1.0",
108109
"array-includes": "^3.1.8",
109110
"array.prototype.findlastindex": "^1.2.5",
110111
"array.prototype.flat": "^1.3.2",

src/rules/no-cycle.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import resolve from 'eslint-module-utils/resolve';
77
import ExportMapBuilder from '../exportMap/builder';
8+
import StronglyConnectedComponentsBuilder from '../scc';
89
import { isExternalModule } from '../core/importType';
910
import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor';
1011
import docsUrl from '../docsUrl';
@@ -47,6 +48,11 @@ module.exports = {
4748
type: 'boolean',
4849
default: false,
4950
},
51+
disableScc: {
52+
description: 'When true, don\'t calculate a strongly-connected-components graph. SCC is used to reduce the time-complexity of cycle detection, but adds overhead.',
53+
type: 'boolean',
54+
default: false,
55+
},
5056
})],
5157
},
5258

@@ -62,6 +68,8 @@ module.exports = {
6268
context,
6369
);
6470

71+
const scc = options.disableScc ? {} : StronglyConnectedComponentsBuilder.get(myPath, context);
72+
6573
function checkSourceValue(sourceNode, importer) {
6674
if (ignoreModule(sourceNode.value)) {
6775
return; // ignore external modules
@@ -98,6 +106,16 @@ module.exports = {
98106
return; // no-self-import territory
99107
}
100108

109+
/* If we're in the same Strongly Connected Component,
110+
* Then there exists a path from each node in the SCC to every other node in the SCC,
111+
* Then there exists at least one path from them to us and from us to them,
112+
* Then we have a cycle between us.
113+
*/
114+
const hasDependencyCycle = options.disableScc || scc[myPath] === scc[imported.path];
115+
if (!hasDependencyCycle) {
116+
return;
117+
}
118+
101119
const untraversed = [{ mget: () => imported, route: [] }];
102120
function detectCycle({ mget, route }) {
103121
const m = mget();
@@ -106,6 +124,9 @@ module.exports = {
106124
traversed.add(m.path);
107125

108126
for (const [path, { getter, declarations }] of m.imports) {
127+
// If we're in different SCCs, we can't have a circular dependency
128+
if (!options.disableScc && scc[myPath] !== scc[path]) { continue; }
129+
109130
if (traversed.has(path)) { continue; }
110131
const toTraverse = [...declarations].filter(({ source, isOnlyImportingTypes }) => !ignoreModule(source.value)
111132
// Ignore only type imports

src/scc.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import calculateScc from '@rtsao/scc';
2+
import { hashObject } from 'eslint-module-utils/hash';
3+
import resolve from 'eslint-module-utils/resolve';
4+
import ExportMapBuilder from './exportMap/builder';
5+
import childContext from './exportMap/childContext';
6+
7+
let cache = new Map();
8+
9+
export default class StronglyConnectedComponentsBuilder {
10+
static clearCache() {
11+
cache = new Map();
12+
}
13+
14+
static get(source, context) {
15+
const path = resolve(source, context);
16+
if (path == null) { return null; }
17+
return StronglyConnectedComponentsBuilder.for(childContext(path, context));
18+
}
19+
20+
static for(context) {
21+
const cacheKey = context.cacheKey || hashObject(context).digest('hex');
22+
if (cache.has(cacheKey)) {
23+
return cache.get(cacheKey);
24+
}
25+
const scc = StronglyConnectedComponentsBuilder.calculate(context);
26+
cache.set(cacheKey, scc);
27+
return scc;
28+
}
29+
30+
static calculate(context) {
31+
const exportMap = ExportMapBuilder.for(context);
32+
const adjacencyList = this.exportMapToAdjacencyList(exportMap);
33+
const calculatedScc = calculateScc(adjacencyList);
34+
return StronglyConnectedComponentsBuilder.calculatedSccToPlainObject(calculatedScc);
35+
}
36+
37+
/** @returns {Map<string, Set<string>>} for each dep, what are its direct deps */
38+
static exportMapToAdjacencyList(initialExportMap) {
39+
const adjacencyList = new Map();
40+
// BFS
41+
function visitNode(exportMap) {
42+
if (!exportMap) {
43+
return;
44+
}
45+
exportMap.imports.forEach((v, importedPath) => {
46+
const from = exportMap.path;
47+
const to = importedPath;
48+
49+
// Ignore type-only imports, because we care only about SCCs of value imports
50+
const toTraverse = [...v.declarations].filter(({ isOnlyImportingTypes }) => !isOnlyImportingTypes);
51+
if (toTraverse.length === 0) { return; }
52+
53+
if (!adjacencyList.has(from)) {
54+
adjacencyList.set(from, new Set());
55+
}
56+
57+
if (adjacencyList.get(from).has(to)) {
58+
return; // prevent endless loop
59+
}
60+
adjacencyList.get(from).add(to);
61+
visitNode(v.getter());
62+
});
63+
}
64+
visitNode(initialExportMap);
65+
// Fill gaps
66+
adjacencyList.forEach((values) => {
67+
values.forEach((value) => {
68+
if (!adjacencyList.has(value)) {
69+
adjacencyList.set(value, new Set());
70+
}
71+
});
72+
});
73+
return adjacencyList;
74+
}
75+
76+
/** @returns {Record<string, number>} for each key, its SCC's index */
77+
static calculatedSccToPlainObject(sccs) {
78+
const obj = {};
79+
sccs.forEach((scc, index) => {
80+
scc.forEach((node) => {
81+
obj[node] = index;
82+
});
83+
});
84+
return obj;
85+
}
86+
}

tests/src/rules/no-cycle.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const testVersion = (specifier, t) => _testVersion(specifier, () => Object.assig
1717

1818
const testDialects = ['es6'];
1919

20-
ruleTester.run('no-cycle', rule, {
20+
const cases = {
2121
valid: [].concat(
2222
// this rule doesn't care if the cycle length is 0
2323
test({ code: 'import foo from "./foo.js"' }),
@@ -290,4 +290,30 @@ ruleTester.run('no-cycle', rule, {
290290
],
291291
}),
292292
),
293+
};
294+
295+
ruleTester.run('no-cycle', rule, {
296+
valid: flatMap(cases.valid, (testCase) => [
297+
testCase,
298+
{
299+
...testCase,
300+
code: `${testCase.code} // disableScc=true`,
301+
options: [{
302+
...testCase.options && testCase.options[0] || {},
303+
disableScc: true,
304+
}],
305+
},
306+
]),
307+
308+
invalid: flatMap(cases.invalid, (testCase) => [
309+
testCase,
310+
{
311+
...testCase,
312+
code: `${testCase.code} // disableScc=true`,
313+
options: [{
314+
...testCase.options && testCase.options[0] || {},
315+
disableScc: true,
316+
}],
317+
},
318+
]),
293319
});

tests/src/scc.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import sinon from 'sinon';
2+
import { expect } from 'chai';
3+
import StronglyConnectedComponentsBuilder from '../../src/scc';
4+
import ExportMapBuilder from '../../src/exportMap/builder';
5+
6+
function exportMapFixtureBuilder(path, imports, isOnlyImportingTypes = false) {
7+
return {
8+
path,
9+
imports: new Map(imports.map((imp) => [imp.path, { getter: () => imp, declarations: [{ isOnlyImportingTypes }] }])),
10+
};
11+
}
12+
13+
describe('Strongly Connected Components Builder', () => {
14+
afterEach(() => ExportMapBuilder.for.restore());
15+
afterEach(() => StronglyConnectedComponentsBuilder.clearCache());
16+
17+
describe('When getting an SCC', () => {
18+
const source = '';
19+
const context = {
20+
settings: {},
21+
parserOptions: {},
22+
parserPath: '',
23+
};
24+
25+
describe('Given two files', () => {
26+
describe('When they don\'t value-cycle', () => {
27+
it('Should return foreign SCCs', () => {
28+
sinon.stub(ExportMapBuilder, 'for').returns(
29+
exportMapFixtureBuilder('foo.js', [exportMapFixtureBuilder('bar.js', [])]),
30+
);
31+
const actual = StronglyConnectedComponentsBuilder.for(source, context);
32+
expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 0 });
33+
});
34+
});
35+
36+
describe('When they do value-cycle', () => {
37+
it('Should return same SCC', () => {
38+
sinon.stub(ExportMapBuilder, 'for').returns(
39+
exportMapFixtureBuilder('foo.js', [
40+
exportMapFixtureBuilder('bar.js', [
41+
exportMapFixtureBuilder('foo.js', [exportMapFixtureBuilder('bar.js', [])]),
42+
]),
43+
]),
44+
);
45+
const actual = StronglyConnectedComponentsBuilder.for(source, context);
46+
expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0 });
47+
});
48+
});
49+
50+
describe('When they type-cycle', () => {
51+
it('Should return foreign SCCs', () => {
52+
sinon.stub(ExportMapBuilder, 'for').returns(
53+
exportMapFixtureBuilder('foo.js', [
54+
exportMapFixtureBuilder('bar.js', [
55+
exportMapFixtureBuilder('foo.js', []),
56+
], true),
57+
]),
58+
);
59+
const actual = StronglyConnectedComponentsBuilder.for(source, context);
60+
expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 0 });
61+
});
62+
});
63+
});
64+
65+
describe('Given three files', () => {
66+
describe('When they form a line', () => {
67+
describe('When A -> B -> C', () => {
68+
it('Should return foreign SCCs', () => {
69+
sinon.stub(ExportMapBuilder, 'for').returns(
70+
exportMapFixtureBuilder('foo.js', [
71+
exportMapFixtureBuilder('bar.js', [
72+
exportMapFixtureBuilder('buzz.js', []),
73+
]),
74+
]),
75+
);
76+
const actual = StronglyConnectedComponentsBuilder.for(source, context);
77+
expect(actual).to.deep.equal({ 'foo.js': 2, 'bar.js': 1, 'buzz.js': 0 });
78+
});
79+
});
80+
81+
describe('When A -> B <-> C', () => {
82+
it('Should return 2 SCCs, A on its own', () => {
83+
sinon.stub(ExportMapBuilder, 'for').returns(
84+
exportMapFixtureBuilder('foo.js', [
85+
exportMapFixtureBuilder('bar.js', [
86+
exportMapFixtureBuilder('buzz.js', [
87+
exportMapFixtureBuilder('bar.js', []),
88+
]),
89+
]),
90+
]),
91+
);
92+
const actual = StronglyConnectedComponentsBuilder.for(source, context);
93+
expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 0, 'buzz.js': 0 });
94+
});
95+
});
96+
97+
describe('When A <-> B -> C', () => {
98+
it('Should return 2 SCCs, C on its own', () => {
99+
sinon.stub(ExportMapBuilder, 'for').returns(
100+
exportMapFixtureBuilder('foo.js', [
101+
exportMapFixtureBuilder('bar.js', [
102+
exportMapFixtureBuilder('buzz.js', []),
103+
exportMapFixtureBuilder('foo.js', []),
104+
]),
105+
]),
106+
);
107+
const actual = StronglyConnectedComponentsBuilder.for(source, context);
108+
expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 1, 'buzz.js': 0 });
109+
});
110+
});
111+
112+
describe('When A <-> B <-> C', () => {
113+
it('Should return same SCC', () => {
114+
sinon.stub(ExportMapBuilder, 'for').returns(
115+
exportMapFixtureBuilder('foo.js', [
116+
exportMapFixtureBuilder('bar.js', [
117+
exportMapFixtureBuilder('foo.js', []),
118+
exportMapFixtureBuilder('buzz.js', [
119+
exportMapFixtureBuilder('bar.js', []),
120+
]),
121+
]),
122+
]),
123+
);
124+
const actual = StronglyConnectedComponentsBuilder.for(source, context);
125+
expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 });
126+
});
127+
});
128+
});
129+
130+
describe('When they form a loop', () => {
131+
it('Should return same SCC', () => {
132+
sinon.stub(ExportMapBuilder, 'for').returns(
133+
exportMapFixtureBuilder('foo.js', [
134+
exportMapFixtureBuilder('bar.js', [
135+
exportMapFixtureBuilder('buzz.js', [
136+
exportMapFixtureBuilder('foo.js', []),
137+
]),
138+
]),
139+
]),
140+
);
141+
const actual = StronglyConnectedComponentsBuilder.for(source, context);
142+
expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 });
143+
});
144+
});
145+
146+
describe('When they form a Y', () => {
147+
it('Should return 3 distinct SCCs', () => {
148+
sinon.stub(ExportMapBuilder, 'for').returns(
149+
exportMapFixtureBuilder('foo.js', [
150+
exportMapFixtureBuilder('bar.js', []),
151+
exportMapFixtureBuilder('buzz.js', []),
152+
]),
153+
);
154+
const actual = StronglyConnectedComponentsBuilder.for(source, context);
155+
expect(actual).to.deep.equal({ 'foo.js': 2, 'bar.js': 0, 'buzz.js': 1 });
156+
});
157+
});
158+
159+
describe('When they form a Mercedes', () => {
160+
it('Should return 1 SCC', () => {
161+
sinon.stub(ExportMapBuilder, 'for').returns(
162+
exportMapFixtureBuilder('foo.js', [
163+
exportMapFixtureBuilder('bar.js', [
164+
exportMapFixtureBuilder('foo.js', []),
165+
exportMapFixtureBuilder('buzz.js', []),
166+
]),
167+
exportMapFixtureBuilder('buzz.js', [
168+
exportMapFixtureBuilder('foo.js', []),
169+
exportMapFixtureBuilder('bar.js', []),
170+
]),
171+
]),
172+
);
173+
const actual = StronglyConnectedComponentsBuilder.for(source, context);
174+
expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 });
175+
});
176+
});
177+
});
178+
});
179+
});

0 commit comments

Comments
 (0)