refactor(migrations): add initial helpers and registry for tracking inputs (#57082)

This commit adds the initial set of helpers and registries for tracking
inputs discovered in the signal input migration.

A few short summary notes:

- Every input has an unique key. This key is used for global analysis
  that may be performed when the migration is executed on a per
  individual unit basis in Google3 via e.g. go/tsunami — The keys allow
  us to build a combined global metadata for e.g. references or figuring
  out which inputs are incompatible or not.

- A known input may be _any_ input in an compilation unit/ the program.
  E.g. even inputs from `d.ts`. We keep track of these inputs so that we
  can later figure out if a reference `ts.Identifier` points to any of
  those. In addition it allows us to attach incompatibility metadata to
  those. I.e. incompatible for migration because "something writes to
  the input".

PR Close #57082
This commit is contained in:
Paul Gschwendtner 2024-07-22 14:57:13 +00:00 committed by Andrew Scott
parent 521a8d287b
commit a196c8d8db
6 changed files with 337 additions and 0 deletions

View file

@ -0,0 +1,52 @@
/**
* @license
* Copyright Google LLC 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 ts from 'typescript';
import {ExtractedInput} from './input_decorator';
import {InputDescriptor, InputUniqueKey} from '../utils/input_id';
import {ClassIncompatibilityReason, InputMemberIncompatibility} from './incompatibility';
/**
* Class that holds information about a given directive and its input fields.
*/
export class DirectiveInfo {
/**
* Map of inputs detected in the given class.
* Maps string-based input ids to the detailed input metadata.
*/
inputFields = new Map<InputUniqueKey, {descriptor: InputDescriptor; metadata: ExtractedInput}>();
/** Map of input IDs and their incompatibilities. */
memberIncompatibility = new Map<InputUniqueKey, InputMemberIncompatibility>();
/** Whether the whole class is incompatible. */
incompatible: ClassIncompatibilityReason | null = null;
constructor(public clazz: ts.ClassDeclaration) {}
/**
* Checks whether the class is skipped for migration because all of
* the inputs are marked as incompatible, or the class itself.
*/
isClassSkippedForMigration(): boolean {
return (
this.incompatible !== null ||
Array.from(this.inputFields.values()).every(({descriptor}) =>
this.isInputMemberIncompatible(descriptor),
)
);
}
/**
* Whether the given input member is incompatible. If the class is incompatible,
* then the member is as well.
*/
isInputMemberIncompatible(input: InputDescriptor): boolean {
return this.incompatible !== null || this.memberIncompatibility.has(input.key);
}
}

View file

@ -0,0 +1,46 @@
/**
* @license
* Copyright Google LLC 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 ts from 'typescript';
/** Reasons why an input cannot be migrated. */
export enum InputIncompatibilityReason {
Accessor,
WriteAssignment,
OverriddenByDerivedClass,
RedeclaredViaDerivedClassDecorator,
TypeConflictWithBaseClass,
ParentIsIncompatible,
SpyOnThatOverwritesField,
NarrowedInTemplateButNotSupportedYetTODO,
IgnoredBecauseOfLanguageServiceRefactoringRange,
RequiredInputButNoGoodExplicitTypeExtractable,
}
/** Reasons why a whole class and its inputs cannot be migrated. */
export enum ClassIncompatibilityReason {
ClassManuallyInstantiated,
ClassReferencedInPotentiallyBadLocation,
}
/** Description of an input incompatibility and some helpful debug context. */
export interface InputMemberIncompatibility {
reason: InputIncompatibilityReason;
context: ts.Node | null;
}
/** Whether the given value refers to an input member incompatibility. */
export function isInputMemberIncompatibility(value: unknown): value is InputMemberIncompatibility {
return (
(value as Partial<InputMemberIncompatibility>).reason !== undefined &&
(value as Partial<InputMemberIncompatibility>).context !== undefined &&
InputIncompatibilityReason.hasOwnProperty(
(value as Partial<InputMemberIncompatibility>).reason!,
)
);
}

View file

@ -0,0 +1,28 @@
/**
* @license
* Copyright Google LLC 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 ts from 'typescript';
import {getMemberName} from '../utils/class_member_names';
/** Variants of input names supported by Angular compiler's input detection. */
export type InputNameNode = ts.Identifier | ts.StringLiteral | ts.PrivateIdentifier;
/** Describes a TypeScript node that can be an Angular `@Input()` declaration. */
export type InputNode = (ts.AccessorDeclaration | ts.PropertyDeclaration) & {
name: InputNameNode;
parent: ts.ClassDeclaration;
};
/** Checks whether the given node can be an `@Input()` declaration node. */
export function isInputContainerNode(node: ts.Node): node is InputNode {
return (
((ts.isAccessor(node) && ts.isClassDeclaration(node.parent)) ||
ts.isPropertyDeclaration(node)) &&
getMemberName(node) !== null
);
}

View file

@ -0,0 +1,112 @@
/**
* @license
* Copyright Google LLC 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 ts from 'typescript';
import {InputDescriptor, InputUniqueKey} from '../utils/input_id';
import {ExtractedInput} from './input_decorator';
import {InputNode} from './input_node';
import {DirectiveInfo} from './directive_info';
import {ClassIncompatibilityReason, InputMemberIncompatibility} from './incompatibility';
/**
* Public interface describing a single known `@Input()` in the
* compilation.
*
* A known `@Input()` may be defined in sources, or inside some `d.ts` files
* loaded into the program.
*/
export type KnownInputInfo = {
metadata: ExtractedInput;
descriptor: InputDescriptor;
container: DirectiveInfo;
isIncompatible: () => boolean;
};
/**
* Registry keeping track of all known `@Input()`s in the compilation.
*
* A known `@Input()` may be defined in sources, or inside some `d.ts` files
* loaded into the program.
*/
export class KnownInputs {
/**
* Known inputs from the whole program.
*/
knownInputIds = new Map<InputUniqueKey, KnownInputInfo>();
/** Known container classes of inputs. */
private _allClasses = new Set<ts.ClassDeclaration>();
/** Maps classes to their directive info. */
private _classToDirectiveInfo = new Map<ts.ClassDeclaration, DirectiveInfo>();
/** Whether the given input exists. */
has(descr: Pick<InputDescriptor, 'key'>): boolean {
return this.knownInputIds.has(descr.key);
}
/** Whether the given class contains `@Input`s. */
isInputContainingClass(clazz: ts.ClassDeclaration): boolean {
return this._classToDirectiveInfo.has(clazz);
}
/** Gets precise `@Input()` information for the given class. */
getDirectiveInfoForClass(clazz: ts.ClassDeclaration): DirectiveInfo | undefined {
return this._classToDirectiveInfo.get(clazz);
}
/** Gets known input information for the given `@Input()`. */
get(descr: Pick<InputDescriptor, 'key'>): KnownInputInfo | undefined {
return this.knownInputIds.get(descr.key);
}
/** Gets all classes containing `@Input`s in the compilation. */
getAllInputContainingClasses(): ts.ClassDeclaration[] {
return Array.from(this._allClasses.values());
}
/** Registers an `@Input()` in the registry. */
register(data: {descriptor: InputDescriptor; node: InputNode; metadata: ExtractedInput}) {
if (!this._classToDirectiveInfo.has(data.node.parent)) {
this._classToDirectiveInfo.set(data.node.parent, new DirectiveInfo(data.node.parent));
}
const directiveInfo = this._classToDirectiveInfo.get(data.node.parent)!;
directiveInfo.inputFields.set(data.descriptor.key, {
descriptor: data.descriptor,
metadata: data.metadata,
});
this.knownInputIds.set(data.descriptor.key, {
metadata: data.metadata,
descriptor: data.descriptor,
container: directiveInfo,
isIncompatible: () => directiveInfo.isInputMemberIncompatible(data.descriptor),
});
this._allClasses.add(data.node.parent);
}
/** Marks the given input as incompatible for migration. */
markInputAsIncompatible(input: InputDescriptor, incompatibility: InputMemberIncompatibility) {
if (!this.knownInputIds.has(input.key)) {
throw new Error(`Input cannot be marked as incompatible because it's not registered.`);
}
this.knownInputIds
.get(input.key)!
.container.memberIncompatibility.set(input.key, incompatibility);
}
/** Marks the given class as incompatible for migration. */
markDirectiveAsIncompatible(
clazz: ts.ClassDeclaration,
incompatibility: ClassIncompatibilityReason,
) {
if (!this._classToDirectiveInfo.has(clazz)) {
throw new Error(`Class cannot be marked as incompatible because it's not known.`);
}
this._classToDirectiveInfo.get(clazz)!.incompatible = incompatibility;
}
}

View file

@ -0,0 +1,37 @@
/**
* @license
* Copyright Google LLC 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 ts from 'typescript';
import {MigrationHost} from '../migration_host';
import {isInputContainerNode} from '../input_detection/input_node';
import {getInputDescriptor} from '../utils/input_id';
import {KnownInputInfo, KnownInputs} from './known_inputs';
/**
* Attempts to resolve the known `@Input` metadata for the given
* type checking symbol. Returns `null` if it's not for an input.
*/
export function attemptRetrieveInputFromSymbol(
host: MigrationHost,
memberSymbol: ts.Symbol,
knownInputs: KnownInputs,
): KnownInputInfo | null {
// Even for declared classes from `.d.ts`, the value declaration
// should exist and point to the property declaration.
if (
memberSymbol.valueDeclaration !== undefined &&
isInputContainerNode(memberSymbol.valueDeclaration)
) {
const member = memberSymbol.valueDeclaration;
// If the member itself is an input that is being migrated, we
// do not need to check, as overriding would be fine then— like before.
const memberInputDescr = isInputContainerNode(member) ? getInputDescriptor(host, member) : null;
return memberInputDescr !== null ? knownInputs.get(memberInputDescr) ?? null : null;
}
return null;
}

View file

@ -0,0 +1,62 @@
/**
* @license
* Copyright Google LLC 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 ts from 'typescript';
import {MigrationHost} from '../migration_host';
import {InputNode} from '../input_detection/input_node';
/**
* Unique key for an input in a project.
*
* This is the serializable variant, raw string form that
* is serializable and allows for cross-target knowledge
* needed for the batching capability (via e.g. go/tsunami).
*/
export type InputUniqueKey = {__inputUniqueKey: true};
/**
* Interface that describes an input recognized in the
* migration and project.
*/
export interface InputDescriptor {
key: InputUniqueKey;
node: InputNode;
}
/**
* Gets the descriptor for the given input node.
*
* An input descriptor describes a recognized input in the
* whole project (regardless of batching) and allows easy
* access to the associated TypeScript declaration node, while
* also providing a unique key for the input that can be used
* for serializable communication between compilation units
* (e.g. when running via batching; in e.g. go/tsunami).
*/
export function getInputDescriptor(host: MigrationHost, node: InputNode): InputDescriptor {
let className: string;
if (ts.isAccessor(node)) {
className = node.parent.name?.text || '<anonymous>';
} else {
className = node.parent.name?.text ?? '<anonymous>';
}
const fileId = host.fileToId(node.getSourceFile());
return {
key: `${fileId}@@${className}@@${node.name.text}` as unknown as InputUniqueKey,
node,
};
}
/** Whether the given value is an input descriptor. */
export function isInputDescriptor(v: unknown): v is InputDescriptor {
return (
(v as Partial<InputDescriptor>).key !== undefined &&
(v as Partial<InputDescriptor>).node !== undefined
);
}