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
|
@ -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",
|
||||
],
|
||||
)
|
|
@ -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';
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
|
@ -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",
|
||||
],
|
||||
)
|
|
@ -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))),
|
||||
};
|
||||
}
|
|
@ -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(',');
|
||||
}
|
|
@ -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…
Reference in New Issue