Skip to content

Commit 75d1cae

Browse files
devversiondylhunn
authored andcommitted
refactor(compiler-cli): support enforcing field access for initializer APIs (#55070)
An initializer API like `input`, `output`, or signal queries may not be compatible with certain access levels. E.g. queries cannot work with ES private class fields. This commit introduces a check for access levels into the initializer API recognition— enforcing that every initializer API *clearly* specifies what type of access is allowed. PR Close #55070
1 parent 53fe455 commit 75d1cae

File tree

6 files changed

+200
-34
lines changed

6 files changed

+200
-34
lines changed

goldens/public-api/compiler-cli/error_code.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export enum ErrorCode {
5656
INACCESSIBLE_DEFERRED_TRIGGER_ELEMENT = 8010,
5757
INCORRECTLY_DECLARED_ON_STATIC_MEMBER = 1100,
5858
INITIALIZER_API_DECORATOR_METADATA_COLLISION = 1051,
59+
INITIALIZER_API_DISALLOWED_MEMBER_VISIBILITY = 1053,
5960
INITIALIZER_API_NO_REQUIRED_FUNCTION = 1052,
6061
INITIALIZER_API_WITH_DISALLOWED_DECORATOR = 1050,
6162
INJECTABLE_DUPLICATE_PROV = 9001,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {ErrorCode, FatalDiagnosticError, makeDiagnosticChain} from '../../../diagnostics';
10+
import {ClassMember} from '../../../reflection';
11+
import {classMemberAccessLevelToString} from '../../../reflection/src/util';
12+
13+
import {InitializerFunctionMetadata} from './initializer_functions';
14+
15+
/**
16+
* Validates that the initializer member is compatible with the given class
17+
* member in terms of field access and visibility.
18+
*
19+
* @throws {FatalDiagnosticError} If the recognized initializer API is
20+
* incompatible.
21+
*/
22+
export function validateAccessOfInitializerApiMember(
23+
{api, call}: InitializerFunctionMetadata, member: Pick<ClassMember, 'accessLevel'>): void {
24+
if (!api.allowedAccessLevels.some(level => level === member.accessLevel)) {
25+
throw new FatalDiagnosticError(
26+
ErrorCode.INITIALIZER_API_DISALLOWED_MEMBER_VISIBILITY, call,
27+
makeDiagnosticChain(
28+
`Cannot use "${api.functionName}" on a class member that is declared as ${
29+
classMemberAccessLevelToString(member.accessLevel)}.`,
30+
[makeDiagnosticChain(
31+
`Update the class field to be either: ` +
32+
api.allowedAccessLevels.map(l => classMemberAccessLevelToString(l)).join(', '))]));
33+
}
34+
}

packages/compiler-cli/src/ngtsc/annotations/directive/src/initializer_functions.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import ts from 'typescript';
1010

1111
import {ImportedSymbolsTracker} from '../../../imports';
12-
import {ClassMember, ReflectionHost} from '../../../reflection';
12+
import {ClassMemberAccessLevel, ReflectionHost} from '../../../reflection';
1313

1414
/**
1515
* @fileoverview
@@ -24,16 +24,20 @@ import {ClassMember, ReflectionHost} from '../../../reflection';
2424
*/
2525

2626
export interface InitializerApiFunction {
27+
/** Module name where the initializer function is imported from. */
2728
owningModule: '@angular/core'|'@angular/core/rxjs-interop';
29+
/** Export name of the initializer function. */
2830
functionName: ('input'|'model'|'output'|'outputFromObservable'|'viewChild'|'viewChildren'|
2931
'contentChild'|'contentChildren');
32+
/** Class member access levels compatible with the API. */
33+
allowedAccessLevels: ClassMemberAccessLevel[];
3034
}
3135

3236
/**
3337
* Metadata describing an Angular class member that was recognized through
3438
* a function initializer. Like `input`, `input.required` or `viewChild`.
3539
*/
36-
interface InitializerFunctionMetadata {
40+
export interface InitializerFunctionMetadata {
3741
/** Initializer API function that was recognized. */
3842
api: InitializerApiFunction;
3943
/** Node referring to the call expression. */
@@ -58,11 +62,12 @@ interface StaticInitializerData {
5862
}
5963

6064
/**
61-
* Attempts to identify an Angular class member that is declared via
62-
* its initializer referring to a given initializer API function.
65+
* Attempts to identify an Angular initializer function call.
6366
*
6467
* Note that multiple possible initializer API function names can be specified,
6568
* allowing for checking multiple types in one pass.
69+
*
70+
* @returns The parsed initializer API, or null if none was found.
6671
*/
6772
export function tryParseInitializerApi<Functions extends InitializerApiFunction[]>(
6873
functions: Functions, expression: ts.Expression, reflector: ReflectionHost,
@@ -80,19 +85,21 @@ export function tryParseInitializerApi<Functions extends InitializerApiFunction[
8085
return null;
8186
}
8287

88+
const {api, apiReference, isRequired} = staticResult;
89+
8390
// Once we've statically determined that the initializer is one of the APIs we're looking for, we
8491
// need to verify it using the type checker which accounts for things like shadowed variables.
8592
// This should be done as the absolute last step since using the type check can be expensive.
86-
const resolvedImport = reflector.getImportOfIdentifier(staticResult.apiReference);
87-
if (resolvedImport === null || staticResult.api.functionName !== resolvedImport.name ||
88-
staticResult.api.owningModule !== resolvedImport.from) {
93+
const resolvedImport = reflector.getImportOfIdentifier(apiReference);
94+
if (resolvedImport === null || api.functionName !== resolvedImport.name ||
95+
api.owningModule !== resolvedImport.from) {
8996
return null;
9097
}
9198

9299
return {
93-
api: staticResult.api,
100+
api,
94101
call: expression,
95-
isRequired: staticResult.isRequired,
102+
isRequired,
96103
};
97104
}
98105

packages/compiler-cli/src/ngtsc/annotations/directive/test/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ ts_library(
1313
"//packages/compiler",
1414
"//packages/compiler-cli/src/ngtsc/annotations/common",
1515
"//packages/compiler-cli/src/ngtsc/annotations/directive",
16+
"//packages/compiler-cli/src/ngtsc/diagnostics",
1617
"//packages/compiler-cli/src/ngtsc/file_system",
1718
"//packages/compiler-cli/src/ngtsc/file_system/testing",
1819
"//packages/compiler-cli/src/ngtsc/imports",

packages/compiler-cli/src/ngtsc/annotations/directive/test/initializer_functions_spec.ts

Lines changed: 142 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,22 @@
88

99
import ts from 'typescript';
1010

11+
import {ErrorCode, FatalDiagnosticError} from '../../../diagnostics';
1112
import {absoluteFrom} from '../../../file_system';
1213
import {runInEachFileSystem} from '../../../file_system/testing';
1314
import {ImportedSymbolsTracker} from '../../../imports';
14-
import {ClassMember, TypeScriptReflectionHost} from '../../../reflection';
15+
import {ClassMember, ClassMemberAccessLevel, TypeScriptReflectionHost} from '../../../reflection';
16+
import {reflectClassMember} from '../../../reflection/src/typescript';
1517
import {makeProgram} from '../../../testing';
18+
import {validateAccessOfInitializerApiMember} from '../src/initializer_function_access';
1619
import {InitializerApiFunction, tryParseInitializerApi} from '../src/initializer_functions';
1720

1821

1922
runInEachFileSystem(() => {
2023
const modelApi: InitializerApiFunction = {
2124
functionName: 'model',
2225
owningModule: '@angular/core',
26+
allowedAccessLevels: [ClassMemberAccessLevel.PublicWritable],
2327
};
2428

2529
describe('initializer function detection', () => {
@@ -54,7 +58,11 @@ runInEachFileSystem(() => {
5458

5559
const result = tryParseInitializerApi(
5660
[
57-
{functionName: 'input', owningModule: '@angular/core'},
61+
{
62+
functionName: 'input',
63+
owningModule: '@angular/core',
64+
allowedAccessLevels: [ClassMemberAccessLevel.PublicWritable],
65+
},
5866
modelApi,
5967
],
6068
member.value!, reflector, importTracker);
@@ -80,14 +88,19 @@ runInEachFileSystem(() => {
8088
const result = tryParseInitializerApi(
8189
[
8290
modelApi,
83-
{functionName: 'outputFromObservable', owningModule: '@angular/core/rxjs-interop'},
91+
{
92+
functionName: 'outputFromObservable',
93+
owningModule: '@angular/core/rxjs-interop',
94+
allowedAccessLevels: [ClassMemberAccessLevel.PublicWritable],
95+
},
8496
],
8597
member.value!, reflector, importTracker);
8698

8799
expect(result).toEqual({
88100
api: {
89101
functionName: 'outputFromObservable',
90102
owningModule: '@angular/core/rxjs-interop',
103+
allowedAccessLevels: [ClassMemberAccessLevel.PublicWritable],
91104
},
92105
isRequired: false,
93106
call: jasmine.objectContaining({kind: ts.SyntaxKind.CallExpression}),
@@ -286,40 +299,142 @@ runInEachFileSystem(() => {
286299
call: jasmine.objectContaining({kind: ts.SyntaxKind.CallExpression}),
287300
});
288301
});
302+
303+
describe('`validateAccessOfInitializerApiMember`', () => {
304+
it('should report errors if a private field is used, but not allowed', () => {
305+
const {member, reflector, importTracker} = setup(`
306+
import {Directive, model} from '@angular/core';
307+
308+
@Directive()
309+
class Dir {
310+
private test = model(1);
311+
}
312+
`);
313+
314+
const result = tryParseInitializerApi([modelApi], member.value!, reflector, importTracker);
315+
316+
expect(result).not.toBeNull();
317+
expect(() => validateAccessOfInitializerApiMember(result!, member))
318+
.toThrowMatching(
319+
err => err instanceof FatalDiagnosticError &&
320+
err.code === ErrorCode.INITIALIZER_API_DISALLOWED_MEMBER_VISIBILITY);
321+
});
322+
323+
it('should report errors if a protected field is used, but not allowed', () => {
324+
const {member, reflector, importTracker} = setup(`
325+
import {Directive, model} from '@angular/core';
326+
327+
@Directive()
328+
class Dir {
329+
protected test = model(1);
330+
}
331+
`);
332+
333+
const result = tryParseInitializerApi([modelApi], member.value!, reflector, importTracker);
334+
335+
expect(result).not.toBeNull();
336+
expect(() => validateAccessOfInitializerApiMember(result!, member))
337+
.toThrowMatching(
338+
err => err instanceof FatalDiagnosticError &&
339+
err.code === ErrorCode.INITIALIZER_API_DISALLOWED_MEMBER_VISIBILITY);
340+
});
341+
342+
it('should report errors if an ECMAScript private field is used, but not allowed', () => {
343+
const {member, reflector, importTracker} = setup(`
344+
import {Directive, model} from '@angular/core';
345+
346+
@Directive()
347+
class Dir {
348+
#test = model(1);
349+
}
350+
`);
351+
352+
const result = tryParseInitializerApi([modelApi], member.value!, reflector, importTracker);
353+
354+
expect(result).not.toBeNull();
355+
expect(() => validateAccessOfInitializerApiMember(result!, member))
356+
.toThrowMatching(
357+
err => err instanceof FatalDiagnosticError &&
358+
err.code === ErrorCode.INITIALIZER_API_DISALLOWED_MEMBER_VISIBILITY);
359+
});
360+
361+
it('should report errors if a readonly public field is used, but not allowed', () => {
362+
const {member, reflector, importTracker} = setup(`
363+
import {Directive, model} from '@angular/core';
364+
365+
@Directive()
366+
class Dir {
367+
// test model initializer API definition doesn't even allow readonly!
368+
readonly test = model(1);
369+
}
370+
`);
371+
372+
const result = tryParseInitializerApi([modelApi], member.value!, reflector, importTracker);
373+
374+
expect(result).not.toBeNull();
375+
expect(() => validateAccessOfInitializerApiMember(result!, member))
376+
.toThrowMatching(
377+
err => err instanceof FatalDiagnosticError &&
378+
err.code === ErrorCode.INITIALIZER_API_DISALLOWED_MEMBER_VISIBILITY);
379+
});
380+
381+
it('should allow private field if API explicitly allows it', () => {
382+
const {member, reflector, importTracker} = setup(`
383+
import {Directive, model} from '@angular/core';
384+
385+
@Directive()
386+
class Dir {
387+
// test model initializer API definition doesn't even allow readonly!
388+
private test = model(1);
389+
}
390+
`);
391+
392+
const result = tryParseInitializerApi(
393+
[{...modelApi, allowedAccessLevels: [ClassMemberAccessLevel.Private]}], member.value!,
394+
reflector, importTracker);
395+
396+
expect(result?.api).toEqual(jasmine.objectContaining<InitializerApiFunction>({
397+
functionName: 'model'
398+
}));
399+
expect(() => validateAccessOfInitializerApiMember(result!, member)).not.toThrow();
400+
});
401+
});
289402
});
290403

291404

292405
function setup(contents: string) {
293406
const fileName = absoluteFrom('/test.ts');
294-
const {program} = makeProgram([
295-
{
296-
name: absoluteFrom('/node_modules/@angular/core/index.d.ts'),
297-
contents: `
407+
const {program} = makeProgram(
408+
[
409+
{
410+
name: absoluteFrom('/node_modules/@angular/core/index.d.ts'),
411+
contents: `
298412
export const Directive: any;
299413
export const input: any;
300414
export const model: any;
301415
`,
302-
},
303-
{
304-
name: absoluteFrom('/node_modules/@angular/core/rxjs-interop/index.d.ts'),
305-
contents: `
416+
},
417+
{
418+
name: absoluteFrom('/node_modules/@angular/core/rxjs-interop/index.d.ts'),
419+
contents: `
306420
export const outputFromObservable: any;
307421
`,
308-
},
309-
{
310-
name: absoluteFrom('/node_modules/@unknown/utils/index.d.ts'),
311-
contents: `
422+
},
423+
{
424+
name: absoluteFrom('/node_modules/@unknown/utils/index.d.ts'),
425+
contents: `
312426
export declare function toString(value: any): string;
313427
`,
314-
},
315-
{
316-
name: absoluteFrom('/node_modules/@not-angular/core/index.d.ts'),
317-
contents: `
428+
},
429+
{
430+
name: absoluteFrom('/node_modules/@not-angular/core/index.d.ts'),
431+
contents: `
318432
export const model: any;
319433
`,
320-
},
321-
{name: fileName, contents}
322-
]);
434+
},
435+
{name: fileName, contents}
436+
],
437+
{target: ts.ScriptTarget.ESNext});
323438
const sourceFile = program.getSourceFile(fileName);
324439
const importTracker = new ImportedSymbolsTracker();
325440
const reflector = new TypeScriptReflectionHost(program.getTypeChecker());
@@ -328,11 +443,13 @@ function setup(contents: string) {
328443
throw new Error(`Cannot resolve test file ${fileName}`);
329444
}
330445

331-
let member: Pick<ClassMember, 'value'>|null = null;
446+
let member: Pick<ClassMember, 'value'|'accessLevel'>|null = null;
332447

333448
(function walk(node: ts.Node) {
334-
if (ts.isPropertyDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === 'test') {
335-
member = {value: node.initializer ?? null};
449+
if (ts.isPropertyDeclaration(node) &&
450+
(ts.isIdentifier(node.name) && node.name.text === 'test' ||
451+
ts.isPrivateIdentifier(node.name) && node.name.text === '#test')) {
452+
member = reflectClassMember(node);
336453
} else {
337454
ts.forEachChild(node, walk);
338455
}

packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export enum ErrorCode {
4444
*/
4545
INITIALIZER_API_NO_REQUIRED_FUNCTION = 1052,
4646

47+
/**
48+
* Raised whenever an initializer API is used on a class member
49+
* and the given access modifiers (e.g. `private`) are not allowed.
50+
*/
51+
INITIALIZER_API_DISALLOWED_MEMBER_VISIBILITY = 1053,
52+
4753
/**
4854
* An Angular feature, like inputs, outputs or queries is incorrectly
4955
* declared on a static member.

0 commit comments

Comments
 (0)