fix(core): undecorated-classes-with-decorated-fields migration does not decorate derived classes (#35339)
The `undecorated-classes-with-decorated-fields` migration has been
introduced with 904a2018e0
, but misses
logic for decorating derived classes of undecorated classes which use
Angular features. Example scenario:
```ts
export abstract class MyBaseClass {
@Input() someInput = true;
}
export abstract class BaseClassTwo extends MyBaseClass {}
@Component(...)
export class MyButton extends BaseClassTwo {}
```
Both abstract classes would need to be migrated. Previously, the migration
only added `@Directive()` to `MyBaseClass`, but with this change, it
also decorates `BaseClassTwo`.
This is necessary because the Angular Compiler requires `BaseClassTwo` to
have a directive definition when it flattens the directive metadata for
`MyButton` in order to perform type checking. Technically, not decorating
`BaseClassTwo` does not break at runtime.
We basically want to enforce consistent use of `@Directive` to simplify the
mental model. [See the migration guide](https://angular.io/guide/migration-undecorated-classes#migrating-classes-that-use-field-decorators).
Fixes #34376.
PR Close #35339
This commit is contained in:
parent
2366480250
commit
32eafef6a7
|
@ -13,6 +13,7 @@ ts_library(
|
||||||
"//packages/core/schematics/migrations/static-queries",
|
"//packages/core/schematics/migrations/static-queries",
|
||||||
"//packages/core/schematics/migrations/template-var-assignment",
|
"//packages/core/schematics/migrations/template-var-assignment",
|
||||||
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields",
|
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields",
|
||||||
|
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/google3",
|
||||||
"//packages/core/schematics/utils",
|
"//packages/core/schematics/utils",
|
||||||
"//packages/core/schematics/utils/tslint",
|
"//packages/core/schematics/utils/tslint",
|
||||||
"@npm//tslint",
|
"@npm//tslint",
|
||||||
|
|
|
@ -6,12 +6,10 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Replacement, RuleFailure, Rules} from 'tslint';
|
import {RuleFailure, Rules} from 'tslint';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
|
import {TslintUpdateRecorder} from '../undecorated-classes-with-decorated-fields/google3/tslint_update_recorder';
|
||||||
import {FALLBACK_DECORATOR, addImport, getNamedImports, getUndecoratedClassesWithDecoratedFields, hasNamedImport} from '../undecorated-classes-with-decorated-fields/utils';
|
import {UndecoratedClassesWithDecoratedFieldsTransform} from '../undecorated-classes-with-decorated-fields/transform';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TSLint rule that adds an Angular decorator to classes that have Angular field decorators.
|
* TSLint rule that adds an Angular decorator to classes that have Angular field decorators.
|
||||||
|
@ -20,37 +18,32 @@ import {FALLBACK_DECORATOR, addImport, getNamedImports, getUndecoratedClassesWit
|
||||||
export class Rule extends Rules.TypedRule {
|
export class Rule extends Rules.TypedRule {
|
||||||
applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] {
|
applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] {
|
||||||
const typeChecker = program.getTypeChecker();
|
const typeChecker = program.getTypeChecker();
|
||||||
const printer = ts.createPrinter();
|
const ruleName = this.ruleName;
|
||||||
const classes = getUndecoratedClassesWithDecoratedFields(sourceFile, typeChecker);
|
const sourceFiles = program.getSourceFiles().filter(
|
||||||
|
s => !s.isDeclarationFile && !program.isSourceFileFromExternalLibrary(s));
|
||||||
|
const updateRecorders = new Map<ts.SourceFile, TslintUpdateRecorder>();
|
||||||
|
const transform =
|
||||||
|
new UndecoratedClassesWithDecoratedFieldsTransform(typeChecker, getUpdateRecorder);
|
||||||
|
|
||||||
return classes.map((current, index) => {
|
// Migrate all source files in the project.
|
||||||
const {classDeclaration: declaration, importDeclaration} = current;
|
transform.migrate(sourceFiles);
|
||||||
const name = declaration.name;
|
|
||||||
|
|
||||||
// Set the class identifier node (if available) as the failing node so IDEs don't highlight
|
// Record the changes collected in the import manager.
|
||||||
// the entire class with red. This is similar to how errors are shown for classes in other
|
transform.recordChanges();
|
||||||
// cases like an interface not being implemented correctly.
|
|
||||||
const start = (name || declaration).getStart();
|
|
||||||
const end = (name || declaration).getEnd();
|
|
||||||
const fixes = [Replacement.appendText(declaration.getStart(), `@${FALLBACK_DECORATOR}()\n`)];
|
|
||||||
|
|
||||||
// If it's the first class that we're processing in this file, add `Directive` to the imports.
|
if (updateRecorders.has(sourceFile)) {
|
||||||
if (index === 0 && !hasNamedImport(importDeclaration, FALLBACK_DECORATOR)) {
|
return updateRecorders.get(sourceFile) !.failures;
|
||||||
const namedImports = getNamedImports(importDeclaration);
|
}
|
||||||
|
return [];
|
||||||
|
|
||||||
if (namedImports) {
|
/** Gets the update recorder for the specified source file. */
|
||||||
fixes.push(new Replacement(
|
function getUpdateRecorder(sourceFile: ts.SourceFile): TslintUpdateRecorder {
|
||||||
namedImports.getStart(), namedImports.getWidth(),
|
if (updateRecorders.has(sourceFile)) {
|
||||||
printer.printNode(
|
return updateRecorders.get(sourceFile) !;
|
||||||
ts.EmitHint.Unspecified, addImport(namedImports, FALLBACK_DECORATOR),
|
|
||||||
sourceFile)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const recorder = new TslintUpdateRecorder(ruleName, sourceFile);
|
||||||
return new RuleFailure(
|
updateRecorders.set(sourceFile, recorder);
|
||||||
sourceFile, start, end,
|
return recorder;
|
||||||
'Classes with decorated fields must have an Angular decorator as well.',
|
}
|
||||||
'undecorated-classes-with-decorated-fields', fixes);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ ts_library(
|
||||||
visibility = [
|
visibility = [
|
||||||
"//packages/core/schematics:__pkg__",
|
"//packages/core/schematics:__pkg__",
|
||||||
"//packages/core/schematics/migrations/google3:__pkg__",
|
"//packages/core/schematics/migrations/google3:__pkg__",
|
||||||
|
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/google3:__pkg__",
|
||||||
"//packages/core/schematics/test:__pkg__",
|
"//packages/core/schematics/test:__pkg__",
|
||||||
],
|
],
|
||||||
deps = [
|
deps = [
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
load("//tools:defaults.bzl", "ts_library")
|
||||||
|
|
||||||
|
ts_library(
|
||||||
|
name = "google3",
|
||||||
|
srcs = glob(["**/*.ts"]),
|
||||||
|
tsconfig = "//packages/core/schematics:tsconfig.json",
|
||||||
|
visibility = ["//packages/core/schematics/migrations/google3:__pkg__"],
|
||||||
|
deps = [
|
||||||
|
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields",
|
||||||
|
"@npm//tslint",
|
||||||
|
"@npm//typescript",
|
||||||
|
],
|
||||||
|
)
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* @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 {Replacement, RuleFailure} from 'tslint';
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {UpdateRecorder} from '../update_recorder';
|
||||||
|
|
||||||
|
export class TslintUpdateRecorder implements UpdateRecorder {
|
||||||
|
failures: RuleFailure[] = [];
|
||||||
|
|
||||||
|
constructor(private ruleName: string, private sourceFile: ts.SourceFile) {}
|
||||||
|
|
||||||
|
/** Adds the specified decorator to the given class declaration. */
|
||||||
|
addClassDecorator(node: ts.ClassDeclaration, decoratorText: string) {
|
||||||
|
// Adding a decorator should be the last replacement. Replacements/rule failures
|
||||||
|
// are handled in reverse and in case a decorator and import are inserted at
|
||||||
|
// the start of the file, the class decorator should come after the import.
|
||||||
|
this.failures.unshift(new RuleFailure(
|
||||||
|
this.sourceFile, node.getStart(), 0, `Class needs to be decorated with ` +
|
||||||
|
`"${decoratorText}" because it uses Angular features.`,
|
||||||
|
this.ruleName, Replacement.appendText(node.getStart(), `${decoratorText}\n`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds the specified import to the source file at the given position */
|
||||||
|
addNewImport(start: number, importText: string) {
|
||||||
|
this.failures.push(new RuleFailure(
|
||||||
|
this.sourceFile, start, 0, `Source file needs to have import: "${importText}"`,
|
||||||
|
this.ruleName, Replacement.appendText(start, importText)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates existing named imports to the given new named imports. */
|
||||||
|
updateExistingImport(namedBindings: ts.NamedImports, newNamedBindings: string): void {
|
||||||
|
const fix = [
|
||||||
|
Replacement.deleteText(namedBindings.getStart(), namedBindings.getWidth()),
|
||||||
|
Replacement.appendText(namedBindings.getStart(), newNamedBindings),
|
||||||
|
];
|
||||||
|
this.failures.push(new RuleFailure(
|
||||||
|
this.sourceFile, namedBindings.getStart(), namedBindings.getEnd(),
|
||||||
|
`Import needs to be updated to import symbols: "${newNamedBindings}"`, this.ruleName, fix));
|
||||||
|
}
|
||||||
|
|
||||||
|
commitUpdate() {}
|
||||||
|
}
|
|
@ -6,21 +6,21 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Rule, SchematicContext, SchematicsException, Tree} from '@angular-devkit/schematics';
|
import {Rule, SchematicsException, Tree,} from '@angular-devkit/schematics';
|
||||||
import {dirname, relative} from 'path';
|
import {dirname, relative} from 'path';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
|
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
|
||||||
import {createMigrationCompilerHost} from '../../utils/typescript/compiler_host';
|
import {createMigrationCompilerHost} from '../../utils/typescript/compiler_host';
|
||||||
import {parseTsconfigFile} from '../../utils/typescript/parse_tsconfig';
|
import {parseTsconfigFile} from '../../utils/typescript/parse_tsconfig';
|
||||||
import {FALLBACK_DECORATOR, addImport, getNamedImports, getUndecoratedClassesWithDecoratedFields, hasNamedImport} from './utils';
|
import {UpdateRecorder} from './update_recorder';
|
||||||
|
import {UndecoratedClassesWithDecoratedFieldsTransform} from './transform';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migration that adds an Angular decorator to classes that have Angular field decorators.
|
* Migration that adds an Angular decorator to classes that have Angular field decorators.
|
||||||
* https://hackmd.io/vuQfavzfRG6KUCtU7oK_EA
|
* https://hackmd.io/vuQfavzfRG6KUCtU7oK_EA
|
||||||
*/
|
*/
|
||||||
export default function(): Rule {
|
export default function(): Rule {
|
||||||
return (tree: Tree, context: SchematicContext) => {
|
return (tree: Tree) => {
|
||||||
const {buildPaths, testPaths} = getProjectTsConfigPaths(tree);
|
const {buildPaths, testPaths} = getProjectTsConfigPaths(tree);
|
||||||
const basePath = process.cwd();
|
const basePath = process.cwd();
|
||||||
const allPaths = [...buildPaths, ...testPaths];
|
const allPaths = [...buildPaths, ...testPaths];
|
||||||
|
@ -41,39 +41,49 @@ function runUndecoratedClassesMigration(tree: Tree, tsconfigPath: string, basePa
|
||||||
const host = createMigrationCompilerHost(tree, parsed.options, basePath);
|
const host = createMigrationCompilerHost(tree, parsed.options, basePath);
|
||||||
const program = ts.createProgram(parsed.fileNames, parsed.options, host);
|
const program = ts.createProgram(parsed.fileNames, parsed.options, host);
|
||||||
const typeChecker = program.getTypeChecker();
|
const typeChecker = program.getTypeChecker();
|
||||||
const printer = ts.createPrinter();
|
|
||||||
const sourceFiles = program.getSourceFiles().filter(
|
const sourceFiles = program.getSourceFiles().filter(
|
||||||
file => !file.isDeclarationFile && !program.isSourceFileFromExternalLibrary(file));
|
file => !file.isDeclarationFile && !program.isSourceFileFromExternalLibrary(file));
|
||||||
|
const updateRecorders = new Map<ts.SourceFile, UpdateRecorder>();
|
||||||
|
const transform =
|
||||||
|
new UndecoratedClassesWithDecoratedFieldsTransform(typeChecker, getUpdateRecorder);
|
||||||
|
|
||||||
sourceFiles.forEach(sourceFile => {
|
// Migrate all source files in the project.
|
||||||
const classes = getUndecoratedClassesWithDecoratedFields(sourceFile, typeChecker);
|
transform.migrate(sourceFiles);
|
||||||
|
|
||||||
if (classes.length === 0) {
|
// Record the changes collected in the import manager.
|
||||||
return;
|
transform.recordChanges();
|
||||||
|
|
||||||
|
// Walk through each update recorder and commit the update. We need to commit the
|
||||||
|
// updates in batches per source file as there can be only one recorder per source
|
||||||
|
// file in order to avoid shifted character offsets.
|
||||||
|
updateRecorders.forEach(recorder => recorder.commitUpdate());
|
||||||
|
|
||||||
|
/** Gets the update recorder for the specified source file. */
|
||||||
|
function getUpdateRecorder(sourceFile: ts.SourceFile): UpdateRecorder {
|
||||||
|
if (updateRecorders.has(sourceFile)) {
|
||||||
|
return updateRecorders.get(sourceFile) !;
|
||||||
}
|
}
|
||||||
|
const treeRecorder = tree.beginUpdate(relative(basePath, sourceFile.fileName));
|
||||||
const update = tree.beginUpdate(relative(basePath, sourceFile.fileName));
|
const recorder: UpdateRecorder = {
|
||||||
|
addClassDecorator(node: ts.ClassDeclaration, text: string) {
|
||||||
classes.forEach((current, index) => {
|
// New imports should be inserted at the left while decorators should be inserted
|
||||||
// If it's the first class that we're processing in this file, add `Directive` to the imports.
|
// at the right in order to ensure that imports are inserted before the decorator
|
||||||
if (index === 0 && !hasNamedImport(current.importDeclaration, FALLBACK_DECORATOR)) {
|
// if the start position of import and decorator is the source file start.
|
||||||
const namedImports = getNamedImports(current.importDeclaration);
|
treeRecorder.insertRight(node.getStart(), `${text}\n`);
|
||||||
|
},
|
||||||
if (namedImports) {
|
addNewImport(start: number, importText: string) {
|
||||||
update.remove(namedImports.getStart(), namedImports.getWidth());
|
// New imports should be inserted at the left while decorators should be inserted
|
||||||
update.insertRight(
|
// at the right in order to ensure that imports are inserted before the decorator
|
||||||
namedImports.getStart(),
|
// if the start position of import and decorator is the source file start.
|
||||||
printer.printNode(
|
treeRecorder.insertLeft(start, importText);
|
||||||
ts.EmitHint.Unspecified, addImport(namedImports, FALLBACK_DECORATOR),
|
},
|
||||||
sourceFile));
|
updateExistingImport(namedBindings: ts.NamedImports, newNamedBindings: string) {
|
||||||
}
|
treeRecorder.remove(namedBindings.getStart(), namedBindings.getWidth());
|
||||||
}
|
treeRecorder.insertRight(namedBindings.getStart(), newNamedBindings);
|
||||||
|
},
|
||||||
// We don't need to go through the AST to insert the decorator, because the change
|
commitUpdate() { tree.commitUpdate(treeRecorder); }
|
||||||
// is pretty basic. Also this has a better chance of preserving the user's formatting.
|
};
|
||||||
update.insertLeft(current.classDeclaration.getStart(), `@${FALLBACK_DECORATOR}()\n`);
|
updateRecorders.set(sourceFile, recorder);
|
||||||
});
|
return recorder;
|
||||||
|
}
|
||||||
tree.commitUpdate(update);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
/**
|
||||||
|
* @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 {ImportManager} from '../../utils/import_manager';
|
||||||
|
import {getAngularDecorators} from '../../utils/ng_decorators';
|
||||||
|
import {findBaseClassDeclarations} from '../../utils/typescript/find_base_classes';
|
||||||
|
|
||||||
|
import {UpdateRecorder} from './update_recorder';
|
||||||
|
|
||||||
|
export class UndecoratedClassesWithDecoratedFieldsTransform {
|
||||||
|
private printer = ts.createPrinter();
|
||||||
|
private importManager = new ImportManager(this.getUpdateRecorder, this.printer);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private typeChecker: ts.TypeChecker,
|
||||||
|
private getUpdateRecorder: (sf: ts.SourceFile) => UpdateRecorder) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates the specified source files. The transform adds the abstract `@Directive`
|
||||||
|
* decorator to classes that have Angular field decorators but are not decorated.
|
||||||
|
* https://hackmd.io/vuQfavzfRG6KUCtU7oK_EA
|
||||||
|
*/
|
||||||
|
migrate(sourceFiles: ts.SourceFile[]) {
|
||||||
|
this._findUndecoratedDirectives(sourceFiles).forEach(node => {
|
||||||
|
const sourceFile = node.getSourceFile();
|
||||||
|
const recorder = this.getUpdateRecorder(sourceFile);
|
||||||
|
const directiveExpr =
|
||||||
|
this.importManager.addImportToSourceFile(sourceFile, 'Directive', '@angular/core');
|
||||||
|
const decoratorExpr = ts.createDecorator(ts.createCall(directiveExpr, undefined, undefined));
|
||||||
|
recorder.addClassDecorator(
|
||||||
|
node, this.printer.printNode(ts.EmitHint.Unspecified, decoratorExpr, sourceFile));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records all changes that were made in the import manager. */
|
||||||
|
recordChanges() { this.importManager.recordChanges(); }
|
||||||
|
|
||||||
|
/** Finds undecorated directives in the specified source files. */
|
||||||
|
private _findUndecoratedDirectives(sourceFiles: ts.SourceFile[]) {
|
||||||
|
const typeChecker = this.typeChecker;
|
||||||
|
const undecoratedDirectives = new Set<ts.ClassDeclaration>();
|
||||||
|
const undecoratedClasses = new Set<ts.ClassDeclaration>();
|
||||||
|
const decoratedDirectives = new WeakSet<ts.ClassDeclaration>();
|
||||||
|
|
||||||
|
const visitNode = (node: ts.Node) => {
|
||||||
|
node.forEachChild(visitNode);
|
||||||
|
if (!ts.isClassDeclaration(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ngDecorators = node.decorators && getAngularDecorators(typeChecker, node.decorators);
|
||||||
|
const isDirectiveOrComponent = ngDecorators !== undefined &&
|
||||||
|
ngDecorators.some(({name}) => name === 'Directive' || name === 'Component');
|
||||||
|
if (isDirectiveOrComponent) {
|
||||||
|
decoratedDirectives.add(node);
|
||||||
|
} else {
|
||||||
|
if (this._hasAngularDecoratedClassMember(node)) {
|
||||||
|
undecoratedDirectives.add(node);
|
||||||
|
} else {
|
||||||
|
undecoratedClasses.add(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sourceFiles.forEach(sourceFile => sourceFile.forEachChild(visitNode));
|
||||||
|
|
||||||
|
// We collected all class declarations that use Angular features but are not decorated. For
|
||||||
|
// such undecorated directives, the derived classes also need to be migrated. To achieve this,
|
||||||
|
// we walk through all undecorated classes and mark those which extend from an undecorated
|
||||||
|
// directive as undecorated directive too.
|
||||||
|
undecoratedClasses.forEach(node => {
|
||||||
|
for (const {node: baseClass} of findBaseClassDeclarations(node, this.typeChecker)) {
|
||||||
|
// If the undecorated class inherits from a decorated directive, skip the current class.
|
||||||
|
// We do this because undecorated classes which inherit from directives/components are
|
||||||
|
// handled as part of the the `undecorated-classes-with-di` migration which copies
|
||||||
|
// inherited metadata.
|
||||||
|
if (decoratedDirectives.has(baseClass)) {
|
||||||
|
break;
|
||||||
|
} else if (undecoratedDirectives.has(baseClass)) {
|
||||||
|
undecoratedDirectives.add(node);
|
||||||
|
undecoratedClasses.delete(node);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return undecoratedDirectives;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _hasAngularDecoratedClassMember(node: ts.ClassDeclaration): boolean {
|
||||||
|
return node.members.some(
|
||||||
|
m => m.decorators && getAngularDecorators(this.typeChecker, m.decorators).length !== 0);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* @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 {ImportManagerUpdateRecorder} from '../../utils/import_manager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update recorder interface that is used to transform source files
|
||||||
|
* in a non-colliding way.
|
||||||
|
*/
|
||||||
|
export interface UpdateRecorder extends ImportManagerUpdateRecorder {
|
||||||
|
addClassDecorator(node: ts.ClassDeclaration, text: string): void;
|
||||||
|
commitUpdate(): void;
|
||||||
|
}
|
|
@ -1,71 +0,0 @@
|
||||||
/**
|
|
||||||
* @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 {getAngularDecorators} from '../../utils/ng_decorators';
|
|
||||||
|
|
||||||
/** Name of the decorator that should be added to undecorated classes. */
|
|
||||||
export const FALLBACK_DECORATOR = 'Directive';
|
|
||||||
|
|
||||||
/** Finds all of the undecorated classes that have decorated fields within a file. */
|
|
||||||
export function getUndecoratedClassesWithDecoratedFields(
|
|
||||||
sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker) {
|
|
||||||
const classes: UndecoratedClassWithDecoratedFields[] = [];
|
|
||||||
|
|
||||||
sourceFile.forEachChild(function walk(node: ts.Node) {
|
|
||||||
if (ts.isClassDeclaration(node) &&
|
|
||||||
(!node.decorators || !getAngularDecorators(typeChecker, node.decorators).length)) {
|
|
||||||
for (const member of node.members) {
|
|
||||||
const angularDecorators =
|
|
||||||
member.decorators && getAngularDecorators(typeChecker, member.decorators);
|
|
||||||
|
|
||||||
if (angularDecorators && angularDecorators.length) {
|
|
||||||
classes.push(
|
|
||||||
{classDeclaration: node, importDeclaration: angularDecorators[0].importNode});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
node.forEachChild(walk);
|
|
||||||
});
|
|
||||||
|
|
||||||
return classes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Checks whether an import declaration has an import with a certain name. */
|
|
||||||
export function hasNamedImport(declaration: ts.ImportDeclaration, symbolName: string): boolean {
|
|
||||||
const namedImports = getNamedImports(declaration);
|
|
||||||
|
|
||||||
if (namedImports) {
|
|
||||||
return namedImports.elements.some(element => {
|
|
||||||
const {name, propertyName} = element;
|
|
||||||
return propertyName ? propertyName.text === symbolName : name.text === symbolName;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extracts the NamedImports node from an import declaration. */
|
|
||||||
export function getNamedImports(declaration: ts.ImportDeclaration): ts.NamedImports|null {
|
|
||||||
const namedBindings = declaration.importClause && declaration.importClause.namedBindings;
|
|
||||||
return (namedBindings && ts.isNamedImports(namedBindings)) ? namedBindings : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds a new import to a NamedImports node. */
|
|
||||||
export function addImport(declaration: ts.NamedImports, symbolName: string) {
|
|
||||||
return ts.updateNamedImports(declaration, [
|
|
||||||
...declaration.elements, ts.createImportSpecifier(undefined, ts.createIdentifier(symbolName))
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UndecoratedClassWithDecoratedFields {
|
|
||||||
classDeclaration: ts.ClassDeclaration;
|
|
||||||
importDeclaration: ts.ImportDeclaration;
|
|
||||||
}
|
|
|
@ -64,7 +64,7 @@ describe('Google3 undecorated classes with decorated fields TSLint rule', () =>
|
||||||
|
|
||||||
expect(failures.length).toBe(1);
|
expect(failures.length).toBe(1);
|
||||||
expect(failures[0])
|
expect(failures[0])
|
||||||
.toBe('Classes with decorated fields must have an Angular decorator as well.');
|
.toBe('Class needs to be decorated with "@Directive()" because it uses Angular features.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should add an import for Directive if there isn't one already`, () => {
|
it(`should add an import for Directive if there isn't one already`, () => {
|
||||||
|
@ -97,6 +97,27 @@ describe('Google3 undecorated classes with decorated fields TSLint rule', () =>
|
||||||
expect(getFile('/index.ts')).toContain(`import { Directive, Input } from '@angular/core';`);
|
expect(getFile('/index.ts')).toContain(`import { Directive, Input } from '@angular/core';`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not generate conflicting imports there is a different `Directive` symbol', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { HostBinding } from '@angular/core';
|
||||||
|
|
||||||
|
export class Directive {
|
||||||
|
// Simulates a scenario where a library defines a class named "Directive".
|
||||||
|
// We don't want to generate a conflicting import.
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MyLibrarySharedBaseClass {
|
||||||
|
@HostBinding('class.active') isActive: boolean;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
const fileContent = getFile('/index.ts');
|
||||||
|
expect(fileContent)
|
||||||
|
.toContain(`import { HostBinding, Directive as Directive_1 } from '@angular/core';`);
|
||||||
|
expect(fileContent).toMatch(/@Directive_1\(\)\s+export class MyLibrarySharedBaseClass/);
|
||||||
|
});
|
||||||
|
|
||||||
it('should add @Directive to undecorated classes that have @Input', () => {
|
it('should add @Directive to undecorated classes that have @Input', () => {
|
||||||
writeFile('/index.ts', `
|
writeFile('/index.ts', `
|
||||||
import { Input } from '@angular/core';
|
import { Input } from '@angular/core';
|
||||||
|
@ -229,4 +250,35 @@ describe('Google3 undecorated classes with decorated fields TSLint rule', () =>
|
||||||
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated derived classes of a migrated class', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Input, Directive, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DerivedA extends Base {}
|
||||||
|
export class DerivedB extends DerivedA {}
|
||||||
|
export class DerivedC extends DerivedB {}
|
||||||
|
|
||||||
|
@Directive({selector: 'my-comp'})
|
||||||
|
export class MyComp extends DerivedC {}
|
||||||
|
|
||||||
|
export class MyCompWrapped extends MyComp {}
|
||||||
|
|
||||||
|
@NgModule({declarations: [MyComp, MyCompWrapped]})
|
||||||
|
export class AppModule {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
const fileContent = getFile('/index.ts');
|
||||||
|
expect(fileContent).toContain(`import { Input, Directive, NgModule } from '@angular/core';`);
|
||||||
|
expect(fileContent).toMatch(/@Directive\(\)\s+export class Base/);
|
||||||
|
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedA/);
|
||||||
|
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedB/);
|
||||||
|
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedC/);
|
||||||
|
expect(fileContent).toMatch(/}\s+@Directive\(\{selector: 'my-comp'}\)\s+export class MyComp/);
|
||||||
|
expect(fileContent).toMatch(/}\s+export class MyCompWrapped/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -74,6 +74,27 @@ describe('Undecorated classes with decorated fields migration', () => {
|
||||||
.toContain(`import { Directive, Input } from '@angular/core';`);
|
.toContain(`import { Directive, Input } from '@angular/core';`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not generate conflicting imports there is a different `Directive` symbol', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { HostBinding } from '@angular/core';
|
||||||
|
|
||||||
|
export class Directive {
|
||||||
|
// Simulates a scenario where a library defines a class named "Directive".
|
||||||
|
// We don't want to generate a conflicting import.
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MyLibrarySharedBaseClass {
|
||||||
|
@HostBinding('class.active') isActive: boolean;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
const fileContent = tree.readContent('/index.ts');
|
||||||
|
expect(fileContent)
|
||||||
|
.toContain(`import { HostBinding, Directive as Directive_1 } from '@angular/core';`);
|
||||||
|
expect(fileContent).toMatch(/@Directive_1\(\)\s+export class MyLibrarySharedBaseClass/);
|
||||||
|
});
|
||||||
|
|
||||||
it('should add @Directive to undecorated classes that have @Input', async() => {
|
it('should add @Directive to undecorated classes that have @Input', async() => {
|
||||||
writeFile('/index.ts', `
|
writeFile('/index.ts', `
|
||||||
import { Input } from '@angular/core';
|
import { Input } from '@angular/core';
|
||||||
|
@ -206,6 +227,38 @@ describe('Undecorated classes with decorated fields migration', () => {
|
||||||
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated derived classes of a migrated class', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Input, Directive, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DerivedA extends Base {}
|
||||||
|
export class DerivedB extends DerivedA {}
|
||||||
|
export class DerivedC extends DerivedB {}
|
||||||
|
|
||||||
|
@Directive({selector: 'my-comp'})
|
||||||
|
export class MyComp extends DerivedC {}
|
||||||
|
|
||||||
|
export class MyCompWrapped extends MyComp {}
|
||||||
|
|
||||||
|
@NgModule({declarations: [MyComp, MyCompWrapped]})
|
||||||
|
export class AppModule {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
const fileContent = tree.readContent('/index.ts');
|
||||||
|
expect(fileContent).toContain(`import { Input, Directive, NgModule } from '@angular/core';`);
|
||||||
|
expect(fileContent).toMatch(/@Directive\(\)\s+export class Base/);
|
||||||
|
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedA/);
|
||||||
|
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedB/);
|
||||||
|
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedC/);
|
||||||
|
expect(fileContent).toMatch(/}\s+@Directive\(\{selector: 'my-comp'}\)\s+export class MyComp/);
|
||||||
|
expect(fileContent).toMatch(/}\s+export class MyCompWrapped/);
|
||||||
|
});
|
||||||
|
|
||||||
function writeFile(filePath: string, contents: string) {
|
function writeFile(filePath: string, contents: string) {
|
||||||
host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents));
|
host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents));
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue