Skip to content

Commit cac9199

Browse files
alxhubjasonaden
authored andcommitted
feat(ivy): cycle detector for TypeScript programs (angular#28169)
This commit implements a cycle detector which looks at the import graph of TypeScript programs and can determine whether the addition of an edge is sufficient to create a cycle. As part of the implementation, module name to source file resolution is implemented via a ModuleResolver, using TS APIs. PR Close angular#28169
1 parent a789a3f commit cac9199

File tree

8 files changed

+360
-0
lines changed

8 files changed

+360
-0
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package(default_visibility = ["//visibility:public"])
2+
3+
load("//tools:defaults.bzl", "ts_library")
4+
5+
ts_library(
6+
name = "cycles",
7+
srcs = glob([
8+
"index.ts",
9+
"src/**/*.ts",
10+
]),
11+
module_name = "@angular/compiler-cli/src/ngtsc/cycles",
12+
deps = [
13+
"//packages/compiler-cli/src/ngtsc/imports",
14+
"@ngdeps//typescript",
15+
],
16+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export {CycleAnalyzer} from './src/analyzer';
10+
export {ImportGraph} from './src/imports';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as ts from 'typescript';
10+
11+
import {ImportGraph} from './imports';
12+
13+
/**
14+
* Analyzes a `ts.Program` for cycles.
15+
*/
16+
export class CycleAnalyzer {
17+
constructor(private importGraph: ImportGraph) {}
18+
19+
/**
20+
* Check whether adding an import from `from` to `to` would create a cycle in the `ts.Program`.
21+
*/
22+
wouldCreateCycle(from: ts.SourceFile, to: ts.SourceFile): boolean {
23+
// Import of 'from' -> 'to' is illegal if an edge 'to' -> 'from' already exists.
24+
return this.importGraph.transitiveImportsOf(to).has(from);
25+
}
26+
27+
/**
28+
* Record a synthetic import from `from` to `to`.
29+
*
30+
* This is an import that doesn't exist in the `ts.Program` but will be considered as part of the
31+
* import graph for cycle creation.
32+
*/
33+
recordSyntheticImport(from: ts.SourceFile, to: ts.SourceFile): void {
34+
this.importGraph.addSyntheticImport(from, to);
35+
}
36+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as ts from 'typescript';
10+
11+
import {ModuleResolver} from '../../imports';
12+
13+
/**
14+
* A cached graph of imports in the `ts.Program`.
15+
*
16+
* The `ImportGraph` keeps track of dependencies (imports) of individual `ts.SourceFile`s. Only
17+
* dependencies within the same program are tracked; imports into packages on NPM are not.
18+
*/
19+
export class ImportGraph {
20+
private map = new Map<ts.SourceFile, Set<ts.SourceFile>>();
21+
22+
constructor(private resolver: ModuleResolver) {}
23+
24+
/**
25+
* List the direct (not transitive) imports of a given `ts.SourceFile`.
26+
*
27+
* This operation is cached.
28+
*/
29+
importsOf(sf: ts.SourceFile): Set<ts.SourceFile> {
30+
if (!this.map.has(sf)) {
31+
this.map.set(sf, this.scanImports(sf));
32+
}
33+
return this.map.get(sf) !;
34+
}
35+
36+
/**
37+
* Lists the transitive imports of a given `ts.SourceFile`.
38+
*/
39+
transitiveImportsOf(sf: ts.SourceFile): Set<ts.SourceFile> {
40+
const imports = new Set<ts.SourceFile>();
41+
this.transitiveImportsOfHelper(sf, imports);
42+
return imports;
43+
}
44+
45+
private transitiveImportsOfHelper(sf: ts.SourceFile, results: Set<ts.SourceFile>): void {
46+
if (results.has(sf)) {
47+
return;
48+
}
49+
results.add(sf);
50+
this.importsOf(sf).forEach(imported => { this.transitiveImportsOfHelper(imported, results); });
51+
}
52+
53+
/**
54+
* Add a record of an import from `sf` to `imported`, that's not present in the original
55+
* `ts.Program` but will be remembered by the `ImportGraph`.
56+
*/
57+
addSyntheticImport(sf: ts.SourceFile, imported: ts.SourceFile): void {
58+
if (isLocalFile(imported)) {
59+
this.importsOf(sf).add(imported);
60+
}
61+
}
62+
63+
private scanImports(sf: ts.SourceFile): Set<ts.SourceFile> {
64+
const imports = new Set<ts.SourceFile>();
65+
// Look through the source file for import statements.
66+
sf.statements.forEach(stmt => {
67+
if ((ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt)) &&
68+
stmt.moduleSpecifier !== undefined && ts.isStringLiteral(stmt.moduleSpecifier)) {
69+
// Resolve the module to a file, and check whether that file is in the ts.Program.
70+
const moduleName = stmt.moduleSpecifier.text;
71+
const moduleFile = this.resolver.resolveModuleName(moduleName, sf);
72+
if (moduleFile !== null && isLocalFile(moduleFile)) {
73+
// Record this local import.
74+
imports.add(moduleFile);
75+
}
76+
}
77+
});
78+
return imports;
79+
}
80+
}
81+
82+
function isLocalFile(sf: ts.SourceFile): boolean {
83+
return !sf.fileName.endsWith('.d.ts');
84+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package(default_visibility = ["//visibility:public"])
2+
3+
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")
4+
5+
ts_library(
6+
name = "test_lib",
7+
testonly = True,
8+
srcs = glob([
9+
"**/*.ts",
10+
]),
11+
deps = [
12+
"//packages:types",
13+
"//packages/compiler-cli/src/ngtsc/cycles",
14+
"//packages/compiler-cli/src/ngtsc/imports",
15+
"//packages/compiler-cli/src/ngtsc/testing",
16+
"@ngdeps//typescript",
17+
],
18+
)
19+
20+
jasmine_node_test(
21+
name = "test",
22+
bootstrap = ["angular/tools/testing/init_node_no_angular_spec.js"],
23+
deps = [
24+
":test_lib",
25+
"//tools/testing:node_no_angular",
26+
],
27+
)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as ts from 'typescript';
10+
11+
import {ModuleResolver} from '../../imports';
12+
import {CycleAnalyzer} from '../src/analyzer';
13+
import {ImportGraph} from '../src/imports';
14+
15+
import {makeProgramFromGraph} from './util';
16+
17+
describe('cycle analyzer', () => {
18+
it('should not detect a cycle when there isn\'t one', () => {
19+
const {program, analyzer} = makeAnalyzer('a:b,c;b;c');
20+
const b = program.getSourceFile('b.ts') !;
21+
const c = program.getSourceFile('c.ts') !;
22+
expect(analyzer.wouldCreateCycle(b, c)).toBe(false);
23+
expect(analyzer.wouldCreateCycle(c, b)).toBe(false);
24+
});
25+
26+
it('should detect a simple cycle between two files', () => {
27+
const {program, analyzer} = makeAnalyzer('a:b;b');
28+
const a = program.getSourceFile('a.ts') !;
29+
const b = program.getSourceFile('b.ts') !;
30+
expect(analyzer.wouldCreateCycle(a, b)).toBe(false);
31+
expect(analyzer.wouldCreateCycle(b, a)).toBe(true);
32+
});
33+
34+
it('should detect a cycle with a re-export in the chain', () => {
35+
const {program, analyzer} = makeAnalyzer('a:*b;b:c;c');
36+
const a = program.getSourceFile('a.ts') !;
37+
const c = program.getSourceFile('c.ts') !;
38+
expect(analyzer.wouldCreateCycle(a, c)).toBe(false);
39+
expect(analyzer.wouldCreateCycle(c, a)).toBe(true);
40+
});
41+
42+
it('should detect a cycle in a more complex program', () => {
43+
const {program, analyzer} = makeAnalyzer('a:*b,*c;b:*e,*f;c:*g,*h;e:f;f:c;g;h:g');
44+
const b = program.getSourceFile('b.ts') !;
45+
const g = program.getSourceFile('g.ts') !;
46+
expect(analyzer.wouldCreateCycle(b, g)).toBe(false);
47+
expect(analyzer.wouldCreateCycle(g, b)).toBe(true);
48+
});
49+
50+
it('should detect a cycle caused by a synthetic edge', () => {
51+
const {program, analyzer} = makeAnalyzer('a:b,c;b;c');
52+
const b = program.getSourceFile('b.ts') !;
53+
const c = program.getSourceFile('c.ts') !;
54+
expect(analyzer.wouldCreateCycle(b, c)).toBe(false);
55+
analyzer.recordSyntheticImport(c, b);
56+
expect(analyzer.wouldCreateCycle(b, c)).toBe(true);
57+
});
58+
});
59+
60+
function makeAnalyzer(graph: string): {program: ts.Program, analyzer: CycleAnalyzer} {
61+
const {program, options, host} = makeProgramFromGraph(graph);
62+
return {
63+
program,
64+
analyzer: new CycleAnalyzer(new ImportGraph(new ModuleResolver(program, options, host))),
65+
};
66+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as ts from 'typescript';
10+
11+
import {ModuleResolver} from '../../imports';
12+
import {ImportGraph} from '../src/imports';
13+
14+
import {makeProgramFromGraph} from './util';
15+
16+
describe('import graph', () => {
17+
it('should record imports of a simple program', () => {
18+
const {program, graph} = makeImportGraph('a:b;b:c;c');
19+
const a = program.getSourceFile('a.ts') !;
20+
const b = program.getSourceFile('b.ts') !;
21+
const c = program.getSourceFile('c.ts') !;
22+
expect(importsToString(graph.importsOf(a))).toBe('b');
23+
expect(importsToString(graph.importsOf(b))).toBe('c');
24+
});
25+
26+
it('should calculate transitive imports of a simple program', () => {
27+
const {program, graph} = makeImportGraph('a:b;b:c;c');
28+
const a = program.getSourceFile('a.ts') !;
29+
const b = program.getSourceFile('b.ts') !;
30+
const c = program.getSourceFile('c.ts') !;
31+
expect(importsToString(graph.transitiveImportsOf(a))).toBe('a,b,c');
32+
});
33+
34+
it('should calculate transitive imports in a more complex program (with a cycle)', () => {
35+
const {program, graph} = makeImportGraph('a:*b,*c;b:*e,*f;c:*g,*h;e:f;f;g:e;h:g');
36+
const c = program.getSourceFile('c.ts') !;
37+
expect(importsToString(graph.transitiveImportsOf(c))).toBe('c,e,f,g,h');
38+
});
39+
40+
it('should reflect the addition of a synthetic import', () => {
41+
const {program, graph} = makeImportGraph('a:b,c,d;b;c;d:b');
42+
const b = program.getSourceFile('b.ts') !;
43+
const c = program.getSourceFile('c.ts') !;
44+
const d = program.getSourceFile('d.ts') !;
45+
expect(importsToString(graph.importsOf(b))).toEqual('');
46+
expect(importsToString(graph.transitiveImportsOf(d))).toEqual('b,d');
47+
graph.addSyntheticImport(b, c);
48+
expect(importsToString(graph.importsOf(b))).toEqual('c');
49+
expect(importsToString(graph.transitiveImportsOf(d))).toEqual('b,c,d');
50+
});
51+
});
52+
53+
function makeImportGraph(graph: string): {program: ts.Program, graph: ImportGraph} {
54+
const {program, options, host} = makeProgramFromGraph(graph);
55+
return {
56+
program,
57+
graph: new ImportGraph(new ModuleResolver(program, options, host)),
58+
};
59+
}
60+
61+
function importsToString(imports: Set<ts.SourceFile>): string {
62+
return Array.from(imports).map(sf => sf.fileName.substr(1).replace('.ts', '')).sort().join(',');
63+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as ts from 'typescript';
10+
11+
import {makeProgram} from '../../testing/in_memory_typescript';
12+
13+
/**
14+
* Construct a TS program consisting solely of an import graph, from a string-based representation
15+
* of the graph.
16+
*
17+
* The `graph` string consists of semicolon separated files, where each file is specified
18+
* as a name and (optionally) a list of comma-separated imports or exports. For example:
19+
*
20+
* "a:b,c;b;c"
21+
*
22+
* specifies a program with three files (a.ts, b.ts, c.ts) where a.ts imports from both b.ts and
23+
* c.ts.
24+
*
25+
* A more complicated example has a dependency from b.ts to c.ts: "a:b,c;b:c;c".
26+
*
27+
* A * preceding a file name in the list of imports indicates that the dependency should be an
28+
* "export" and not an "import" dependency. For example:
29+
*
30+
* "a:*b,c;b;c"
31+
*
32+
* represents a program where a.ts exports from b.ts and imports from c.ts.
33+
*/
34+
export function makeProgramFromGraph(graph: string): {
35+
program: ts.Program,
36+
host: ts.CompilerHost,
37+
options: ts.CompilerOptions,
38+
} {
39+
const files = graph.split(';').map(fileSegment => {
40+
const [name, importList] = fileSegment.split(':');
41+
const contents = (importList ? importList.split(',') : [])
42+
.map(i => {
43+
if (i.startsWith('*')) {
44+
const sym = i.substr(1);
45+
return `export {${sym}} from './${sym}';`;
46+
} else {
47+
return `import {${i}} from './${i}';`;
48+
}
49+
})
50+
.join('\n') +
51+
`export const ${name} = '${name}';\n`;
52+
return {
53+
name: `${name}.ts`,
54+
contents,
55+
};
56+
});
57+
return makeProgram(files);
58+
}

0 commit comments

Comments
 (0)