diff --git a/packages/core/BUILD.bazel b/packages/core/BUILD.bazel index 1fcc01021e..a722331a72 100644 --- a/packages/core/BUILD.bazel +++ b/packages/core/BUILD.bazel @@ -29,6 +29,9 @@ ng_package( "//packages/core/testing:package.json", ], entry_point = "packages/core/index.js", + packages = [ + "//packages/core/schematics:npm_package", + ], tags = [ "release-with-framework", ], diff --git a/packages/core/package.json b/packages/core/package.json index cf624f468d..fea6ca27ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,6 +24,7 @@ "url": "https://github.com/angular/angular.git" }, "ng-update": { + "migrations":"./schematics/migrations.json", "packageGroup": "NG_UPDATE_PACKAGE_GROUP" }, "sideEffects": false diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel new file mode 100644 index 0000000000..9bb25621fa --- /dev/null +++ b/packages/core/schematics/BUILD.bazel @@ -0,0 +1,13 @@ +load("//tools:defaults.bzl", "npm_package") + +exports_files([ + "tsconfig.json", + "migrations.json", +]) + +npm_package( + name = "npm_package", + srcs = ["migrations.json"], + visibility = ["//packages/core:__pkg__"], + deps = ["//packages/core/schematics/migrations/static-queries"], +) diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json new file mode 100644 index 0000000000..8293d9f2e8 --- /dev/null +++ b/packages/core/schematics/migrations.json @@ -0,0 +1,9 @@ +{ + "schematics": { + "migration-v8-static-queries": { + "version": "8", + "description": "Migrates ViewChild and ContentChild to explicit query timing", + "factory": "./migrations/static-queries/index" + } + } +} diff --git a/packages/core/schematics/migrations/static-queries/BUILD.bazel b/packages/core/schematics/migrations/static-queries/BUILD.bazel new file mode 100644 index 0000000000..7c2f0e9c63 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/BUILD.bazel @@ -0,0 +1,20 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "static-queries", + srcs = glob( + ["**/*.ts"], + exclude = ["index_spec.ts"], + ), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], + deps = [ + "//packages/core/schematics/utils", + "@npm//@angular-devkit/schematics", + "@npm//@types/node", + "@npm//typescript", + ], +) diff --git a/packages/core/schematics/migrations/static-queries/angular/analyze_query_usage.ts b/packages/core/schematics/migrations/static-queries/angular/analyze_query_usage.ts new file mode 100644 index 0000000000..a6debd1c53 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/analyze_query_usage.ts @@ -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 {hasPropertyNameText} from '../typescript/property_name'; +import {DeclarationUsageVisitor} from './declaration_usage_visitor'; +import {ClassMetadataMap} from './ng_query_visitor'; +import {NgQueryDefinition, QueryTiming, QueryType} from './query-definition'; + + +/** + * Object that maps a given type of query to a list of lifecycle hooks that + * could be used to access such a query statically. + */ +const STATIC_QUERY_LIFECYCLE_HOOKS = { + [QueryType.ViewChild]: ['ngOnInit', 'ngAfterContentInit', 'ngAfterContentChecked'], + [QueryType.ContentChild]: ['ngOnInit'], +}; + +/** + * Analyzes the usage of the given query and determines the query timing based + * on the current usage of the query. + */ +export function analyzeNgQueryUsage( + query: NgQueryDefinition, classMetadata: ClassMetadataMap, + typeChecker: ts.TypeChecker): QueryTiming { + return isQueryUsedStatically(query.container, query, classMetadata, typeChecker, []) ? + QueryTiming.STATIC : + QueryTiming.DYNAMIC; +} + +/** Checks whether a given class or it's derived classes use the specified query statically. */ +function isQueryUsedStatically( + classDecl: ts.ClassDeclaration, query: NgQueryDefinition, classMetadataMap: ClassMetadataMap, + typeChecker: ts.TypeChecker, knownInputNames: string[]): boolean { + const usageVisitor = new DeclarationUsageVisitor(query.property, typeChecker); + const classMetadata = classMetadataMap.get(classDecl); + + // In case there is metadata for the current class, we collect all resolved Angular input + // names and add them to the list of known inputs that need to be checked for usages of + // the current query. e.g. queries used in an @Input() *setter* are always static. + if (classMetadata) { + knownInputNames.push(...classMetadata.ngInputNames); + } + + // List of TypeScript nodes which can contain usages of the given query in order to + // access it statically. e.g. + // (1) queries used in the "ngOnInit" lifecycle hook are static. + // (2) inputs with setters can access queries statically. + const possibleStaticQueryNodes: ts.Node[] = classDecl.members.filter(m => { + if (ts.isMethodDeclaration(m) && hasPropertyNameText(m.name) && + STATIC_QUERY_LIFECYCLE_HOOKS[query.type].indexOf(m.name.text) !== -1) { + return true; + } else if ( + knownInputNames && ts.isSetAccessor(m) && hasPropertyNameText(m.name) && + knownInputNames.indexOf(m.name.text) !== -1) { + return true; + } + return false; + }); + + // In case nodes that can possibly access a query statically have been found, check + // if the query declaration is used within any of these nodes. + if (possibleStaticQueryNodes.length && + possibleStaticQueryNodes.some(hookNode => usageVisitor.isUsedInNode(hookNode))) { + return true; + } + + // In case there are classes that derive from the current class, visit each + // derived class as inherited queries could be used statically. + if (classMetadata) { + return classMetadata.derivedClasses.some( + derivedClass => isQueryUsedStatically( + derivedClass, query, classMetadataMap, typeChecker, knownInputNames)); + } + + return false; +} diff --git a/packages/core/schematics/migrations/static-queries/angular/declaration_usage_visitor.ts b/packages/core/schematics/migrations/static-queries/angular/declaration_usage_visitor.ts new file mode 100644 index 0000000000..db6a7c7ac5 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/declaration_usage_visitor.ts @@ -0,0 +1,88 @@ +/** + * @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'; + +/** + * Class that can be used to determine if a given TypeScript node is used within + * other given TypeScript nodes. This is achieved by walking through all children + * of the given node and checking for usages of the given declaration. The visitor + * also handles potential control flow changes caused by call/new expressions. + */ +export class DeclarationUsageVisitor { + /** Set of visited symbols that caused a jump in control flow. */ + private visitedJumpExprSymbols = new Set(); + + constructor(private declaration: ts.Node, private typeChecker: ts.TypeChecker) {} + + private isReferringToSymbol(node: ts.Node): boolean { + const symbol = this.typeChecker.getSymbolAtLocation(node); + return !!symbol && symbol.valueDeclaration === this.declaration; + } + + private addJumpExpressionToQueue(node: ts.Expression, nodeQueue: ts.Node[]) { + const callExprSymbol = this.typeChecker.getSymbolAtLocation(node); + + // Note that we should not add previously visited symbols to the queue as this + // could cause cycles. + if (callExprSymbol && callExprSymbol.valueDeclaration && + !this.visitedJumpExprSymbols.has(callExprSymbol)) { + this.visitedJumpExprSymbols.add(callExprSymbol); + nodeQueue.push(callExprSymbol.valueDeclaration); + } + } + + private addNewExpressionToQueue(node: ts.NewExpression, nodeQueue: ts.Node[]) { + const newExprSymbol = this.typeChecker.getSymbolAtLocation(node.expression); + + // Only handle new expressions which resolve to classes. Technically "new" could + // also call void functions or objects with a constructor signature. Also note that + // we should not visit already visited symbols as this could cause cycles. + if (!newExprSymbol || !newExprSymbol.valueDeclaration || + !ts.isClassDeclaration(newExprSymbol.valueDeclaration) || + this.visitedJumpExprSymbols.has(newExprSymbol)) { + return; + } + + const targetConstructor = + newExprSymbol.valueDeclaration.members.find(d => ts.isConstructorDeclaration(d)); + + if (targetConstructor) { + this.visitedJumpExprSymbols.add(newExprSymbol); + nodeQueue.push(targetConstructor); + } + } + + isUsedInNode(searchNode: ts.Node): boolean { + const nodeQueue: ts.Node[] = [searchNode]; + this.visitedJumpExprSymbols.clear(); + + while (nodeQueue.length) { + const node = nodeQueue.shift() !; + + if (ts.isIdentifier(node) && this.isReferringToSymbol(node)) { + return true; + } + + // Handle call expressions within TypeScript nodes that cause a jump in control + // flow. We resolve the call expression value declaration and add it to the node queue. + if (ts.isCallExpression(node)) { + this.addJumpExpressionToQueue(node.expression, nodeQueue); + } + + // Handle new expressions that cause a jump in control flow. We resolve the + // constructor declaration of the target class and add it to the node queue. + if (ts.isNewExpression(node)) { + this.addNewExpressionToQueue(node, nodeQueue); + } + + nodeQueue.push(...node.getChildren()); + } + return false; + } +} diff --git a/packages/core/schematics/migrations/static-queries/angular/decorators.ts b/packages/core/schematics/migrations/static-queries/angular/decorators.ts new file mode 100644 index 0000000000..2e11f6966d --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/decorators.ts @@ -0,0 +1,26 @@ +/** + * @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 {getCallDecoratorImport} from '../typescript/decorators'; + +export interface NgDecorator { + name: string; + node: ts.Decorator; +} + +/** + * Gets all decorators which are imported from an Angular package (e.g. "@angular/core") + * from a list of decorators. + */ +export function getAngularDecorators( + typeChecker: ts.TypeChecker, decorators: ReadonlyArray): NgDecorator[] { + return decorators.map(node => ({node, importData: getCallDecoratorImport(typeChecker, node)})) + .filter(({importData}) => importData && importData.importModule.startsWith('@angular/')) + .map(({node, importData}) => ({node, name: importData !.name})); +} diff --git a/packages/core/schematics/migrations/static-queries/angular/directive_inputs.ts b/packages/core/schematics/migrations/static-queries/angular/directive_inputs.ts new file mode 100644 index 0000000000..b9c0b661e5 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/directive_inputs.ts @@ -0,0 +1,88 @@ +/** + * @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 {getPropertyNameText, hasPropertyNameText} from '../typescript/property_name'; +import {getAngularDecorators} from './decorators'; + +/** Analyzes the given class and resolves the name of all inputs which are declared. */ +export function getInputNamesOfClass( + node: ts.ClassDeclaration, typeChecker: ts.TypeChecker): string[] { + const resolvedInputSetters: string[] = []; + + // Determines the names of all inputs defined in the current class declaration by + // checking whether a given property/getter/setter has the "@Input" decorator applied. + node.members.forEach(m => { + if (!m.decorators || !m.decorators.length || + !ts.isPropertyDeclaration(m) && !ts.isSetAccessor(m) && !ts.isGetAccessor(m)) { + return; + } + + const inputDecorator = + getAngularDecorators(typeChecker, m.decorators !).find(d => d.name === 'Input'); + + if (inputDecorator && hasPropertyNameText(m.name)) { + resolvedInputSetters.push(m.name.text); + } + }); + + // Besides looking for immediate setters in the current class declaration, developers + // can also define inputs in the directive metadata using the "inputs" property. We + // also need to determine these inputs which are declared in the directive metadata. + const metadataInputs = getInputNamesFromMetadata(node, typeChecker); + + if (metadataInputs) { + resolvedInputSetters.push(...metadataInputs); + } + + return resolvedInputSetters; +} + +/** + * Determines the names of all inputs declared in the directive/component metadata + * of the given class. + */ +function getInputNamesFromMetadata( + node: ts.ClassDeclaration, typeChecker: ts.TypeChecker): string[]|null { + if (!node.decorators || !node.decorators.length) { + return null; + } + + const decorator = getAngularDecorators(typeChecker, node.decorators) + .find(d => d.name === 'Directive' || d.name === 'Component'); + + // In case no directive/component decorator could be found for this class, just + // return null as there is no metadata where an input could be declared. + if (!decorator) { + return null; + } + + const decoratorCall = decorator.node.expression as ts.CallExpression; + + // In case the decorator does define any metadata, there is no metadata + // where inputs could be declared. This is an edge case because there + // always needs to be an object literal, but in case there isn't we just + // want to skip the invalid decorator and return null. + if (!ts.isObjectLiteralExpression(decoratorCall.arguments[0])) { + return null; + } + + const metadata = decoratorCall.arguments[0] as ts.ObjectLiteralExpression; + const inputs = metadata.properties.filter(ts.isPropertyAssignment) + .find(p => getPropertyNameText(p.name) === 'inputs'); + + // In case there is no "inputs" property in the directive metadata, + // just return "null" as no inputs can be declared for this class. + if (!inputs || !ts.isArrayLiteralExpression(inputs.initializer)) { + return null; + } + + return inputs.initializer.elements.filter(ts.isStringLiteralLike) + .map(element => element.text.split(':')[0].trim()); +} diff --git a/packages/core/schematics/migrations/static-queries/angular/ng_query_visitor.ts b/packages/core/schematics/migrations/static-queries/angular/ng_query_visitor.ts new file mode 100644 index 0000000000..777ee8bbf7 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/ng_query_visitor.ts @@ -0,0 +1,131 @@ +/** + * @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 {findParentClassDeclaration, getBaseTypeIdentifiers} from '../typescript/class_declaration'; + +import {getAngularDecorators} from './decorators'; +import {getInputNamesOfClass} from './directive_inputs'; +import {NgQueryDefinition, QueryType} from './query-definition'; + +/** Resolved metadata of a given class. */ +export interface ClassMetadata { + /** List of class declarations that derive from the given class. */ + derivedClasses: ts.ClassDeclaration[]; + /** List of property names that declare an Angular input within the given class. */ + ngInputNames: string[]; +} + +/** Type that describes a map which can be used to get a class declaration's metadata. */ +export type ClassMetadataMap = Map; + +/** + * Visitor that can be used to determine Angular queries within given TypeScript nodes. + * Besides resolving queries, the visitor also records class relations and searches for + * Angular input setters which can be used to analyze the timing usage of a given query. + */ +export class NgQueryResolveVisitor { + /** Resolved Angular query definitions. */ + resolvedQueries = new Map(); + + /** Maps a class declaration to its class metadata. */ + classMetadata: ClassMetadataMap = new Map(); + + constructor(public typeChecker: ts.TypeChecker) {} + + visitNode(node: ts.Node) { + switch (node.kind) { + case ts.SyntaxKind.PropertyDeclaration: + this.visitPropertyDeclaration(node as ts.PropertyDeclaration); + break; + case ts.SyntaxKind.ClassDeclaration: + this.visitClassDeclaration(node as ts.ClassDeclaration); + break; + } + + ts.forEachChild(node, node => this.visitNode(node)); + } + + private visitPropertyDeclaration(node: ts.PropertyDeclaration) { + if (!node.decorators || !node.decorators.length) { + return; + } + + const ngDecorators = getAngularDecorators(this.typeChecker, node.decorators); + const queryDecorator = + ngDecorators.find(({name}) => name === 'ViewChild' || name === 'ContentChild'); + + // Ensure that the current property declaration is defining a query. + if (!queryDecorator) { + return; + } + + const queryContainer = findParentClassDeclaration(node); + + // If the query is not located within a class declaration, skip this node. + if (!queryContainer) { + return; + } + + const sourceFile = node.getSourceFile(); + const newQueries = this.resolvedQueries.get(sourceFile) || []; + + this.resolvedQueries.set(sourceFile, newQueries.concat({ + type: queryDecorator.name === 'ViewChild' ? QueryType.ViewChild : QueryType.ContentChild, + property: node, + decorator: queryDecorator, + container: queryContainer, + })); + } + + private visitClassDeclaration(node: ts.ClassDeclaration) { + this._recordClassInputSetters(node); + this._recordClassInheritances(node); + } + + private _recordClassInputSetters(node: ts.ClassDeclaration) { + const resolvedInputNames = getInputNamesOfClass(node, this.typeChecker); + + if (resolvedInputNames) { + const classMetadata = this._getClassMetadata(node); + + classMetadata.ngInputNames = resolvedInputNames; + this.classMetadata.set(node, classMetadata); + } + } + + private _recordClassInheritances(node: ts.ClassDeclaration) { + const baseTypes = getBaseTypeIdentifiers(node); + + if (!baseTypes || !baseTypes.length) { + return; + } + + baseTypes.forEach(baseTypeIdentifier => { + // We need to resolve the value declaration through the resolved type as the base + // class could be declared in different source files and the local symbol won't + // contain a value declaration as the value is not declared locally. + const symbol = this.typeChecker.getTypeAtLocation(baseTypeIdentifier).getSymbol(); + + if (symbol && symbol.valueDeclaration && ts.isClassDeclaration(symbol.valueDeclaration)) { + const extendedClass = symbol.valueDeclaration; + const classMetadata = this._getClassMetadata(extendedClass); + + // Record all classes that derive from the given class. This makes it easy to + // determine all classes that could potentially use inherited queries statically. + classMetadata.derivedClasses.push(node); + this.classMetadata.set(extendedClass, classMetadata); + } + }); + } + + private _getClassMetadata(node: ts.ClassDeclaration): ClassMetadata { + return this.classMetadata.get(node) || {derivedClasses: [], ngInputNames: []}; + } +} diff --git a/packages/core/schematics/migrations/static-queries/angular/query-definition.ts b/packages/core/schematics/migrations/static-queries/angular/query-definition.ts new file mode 100644 index 0000000000..1a9fbb00c0 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/query-definition.ts @@ -0,0 +1,37 @@ +/** + * @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 {NgDecorator} from './decorators'; + + +/** Timing of a given query. Either static or dynamic. */ +export enum QueryTiming { + STATIC, + DYNAMIC +} + +/** Type of a given query. */ +export enum QueryType { + ViewChild, + ContentChild +} + +export interface NgQueryDefinition { + /** Type of the query definition. */ + type: QueryType; + + /** Property that declares the query. */ + property: ts.PropertyDeclaration; + + /** Decorator that declares this as a query. */ + decorator: NgDecorator; + + /** Class declaration that holds this query. */ + container: ts.ClassDeclaration; +} diff --git a/packages/core/schematics/migrations/static-queries/index.ts b/packages/core/schematics/migrations/static-queries/index.ts new file mode 100644 index 0000000000..e0149a4fb2 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/index.ts @@ -0,0 +1,29 @@ +/** + * @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 {Rule, SchematicsException, Tree} from '@angular-devkit/schematics'; +import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; +import {runStaticQueryMigration} from './migration'; + +/** Entry point for the V8 static-query migration. */ +export default function(): Rule { + return (tree: Tree) => { + const projectTsConfigPaths = getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + + if (!projectTsConfigPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot migrate queries ' + + 'to explicit timing.'); + } + + for (const tsconfigPath of projectTsConfigPaths) { + runStaticQueryMigration(tree, tsconfigPath, basePath); + } + }; +} diff --git a/packages/core/schematics/migrations/static-queries/migration.ts b/packages/core/schematics/migrations/static-queries/migration.ts new file mode 100644 index 0000000000..d54a08b784 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/migration.ts @@ -0,0 +1,97 @@ +/** + * @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 {Tree, UpdateRecorder} from '@angular-devkit/schematics'; +import {dirname, relative, resolve} from 'path'; +import * as ts from 'typescript'; + +import {analyzeNgQueryUsage} from './angular/analyze_query_usage'; +import {NgQueryResolveVisitor} from './angular/ng_query_visitor'; +import {NgQueryDefinition, QueryTiming} from './angular/query-definition'; +import {getPropertyNameText} from './typescript/property_name'; +import {parseTsconfigFile} from './typescript/tsconfig'; + +/** + * Runs the static query migration for the given TypeScript project. The schematic + * analyzes all queries within the project and sets up the query timing based on + * the current usage of the query property. e.g. a view query that is not used in any + * lifecycle hook does not need to be static and can be set up with "static: false". + */ +export function runStaticQueryMigration(tree: Tree, tsconfigPath: string, basePath: string) { + const parsed = parseTsconfigFile(tsconfigPath, dirname(tsconfigPath)); + const host = ts.createCompilerHost(parsed.options, true); + const program = ts.createProgram(parsed.fileNames, parsed.options, host); + const typeChecker = program.getTypeChecker(); + const queryVisitor = new NgQueryResolveVisitor(typeChecker); + const rootSourceFiles = program.getRootFileNames().map(f => program.getSourceFile(f) !); + const printer = ts.createPrinter(); + + // Analyze source files by detecting queries and class relations. + rootSourceFiles.forEach(sourceFile => queryVisitor.visitNode(sourceFile)); + + const {resolvedQueries, classMetadata} = queryVisitor; + + // Walk through all source files that contain resolved queries and update + // the source files if needed. Note that we need to update multiple queries + // within a source file within the same recorder in order to not throw off + // the TypeScript node offsets. + resolvedQueries.forEach((queries, sourceFile) => { + const update = tree.beginUpdate(relative(basePath, sourceFile.fileName)); + + // Compute the query usage for all resolved queries and update the + // query definitions to explicitly declare the query timing (static or dynamic) + queries.forEach(q => { + const timing = analyzeNgQueryUsage(q, classMetadata, typeChecker); + recordQueryUsageTransformation(q, update, timing, printer, sourceFile); + }); + + tree.commitUpdate(update); + }); +} + +/** + * Transforms the query decorator by explicitly specifying the timing based on the + * determined timing. The changes will be added to the specified update recorder. + */ +function recordQueryUsageTransformation( + query: NgQueryDefinition, recorder: UpdateRecorder, timing: QueryTiming, printer: ts.Printer, + sourceFile: ts.SourceFile) { + const queryExpr = query.decorator.node.expression as ts.CallExpression; + const queryArguments = queryExpr.arguments; + const timingPropertyAssignment = ts.createPropertyAssignment( + 'static', timing === QueryTiming.STATIC ? ts.createTrue() : ts.createFalse()); + let newCallText = ''; + + // If the query decorator is already called with two arguments, we need to + // keep the existing options untouched and just add the new property if needed. + if (queryArguments.length === 2) { + const existingOptions = queryArguments[1] as ts.ObjectLiteralExpression; + + // In case the options already contains a property for the "static" flag, we just + // skip this query and leave it untouched. + if (existingOptions.properties.some( + p => !!p.name && getPropertyNameText(p.name) === 'static')) { + return; + } + + const updatedOptions = ts.updateObjectLiteral( + existingOptions, existingOptions.properties.concat(timingPropertyAssignment)); + const updatedCall = ts.updateCall( + queryExpr, queryExpr.expression, queryExpr.typeArguments, + [queryArguments[0], updatedOptions]); + newCallText = printer.printNode(ts.EmitHint.Unspecified, updatedCall, sourceFile); + } else { + const newCall = ts.updateCall( + queryExpr, queryExpr.expression, queryExpr.typeArguments, + [queryArguments[0], ts.createObjectLiteral([timingPropertyAssignment])]); + newCallText = printer.printNode(ts.EmitHint.Unspecified, newCall, sourceFile); + } + + recorder.remove(queryExpr.getStart(), queryExpr.getWidth()); + recorder.insertRight(queryExpr.getStart(), newCallText); +} diff --git a/packages/core/schematics/migrations/static-queries/typescript/class_declaration.ts b/packages/core/schematics/migrations/static-queries/typescript/class_declaration.ts new file mode 100644 index 0000000000..3a835539b6 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/typescript/class_declaration.ts @@ -0,0 +1,32 @@ +/** + * @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'; + +/** Determines the base type identifiers of a specified class declaration. */ +export function getBaseTypeIdentifiers(node: ts.ClassDeclaration): ts.Identifier[]|null { + if (!node.heritageClauses) { + return null; + } + + return node.heritageClauses.filter(clause => clause.token === ts.SyntaxKind.ExtendsKeyword) + .reduce((types, clause) => types.concat(clause.types), [] as ts.ExpressionWithTypeArguments[]) + .map(typeExpression => typeExpression.expression) + .filter(ts.isIdentifier); +} + +/** Gets the first found parent class declaration of a given node. */ +export function findParentClassDeclaration(node: ts.Node): ts.ClassDeclaration|null { + while (!ts.isClassDeclaration(node)) { + if (ts.isSourceFile(node)) { + return null; + } + node = node.parent; + } + return node; +} diff --git a/packages/core/schematics/migrations/static-queries/typescript/decorators.ts b/packages/core/schematics/migrations/static-queries/typescript/decorators.ts new file mode 100644 index 0000000000..75a900c2b4 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/typescript/decorators.ts @@ -0,0 +1,24 @@ +/** + * @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 {Import, getImportOfIdentifier} from './imports'; + +export function getCallDecoratorImport( + typeChecker: ts.TypeChecker, decorator: ts.Decorator): Import|null { + // Note that this does not cover the edge case where decorators are called from + // a namespace import: e.g. "@core.Component()". This is not handled by Ngtsc either. + if (!ts.isCallExpression(decorator.expression) || + !ts.isIdentifier(decorator.expression.expression)) { + return null; + } + + const identifier = decorator.expression.expression; + return getImportOfIdentifier(typeChecker, identifier); +} diff --git a/packages/core/schematics/migrations/static-queries/typescript/imports.ts b/packages/core/schematics/migrations/static-queries/typescript/imports.ts new file mode 100644 index 0000000000..65142eaefa --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/typescript/imports.ts @@ -0,0 +1,42 @@ +/** + * @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'; + +export type Import = { + name: string, + importModule: string +}; + +/** Gets import information about the specified identifier by using the Type checker. */ +export function getImportOfIdentifier(typeChecker: ts.TypeChecker, node: ts.Identifier): Import| + null { + const symbol = typeChecker.getSymbolAtLocation(node); + + if (!symbol || !symbol.declarations.length) { + return null; + } + + const decl = symbol.declarations[0]; + + if (!ts.isImportSpecifier(decl)) { + return null; + } + + const importDecl = decl.parent.parent.parent; + + if (!ts.isStringLiteral(importDecl.moduleSpecifier)) { + return null; + } + + return { + // Handles aliased imports: e.g. "import {Component as myComp} from ..."; + name: decl.propertyName ? decl.propertyName.text : decl.name.text, + importModule: importDecl.moduleSpecifier.text + }; +} diff --git a/packages/core/schematics/migrations/static-queries/typescript/property_name.ts b/packages/core/schematics/migrations/static-queries/typescript/property_name.ts new file mode 100644 index 0000000000..79b4cc56f9 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/typescript/property_name.ts @@ -0,0 +1,28 @@ +/** + * @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'; + +/** Type that describes a property name with an obtainable text. */ +type PropertyNameWithText = Exclude; + +/** + * Gets the text of the given property name. Returns null if the property + * name couldn't be determined statically. + */ +export function getPropertyNameText(node: ts.PropertyName): string|null { + if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) { + return node.text; + } + return null; +} + +/** Checks whether the given property name has a text. */ +export function hasPropertyNameText(node: ts.PropertyName): node is PropertyNameWithText { + return ts.isStringLiteral(node) || ts.isNumericLiteral(node) || ts.isIdentifier(node); +} diff --git a/packages/core/schematics/migrations/static-queries/typescript/tsconfig.ts b/packages/core/schematics/migrations/static-queries/typescript/tsconfig.ts new file mode 100644 index 0000000000..220448a0ef --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/typescript/tsconfig.ts @@ -0,0 +1,21 @@ +/** + * @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'; + +export function parseTsconfigFile(tsconfigPath: string, basePath: string): ts.ParsedCommandLine { + const {config} = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + const parseConfigHost = { + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + fileExists: ts.sys.fileExists, + readDirectory: ts.sys.readDirectory, + readFile: ts.sys.readFile, + }; + + return ts.parseJsonConfigFileContent(config, parseConfigHost, basePath, {}); +} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel new file mode 100644 index 0000000000..fd1877312e --- /dev/null +++ b/packages/core/schematics/test/BUILD.bazel @@ -0,0 +1,22 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob(["**/*.ts"]), + data = [ + "//packages/core/schematics:migrations.json", + "@npm//shelljs", + ], + deps = [ + "//packages/core/schematics/migrations/static-queries", + "//packages/core/schematics/utils", + "@npm//@angular-devkit/schematics", + "@npm//@types/shelljs", + ], +) + +jasmine_node_test( + name = "test", + deps = [":test_lib"], +) diff --git a/packages/core/schematics/test/project_tsconfig_paths_spec.ts b/packages/core/schematics/test/project_tsconfig_paths_spec.ts new file mode 100644 index 0000000000..b26137eb37 --- /dev/null +++ b/packages/core/schematics/test/project_tsconfig_paths_spec.ts @@ -0,0 +1,47 @@ +/** + * @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 {HostTree} from '@angular-devkit/schematics'; +import {UnitTestTree} from '@angular-devkit/schematics/testing'; +import {getProjectTsConfigPaths} from '../utils/project_tsconfig_paths'; + +describe('project tsconfig paths', () => { + let testTree: UnitTestTree; + + beforeEach(() => { testTree = new UnitTestTree(new HostTree()); }); + + it('should detect build tsconfig path inside of angular.json file', () => { + testTree.create('/my-custom-config.json', ''); + testTree.create('/angular.json', JSON.stringify({ + projects: + {my_name: {architect: {build: {options: {tsConfig: './my-custom-config.json'}}}}} + })); + + expect(getProjectTsConfigPaths(testTree)).toEqual(['./my-custom-config.json']); + }); + + it('should detect test tsconfig path inside of .angular.json file', () => { + testTree.create('/my-test-config.json', ''); + testTree.create('/.angular.json', JSON.stringify({ + projects: + {with_tests: {architect: {test: {options: {tsConfig: './my-test-config.json'}}}}} + })); + + expect(getProjectTsConfigPaths(testTree)).toEqual(['./my-test-config.json']); + }); + + it('should detect common tsconfigs if no workspace config could be found', () => { + testTree.create('/tsconfig.json', ''); + testTree.create('/src/tsconfig.json', ''); + testTree.create('/src/tsconfig.app.json', ''); + + expect(getProjectTsConfigPaths(testTree)).toEqual([ + './tsconfig.json', './src/tsconfig.json', './src/tsconfig.app.json' + ]); + }); +}); diff --git a/packages/core/schematics/test/static_queries_migration_spec.ts b/packages/core/schematics/test/static_queries_migration_spec.ts new file mode 100644 index 0000000000..6ed28cbf39 --- /dev/null +++ b/packages/core/schematics/test/static_queries_migration_spec.ts @@ -0,0 +1,524 @@ +/** + * @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 {getSystemPath, normalize, virtualFs} from '@angular-devkit/core'; +import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing'; +import {HostTree} from '@angular-devkit/schematics'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; +import * as shx from 'shelljs'; + +describe('static-queries migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + let previousWorkingDir: string; + + beforeEach(() => { + runner = new SchematicTestRunner('test', require.resolve('../migrations.json')); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + + writeFile('/tsconfig.json', JSON.stringify({ + compilerOptions: { + lib: ['es2015'], + } + })); + + previousWorkingDir = shx.pwd(); + tmpDirPath = getSystemPath(host.root); + + // Switch into the temporary directory path. This allows us to run + // the schematic against our custom unit test tree. + shx.cd(tmpDirPath); + }); + + afterEach(() => { + shx.cd(previousWorkingDir); + shx.rm('-r', tmpDirPath); + }); + + describe('ViewChild', () => { + createQueryTests('ViewChild'); + + it('should mark view queries used in "ngAfterContentInit" as static', () => { + writeFile('/index.ts', ` + import {Component, ViewChild} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @ViewChild('test') query: any; + + ngAfterContentInit() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@ViewChild('test', { static: true }) query: any;`); + }); + + it('should mark view queries used in "ngAfterContentChecked" as static', () => { + writeFile('/index.ts', ` + import {Component, ViewChild} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @ViewChild('test') query: any; + + ngAfterContentChecked() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@ViewChild('test', { static: true }) query: any;`); + }); + }); + + describe('ContentChild', () => { + createQueryTests('ContentChild'); + + it('should not mark content queries used in "ngAfterContentInit" as static', () => { + writeFile('/index.ts', ` + import {Component, ContentChild} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @ContentChild('test') query: any; + + ngAfterContentInit() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@ContentChild('test', { static: false }) query: any;`); + }); + + it('should not mark content queries used in "ngAfterContentChecked" as static', () => { + writeFile('/index.ts', ` + import {Component, ContentChild} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @ContentChild('test') query: any; + + ngAfterContentChecked() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@ContentChild('test', { static: false }) query: any;`); + }); + }); + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { runner.runSchematic('migration-v8-static-queries', {}, tree); } + + function createQueryTests(queryType: 'ViewChild' | 'ContentChild') { + it('should mark queries as dynamic', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') unused: any; + @${queryType}('dynamic') dynamic: any; + + onClick() { + this.dynamicQuery.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: false }) unused: any;`); + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('dynamic', { static: false }) dynamic: any`); + }); + + it('should mark queries used in "ngOnInit" as static', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + ngOnInit() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should keep existing query options when updating timing', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test', { /* test */ read: null }) query: any; + + ngOnInit() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { /* test */ read: null, static: true }) query: any;`); + }); + + it('should not overwrite existing explicit query timing', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test', {static: /* untouched */ someVal}) query: any; + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', {static: /* untouched */ someVal}) query: any;`); + }); + + it('should detect queries used in deep method chain', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + // We intentionally add this comma for the second parameter in order + // to ensure that the migration does not incorrectly create an invalid + // decorator call with three parameters. e.g. "ViewQuery('test', {...}, )" + @${queryType}('test', ) query: any; + + ngOnInit() { + this.a(); + } + + a() { + this.b(); + } + + b() { + this.c(); + } + + c() { + console.log(this.query); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should properly exit if recursive function is analyzed', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + ngOnInit() { + this.recursive(); + } + + recursive() { + this.recursive(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: false }) query: any;`); + }); + + it('should detect queries used in newly instantiated classes', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + @${queryType}('test') query2: any; + + ngOnInit() { + new A(this); + } + } + + export class A { + constructor(ctx: MyComp) { + ctx.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: false }) query2: any;`); + }); + + it('should detect queries in lifecycle hook with string literal name', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + 'ngOnInit'() { + this.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should detect static queries within nested inheritance', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + } + + export class A extends MyComp {} + export class B extends A { + + ngOnInit() { + this.query.testFn(); + } + + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should detect static queries used within input setters', () => { + writeFile('/index.ts', ` + import {Component, Input, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + @Input() + get myVal() { return null; } + set myVal(newVal: any) { + this.query.classList.add('setter'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should detect inputs defined in metadata', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({ + template: '', + inputs: ["myVal"], + }) + export class MyComp { + @${queryType}('test') query: any; + + // We don't use the input decorator here as we want to verify + // that it properly detects the input through the component metadata. + get myVal() { return null; } + set myVal(newVal: any) { + this.query.classList.add('setter'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should detect aliased inputs declared in metadata', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({ + template: '', + inputs: ['prop: publicName'], + }) + export class MyComp { + @${queryType}('test') query: any; + + set prop(val: any) { + this.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should not mark query as static if query is used in non-input setter', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + set myProperty(val: any) { + this.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: false }) query: any;`); + }); + + it('should detect input decorator on setter', () => { + writeFile('/index.ts', ` + import {Input, Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + get myProperty() { return null; } + + // Usually the decorator is set on the get accessor, but it's also possible + // to declare the input on the setter. This ensures that it is handled properly. + @Input() + set myProperty(val: any) { + this.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should detect setter inputs in derived classes', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({ + template: '', + inputs: ['childSetter'], + }) + export class MyComp { + protected @${queryType}('test') query: any; + } + + export class B extends MyComp { + set childSetter(newVal: any) { + this.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should properly detect static query in external derived class', () => { + writeFile('/src/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + } + `); + + writeFile('/src/external.ts', ` + import {MyComp} from './index'; + + export class ExternalComp extends MyComp { + ngOnInit() { + this.query.test(); + } + } + `); + + // Move the tsconfig into a subdirectory. This ensures that the update is properly + // recorded for TypeScript projects not at the schematic tree root. + host.sync.rename(normalize('/tsconfig.json'), normalize('/src/tsconfig.json')); + + runMigration(); + + expect(tree.readContent('/src/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + } +}); diff --git a/packages/core/schematics/tsconfig.json b/packages/core/schematics/tsconfig.json new file mode 100644 index 0000000000..6e6c7ac264 --- /dev/null +++ b/packages/core/schematics/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "strictNullChecks": true, + "noImplicitReturns": true, + "lib": ["es2015"], + "types": [] + } +} diff --git a/packages/core/schematics/utils/BUILD.bazel b/packages/core/schematics/utils/BUILD.bazel new file mode 100644 index 0000000000..cffc353f4d --- /dev/null +++ b/packages/core/schematics/utils/BUILD.bazel @@ -0,0 +1,12 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "utils", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*_spec.ts"], + ), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = ["//packages/core/schematics:__subpackages__"], + deps = ["@npm//@angular-devkit/schematics"], +) diff --git a/packages/core/schematics/utils/project_tsconfig_paths.ts b/packages/core/schematics/utils/project_tsconfig_paths.ts new file mode 100644 index 0000000000..4623f012af --- /dev/null +++ b/packages/core/schematics/utils/project_tsconfig_paths.ts @@ -0,0 +1,70 @@ +/** + * @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 {Tree} from '@angular-devkit/schematics'; + +/** Name of the default Angular CLI workspace configuration files. */ +const defaultWorkspaceConfigPaths = ['/angular.json', '/.angular.json']; + +/** + * Gets all tsconfig paths from a CLI project by reading the workspace configuration + * and looking for common tsconfig locations. + */ +export function getProjectTsConfigPaths(tree: Tree): string[] { + // Start with some tsconfig paths that are generally used within CLI projects. + const tsconfigPaths = new Set([ + './tsconfig.json', + './src/tsconfig.json', + './src/tsconfig.app.json', + ]); + + // Add any tsconfig directly referenced in a build or test task of the angular.json workspace. + const workspace = getWorkspaceConfigGracefully(tree); + + if (workspace) { + const projects = Object.keys(workspace.projects).map(name => workspace.projects[name]); + for (const project of projects) { + ['build', 'test'].forEach(targetName => { + if (project.targets && project.targets[targetName] && project.targets[targetName].options && + project.targets[targetName].options.tsConfig) { + tsconfigPaths.add(project.targets[targetName].options.tsConfig); + } + + if (project.architect && project.architect[targetName] && + project.architect[targetName].options && + project.architect[targetName].options.tsConfig) { + tsconfigPaths.add(project.architect[targetName].options.tsConfig); + } + }); + } + } + + // Filter out tsconfig files that don't exist in the CLI project. + return Array.from(tsconfigPaths).filter(p => tree.exists(p)); +} + +/** + * Resolve the workspace configuration of the specified tree gracefully. We cannot use the utility + * functions from the default Angular schematics because those might not be present in older + * versions of the CLI. Also it's important to resolve the workspace gracefully because + * the CLI project could be still using `.angular-cli.json` instead of thew new config. + */ +function getWorkspaceConfigGracefully(tree: Tree): any { + const path = defaultWorkspaceConfigPaths.find(filePath => tree.exists(filePath)); + const configBuffer = tree.read(path !); + + if (!path || !configBuffer) { + return null; + } + + try { + return JSON.parse(configBuffer.toString()); + } catch { + return null; + } +}