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:
Alex Rickabaugh 2019-01-15 12:26:10 -08:00 committed by Jason Aden
parent a789a3f532
commit cac9199d7c
8 changed files with 360 additions and 0 deletions

View 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",
],
)

View 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';

View 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);
}
}

View 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');
}

View 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",
],
)

View 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))),
};
}

View 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(',');
}

View 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);
}