refactor(ivy): ngcc - implement new module resolver (#29643)

When working out the dependencies between entry-points
ngcc must parse the import statements and then resolve the
import path to the actual file.  This is complicated because module
resolution is not trivial.

Previously ngcc used the node.js `require.resolve`, with some
hacking to resolve modules. This change refactors the `DependencyHost`
to use a new custom `ModuleResolver`, which is optimized for this use
case.

Moreover, because we are in full control of the resolution,
we can support TS `paths` aliases, where not all imports come from
`node_modules`. This is the case in some CLI projects where there are
compiled libraries that are stored locally in a `dist` folder.
See //FW-1210.

PR Close #29643
This commit is contained in:
Pete Bacon Darwin 2019-04-28 20:47:57 +01:00 committed by Andrew Kushnir
parent eef4ca5dd3
commit 4a2405929c
9 changed files with 691 additions and 230 deletions

View File

@ -19,6 +19,7 @@ import {DependencyResolver} from './packages/dependency_resolver';
import {EntryPointFormat, EntryPointJsonProperty, SUPPORTED_FORMAT_PROPERTIES, getEntryPointFormat} from './packages/entry_point';
import {makeEntryPointBundle} from './packages/entry_point_bundle';
import {EntryPointFinder} from './packages/entry_point_finder';
import {ModuleResolver} from './packages/module_resolver';
import {Transformer} from './packages/transformer';
import {FileWriter} from './writing/file_writer';
import {InPlaceFileWriter} from './writing/in_place_file_writer';
@ -74,7 +75,8 @@ export function mainNgcc({basePath, targetEntryPointPath,
compileAllFormats = true, createNewEntryPointFormats = false,
logger = new ConsoleLogger(LogLevel.info)}: NgccOptions): void {
const transformer = new Transformer(logger);
const host = new DependencyHost();
const moduleResolver = new ModuleResolver();
const host = new DependencyHost(moduleResolver);
const resolver = new DependencyResolver(logger, host);
const finder = new EntryPointFinder(logger, resolver);
const fileWriter = getFileWriter(createNewEntryPointFormats);

View File

@ -6,16 +6,20 @@
* found in the LICENSE file at https://angular.io/license
*/
import * as path from 'canonical-path';
import * as fs from 'fs';
import * as ts from 'typescript';
import {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path';
import {AbsoluteFsPath} from '../../../src/ngtsc/path';
import {ModuleResolver, ResolvedDeepImport, ResolvedRelativeModule} from './module_resolver';
/**
* Helper functions for computing dependencies.
*/
export class DependencyHost {
constructor(private moduleResolver: ModuleResolver) {}
/**
* Get a list of the resolved paths to all the dependencies of this entry point.
* @param from An absolute path to the file whose dependencies we want to get.
@ -24,17 +28,15 @@ export class DependencyHost {
* @param missing A set that will have the dependencies that could not be found added to it.
* @param deepImports A set that will have the import paths that exist but cannot be mapped to
* entry-points, i.e. deep-imports.
* @param internal A set that is used to track internal dependencies to prevent getting stuck in a
* @param alreadySeen A set that is used to track internal dependencies to prevent getting stuck
* in a
* circular dependency loop.
*/
computeDependencies(
from: AbsoluteFsPath, dependencies: Set<AbsoluteFsPath> = new Set(),
missing: Set<PathSegment> = new Set(), deepImports: Set<PathSegment> = new Set(),
internal: Set<AbsoluteFsPath> = new Set()): {
dependencies: Set<AbsoluteFsPath>,
missing: Set<PathSegment>,
deepImports: Set<PathSegment>
} {
missing: Set<string> = new Set(), deepImports: Set<string> = new Set(),
alreadySeen: Set<AbsoluteFsPath> = new Set()):
{dependencies: Set<AbsoluteFsPath>, missing: Set<string>, deepImports: Set<string>} {
const fromContents = fs.readFileSync(from, 'utf8');
if (!this.hasImportOrReexportStatements(fromContents)) {
return {dependencies, missing, deepImports};
@ -49,86 +51,30 @@ export class DependencyHost {
// Grab the id of the module that is being imported
.map(stmt => stmt.moduleSpecifier.text)
// Resolve this module id into an absolute path
.forEach((importPath: PathSegment) => {
if (importPath.startsWith('.')) {
// This is an internal import so follow it
const internalDependency = this.resolveInternal(from, importPath);
// Avoid circular dependencies
if (!internal.has(internalDependency)) {
internal.add(internalDependency);
this.computeDependencies(
internalDependency, dependencies, missing, deepImports, internal);
}
} else {
const resolvedEntryPoint = this.tryResolveEntryPoint(from, importPath);
if (resolvedEntryPoint !== null) {
dependencies.add(resolvedEntryPoint);
.forEach(importPath => {
const resolvedModule = this.moduleResolver.resolveModuleImport(importPath, from);
if (resolvedModule) {
if (resolvedModule instanceof ResolvedRelativeModule) {
const internalDependency = resolvedModule.modulePath;
if (!alreadySeen.has(internalDependency)) {
alreadySeen.add(internalDependency);
this.computeDependencies(
internalDependency, dependencies, missing, deepImports, alreadySeen);
}
} else {
// If the import could not be resolved as entry point, it either does not exist
// at all or is a deep import.
const deeplyImportedFile = this.tryResolve(from, importPath);
if (deeplyImportedFile !== null) {
deepImports.add(importPath);
if (resolvedModule instanceof ResolvedDeepImport) {
deepImports.add(resolvedModule.importPath);
} else {
missing.add(importPath);
dependencies.add(resolvedModule.entryPointPath);
}
}
} else {
missing.add(importPath);
}
});
return {dependencies, missing, deepImports};
}
/**
* Resolve an internal module import.
* @param from the absolute file path from where to start trying to resolve this module
* @param to the module specifier of the internal dependency to resolve
* @returns the resolved path to the import.
*/
resolveInternal(from: AbsoluteFsPath, to: PathSegment): AbsoluteFsPath {
const fromDirectory = path.dirname(from);
// `fromDirectory` is absolute so we don't need to worry about telling `require.resolve`
// about it by adding it to a `paths` parameter - unlike `tryResolve` below.
return AbsoluteFsPath.from(require.resolve(path.resolve(fromDirectory, to)));
}
/**
* We don't want to resolve external dependencies directly because if it is a path to a
* sub-entry-point (e.g. @angular/animations/browser rather than @angular/animations)
* then `require.resolve()` may return a path to a UMD bundle, which may actually live
* in the folder containing the sub-entry-point
* (e.g. @angular/animations/bundles/animations-browser.umd.js).
*
* Instead we try to resolve it as a package, which is what we would need anyway for it to be
* compilable by ngcc.
*
* If `to` is actually a path to a file then this will fail, which is what we want.
*
* @param from the file path from where to start trying to resolve this module
* @param to the module specifier of the dependency to resolve
* @returns the resolved path to the entry point directory of the import or null
* if it cannot be resolved.
*/
tryResolveEntryPoint(from: AbsoluteFsPath, to: PathSegment): AbsoluteFsPath|null {
const entryPoint = this.tryResolve(from, `${to}/package.json` as PathSegment);
return entryPoint && AbsoluteFsPath.from(path.dirname(entryPoint));
}
/**
* Resolve the absolute path of a module from a particular starting point.
*
* @param from the file path from where to start trying to resolve this module
* @param to the module specifier of the dependency to resolve
* @returns an absolute path to the entry-point of the dependency or null if it could not be
* resolved.
*/
tryResolve(from: AbsoluteFsPath, to: PathSegment): AbsoluteFsPath|null {
try {
return AbsoluteFsPath.from(require.resolve(to, {paths: [from]}));
} catch (e) {
return null;
}
}
/**
* Check whether the given statement is an import with a string literal module specifier.
* @param stmt the statement node to check.

View File

@ -0,0 +1,280 @@
/**
* @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 fs from 'fs';
import {AbsoluteFsPath} from '../../../src/ngtsc/path';
import {PathMappings, isRelativePath} from '../utils';
/**
* This is a very cut-down implementation of the TypeScript module resolution strategy.
*
* It is specific to the needs of ngcc and is not intended to be a drop-in replacement
* for the TS module resolver. It is used to compute the dependencies between entry-points
* that may be compiled by ngcc.
*
* The algorithm only finds `.js` files for internal/relative imports and paths to
* the folder containing the `package.json` of the entry-point for external imports.
*
* It can cope with nested `node_modules` folders and also supports `paths`/`baseUrl`
* configuration properties, as provided in a `ts.CompilerOptions` object.
*/
export class ModuleResolver {
private pathMappings: ProcessedPathMapping[];
constructor(pathMappings?: PathMappings, private relativeExtensions = ['.js', '/index.js']) {
this.pathMappings = pathMappings ? this.processPathMappings(pathMappings) : [];
}
/**
* Resolve an absolute path for the `moduleName` imported into a file at `fromPath`.
* @param moduleName The name of the import to resolve.
* @param fromPath The path to the file containing the import.
* @returns A path to the resolved module or null if missing.
* Specifically:
* * the absolute path to the package.json of an external module
* * a JavaScript file of an internal module
* * null if none exists.
*/
resolveModuleImport(moduleName: string, fromPath: AbsoluteFsPath): ResolvedModule|null {
if (isRelativePath(moduleName)) {
return this.resolveAsRelativePath(moduleName, fromPath);
} else {
return this.pathMappings.length && this.resolveByPathMappings(moduleName, fromPath) ||
this.resolveAsEntryPoint(moduleName, fromPath);
}
}
/**
* Convert the `pathMappings` into a collection of `PathMapper` functions.
*/
private processPathMappings(pathMappings: PathMappings): ProcessedPathMapping[] {
const baseUrl = AbsoluteFsPath.from(pathMappings.baseUrl);
return Object.keys(pathMappings.paths).map(pathPattern => {
const matcher = splitOnStar(pathPattern);
const templates = pathMappings.paths[pathPattern].map(splitOnStar);
return {matcher, templates, baseUrl};
});
}
/**
* Try to resolve a module name, as a relative path, from the `fromPath`.
*
* As it is relative, it only looks for files that end in one of the `relativeExtensions`.
* For example: `${moduleName}.js` or `${moduleName}/index.js`.
* If neither of these files exist then the method returns `null`.
*/
private resolveAsRelativePath(moduleName: string, fromPath: AbsoluteFsPath): ResolvedModule|null {
const resolvedPath = this.resolvePath(
AbsoluteFsPath.resolve(AbsoluteFsPath.dirname(fromPath), moduleName),
this.relativeExtensions);
return resolvedPath && new ResolvedRelativeModule(resolvedPath);
}
/**
* Try to resolve the `moduleName`, by applying the computed `pathMappings` and
* then trying to resolve the mapped path as a relative or external import.
*
* Whether the mapped path is relative is defined as it being "below the `fromPath`" and not
* containing `node_modules`.
*
* If the mapped path is not relative but does not resolve to an external entry-point, then we
* check whether it would have resolved to a relative path, in which case it is marked as a
* "deep-import".
*/
private resolveByPathMappings(moduleName: string, fromPath: AbsoluteFsPath): ResolvedModule|null {
const mappedPaths = this.findMappedPaths(moduleName);
if (mappedPaths.length > 0) {
const packagePath = this.findPackagePath(fromPath);
if (packagePath !== null) {
for (const mappedPath of mappedPaths) {
const isRelative =
mappedPath.startsWith(packagePath) && !mappedPath.includes('node_modules');
if (isRelative) {
return this.resolveAsRelativePath(mappedPath, fromPath);
} else if (this.isEntryPoint(mappedPath)) {
return new ResolvedExternalModule(mappedPath);
} else if (this.resolveAsRelativePath(mappedPath, fromPath)) {
return new ResolvedDeepImport(mappedPath);
}
}
}
}
return null;
}
/**
* Try to resolve the `moduleName` as an external entry-point by searching the `node_modules`
* folders up the tree for a matching `.../node_modules/${moduleName}`.
*
* If a folder is found but the path does not contain a `package.json` then it is marked as a
* "deep-import".
*/
private resolveAsEntryPoint(moduleName: string, fromPath: AbsoluteFsPath): ResolvedModule|null {
let folder = fromPath;
while (folder !== '/') {
folder = AbsoluteFsPath.dirname(folder);
if (folder.endsWith('node_modules')) {
// Skip up if the folder already ends in node_modules
folder = AbsoluteFsPath.dirname(folder);
}
const modulePath = AbsoluteFsPath.resolve(folder, 'node_modules', moduleName);
if (this.isEntryPoint(modulePath)) {
return new ResolvedExternalModule(modulePath);
} else if (this.resolveAsRelativePath(modulePath, fromPath)) {
return new ResolvedDeepImport(modulePath);
}
}
return null;
}
/**
* Attempt to resolve a `path` to a file by appending the provided `postFixes`
* to the `path` and checking if the file exists on disk.
* @returns An absolute path to the first matching existing file, or `null` if none exist.
*/
private resolvePath(path: string, postFixes: string[]): AbsoluteFsPath|null {
for (const postFix of postFixes) {
const testPath = path + postFix;
if (fs.existsSync(testPath)) {
return AbsoluteFsPath.from(testPath);
}
}
return null;
}
/**
* Can we consider the given path as an entry-point to a package?
*
* This is achieved by checking for the existence of `${modulePath}/package.json`.
*/
private isEntryPoint(modulePath: AbsoluteFsPath): boolean {
return fs.existsSync(AbsoluteFsPath.join(modulePath, 'package.json'));
}
/**
* Apply the `pathMappers` to the `moduleName` and return all the possible
* paths that match.
*
* The mapped path is computed for each template in `mapping.templates` by
* replacing the `matcher.prefix` and `matcher.postfix` strings in `path with the
* `template.prefix` and `template.postfix` strings.
*/
private findMappedPaths(moduleName: string): AbsoluteFsPath[] {
const matches = this.pathMappings.map(mapping => this.matchMapping(moduleName, mapping));
let bestMapping: ProcessedPathMapping|undefined;
let bestMatch: string|undefined;
for (let index = 0; index < this.pathMappings.length; index++) {
const mapping = this.pathMappings[index];
const match = matches[index];
if (match !== null) {
// If this mapping had no wildcard then this must be a complete match.
if (!mapping.matcher.hasWildcard) {
bestMatch = match;
bestMapping = mapping;
break;
}
// The best matched mapping is the one with the longest prefix.
if (!bestMapping || mapping.matcher.prefix > bestMapping.matcher.prefix) {
bestMatch = match;
bestMapping = mapping;
}
}
}
return (bestMapping && bestMatch) ? this.computeMappedTemplates(bestMapping, bestMatch) : [];
}
/**
* Attempt to find a mapped path for the given `path` and a `mapping`.
*
* The `path` matches the `mapping` if if it starts with `matcher.prefix` and ends with
* `matcher.postfix`.
*
* @returns the wildcard segment of a matched `path`, or `null` if no match.
*/
private matchMapping(path: string, mapping: ProcessedPathMapping): string|null {
const {prefix, postfix, hasWildcard} = mapping.matcher;
if (path.startsWith(prefix) && path.endsWith(postfix)) {
return hasWildcard ? path.substring(prefix.length, path.length - postfix.length) : '';
}
return null;
}
/**
* Compute the candidate paths from the given mapping's templates using the matched
* string.
*/
private computeMappedTemplates(mapping: ProcessedPathMapping, match: string) {
return mapping.templates.map(
template =>
AbsoluteFsPath.resolve(mapping.baseUrl, template.prefix + match + template.postfix));
}
/**
* Search up the folder tree for the first folder that contains `package.json`
* or `null` if none is found.
*/
private findPackagePath(path: AbsoluteFsPath): AbsoluteFsPath|null {
let folder = path;
while (folder !== '/') {
folder = AbsoluteFsPath.dirname(folder);
if (fs.existsSync(AbsoluteFsPath.join(folder, 'package.json'))) {
return folder;
}
}
return null;
}
}
/** The result of resolving an import to a module. */
export type ResolvedModule = ResolvedExternalModule | ResolvedRelativeModule | ResolvedDeepImport;
/**
* A module that is external to the package doing the importing.
* In this case we capture the folder containing the entry-point.
*/
export class ResolvedExternalModule {
constructor(public entryPointPath: AbsoluteFsPath) {}
}
/**
* A module that is relative to the module doing the importing, and so internal to the
* source module's package.
*/
export class ResolvedRelativeModule {
constructor(public modulePath: AbsoluteFsPath) {}
}
/**
* A module that is external to the package doing the importing but pointing to a
* module that is deep inside a package, rather than to an entry-point of the package.
*/
export class ResolvedDeepImport {
constructor(public importPath: AbsoluteFsPath) {}
}
function splitOnStar(str: string): PathMappingPattern {
const [prefix, postfix] = str.split('*', 2);
return {prefix, postfix: postfix || '', hasWildcard: postfix !== undefined};
}
interface ProcessedPathMapping {
baseUrl: AbsoluteFsPath;
matcher: PathMappingPattern;
templates: PathMappingPattern[];
}
interface PathMappingPattern {
prefix: string;
postfix: string;
hasWildcard: boolean;
}

View File

@ -51,3 +51,17 @@ export function hasNameIdentifier(declaration: ts.Declaration): declaration is t
const namedDeclaration: ts.Declaration&{name?: ts.Node} = declaration;
return namedDeclaration.name !== undefined && ts.isIdentifier(namedDeclaration.name);
}
export type PathMappings = {
baseUrl: string,
paths: {[key: string]: string[]}
};
/**
* Test whether a path is "relative".
*
* Relative paths start with `/`, `./` or `../`; or are simply `.` or `..`.
*/
export function isRelativePath(path: string): boolean {
return /^\/|^\.\.?($|\/)/.test(path);
}

View File

@ -5,24 +5,18 @@
* 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 path from 'canonical-path';
import * as mockFs from 'mock-fs';
import * as ts from 'typescript';
import {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path';
import {AbsoluteFsPath} from '../../../src/ngtsc/path';
import {DependencyHost} from '../../src/packages/dependency_host';
const Module = require('module');
interface DepMap {
[path: string]: {resolved: string[], missing: string[]};
}
import {ModuleResolver} from '../../src/packages/module_resolver';
const _ = AbsoluteFsPath.from;
describe('DependencyHost', () => {
let host: DependencyHost;
beforeEach(() => host = new DependencyHost());
beforeEach(() => host = new DependencyHost(new ModuleResolver()));
describe('getDependencies()', () => {
beforeEach(createMockFileSystem);
@ -31,47 +25,36 @@ describe('DependencyHost', () => {
it('should not generate a TS AST if the source does not contain any imports or re-exports',
() => {
spyOn(ts, 'createSourceFile');
host.computeDependencies(
_('/no/imports/or/re-exports.js'), new Set(), new Set(), new Set());
host.computeDependencies(_('/no/imports/or/re-exports/index.js'));
expect(ts.createSourceFile).not.toHaveBeenCalled();
});
it('should resolve all the external imports of the source file', () => {
spyOn(host, 'tryResolveEntryPoint')
.and.callFake((from: string, importPath: string) => `RESOLVED/${importPath}`);
const resolved = new Set();
const missing = new Set();
const deepImports = new Set();
host.computeDependencies(_('/external/imports.js'), resolved, missing, deepImports);
expect(resolved.size).toBe(2);
expect(resolved.has('RESOLVED/path/to/x')).toBe(true);
expect(resolved.has('RESOLVED/path/to/y')).toBe(true);
const {dependencies, missing, deepImports} =
host.computeDependencies(_('/external/imports/index.js'));
expect(dependencies.size).toBe(2);
expect(missing.size).toBe(0);
expect(deepImports.size).toBe(0);
expect(dependencies.has(_('/node_modules/lib-1'))).toBe(true);
expect(dependencies.has(_('/node_modules/lib-1/sub-1'))).toBe(true);
});
it('should resolve all the external re-exports of the source file', () => {
spyOn(host, 'tryResolveEntryPoint')
.and.callFake((from: string, importPath: string) => `RESOLVED/${importPath}`);
const resolved = new Set();
const missing = new Set();
const deepImports = new Set();
host.computeDependencies(_('/external/re-exports.js'), resolved, missing, deepImports);
expect(resolved.size).toBe(2);
expect(resolved.has('RESOLVED/path/to/x')).toBe(true);
expect(resolved.has('RESOLVED/path/to/y')).toBe(true);
const {dependencies, missing, deepImports} =
host.computeDependencies(_('/external/re-exports/index.js'));
expect(dependencies.size).toBe(2);
expect(missing.size).toBe(0);
expect(deepImports.size).toBe(0);
expect(dependencies.has(_('/node_modules/lib-1'))).toBe(true);
expect(dependencies.has(_('/node_modules/lib-1/sub-1'))).toBe(true);
});
it('should capture missing external imports', () => {
spyOn(host, 'tryResolveEntryPoint')
.and.callFake(
(from: string, importPath: string) =>
importPath === 'missing' ? null : `RESOLVED/${importPath}`);
spyOn(host, 'tryResolve').and.callFake(() => null);
const resolved = new Set();
const missing = new Set();
const deepImports = new Set();
host.computeDependencies(_('/external/imports-missing.js'), resolved, missing, deepImports);
expect(resolved.size).toBe(1);
expect(resolved.has('RESOLVED/path/to/x')).toBe(true);
const {dependencies, missing, deepImports} =
host.computeDependencies(_('/external/imports-missing/index.js'));
expect(dependencies.size).toBe(1);
expect(dependencies.has(_('/node_modules/lib-1'))).toBe(true);
expect(missing.size).toBe(1);
expect(missing.has('missing')).toBe(true);
expect(deepImports.size).toBe(0);
@ -81,125 +64,113 @@ describe('DependencyHost', () => {
// This scenario verifies the behavior of the dependency analysis when an external import
// is found that does not map to an entry-point but still exists on disk, i.e. a deep import.
// Such deep imports are captured for diagnostics purposes.
const tryResolveEntryPoint = (from: string, importPath: string) =>
importPath === 'deep/import' ? null : `RESOLVED/${importPath}`;
spyOn(host, 'tryResolveEntryPoint').and.callFake(tryResolveEntryPoint);
spyOn(host, 'tryResolve')
.and.callFake((from: string, importPath: string) => `RESOLVED/${importPath}`);
const resolved = new Set();
const missing = new Set();
const deepImports = new Set();
host.computeDependencies(_('/external/deep-import.js'), resolved, missing, deepImports);
expect(resolved.size).toBe(0);
const {dependencies, missing, deepImports} =
host.computeDependencies(_('/external/deep-import/index.js'));
expect(dependencies.size).toBe(0);
expect(missing.size).toBe(0);
expect(deepImports.size).toBe(1);
expect(deepImports.has('deep/import')).toBe(true);
expect(deepImports.has('/node_modules/lib-1/deep/import')).toBe(true);
});
it('should recurse into internal dependencies', () => {
spyOn(host, 'resolveInternal')
.and.callFake(
(from: string, importPath: string) => path.join('/internal', importPath + '.js'));
spyOn(host, 'tryResolveEntryPoint')
.and.callFake((from: string, importPath: string) => `RESOLVED/${importPath}`);
const getDependenciesSpy = spyOn(host, 'computeDependencies').and.callThrough();
const resolved = new Set();
const missing = new Set();
const deepImports = new Set();
host.computeDependencies(_('/internal/outer.js'), resolved, missing, deepImports);
expect(getDependenciesSpy)
.toHaveBeenCalledWith('/internal/outer.js', resolved, missing, deepImports);
expect(getDependenciesSpy)
.toHaveBeenCalledWith(
'/internal/inner.js', resolved, missing, deepImports, jasmine.any(Set));
expect(resolved.size).toBe(1);
expect(resolved.has('RESOLVED/path/to/y')).toBe(true);
const {dependencies, missing, deepImports} =
host.computeDependencies(_('/internal/outer/index.js'));
expect(dependencies.size).toBe(1);
expect(dependencies.has(_('/node_modules/lib-1/sub-1'))).toBe(true);
expect(missing.size).toBe(0);
expect(deepImports.size).toBe(0);
});
it('should handle circular internal dependencies', () => {
spyOn(host, 'resolveInternal')
.and.callFake(
(from: string, importPath: string) => path.join('/internal', importPath + '.js'));
spyOn(host, 'tryResolveEntryPoint')
.and.callFake((from: string, importPath: string) => `RESOLVED/${importPath}`);
const resolved = new Set();
const missing = new Set();
const deepImports = new Set();
host.computeDependencies(_('/internal/circular-a.js'), resolved, missing, deepImports);
expect(resolved.size).toBe(2);
expect(resolved.has('RESOLVED/path/to/x')).toBe(true);
expect(resolved.has('RESOLVED/path/to/y')).toBe(true);
const {dependencies, missing, deepImports} =
host.computeDependencies(_('/internal/circular-a/index.js'));
expect(dependencies.size).toBe(2);
expect(dependencies.has(_('/node_modules/lib-1'))).toBe(true);
expect(dependencies.has(_('/node_modules/lib-1/sub-1'))).toBe(true);
expect(missing.size).toBe(0);
expect(deepImports.size).toBe(0);
});
it('should support `paths` alias mappings when resolving modules', () => {
host = new DependencyHost(new ModuleResolver({
baseUrl: '/dist',
paths: {
'@app/*': ['*'],
'@lib/*/test': ['lib/*/test'],
}
}));
const {dependencies, missing, deepImports} =
host.computeDependencies(_('/path-alias/index.js'));
expect(dependencies.size).toBe(4);
expect(dependencies.has(_('/dist/components'))).toBe(true);
expect(dependencies.has(_('/dist/shared'))).toBe(true);
expect(dependencies.has(_('/dist/lib/shared/test'))).toBe(true);
expect(dependencies.has(_('/node_modules/lib-1'))).toBe(true);
expect(missing.size).toBe(0);
expect(deepImports.size).toBe(0);
});
function createMockFileSystem() {
mockFs({
'/no/imports/or/re-exports.js': 'some text but no import-like statements',
'/external/imports.js': `import {X} from 'path/to/x';\nimport {Y} from 'path/to/y';`,
'/external/re-exports.js': `export {X} from 'path/to/x';\nexport {Y} from 'path/to/y';`,
'/external/imports-missing.js': `import {X} from 'path/to/x';\nimport {Y} from 'missing';`,
'/external/deep-import.js': `import {Y} from 'deep/import';`,
'/internal/outer.js': `import {X} from './inner';`,
'/internal/inner.js': `import {Y} from 'path/to/y';`,
'/internal/circular-a.js': `import {B} from './circular-b'; import {X} from 'path/to/x';`,
'/internal/circular-b.js': `import {A} from './circular-a'; import {Y} from 'path/to/y';`,
'/no/imports/or/re-exports/index.js': '// some text but no import-like statements',
'/no/imports/or/re-exports/package.json': '{"esm2015": "./index.js"}',
'/no/imports/or/re-exports/index.metadata.json': 'MOCK METADATA',
'/external/imports/index.js': `import {X} from 'lib-1';\nimport {Y} from 'lib-1/sub-1';`,
'/external/imports/package.json': '{"esm2015": "./index.js"}',
'/external/imports/index.metadata.json': 'MOCK METADATA',
'/external/re-exports/index.js': `export {X} from 'lib-1';\nexport {Y} from 'lib-1/sub-1';`,
'/external/re-exports/package.json': '{"esm2015": "./index.js"}',
'/external/re-exports/index.metadata.json': 'MOCK METADATA',
'/external/imports-missing/index.js':
`import {X} from 'lib-1';\nimport {Y} from 'missing';`,
'/external/imports-missing/package.json': '{"esm2015": "./index.js"}',
'/external/imports-missing/index.metadata.json': 'MOCK METADATA',
'/external/deep-import/index.js': `import {Y} from 'lib-1/deep/import';`,
'/external/deep-import/package.json': '{"esm2015": "./index.js"}',
'/external/deep-import/index.metadata.json': 'MOCK METADATA',
'/internal/outer/index.js': `import {X} from '../inner';`,
'/internal/outer/package.json': '{"esm2015": "./index.js"}',
'/internal/outer/index.metadata.json': 'MOCK METADATA',
'/internal/inner/index.js': `import {Y} from 'lib-1/sub-1'; export declare class X {}`,
'/internal/circular-a/index.js':
`import {B} from '../circular-b'; import {X} from '../circular-b'; export {Y} from 'lib-1/sub-1';`,
'/internal/circular-b/index.js':
`import {A} from '../circular-a'; import {Y} from '../circular-a'; export {X} from 'lib-1';`,
'/internal/circular-a/package.json': '{"esm2015": "./index.js"}',
'/internal/circular-a/index.metadata.json': 'MOCK METADATA',
'/re-directed/index.js': `import {Z} from 'lib-1/sub-2';`,
'/re-directed/package.json': '{"esm2015": "./index.js"}',
'/re-directed/index.metadata.json': 'MOCK METADATA',
'/path-alias/index.js':
`import {TestHelper} from '@app/components';\nimport {Service} from '@app/shared';\nimport {TestHelper} from '@lib/shared/test';\nimport {X} from 'lib-1';`,
'/path-alias/package.json': '{"esm2015": "./index.js"}',
'/path-alias/index.metadata.json': 'MOCK METADATA',
'/node_modules/lib-1/index.js': 'export declare class X {}',
'/node_modules/lib-1/package.json': '{"esm2015": "./index.js"}',
'/node_modules/lib-1/index.metadata.json': 'MOCK METADATA',
'/node_modules/lib-1/deep/import/index.js': 'export declare class DeepImport {}',
'/node_modules/lib-1/sub-1/index.js': 'export declare class Y {}',
'/node_modules/lib-1/sub-1/package.json': '{"esm2015": "./index.js"}',
'/node_modules/lib-1/sub-1/index.metadata.json': 'MOCK METADATA',
'/node_modules/lib-1/sub-2.js': `export * from './sub-2/sub-2';`,
'/node_modules/lib-1/sub-2/sub-2.js': `export declare class Z {}';`,
'/node_modules/lib-1/sub-2/package.json': '{"esm2015": "./sub-2.js"}',
'/node_modules/lib-1/sub-2/sub-2.metadata.json': 'MOCK METADATA',
'/dist/components/index.js': `class MyComponent {};`,
'/dist/components/package.json': '{"esm2015": "./index.js"}',
'/dist/components/index.metadata.json': 'MOCK METADATA',
'/dist/shared/index.js': `import {X} from 'lib-1';\nexport class Service {}`,
'/dist/shared/package.json': '{"esm2015": "./index.js"}',
'/dist/shared/index.metadata.json': 'MOCK METADATA',
'/dist/lib/shared/test/index.js': `export class TestHelper {}`,
'/dist/lib/shared/test/package.json': '{"esm2015": "./index.js"}',
'/dist/lib/shared/test/index.metadata.json': 'MOCK METADATA',
});
}
});
describe('resolveInternal', () => {
it('should resolve the dependency via `Module._resolveFilename`', () => {
spyOn(Module, '_resolveFilename').and.returnValue('/RESOLVED_PATH');
const result = host.resolveInternal(
_('/SOURCE/PATH/FILE'), PathSegment.fromFsPath('../TARGET/PATH/FILE'));
expect(result).toEqual('/RESOLVED_PATH');
});
it('should first resolve the `to` on top of the `from` directory', () => {
const resolveSpy = spyOn(Module, '_resolveFilename').and.returnValue('/RESOLVED_PATH');
host.resolveInternal(_('/SOURCE/PATH/FILE'), PathSegment.fromFsPath('../TARGET/PATH/FILE'));
expect(resolveSpy)
.toHaveBeenCalledWith('/SOURCE/TARGET/PATH/FILE', jasmine.any(Object), false, undefined);
});
});
describe('tryResolveExternal', () => {
it('should call `tryResolve`, appending `package.json` to the target path', () => {
const tryResolveSpy = spyOn(host, 'tryResolve').and.returnValue('/PATH/TO/RESOLVED');
host.tryResolveEntryPoint(_('/SOURCE_PATH'), PathSegment.fromFsPath('TARGET_PATH'));
expect(tryResolveSpy).toHaveBeenCalledWith('/SOURCE_PATH', 'TARGET_PATH/package.json');
});
it('should return the directory containing the result from `tryResolve', () => {
spyOn(host, 'tryResolve').and.returnValue('/PATH/TO/RESOLVED');
expect(host.tryResolveEntryPoint(_('/SOURCE_PATH'), PathSegment.fromFsPath('TARGET_PATH')))
.toEqual(_('/PATH/TO'));
});
it('should return null if `tryResolve` returns null', () => {
spyOn(host, 'tryResolve').and.returnValue(null);
expect(host.tryResolveEntryPoint(_('/SOURCE_PATH'), PathSegment.fromFsPath('TARGET_PATH')))
.toEqual(null);
});
});
describe('tryResolve()', () => {
it('should resolve the dependency via `Module._resolveFilename`, passing the `from` path to the `paths` option',
() => {
const resolveSpy = spyOn(Module, '_resolveFilename').and.returnValue('/RESOLVED_PATH');
const result = host.tryResolve(_('/SOURCE_PATH'), PathSegment.fromFsPath('TARGET_PATH'));
expect(resolveSpy).toHaveBeenCalledWith('TARGET_PATH', jasmine.any(Object), false, {
paths: ['/SOURCE_PATH']
});
expect(result).toEqual(_('/RESOLVED_PATH'));
});
it('should return null if `Module._resolveFilename` throws an error', () => {
const resolveSpy =
spyOn(Module, '_resolveFilename').and.throwError(`Cannot find module 'TARGET_PATH'`);
const result = host.tryResolve(_('/SOURCE_PATH'), PathSegment.fromFsPath('TARGET_PATH'));
expect(result).toBe(null);
});
function restoreRealFileSystem() { mockFs.restore(); }
});
describe('isStringImportOrReexport', () => {
@ -257,6 +228,4 @@ describe('DependencyHost', () => {
.toBe(false);
});
});
function restoreRealFileSystem() { mockFs.restore(); }
});
});

View File

@ -9,6 +9,7 @@ import {AbsoluteFsPath} from '../../../src/ngtsc/path';
import {DependencyHost} from '../../src/packages/dependency_host';
import {DependencyResolver, SortedEntryPointsInfo} from '../../src/packages/dependency_resolver';
import {EntryPoint} from '../../src/packages/entry_point';
import {ModuleResolver} from '../../src/packages/module_resolver';
import {MockLogger} from '../helpers/mock_logger';
const _ = AbsoluteFsPath.from;
@ -17,7 +18,7 @@ describe('DependencyResolver', () => {
let host: DependencyHost;
let resolver: DependencyResolver;
beforeEach(() => {
host = new DependencyHost();
host = new DependencyHost(new ModuleResolver());
resolver = new DependencyResolver(new MockLogger(), host);
});
describe('sortEntryPointsByDependency()', () => {

View File

@ -13,6 +13,7 @@ import {DependencyHost} from '../../src/packages/dependency_host';
import {DependencyResolver} from '../../src/packages/dependency_resolver';
import {EntryPoint} from '../../src/packages/entry_point';
import {EntryPointFinder} from '../../src/packages/entry_point_finder';
import {ModuleResolver} from '../../src/packages/module_resolver';
import {MockLogger} from '../helpers/mock_logger';
const _ = AbsoluteFsPath.from;
@ -21,7 +22,7 @@ describe('findEntryPoints()', () => {
let resolver: DependencyResolver;
let finder: EntryPointFinder;
beforeEach(() => {
resolver = new DependencyResolver(new MockLogger(), new DependencyHost());
resolver = new DependencyResolver(new MockLogger(), new DependencyHost(new ModuleResolver()));
spyOn(resolver, 'sortEntryPointsByDependency').and.callFake((entryPoints: EntryPoint[]) => {
return {entryPoints, ignoredEntryPoints: [], ignoredDependencies: []};
});

View File

@ -0,0 +1,212 @@
/**
* @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 mockFs from 'mock-fs';
import {AbsoluteFsPath} from '../../../src/ngtsc/path';
import {ModuleResolver, ResolvedDeepImport, ResolvedExternalModule, ResolvedRelativeModule} from '../../src/packages/module_resolver';
const _ = AbsoluteFsPath.from;
function createMockFileSystem() {
mockFs({
'/libs': {
'local-package': {
'package.json': 'PACKAGE.JSON for local-package',
'index.js': `import {X} from './x';`,
'x.js': `export class X {}`,
'sub-folder': {
'index.js': `import {X} from '../x';`,
},
'node_modules': {
'package-1': {
'sub-folder': {'index.js': `export class Z {}`},
'package.json': 'PACKAGE.JSON for package-1',
},
},
},
'node_modules': {
'package-2': {
'package.json': 'PACKAGE.JSON for package-2',
'node_modules': {
'package-3': {
'package.json': 'PACKAGE.JSON for package-3',
},
},
},
},
},
'/dist': {
'package-4': {
'x.js': `export class X {}`,
'package.json': 'PACKAGE.JSON for package-4',
'sub-folder': {'index.js': `import {X} from '@shared/package-4/x';`},
},
'sub-folder': {
'package-4': {
'package.json': 'PACKAGE.JSON for package-4',
},
'package-5': {
'package.json': 'PACKAGE.JSON for package-5',
'post-fix': {
'package.json': 'PACKAGE.JSON for package-5/post-fix',
}
},
}
},
'/node_modules': {
'top-package': {
'package.json': 'PACKAGE.JSON for top-package',
}
}
});
}
function restoreRealFileSystem() {
mockFs.restore();
}
describe('ModuleResolver', () => {
beforeEach(createMockFileSystem);
afterEach(restoreRealFileSystem);
describe('resolveModule()', () => {
describe('with relative paths', () => {
it('should resolve sibling, child and aunt modules', () => {
const resolver = new ModuleResolver();
expect(resolver.resolveModuleImport('./x', _('/libs/local-package/index.js')))
.toEqual(new ResolvedRelativeModule(_('/libs/local-package/x.js')));
expect(resolver.resolveModuleImport('./sub-folder', _('/libs/local-package/index.js')))
.toEqual(new ResolvedRelativeModule(_('/libs/local-package/sub-folder/index.js')));
expect(resolver.resolveModuleImport('../x', _('/libs/local-package/sub-folder/index.js')))
.toEqual(new ResolvedRelativeModule(_('/libs/local-package/x.js')));
});
it('should return `null` if the resolved module relative module does not exist', () => {
const resolver = new ModuleResolver();
expect(resolver.resolveModuleImport('./y', _('/libs/local-package/index.js'))).toBe(null);
});
});
describe('with non-mapped external paths', () => {
it('should resolve to the package.json of a local node_modules package', () => {
const resolver = new ModuleResolver();
expect(resolver.resolveModuleImport('package-1', _('/libs/local-package/index.js')))
.toEqual(new ResolvedExternalModule(_('/libs/local-package/node_modules/package-1')));
expect(
resolver.resolveModuleImport('package-1', _('/libs/local-package/sub-folder/index.js')))
.toEqual(new ResolvedExternalModule(_('/libs/local-package/node_modules/package-1')));
expect(resolver.resolveModuleImport('package-1', _('/libs/local-package/x.js')))
.toEqual(new ResolvedExternalModule(_('/libs/local-package/node_modules/package-1')));
});
it('should resolve to the package.json of a higher node_modules package', () => {
const resolver = new ModuleResolver();
expect(resolver.resolveModuleImport('package-2', _('/libs/local-package/index.js')))
.toEqual(new ResolvedExternalModule(_('/libs/node_modules/package-2')));
expect(resolver.resolveModuleImport('top-package', _('/libs/local-package/index.js')))
.toEqual(new ResolvedExternalModule(_('/node_modules/top-package')));
});
it('should return `null` if the package cannot be found', () => {
const resolver = new ModuleResolver();
expect(resolver.resolveModuleImport('missing-2', _('/libs/local-package/index.js')))
.toBe(null);
});
it('should return `null` if the package is not accessible because it is in a inner node_modules package',
() => {
const resolver = new ModuleResolver();
expect(resolver.resolveModuleImport('package-3', _('/libs/local-package/index.js')))
.toBe(null);
});
it('should identify deep imports into an external module', () => {
const resolver = new ModuleResolver();
expect(
resolver.resolveModuleImport('package-1/sub-folder', _('/libs/local-package/index.js')))
.toEqual(
new ResolvedDeepImport(_('/libs/local-package/node_modules/package-1/sub-folder')));
});
});
describe('with mapped path external modules', () => {
it('should resolve to the package.json of simple mapped packages', () => {
const resolver =
new ModuleResolver({baseUrl: '/dist', paths: {'*': ['*', 'sub-folder/*']}});
expect(resolver.resolveModuleImport('package-4', _('/libs/local-package/index.js')))
.toEqual(new ResolvedExternalModule(_('/dist/package-4')));
expect(resolver.resolveModuleImport('package-5', _('/libs/local-package/index.js')))
.toEqual(new ResolvedExternalModule(_('/dist/sub-folder/package-5')));
});
it('should select the best match by the length of prefix before the *', () => {
const resolver = new ModuleResolver({
baseUrl: '/dist',
paths: {
'@lib/*': ['*'],
'@lib/sub-folder/*': ['*'],
}
});
// We should match the second path (e.g. `'@lib/sub-folder/*'`), which will actually map to
// `*` and so the final resolved path will not include the `sub-folder` segment.
expect(resolver.resolveModuleImport(
'@lib/sub-folder/package-4', _('/libs/local-package/index.js')))
.toEqual(new ResolvedExternalModule(_('/dist/package-4')));
});
it('should follow the ordering of `paths` when matching mapped packages', () => {
let resolver: ModuleResolver;
resolver = new ModuleResolver({baseUrl: '/dist', paths: {'*': ['*', 'sub-folder/*']}});
expect(resolver.resolveModuleImport('package-4', _('/libs/local-package/index.js')))
.toEqual(new ResolvedExternalModule(_('/dist/package-4')));
resolver = new ModuleResolver({baseUrl: '/dist', paths: {'*': ['sub-folder/*', '*']}});
expect(resolver.resolveModuleImport('package-4', _('/libs/local-package/index.js')))
.toEqual(new ResolvedExternalModule(_('/dist/sub-folder/package-4')));
});
it('should resolve packages when the path mappings have post-fixes', () => {
const resolver =
new ModuleResolver({baseUrl: '/dist', paths: {'*': ['sub-folder/*/post-fix']}});
expect(resolver.resolveModuleImport('package-5', _('/libs/local-package/index.js')))
.toEqual(new ResolvedExternalModule(_('/dist/sub-folder/package-5/post-fix')));
});
it('should match paths against complex path matchers', () => {
const resolver =
new ModuleResolver({baseUrl: '/dist', paths: {'@shared/*': ['sub-folder/*']}});
expect(resolver.resolveModuleImport('@shared/package-4', _('/libs/local-package/index.js')))
.toEqual(new ResolvedExternalModule(_('/dist/sub-folder/package-4')));
expect(resolver.resolveModuleImport('package-5', _('/libs/local-package/index.js')))
.toBe(null);
});
it('should resolve path as "relative" if the mapped path is inside the current package',
() => {
const resolver = new ModuleResolver({baseUrl: '/dist', paths: {'@shared/*': ['*']}});
expect(resolver.resolveModuleImport(
'@shared/package-4/x', _('/dist/package-4/sub-folder/index.js')))
.toEqual(new ResolvedRelativeModule(_('/dist/package-4/x.js')));
});
it('should resolve paths where the wildcard matches more than one path segment', () => {
const resolver =
new ModuleResolver({baseUrl: '/dist', paths: {'@shared/*/post-fix': ['*/post-fix']}});
expect(
resolver.resolveModuleImport(
'@shared/sub-folder/package-5/post-fix', _('/dist/package-4/sub-folder/index.js')))
.toEqual(new ResolvedExternalModule(_('/dist/sub-folder/package-5/post-fix')));
});
});
});
});

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 {isRelativePath} from '../src/utils';
describe('isRelativePath()', () => {
it('should return true for relative paths', () => {
expect(isRelativePath('.')).toBe(true);
expect(isRelativePath('..')).toBe(true);
expect(isRelativePath('./')).toBe(true);
expect(isRelativePath('../')).toBe(true);
expect(isRelativePath('./abc/xyz')).toBe(true);
expect(isRelativePath('../abc/xyz')).toBe(true);
});
it('should return true for absolute paths', () => {
expect(isRelativePath('/')).toBe(true);
expect(isRelativePath('/abc/xyz')).toBe(true);
});
it('should return false for other paths', () => {
expect(isRelativePath('abc')).toBe(false);
expect(isRelativePath('abc/xyz')).toBe(false);
expect(isRelativePath('.abc')).toBe(false);
expect(isRelativePath('..abc')).toBe(false);
expect(isRelativePath('@abc')).toBe(false);
expect(isRelativePath('.abc/xyz')).toBe(false);
expect(isRelativePath('..abc/xyz')).toBe(false);
expect(isRelativePath('@abc/xyz')).toBe(false);
});
});