refactor(ivy): extract selector scope logic to a new ngtsc package (#28852)
This commit splits apart selector_scope.ts in ngtsc and extracts the logic into two separate classes, the LocalModuleScopeRegistry and the DtsModuleScopeResolver. The logic is cleaned up significantly and new tests are added to verify behavior. LocalModuleScopeRegistry implements the NgModule semantics for compilation scopes, and handles NgModules declared in the current compilation unit. DtsModuleScopeResolver implements simpler logic for export scopes and handles NgModules declared in .d.ts files. This is done in preparation for the addition of re-export logic to solve StrictDeps issues. PR Close #28852
This commit is contained in:

committed by
Ben Lesh

parent
fafabc0b92
commit
15c065f9a0
28
packages/compiler-cli/src/ngtsc/scope/test/BUILD.bazel
Normal file
28
packages/compiler-cli/src/ngtsc/scope/test/BUILD.bazel
Normal file
@ -0,0 +1,28 @@
|
||||
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/imports",
|
||||
"//packages/compiler-cli/src/ngtsc/reflection",
|
||||
"//packages/compiler-cli/src/ngtsc/scope",
|
||||
"//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",
|
||||
],
|
||||
)
|
144
packages/compiler-cli/src/ngtsc/scope/test/dependency_spec.ts
Normal file
144
packages/compiler-cli/src/ngtsc/scope/test/dependency_spec.ts
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @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 {Reference} from '../../imports';
|
||||
import {TypeScriptReflectionHost} from '../../reflection';
|
||||
import {makeProgram} from '../../testing/in_memory_typescript';
|
||||
|
||||
import {ExportScope} from '../src/api';
|
||||
import {MetadataDtsModuleScopeResolver} from '../src/dependency';
|
||||
|
||||
const MODULE_FROM_NODE_MODULES_PATH = /.*node_modules\/(\w+)\/index\.d\.ts$/;
|
||||
|
||||
/**
|
||||
* Simple metadata types are added to the top of each testing file, for convenience.
|
||||
*/
|
||||
const PROLOG = `
|
||||
export declare type ModuleMeta<A, B, C, D> = never;
|
||||
export declare type ComponentMeta<A, B, C, D, E, F> = never;
|
||||
export declare type DirectiveMeta<A, B, C, D, E, F> = never;
|
||||
export declare type PipeMeta<A, B> = never;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Construct the testing environment with a given set of absolute modules and their contents.
|
||||
*
|
||||
* This returns both the `MetadataDtsModuleScopeResolver` and a `refs` object which can be
|
||||
* destructured to retrieve references to specific declared classes.
|
||||
*/
|
||||
function makeTestEnv(modules: {[module: string]: string}): {
|
||||
refs: {[name: string]: Reference<ts.ClassDeclaration>},
|
||||
resolver: MetadataDtsModuleScopeResolver,
|
||||
} {
|
||||
// Map the modules object to an array of files for `makeProgram`.
|
||||
const files = Object.keys(modules).map(moduleName => {
|
||||
return {
|
||||
name: `node_modules/${moduleName}/index.d.ts`,
|
||||
contents: PROLOG + (modules as any)[moduleName],
|
||||
};
|
||||
});
|
||||
const {program} = makeProgram(files);
|
||||
const checker = program.getTypeChecker();
|
||||
const resolver =
|
||||
new MetadataDtsModuleScopeResolver(checker, new TypeScriptReflectionHost(checker));
|
||||
|
||||
// Resolver for the refs object.
|
||||
const get = (target: {}, name: string): Reference<ts.ClassDeclaration> => {
|
||||
for (const sf of program.getSourceFiles()) {
|
||||
const symbol = checker.getSymbolAtLocation(sf) !;
|
||||
const exportedSymbol = symbol.exports !.get(name as ts.__String);
|
||||
if (exportedSymbol !== undefined) {
|
||||
const decl = exportedSymbol.valueDeclaration as ts.ClassDeclaration;
|
||||
const specifier = MODULE_FROM_NODE_MODULES_PATH.exec(sf.fileName) ![1];
|
||||
return new Reference(decl, {specifier, resolutionContext: sf.fileName});
|
||||
}
|
||||
}
|
||||
throw new Error('Class not found: ' + name);
|
||||
};
|
||||
|
||||
return {
|
||||
resolver,
|
||||
refs: new Proxy({}, {get}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('MetadataDtsModuleScopeResolver', () => {
|
||||
it('should produce an accurate scope for a basic NgModule', () => {
|
||||
const {resolver, refs} = makeTestEnv({
|
||||
'test': `
|
||||
export declare class Dir {
|
||||
static ngDirectiveDef: DirectiveMeta<Dir, '[dir]', ['exportAs'], {'input': 'input2'},
|
||||
{'output': 'output2'}, ['query']>;
|
||||
}
|
||||
|
||||
export declare class Module {
|
||||
static ngModuleDef: ModuleMeta<Module, [typeof Dir], never, [typeof Dir]>;
|
||||
}
|
||||
`
|
||||
});
|
||||
const {Dir, Module} = refs;
|
||||
const scope = resolver.resolve(Module) !;
|
||||
expect(scopeToRefs(scope)).toEqual([Dir]);
|
||||
});
|
||||
|
||||
it('should produce an accurate scope when a module is exported', () => {
|
||||
const {resolver, refs} = makeTestEnv({
|
||||
'test': `
|
||||
export declare class Dir {
|
||||
static ngDirectiveDef: DirectiveMeta<Dir, '[dir]', never, never, never, never>;
|
||||
}
|
||||
|
||||
export declare class ModuleA {
|
||||
static ngModuleDef: ModuleMeta<ModuleA, [typeof Dir], never, [typeof Dir]>;
|
||||
}
|
||||
|
||||
export declare class ModuleB {
|
||||
static ngModuleDef: ModuleMeta<ModuleB, never, never, [typeof ModuleA]>;
|
||||
}
|
||||
`
|
||||
});
|
||||
const {Dir, ModuleB} = refs;
|
||||
const scope = resolver.resolve(ModuleB) !;
|
||||
expect(scopeToRefs(scope)).toEqual([Dir]);
|
||||
});
|
||||
|
||||
it('should resolve correctly across modules', () => {
|
||||
const {resolver, refs} = makeTestEnv({
|
||||
'declaration': `
|
||||
export declare class Dir {
|
||||
static ngDirectiveDef: DirectiveMeta<Dir, '[dir]', never, never, never, never>;
|
||||
}
|
||||
|
||||
export declare class ModuleA {
|
||||
static ngModuleDef: ModuleMeta<ModuleA, [typeof Dir], never, [typeof Dir]>;
|
||||
}
|
||||
`,
|
||||
'exported': `
|
||||
import * as d from 'declaration';
|
||||
|
||||
export declare class ModuleB {
|
||||
static ngModuleDef: ModuleMeta<ModuleB, never, never, [typeof d.ModuleA]>;
|
||||
}
|
||||
`
|
||||
});
|
||||
const {Dir, ModuleB} = refs;
|
||||
const scope = resolver.resolve(ModuleB) !;
|
||||
expect(scopeToRefs(scope)).toEqual([Dir]);
|
||||
|
||||
// Explicitly verify that the directive has the correct owning module.
|
||||
expect(scope.exported.directives[0].ref.ownedByModuleGuess).toBe('declaration');
|
||||
});
|
||||
});
|
||||
|
||||
function scopeToRefs(scope: ExportScope): Reference<ts.Declaration>[] {
|
||||
const directives = scope.exported.directives.map(dir => dir.ref);
|
||||
const pipes = scope.exported.pipes.map(pipe => pipe.ref);
|
||||
return [...directives, ...pipes].sort((a, b) => a.debugName !.localeCompare(b.debugName !));
|
||||
}
|
159
packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts
Normal file
159
packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @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 {Reference} from '../../imports';
|
||||
import {ScopeData, ScopeDirective, ScopePipe} from '../src/api';
|
||||
import {DtsModuleScopeResolver} from '../src/dependency';
|
||||
import {LocalModuleScopeRegistry} from '../src/local';
|
||||
|
||||
function registerFakeRefs(registry: LocalModuleScopeRegistry):
|
||||
{[name: string]: Reference<ts.ClassDeclaration>} {
|
||||
const get = (target: {}, name: string): Reference<ts.ClassDeclaration> => {
|
||||
const sf = ts.createSourceFile(
|
||||
name + '.ts', `export class ${name} {}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const clazz = sf.statements[0] as ts.ClassDeclaration;
|
||||
const ref = new Reference(clazz);
|
||||
if (name.startsWith('Dir') || name.startsWith('Cmp')) {
|
||||
registry.registerDirective(fakeDirective(ref));
|
||||
} else if (name.startsWith('Pipe')) {
|
||||
registry.registerPipe(fakePipe(ref));
|
||||
}
|
||||
return ref;
|
||||
};
|
||||
return new Proxy({}, {get});
|
||||
}
|
||||
|
||||
describe('LocalModuleScopeRegistry', () => {
|
||||
let registry !: LocalModuleScopeRegistry;
|
||||
|
||||
beforeEach(() => { registry = new LocalModuleScopeRegistry(new MockDtsModuleScopeResolver()); });
|
||||
|
||||
it('should produce an accurate LocalModuleScope for a basic NgModule', () => {
|
||||
const {Dir1, Dir2, Pipe1, Module} = registerFakeRefs(registry);
|
||||
|
||||
registry.registerNgModule(Module.node, {
|
||||
imports: [],
|
||||
declarations: [Dir1, Dir2, Pipe1],
|
||||
exports: [Dir1, Pipe1],
|
||||
});
|
||||
|
||||
const scope = registry.getScopeOfModule(Module.node) !;
|
||||
expect(scopeToRefs(scope.compilation)).toEqual([Dir1, Dir2, Pipe1]);
|
||||
expect(scopeToRefs(scope.exported)).toEqual([Dir1, Pipe1]);
|
||||
});
|
||||
|
||||
it('should produce accurate LocalModuleScopes for a complex module chain', () => {
|
||||
const {DirA, DirB, DirCI, DirCE, ModuleA, ModuleB, ModuleC} = registerFakeRefs(registry);
|
||||
|
||||
registry.registerNgModule(
|
||||
ModuleA.node, {imports: [ModuleB], declarations: [DirA], exports: []});
|
||||
registry.registerNgModule(
|
||||
ModuleB.node, {exports: [ModuleC, DirB], declarations: [DirB], imports: []});
|
||||
registry.registerNgModule(
|
||||
ModuleC.node, {declarations: [DirCI, DirCE], exports: [DirCE], imports: []});
|
||||
|
||||
const scopeA = registry.getScopeOfModule(ModuleA.node) !;
|
||||
expect(scopeToRefs(scopeA.compilation)).toEqual([DirA, DirB, DirCE]);
|
||||
expect(scopeToRefs(scopeA.exported)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not treat exported modules as imported', () => {
|
||||
const {Dir, ModuleA, ModuleB} = registerFakeRefs(registry);
|
||||
|
||||
registry.registerNgModule(ModuleA.node, {exports: [ModuleB], imports: [], declarations: []});
|
||||
registry.registerNgModule(ModuleB.node, {declarations: [Dir], exports: [Dir], imports: []});
|
||||
|
||||
const scopeA = registry.getScopeOfModule(ModuleA.node) !;
|
||||
expect(scopeToRefs(scopeA.compilation)).toEqual([]);
|
||||
expect(scopeToRefs(scopeA.exported)).toEqual([Dir]);
|
||||
});
|
||||
|
||||
it('should deduplicate declarations and exports', () => {
|
||||
const {DirA, ModuleA, DirB, ModuleB, ModuleC} = registerFakeRefs(registry);
|
||||
|
||||
registry.registerNgModule(ModuleA.node, {
|
||||
declarations: [DirA, DirA],
|
||||
imports: [ModuleB, ModuleC],
|
||||
exports: [DirA, DirA, DirB, ModuleB],
|
||||
});
|
||||
registry.registerNgModule(ModuleB.node, {declarations: [DirB], imports: [], exports: [DirB]});
|
||||
registry.registerNgModule(ModuleC.node, {declarations: [], imports: [], exports: [ModuleB]});
|
||||
|
||||
const scope = registry.getScopeOfModule(ModuleA.node) !;
|
||||
expect(scopeToRefs(scope.compilation)).toEqual([DirA, DirB]);
|
||||
expect(scopeToRefs(scope.exported)).toEqual([DirA, DirB]);
|
||||
});
|
||||
|
||||
it('should preserve reference identities in module metadata', () => {
|
||||
const {Dir, Module} = registerFakeRefs(registry);
|
||||
const idSf = ts.createSourceFile('id.ts', 'var id;', ts.ScriptTarget.Latest, true);
|
||||
|
||||
// Create a new Reference to Dir, with a special `ts.Identifier`, and register the directive
|
||||
// using it. This emulates what happens when an NgModule declares a Directive.
|
||||
const idVar = idSf.statements[0] as ts.VariableStatement;
|
||||
const id = idVar.declarationList.declarations[0].name as ts.Identifier;
|
||||
const DirInModule = new Reference(Dir.node);
|
||||
DirInModule.addIdentifier(id);
|
||||
registry.registerNgModule(Module.node, {exports: [], imports: [], declarations: [DirInModule]});
|
||||
|
||||
const scope = registry.getScopeOfModule(Module.node) !;
|
||||
expect(scope.compilation.directives[0].ref.getIdentityIn(idSf)).toBe(id);
|
||||
});
|
||||
|
||||
it('should allow directly exporting a directive that\'s not imported', () => {
|
||||
const {Dir, ModuleA, ModuleB} = registerFakeRefs(registry);
|
||||
|
||||
registry.registerNgModule(ModuleA.node, {exports: [Dir], imports: [ModuleB], declarations: []});
|
||||
registry.registerNgModule(ModuleB.node, {declarations: [Dir], exports: [Dir], imports: []});
|
||||
|
||||
const scopeA = registry.getScopeOfModule(ModuleA.node) !;
|
||||
expect(scopeToRefs(scopeA.exported)).toEqual([Dir]);
|
||||
});
|
||||
|
||||
it('should not allow directly exporting a directive that\'s not imported', () => {
|
||||
const {Dir, ModuleA, ModuleB} = registerFakeRefs(registry);
|
||||
|
||||
registry.registerNgModule(ModuleA.node, {exports: [Dir], imports: [], declarations: []});
|
||||
registry.registerNgModule(ModuleB.node, {declarations: [Dir], exports: [Dir], imports: []});
|
||||
|
||||
expect(() => registry.getScopeOfModule(ModuleA.node)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function fakeDirective(ref: Reference<ts.ClassDeclaration>): ScopeDirective {
|
||||
const name = ref.debugName !;
|
||||
return {
|
||||
ref,
|
||||
name,
|
||||
selector: `[${ref.debugName}]`,
|
||||
isComponent: name.startsWith('Cmp'),
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
exportAs: null,
|
||||
queries: [],
|
||||
hasNgTemplateContextGuard: false,
|
||||
ngTemplateGuards: [],
|
||||
};
|
||||
}
|
||||
|
||||
function fakePipe(ref: Reference<ts.ClassDeclaration>): ScopePipe {
|
||||
const name = ref.debugName !;
|
||||
return {ref, name};
|
||||
}
|
||||
|
||||
class MockDtsModuleScopeResolver implements DtsModuleScopeResolver {
|
||||
resolve(ref: Reference<ts.ClassDeclaration>): null { return null; }
|
||||
}
|
||||
|
||||
function scopeToRefs(scopeData: ScopeData): Reference<ts.Declaration>[] {
|
||||
const directives = scopeData.directives.map(dir => dir.ref);
|
||||
const pipes = scopeData.pipes.map(pipe => pipe.ref);
|
||||
return [...directives, ...pipes].sort((a, b) => a.debugName !.localeCompare(b.debugName !));
|
||||
}
|
Reference in New Issue
Block a user