fix(ngcc): correctly detect dependencies in CommonJS (#34528)

Previously, `CommonJsDependencyHost.collectDependencies()` would only
find dependencies via imports of the form `var foo = require('...');` or
`var foo = require('...'), bar = require('...');` However, CommonJS
files can have imports in many different forms. By failing to recognize
other forms of imports, the associated dependencies were missed, which
in turn resulted in entry-points being compiled out-of-order and failing
due to that.

While we cannot easily capture all different types of imports, this
commit enhances `CommonJsDependencyHost` to recognize the following
common forms of imports:

- Imports in property assignments. E.g.:
  `exports.foo = require('...');` or
  `module.exports = {foo: require('...')};`

- Imports for side-effects only. E.g.:
  `require('...');`

- Star re-exports (with both emitted and imported heleprs). E.g.:
  `__export(require('...'));` or
  `tslib_1.__exportStar(require('...'), exports);`

PR Close #34528
This commit is contained in:
George Kalpakas 2019-12-05 21:02:57 +02:00 committed by atscott
parent eb6e1af46d
commit cfbb1a1e77
2 changed files with 296 additions and 35 deletions

View File

@ -7,7 +7,7 @@
*/ */
import * as ts from 'typescript'; import * as ts from 'typescript';
import {AbsoluteFsPath} from '../../../src/ngtsc/file_system'; import {AbsoluteFsPath} from '../../../src/ngtsc/file_system';
import {isRequireCall} from '../host/commonjs_umd_utils'; import {RequireCall, isReexportStatement, isRequireCall} from '../host/commonjs_umd_utils';
import {DependencyHostBase} from './dependency_host'; import {DependencyHostBase} from './dependency_host';
import {ResolvedDeepImport, ResolvedRelativeModule} from './module_resolver'; import {ResolvedDeepImport, ResolvedRelativeModule} from './module_resolver';
@ -40,33 +40,72 @@ export class CommonJsDependencyHost extends DependencyHostBase {
// Parse the source into a TypeScript AST and then walk it looking for imports and re-exports. // Parse the source into a TypeScript AST and then walk it looking for imports and re-exports.
const sf = const sf =
ts.createSourceFile(file, fromContents, ts.ScriptTarget.ES2015, false, ts.ScriptKind.JS); ts.createSourceFile(file, fromContents, ts.ScriptTarget.ES2015, false, ts.ScriptKind.JS);
const requireCalls: RequireCall[] = [];
for (const statement of sf.statements) { for (const stmt of sf.statements) {
const declarations = if (ts.isVariableStatement(stmt)) {
ts.isVariableStatement(statement) ? statement.declarationList.declarations : []; // Regular import(s):
for (const declaration of declarations) { // `var foo = require('...')` or `var foo = require('...'), bar = require('...')`
if (declaration.initializer && isRequireCall(declaration.initializer)) { const declarations = stmt.declarationList.declarations;
const importPath = declaration.initializer.arguments[0].text; for (const declaration of declarations) {
const resolvedModule = this.moduleResolver.resolveModuleImport(importPath, file); if ((declaration.initializer !== undefined) && isRequireCall(declaration.initializer)) {
if (resolvedModule) { requireCalls.push(declaration.initializer);
if (resolvedModule instanceof ResolvedRelativeModule) {
const internalDependency = resolvedModule.modulePath;
if (!alreadySeen.has(internalDependency)) {
alreadySeen.add(internalDependency);
this.recursivelyCollectDependencies(
internalDependency, dependencies, missing, deepImports, alreadySeen);
}
} else {
if (resolvedModule instanceof ResolvedDeepImport) {
deepImports.add(resolvedModule.importPath);
} else {
dependencies.add(resolvedModule.entryPointPath);
}
}
} else {
missing.add(importPath);
} }
} }
} else if (ts.isExpressionStatement(stmt)) {
if (isRequireCall(stmt.expression)) {
// Import for the side-effects only:
// `require('...')`
requireCalls.push(stmt.expression);
} else if (isReexportStatement(stmt)) {
// Re-export in one of the following formats:
// - `__export(require('...'))`
// - `__export(<identifier>)`
// - `tslib_1.__exportStar(require('...'), exports)`
// - `tslib_1.__exportStar(<identifier>, exports)`
const firstExportArg = stmt.expression.arguments[0];
if (isRequireCall(firstExportArg)) {
// Re-export with `require()` call:
// `__export(require('...'))` or `tslib_1.__exportStar(require('...'), exports)`
requireCalls.push(firstExportArg);
}
} else if (
ts.isBinaryExpression(stmt.expression) &&
(stmt.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken)) {
if (isRequireCall(stmt.expression.right)) {
// Import with assignment. E.g.:
// `exports.foo = require('...')`
requireCalls.push(stmt.expression.right);
} else if (ts.isObjectLiteralExpression(stmt.expression.right)) {
// Import in object literal. E.g.:
// `module.exports = {foo: require('...')}`
stmt.expression.right.properties.forEach(prop => {
if (ts.isPropertyAssignment(prop) && isRequireCall(prop.initializer)) {
requireCalls.push(prop.initializer);
}
});
}
}
}
}
const importPaths = new Set(requireCalls.map(call => call.arguments[0].text));
for (const importPath of importPaths) {
const resolvedModule = this.moduleResolver.resolveModuleImport(importPath, file);
if (resolvedModule === null) {
missing.add(importPath);
} else if (resolvedModule instanceof ResolvedRelativeModule) {
const internalDependency = resolvedModule.modulePath;
if (!alreadySeen.has(internalDependency)) {
alreadySeen.add(internalDependency);
this.recursivelyCollectDependencies(
internalDependency, dependencies, missing, deepImports, alreadySeen);
}
} else if (resolvedModule instanceof ResolvedDeepImport) {
deepImports.add(resolvedModule.importPath);
} else {
dependencies.add(resolvedModule.entryPointPath);
} }
} }
} }

View File

@ -143,6 +143,142 @@ runInEachFileSystem(() => {
expect(dependencies.has(_('/node_modules/lib_1/sub_1'))).toBe(true); expect(dependencies.has(_('/node_modules/lib_1/sub_1'))).toBe(true);
}); });
it('should recognize imports in a variable declaration list', () => {
loadTestFiles([
{
name: _('/test/index.js'),
contents: commonJs({
varDeclarations: [
['lib_1/sub_1', 'lib_1/sub_2'],
],
}),
},
{name: _('/test/package.json'), contents: '{"main": "./index.js"}'},
{name: _('/test/index.metadata.json'), contents: 'MOCK METADATA'},
]);
const {dependencies, missing, deepImports} = createDependencyInfo();
host.collectDependencies(_('/test/index.js'), {dependencies, missing, deepImports});
expect(dependencies.size).toBe(2);
expect(missing.size).toBe(0);
expect(deepImports.size).toBe(0);
expect(dependencies.has(_('/node_modules/lib_1/sub_1'))).toBe(true);
expect(dependencies.has(_('/node_modules/lib_1/sub_2'))).toBe(true);
});
it('should recognize imports as property assignments (on existing object)', () => {
loadTestFiles([
{
name: _('/test/index.js'),
contents: commonJs({
propAssignment: ['lib_1/sub_1', 'lib_1/sub_2'],
}),
},
{name: _('/test/package.json'), contents: '{"main": "./index.js"}'},
{name: _('/test/index.metadata.json'), contents: 'MOCK METADATA'},
]);
const {dependencies, missing, deepImports} = createDependencyInfo();
host.collectDependencies(_('/test/index.js'), {dependencies, missing, deepImports});
expect(dependencies.size).toBe(2);
expect(missing.size).toBe(0);
expect(deepImports.size).toBe(0);
expect(dependencies.has(_('/node_modules/lib_1/sub_1'))).toBe(true);
expect(dependencies.has(_('/node_modules/lib_1/sub_2'))).toBe(true);
});
it('should recognize imports as property assignments (in object literal)', () => {
loadTestFiles([
{
name: _('/test/index.js'),
contents: commonJs({
inObjectLiteral: ['lib_1/sub_1', 'lib_1/sub_2'],
}),
},
{name: _('/test/package.json'), contents: '{"main": "./index.js"}'},
{name: _('/test/index.metadata.json'), contents: 'MOCK METADATA'},
]);
const {dependencies, missing, deepImports} = createDependencyInfo();
host.collectDependencies(_('/test/index.js'), {dependencies, missing, deepImports});
expect(dependencies.size).toBe(2);
expect(missing.size).toBe(0);
expect(deepImports.size).toBe(0);
expect(dependencies.has(_('/node_modules/lib_1/sub_1'))).toBe(true);
expect(dependencies.has(_('/node_modules/lib_1/sub_2'))).toBe(true);
});
it('should recognize imports used for their side-effects only', () => {
loadTestFiles([
{
name: _('/test/index.js'),
contents: commonJs({
forSideEffects: ['lib_1/sub_1', 'lib_1/sub_2'],
}),
},
{name: _('/test/package.json'), contents: '{"main": "./index.js"}'},
{name: _('/test/index.metadata.json'), contents: 'MOCK METADATA'},
]);
const {dependencies, missing, deepImports} = createDependencyInfo();
host.collectDependencies(_('/test/index.js'), {dependencies, missing, deepImports});
expect(dependencies.size).toBe(2);
expect(missing.size).toBe(0);
expect(deepImports.size).toBe(0);
expect(dependencies.has(_('/node_modules/lib_1/sub_1'))).toBe(true);
expect(dependencies.has(_('/node_modules/lib_1/sub_2'))).toBe(true);
});
it('should recognize star re-exports (with both emitted and imported helpers)', () => {
loadTestFiles([
{
name: _('/test/index.js'),
contents: commonJs({
reExportsWithEmittedHelper: ['lib_1', 'lib_1/sub_1'],
reExportsWithImportedHelper: ['lib_1', 'lib_1/sub_2'],
}),
},
{name: _('/test/package.json'), contents: '{"main": "./index.js"}'},
{name: _('/test/index.metadata.json'), contents: 'MOCK METADATA'},
]);
const {dependencies, missing, deepImports} = createDependencyInfo();
host.collectDependencies(_('/test/index.js'), {dependencies, missing, deepImports});
expect(dependencies.size).toBe(3);
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);
expect(dependencies.has(_('/node_modules/lib_1/sub_2'))).toBe(true);
});
it('should not get confused by re-exports with a separate `require()` call', () => {
loadTestFiles([
{
name: _('/test/index.js'),
contents: commonJs({
reExportsWithoutRequire: ['lib_1', 'lib_1/sub_2'],
}),
},
{name: _('/test/package.json'), contents: '{"main": "./index.js"}'},
{name: _('/test/index.metadata.json'), contents: 'MOCK METADATA'},
]);
const {dependencies, missing, deepImports} = createDependencyInfo();
host.collectDependencies(_('/test/index.js'), {dependencies, missing, deepImports});
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_2'))).toBe(true);
});
it('should capture missing external imports', () => { it('should capture missing external imports', () => {
const {dependencies, missing, deepImports} = createDependencyInfo(); const {dependencies, missing, deepImports} = createDependencyInfo();
host.collectDependencies( host.collectDependencies(
@ -224,16 +360,102 @@ runInEachFileSystem(() => {
}); });
}); });
function commonJs(importPaths: string[], exportNames: string[] = []) { interface ImportsPerType {
const commonJsRequires = // var foo = require('...');
importPaths varDeclaration?: string[];
.map(
p => // var foo = require('...'), bar = require('...');
`var ${p.replace('@angular/', '').replace(/\.?\.?\//g, '').replace(/@/,'')} = require('${p}');`) varDeclarations?: string[][];
.join('\n');
// exports.foo = require('...');
propAssignment?: string[];
// module.exports = {foo: require('...')};
inObjectLiteral?: string[];
// require('...');
forSideEffects?: string[];
// __export(require('...'));
reExportsWithEmittedHelper?: string[];
// tslib_1.__exportStar(require('...'), exports);
reExportsWithImportedHelper?: string[];
// var foo = require('...');
// __export(foo);
reExportsWithoutRequire?: string[];
}
function commonJs(importsPerType: ImportsPerType | string[], exportNames: string[] = []): string {
if (Array.isArray(importsPerType)) {
importsPerType = {varDeclaration: importsPerType};
}
const importStatements = generateImportStatements(importsPerType);
const exportStatements = const exportStatements =
exportNames.map(e => ` exports.${e.replace(/.+\./, '')} = ${e};`).join('\n'); exportNames.map(e => `exports.${e.replace(/.+\./, '')} = ${e};`).join('\n');
return `${commonJsRequires}
${exportStatements}`; return `${importStatements}\n\n${exportStatements}`;
}
function generateImportStatements(importsPerType: ImportsPerType): string {
const importStatements: string[] = [];
const {
varDeclaration: importsOfTypeVarDeclaration = [],
varDeclarations: importsOfTypeVarDeclarations = [],
propAssignment: importsOfTypePropAssignment = [],
inObjectLiteral: importsOfTypeInObjectLiteral = [],
forSideEffects: importsOfTypeForSideEffects = [],
reExportsWithEmittedHelper: importsOfTypeReExportsWithEmittedHelper = [],
reExportsWithImportedHelper: importsOfTypeReExportsWithImportedHelper = [],
reExportsWithoutRequire: importsOfTypeReExportsWithoutRequire = [],
} = importsPerType;
// var foo = require('...');
importsOfTypeVarDeclaration.forEach(
p => { importStatements.push(`var ${pathToVarName(p)} = require('${p}');`); });
// var foo = require('...'), bar = require('...');
importsOfTypeVarDeclarations.forEach(pp => {
const declarations = pp.map(p => `${pathToVarName(p)} = require('${p}')`);
importStatements.push(`var ${declarations.join(', ')};`);
});
// exports.foo = require('...');
importsOfTypePropAssignment.forEach(
p => { importStatements.push(`exports.${pathToVarName(p)} = require('${p}');`); });
// module.exports = {foo: require('...')};
const propAssignments =
importsOfTypeInObjectLiteral.map(p => `\n ${pathToVarName(p)}: require('${p}')`)
.join(', ');
importStatements.push(`module.exports = {${propAssignments}\n};`);
// require('...');
importsOfTypeForSideEffects.forEach(p => { importStatements.push(`require('${p}');`); });
// __export(require('...'));
importsOfTypeReExportsWithEmittedHelper.forEach(
p => { importStatements.push(`__export(require('${p}'));`); });
// tslib_1.__exportStar(require('...'), exports);
importsOfTypeReExportsWithImportedHelper.forEach(
p => { importStatements.push(`tslib_1.__exportStar(require('${p}'), exports);`); });
// var foo = require('...');
// __export(foo);
importsOfTypeReExportsWithoutRequire.forEach(p => {
const varName = pathToVarName(p);
importStatements.push(`var ${varName} = require('${p}');`);
importStatements.push(`__export(varName);`);
});
return importStatements.join('\n');
}
function pathToVarName(path: string): string {
return path.replace(/^@(angular\/)?/, '').replace(/\.{0,2}\//g, '');
} }
}); });