feat(ivy): cycle detector for TypeScript programs (#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 #28169
This commit is contained in:
parent
a789a3f532
commit
cac9199d7c
16
packages/compiler-cli/src/ngtsc/cycles/BUILD.bazel
Normal file
16
packages/compiler-cli/src/ngtsc/cycles/BUILD.bazel
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package(default_visibility = ["//visibility:public"])
|
||||||
|
|
||||||
|
load("//tools:defaults.bzl", "ts_library")
|
||||||
|
|
||||||
|
ts_library(
|
||||||
|
name = "cycles",
|
||||||
|
srcs = glob([
|
||||||
|
"index.ts",
|
||||||
|
"src/**/*.ts",
|
||||||
|
]),
|
||||||
|
module_name = "@angular/compiler-cli/src/ngtsc/cycles",
|
||||||
|
deps = [
|
||||||
|
"//packages/compiler-cli/src/ngtsc/imports",
|
||||||
|
"@ngdeps//typescript",
|
||||||
|
],
|
||||||
|
)
|
10
packages/compiler-cli/src/ngtsc/cycles/index.ts
Normal file
10
packages/compiler-cli/src/ngtsc/cycles/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright Google Inc. All Rights Reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by an MIT-style license that can be
|
||||||
|
* found in the LICENSE file at https://angular.io/license
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {CycleAnalyzer} from './src/analyzer';
|
||||||
|
export {ImportGraph} from './src/imports';
|
36
packages/compiler-cli/src/ngtsc/cycles/src/analyzer.ts
Normal file
36
packages/compiler-cli/src/ngtsc/cycles/src/analyzer.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright Google Inc. All Rights Reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by an MIT-style license that can be
|
||||||
|
* found in the LICENSE file at https://angular.io/license
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {ImportGraph} from './imports';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyzes a `ts.Program` for cycles.
|
||||||
|
*/
|
||||||
|
export class CycleAnalyzer {
|
||||||
|
constructor(private importGraph: ImportGraph) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether adding an import from `from` to `to` would create a cycle in the `ts.Program`.
|
||||||
|
*/
|
||||||
|
wouldCreateCycle(from: ts.SourceFile, to: ts.SourceFile): boolean {
|
||||||
|
// Import of 'from' -> 'to' is illegal if an edge 'to' -> 'from' already exists.
|
||||||
|
return this.importGraph.transitiveImportsOf(to).has(from);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a synthetic import from `from` to `to`.
|
||||||
|
*
|
||||||
|
* This is an import that doesn't exist in the `ts.Program` but will be considered as part of the
|
||||||
|
* import graph for cycle creation.
|
||||||
|
*/
|
||||||
|
recordSyntheticImport(from: ts.SourceFile, to: ts.SourceFile): void {
|
||||||
|
this.importGraph.addSyntheticImport(from, to);
|
||||||
|
}
|
||||||
|
}
|
84
packages/compiler-cli/src/ngtsc/cycles/src/imports.ts
Normal file
84
packages/compiler-cli/src/ngtsc/cycles/src/imports.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright Google Inc. All Rights Reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by an MIT-style license that can be
|
||||||
|
* found in the LICENSE file at https://angular.io/license
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {ModuleResolver} from '../../imports';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cached graph of imports in the `ts.Program`.
|
||||||
|
*
|
||||||
|
* The `ImportGraph` keeps track of dependencies (imports) of individual `ts.SourceFile`s. Only
|
||||||
|
* dependencies within the same program are tracked; imports into packages on NPM are not.
|
||||||
|
*/
|
||||||
|
export class ImportGraph {
|
||||||
|
private map = new Map<ts.SourceFile, Set<ts.SourceFile>>();
|
||||||
|
|
||||||
|
constructor(private resolver: ModuleResolver) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the direct (not transitive) imports of a given `ts.SourceFile`.
|
||||||
|
*
|
||||||
|
* This operation is cached.
|
||||||
|
*/
|
||||||
|
importsOf(sf: ts.SourceFile): Set<ts.SourceFile> {
|
||||||
|
if (!this.map.has(sf)) {
|
||||||
|
this.map.set(sf, this.scanImports(sf));
|
||||||
|
}
|
||||||
|
return this.map.get(sf) !;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists the transitive imports of a given `ts.SourceFile`.
|
||||||
|
*/
|
||||||
|
transitiveImportsOf(sf: ts.SourceFile): Set<ts.SourceFile> {
|
||||||
|
const imports = new Set<ts.SourceFile>();
|
||||||
|
this.transitiveImportsOfHelper(sf, imports);
|
||||||
|
return imports;
|
||||||
|
}
|
||||||
|
|
||||||
|
private transitiveImportsOfHelper(sf: ts.SourceFile, results: Set<ts.SourceFile>): void {
|
||||||
|
if (results.has(sf)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.add(sf);
|
||||||
|
this.importsOf(sf).forEach(imported => { this.transitiveImportsOfHelper(imported, results); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a record of an import from `sf` to `imported`, that's not present in the original
|
||||||
|
* `ts.Program` but will be remembered by the `ImportGraph`.
|
||||||
|
*/
|
||||||
|
addSyntheticImport(sf: ts.SourceFile, imported: ts.SourceFile): void {
|
||||||
|
if (isLocalFile(imported)) {
|
||||||
|
this.importsOf(sf).add(imported);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scanImports(sf: ts.SourceFile): Set<ts.SourceFile> {
|
||||||
|
const imports = new Set<ts.SourceFile>();
|
||||||
|
// Look through the source file for import statements.
|
||||||
|
sf.statements.forEach(stmt => {
|
||||||
|
if ((ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt)) &&
|
||||||
|
stmt.moduleSpecifier !== undefined && ts.isStringLiteral(stmt.moduleSpecifier)) {
|
||||||
|
// Resolve the module to a file, and check whether that file is in the ts.Program.
|
||||||
|
const moduleName = stmt.moduleSpecifier.text;
|
||||||
|
const moduleFile = this.resolver.resolveModuleName(moduleName, sf);
|
||||||
|
if (moduleFile !== null && isLocalFile(moduleFile)) {
|
||||||
|
// Record this local import.
|
||||||
|
imports.add(moduleFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return imports;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalFile(sf: ts.SourceFile): boolean {
|
||||||
|
return !sf.fileName.endsWith('.d.ts');
|
||||||
|
}
|
27
packages/compiler-cli/src/ngtsc/cycles/test/BUILD.bazel
Normal file
27
packages/compiler-cli/src/ngtsc/cycles/test/BUILD.bazel
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package(default_visibility = ["//visibility:public"])
|
||||||
|
|
||||||
|
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")
|
||||||
|
|
||||||
|
ts_library(
|
||||||
|
name = "test_lib",
|
||||||
|
testonly = True,
|
||||||
|
srcs = glob([
|
||||||
|
"**/*.ts",
|
||||||
|
]),
|
||||||
|
deps = [
|
||||||
|
"//packages:types",
|
||||||
|
"//packages/compiler-cli/src/ngtsc/cycles",
|
||||||
|
"//packages/compiler-cli/src/ngtsc/imports",
|
||||||
|
"//packages/compiler-cli/src/ngtsc/testing",
|
||||||
|
"@ngdeps//typescript",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
jasmine_node_test(
|
||||||
|
name = "test",
|
||||||
|
bootstrap = ["angular/tools/testing/init_node_no_angular_spec.js"],
|
||||||
|
deps = [
|
||||||
|
":test_lib",
|
||||||
|
"//tools/testing:node_no_angular",
|
||||||
|
],
|
||||||
|
)
|
66
packages/compiler-cli/src/ngtsc/cycles/test/analyzer_spec.ts
Normal file
66
packages/compiler-cli/src/ngtsc/cycles/test/analyzer_spec.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright Google Inc. All Rights Reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by an MIT-style license that can be
|
||||||
|
* found in the LICENSE file at https://angular.io/license
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {ModuleResolver} from '../../imports';
|
||||||
|
import {CycleAnalyzer} from '../src/analyzer';
|
||||||
|
import {ImportGraph} from '../src/imports';
|
||||||
|
|
||||||
|
import {makeProgramFromGraph} from './util';
|
||||||
|
|
||||||
|
describe('cycle analyzer', () => {
|
||||||
|
it('should not detect a cycle when there isn\'t one', () => {
|
||||||
|
const {program, analyzer} = makeAnalyzer('a:b,c;b;c');
|
||||||
|
const b = program.getSourceFile('b.ts') !;
|
||||||
|
const c = program.getSourceFile('c.ts') !;
|
||||||
|
expect(analyzer.wouldCreateCycle(b, c)).toBe(false);
|
||||||
|
expect(analyzer.wouldCreateCycle(c, b)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect a simple cycle between two files', () => {
|
||||||
|
const {program, analyzer} = makeAnalyzer('a:b;b');
|
||||||
|
const a = program.getSourceFile('a.ts') !;
|
||||||
|
const b = program.getSourceFile('b.ts') !;
|
||||||
|
expect(analyzer.wouldCreateCycle(a, b)).toBe(false);
|
||||||
|
expect(analyzer.wouldCreateCycle(b, a)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect a cycle with a re-export in the chain', () => {
|
||||||
|
const {program, analyzer} = makeAnalyzer('a:*b;b:c;c');
|
||||||
|
const a = program.getSourceFile('a.ts') !;
|
||||||
|
const c = program.getSourceFile('c.ts') !;
|
||||||
|
expect(analyzer.wouldCreateCycle(a, c)).toBe(false);
|
||||||
|
expect(analyzer.wouldCreateCycle(c, a)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect a cycle in a more complex program', () => {
|
||||||
|
const {program, analyzer} = makeAnalyzer('a:*b,*c;b:*e,*f;c:*g,*h;e:f;f:c;g;h:g');
|
||||||
|
const b = program.getSourceFile('b.ts') !;
|
||||||
|
const g = program.getSourceFile('g.ts') !;
|
||||||
|
expect(analyzer.wouldCreateCycle(b, g)).toBe(false);
|
||||||
|
expect(analyzer.wouldCreateCycle(g, b)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect a cycle caused by a synthetic edge', () => {
|
||||||
|
const {program, analyzer} = makeAnalyzer('a:b,c;b;c');
|
||||||
|
const b = program.getSourceFile('b.ts') !;
|
||||||
|
const c = program.getSourceFile('c.ts') !;
|
||||||
|
expect(analyzer.wouldCreateCycle(b, c)).toBe(false);
|
||||||
|
analyzer.recordSyntheticImport(c, b);
|
||||||
|
expect(analyzer.wouldCreateCycle(b, c)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeAnalyzer(graph: string): {program: ts.Program, analyzer: CycleAnalyzer} {
|
||||||
|
const {program, options, host} = makeProgramFromGraph(graph);
|
||||||
|
return {
|
||||||
|
program,
|
||||||
|
analyzer: new CycleAnalyzer(new ImportGraph(new ModuleResolver(program, options, host))),
|
||||||
|
};
|
||||||
|
}
|
63
packages/compiler-cli/src/ngtsc/cycles/test/imports_spec.ts
Normal file
63
packages/compiler-cli/src/ngtsc/cycles/test/imports_spec.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright Google Inc. All Rights Reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by an MIT-style license that can be
|
||||||
|
* found in the LICENSE file at https://angular.io/license
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {ModuleResolver} from '../../imports';
|
||||||
|
import {ImportGraph} from '../src/imports';
|
||||||
|
|
||||||
|
import {makeProgramFromGraph} from './util';
|
||||||
|
|
||||||
|
describe('import graph', () => {
|
||||||
|
it('should record imports of a simple program', () => {
|
||||||
|
const {program, graph} = makeImportGraph('a:b;b:c;c');
|
||||||
|
const a = program.getSourceFile('a.ts') !;
|
||||||
|
const b = program.getSourceFile('b.ts') !;
|
||||||
|
const c = program.getSourceFile('c.ts') !;
|
||||||
|
expect(importsToString(graph.importsOf(a))).toBe('b');
|
||||||
|
expect(importsToString(graph.importsOf(b))).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate transitive imports of a simple program', () => {
|
||||||
|
const {program, graph} = makeImportGraph('a:b;b:c;c');
|
||||||
|
const a = program.getSourceFile('a.ts') !;
|
||||||
|
const b = program.getSourceFile('b.ts') !;
|
||||||
|
const c = program.getSourceFile('c.ts') !;
|
||||||
|
expect(importsToString(graph.transitiveImportsOf(a))).toBe('a,b,c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate transitive imports in a more complex program (with a cycle)', () => {
|
||||||
|
const {program, graph} = makeImportGraph('a:*b,*c;b:*e,*f;c:*g,*h;e:f;f;g:e;h:g');
|
||||||
|
const c = program.getSourceFile('c.ts') !;
|
||||||
|
expect(importsToString(graph.transitiveImportsOf(c))).toBe('c,e,f,g,h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reflect the addition of a synthetic import', () => {
|
||||||
|
const {program, graph} = makeImportGraph('a:b,c,d;b;c;d:b');
|
||||||
|
const b = program.getSourceFile('b.ts') !;
|
||||||
|
const c = program.getSourceFile('c.ts') !;
|
||||||
|
const d = program.getSourceFile('d.ts') !;
|
||||||
|
expect(importsToString(graph.importsOf(b))).toEqual('');
|
||||||
|
expect(importsToString(graph.transitiveImportsOf(d))).toEqual('b,d');
|
||||||
|
graph.addSyntheticImport(b, c);
|
||||||
|
expect(importsToString(graph.importsOf(b))).toEqual('c');
|
||||||
|
expect(importsToString(graph.transitiveImportsOf(d))).toEqual('b,c,d');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeImportGraph(graph: string): {program: ts.Program, graph: ImportGraph} {
|
||||||
|
const {program, options, host} = makeProgramFromGraph(graph);
|
||||||
|
return {
|
||||||
|
program,
|
||||||
|
graph: new ImportGraph(new ModuleResolver(program, options, host)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function importsToString(imports: Set<ts.SourceFile>): string {
|
||||||
|
return Array.from(imports).map(sf => sf.fileName.substr(1).replace('.ts', '')).sort().join(',');
|
||||||
|
}
|
58
packages/compiler-cli/src/ngtsc/cycles/test/util.ts
Normal file
58
packages/compiler-cli/src/ngtsc/cycles/test/util.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright Google Inc. All Rights Reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by an MIT-style license that can be
|
||||||
|
* found in the LICENSE file at https://angular.io/license
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {makeProgram} from '../../testing/in_memory_typescript';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a TS program consisting solely of an import graph, from a string-based representation
|
||||||
|
* of the graph.
|
||||||
|
*
|
||||||
|
* The `graph` string consists of semicolon separated files, where each file is specified
|
||||||
|
* as a name and (optionally) a list of comma-separated imports or exports. For example:
|
||||||
|
*
|
||||||
|
* "a:b,c;b;c"
|
||||||
|
*
|
||||||
|
* specifies a program with three files (a.ts, b.ts, c.ts) where a.ts imports from both b.ts and
|
||||||
|
* c.ts.
|
||||||
|
*
|
||||||
|
* A more complicated example has a dependency from b.ts to c.ts: "a:b,c;b:c;c".
|
||||||
|
*
|
||||||
|
* A * preceding a file name in the list of imports indicates that the dependency should be an
|
||||||
|
* "export" and not an "import" dependency. For example:
|
||||||
|
*
|
||||||
|
* "a:*b,c;b;c"
|
||||||
|
*
|
||||||
|
* represents a program where a.ts exports from b.ts and imports from c.ts.
|
||||||
|
*/
|
||||||
|
export function makeProgramFromGraph(graph: string): {
|
||||||
|
program: ts.Program,
|
||||||
|
host: ts.CompilerHost,
|
||||||
|
options: ts.CompilerOptions,
|
||||||
|
} {
|
||||||
|
const files = graph.split(';').map(fileSegment => {
|
||||||
|
const [name, importList] = fileSegment.split(':');
|
||||||
|
const contents = (importList ? importList.split(',') : [])
|
||||||
|
.map(i => {
|
||||||
|
if (i.startsWith('*')) {
|
||||||
|
const sym = i.substr(1);
|
||||||
|
return `export {${sym}} from './${sym}';`;
|
||||||
|
} else {
|
||||||
|
return `import {${i}} from './${i}';`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join('\n') +
|
||||||
|
`export const ${name} = '${name}';\n`;
|
||||||
|
return {
|
||||||
|
name: `${name}.ts`,
|
||||||
|
contents,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return makeProgram(files);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user