Skip to content

Commit c54c897

Browse files
devversionjelbourn
authored andcommitted
refactor(schematics): constructor checks should respect assignability (#13050)
Reworks the constructor signature check rule to take advantage of the TypeScript pre-emit diagnostics. This is necessary because there is no public API to check the relation of two types (e.g. checking if the super-call arguments are assignable to the actual constructor parameter signature) This now means that the constructor checks respect type assignability (e.g. it won't throw incorrectly if a developer does something like: ```ts class ExtendedPlatform extends Platform {} new NativeDateAdapter(a, new ExtendedPlatform(...))` ```
1 parent 7c4fa66 commit c54c897

File tree

3 files changed

+105
-52
lines changed

3 files changed

+105
-52
lines changed

src/lib/schematics/update/rules/signature-check/constructorSignatureCheckRule.ts

Lines changed: 82 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,78 +7,112 @@
77
*/
88

99
import {bold, green} from 'chalk';
10-
import {ProgramAwareRuleWalker, RuleFailure, Rules} from 'tslint';
10+
import {RuleFailure, Rules, WalkContext} from 'tslint';
1111
import * as ts from 'typescript';
1212
import {constructorChecks} from '../../material/data/constructor-checks';
1313

14+
/**
15+
* List of diagnostic codes that refer to pre-emit diagnostics which indicate invalid
16+
* new expression or super call signatures. See the list of diagnostics here:
17+
*
18+
* https://github.com/Microsoft/TypeScript/blob/master/src/compiler/diagnosticMessages.json
19+
*/
20+
const signatureErrorDiagnostics = [
21+
// Type not assignable error diagnostic.
22+
2345,
23+
// Constructor argument length invalid diagnostics
24+
2554, 2555, 2556, 2557,
25+
];
26+
1427
/**
1528
* Rule that visits every TypeScript new expression or super call and checks if the parameter
1629
* type signature is invalid and needs to be updated manually.
1730
*/
1831
export class Rule extends Rules.TypedRule {
1932
applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] {
20-
return this.applyWithWalker(new Walker(sourceFile, this.getOptions(), program));
33+
return this.applyWithFunction(sourceFile, visitSourceFile, null, program);
2134
}
2235
}
2336

24-
export class Walker extends ProgramAwareRuleWalker {
37+
/**
38+
* Function that will be called for each source file of the upgrade project. In order to properly
39+
* determine invalid constructor signatures, we take advantage of the pre-emit diagnostics from
40+
* TypeScript.
41+
*
42+
* By using the diagnostics we can properly respect type assignability because otherwise we
43+
* would need to rely on type equality checking which is too strict.
44+
* See related issue: https://github.com/Microsoft/TypeScript/issues/9879
45+
*/
46+
function visitSourceFile(context: WalkContext<null>, program: ts.Program) {
47+
const sourceFile = context.sourceFile;
48+
const diagnostics = ts.getPreEmitDiagnostics(program, sourceFile)
49+
.filter(diagnostic => signatureErrorDiagnostics.includes(diagnostic.code))
50+
.filter(diagnostic => diagnostic.start !== undefined);
2551

26-
visitNewExpression(node: ts.NewExpression) {
27-
this.checkExpressionSignature(node);
28-
super.visitNewExpression(node);
29-
}
52+
for (const diagnostic of diagnostics) {
53+
const node = findConstructorNode(diagnostic, sourceFile);
3054

31-
visitCallExpression(node: ts.CallExpression) {
32-
if (node.expression.kind === ts.SyntaxKind.SuperKeyword) {
33-
this.checkExpressionSignature(node);
55+
if (!node) {
56+
return;
3457
}
3558

36-
return super.visitCallExpression(node);
37-
}
38-
39-
private getParameterTypesFromSignature(signature: ts.Signature): ts.Type[] {
40-
return signature.getParameters()
41-
.map(param => param.declarations[0] as ts.ParameterDeclaration)
42-
.map(node => node.type)
43-
// TODO(devversion): handle non resolvable constructor types
44-
.map(typeNode => this.getTypeChecker().getTypeFromTypeNode(typeNode!));
45-
}
46-
47-
private checkExpressionSignature(node: ts.CallExpression | ts.NewExpression) {
48-
const classType = this.getTypeChecker().getTypeAtLocation(node.expression);
59+
const classType = program.getTypeChecker().getTypeAtLocation(node.expression);
4960
const className = classType.symbol && classType.symbol.name;
5061
const isNewExpression = ts.isNewExpression(node);
5162

5263
// TODO(devversion): Consider handling pass-through classes better.
5364
// TODO(devversion): e.g. `export class CustomCalendar extends MatCalendar {}`
54-
if (!classType || !constructorChecks.includes(className) || !node.arguments) {
65+
if (!constructorChecks.includes(className)) {
5566
return;
5667
}
5768

58-
const callExpressionSignature = node.arguments
59-
.map(argument => this.getTypeChecker().getTypeAtLocation(argument));
6069
const classSignatures = classType.getConstructSignatures()
61-
.map(signature => this.getParameterTypesFromSignature(signature));
62-
63-
// TODO(devversion): we should check if the type is assignable to the signature
64-
// TODO(devversion): blocked on https://github.com/Microsoft/TypeScript/issues/9879
65-
const doesMatchSignature = classSignatures.some(signature => {
66-
// TODO(devversion): better handling if signature item type is unresolved but assignable
67-
// to everything.
68-
return signature.every((type, index) => callExpressionSignature[index] === type) &&
69-
signature.length === callExpressionSignature.length;
70-
});
71-
72-
if (!doesMatchSignature) {
73-
const expressionName = isNewExpression ? `new ${className}` : 'super';
74-
const signatures = classSignatures
75-
.map(signature => signature.map(t => this.getTypeChecker().typeToString(t)))
76-
.map(signature => `${expressionName}(${signature.join(', ')})`)
77-
.join(' or ');
78-
79-
this.addFailureAtNode(node, `Found "${bold(className)}" constructed with ` +
80-
`an invalid signature. Please manually update the ${bold(expressionName)} expression to ` +
81-
`match the new signature${classSignatures.length > 1 ? 's' : ''}: ${green(signatures)}`);
82-
}
70+
.map(signature => getParameterTypesFromSignature(signature, program));
71+
72+
const expressionName = isNewExpression ? `new ${className}` : 'super';
73+
const signatures = classSignatures
74+
.map(signature => signature.map(t => program.getTypeChecker().typeToString(t)))
75+
.map(signature => `${expressionName}(${signature.join(', ')})`)
76+
.join(' or ');
77+
78+
context.addFailureAtNode(node, `Found "${bold(className)}" constructed with ` +
79+
`an invalid signature. Please manually update the ${bold(expressionName)} expression to ` +
80+
`match the new signature${classSignatures.length > 1 ? 's' : ''}: ${green(signatures)}`);
8381
}
8482
}
83+
84+
/** Resolves the type for each parameter in the specified signature. */
85+
function getParameterTypesFromSignature(signature: ts.Signature, program: ts.Program): ts.Type[] {
86+
return signature.getParameters()
87+
.map(param => param.declarations[0] as ts.ParameterDeclaration)
88+
.map(node => node.type)
89+
.map(typeNode => program.getTypeChecker().getTypeFromTypeNode(typeNode!));
90+
}
91+
92+
/**
93+
* Walks through each node of a source file in order to find a new-expression node or super-call
94+
* expression node that is captured by the specified diagnostic.
95+
*/
96+
function findConstructorNode(diagnostic: ts.Diagnostic, sourceFile: ts.SourceFile):
97+
ts.CallExpression | ts.NewExpression | null {
98+
99+
let resolvedNode: ts.Node | null = null;
100+
101+
const _visitNode = (node: ts.Node) => {
102+
// Check whether the current node contains the diagnostic. If the node contains the diagnostic,
103+
// walk deeper in order to find all constructor expression nodes.
104+
if (node.getStart() <= diagnostic.start! && node.getEnd() >= diagnostic.start!) {
105+
106+
if (ts.isNewExpression(node) ||
107+
(ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.SuperKeyword)) {
108+
resolvedNode = node;
109+
}
110+
111+
ts.forEachChild(node, _visitNode);
112+
}
113+
};
114+
115+
ts.forEachChild(sourceFile, _visitNode);
116+
117+
return resolvedNode;
118+
}

src/lib/schematics/update/test-cases/v5/checks/constructor-checks.spec.ts renamed to src/lib/schematics/update/test-cases/misc/constructor-checks.spec.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import {SchematicTestRunner} from '@angular-devkit/schematics/testing';
2-
import {runPostScheduledTasks} from '../../../../test-setup/post-scheduled-tasks';
3-
import {migrationCollection} from '../../../../test-setup/test-app';
4-
import {createTestAppWithTestCase, resolveBazelDataFile} from '../../index.spec';
2+
import {runPostScheduledTasks} from '../../../test-setup/post-scheduled-tasks';
3+
import {migrationCollection} from '../../../test-setup/test-app';
4+
import {createTestAppWithTestCase, resolveBazelDataFile} from '../index.spec';
55

66
describe('v5 constructor checks', () => {
77

88
it('should properly report invalid constructor expression signatures', async () => {
9-
const inputPath = resolveBazelDataFile(`v5/checks/constructor-checks_input.ts`);
9+
const inputPath = resolveBazelDataFile(`misc/constructor-checks_input.ts`);
1010
const runner = new SchematicTestRunner('schematics', migrationCollection);
1111

1212
runner.runSchematic('migration-01', {}, createTestAppWithTestCase(inputPath));
@@ -30,6 +30,13 @@ describe('v5 constructor checks', () => {
3030

3131
expect(output).toMatch(/Found "MatCalendar".*super.*: super\(any, any, any, any\)/);
3232
expect(output).toMatch(/Found "MatCalendar".*: new \w+\(any, any, any, any\)/);
33+
34+
expect(output).toMatch(/\[97.*Found "NativeDateAdapter"/,
35+
'Expected the constructor checks to report if an argument is not assignable.');
36+
expect(output).not.toMatch(/\[99.*Found "NativeDateAdapter".*/,
37+
'Expected the constructor to not report if an argument is assignable.');
38+
39+
expect(output).not.toMatch(/Found "NonMaterialClass".*: new NonMaterialClass\(\)/);
3340
});
3441
});
3542

src/lib/schematics/update/test-cases/v5/checks/constructor-checks_input.ts renamed to src/lib/schematics/update/test-cases/misc/constructor-checks_input.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ class MatCalendar {
4040
constructor(_intl: any, _adapter: any, _formats: any, _changeDetector: any) {}
4141
}
4242

43+
class NonMaterialClass {}
44+
45+
class DelegateClass {
46+
constructor(_adapter: NativeDateAdapter) {}
47+
}
48+
4349
/* Actual test case using the previously defined definitions. */
4450

4551
class A extends NativeDateAdapter {
@@ -87,3 +93,9 @@ class E extends MatCalendar {
8793
}
8894

8995
const _E = new MatCalendar({}, {}, {}, {}, {}, {}, {});
96+
97+
const _F = new NativeDateAdapter('b', 'invalid-argument');
98+
99+
const _G = new NativeDateAdapter('a', {IOS: true});
100+
101+
const _H = new NonMaterialClass('invalid-argument');

0 commit comments

Comments
 (0)