From a196c8d8db7fc3bbc135c50f87d171571fc6a364 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Mon, 22 Jul 2024 14:57:13 +0000 Subject: [PATCH] refactor(migrations): add initial helpers and registry for tracking inputs (#57082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/input_detection/directive_info.ts | 52 ++++++++ .../src/input_detection/incompatibility.ts | 46 +++++++ .../src/input_detection/input_node.ts | 28 +++++ .../src/input_detection/known_inputs.ts | 112 ++++++++++++++++++ .../src/input_detection/nodes_to_input.ts | 37 ++++++ .../signal-migration/src/utils/input_id.ts | 62 ++++++++++ 6 files changed, 337 insertions(+) create mode 100644 packages/core/schematics/migrations/signal-migration/src/input_detection/directive_info.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/input_detection/input_node.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/input_detection/known_inputs.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/input_detection/nodes_to_input.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/utils/input_id.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/input_detection/directive_info.ts b/packages/core/schematics/migrations/signal-migration/src/input_detection/directive_info.ts new file mode 100644 index 00000000000..5d695fd6821 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/input_detection/directive_info.ts @@ -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(); + + /** Map of input IDs and their incompatibilities. */ + memberIncompatibility = new Map(); + + /** 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); + } +} diff --git a/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.ts b/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.ts new file mode 100644 index 00000000000..9cdec9ac768 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.ts @@ -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).reason !== undefined && + (value as Partial).context !== undefined && + InputIncompatibilityReason.hasOwnProperty( + (value as Partial).reason!, + ) + ); +} diff --git a/packages/core/schematics/migrations/signal-migration/src/input_detection/input_node.ts b/packages/core/schematics/migrations/signal-migration/src/input_detection/input_node.ts new file mode 100644 index 00000000000..e97b7afe696 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/input_detection/input_node.ts @@ -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 + ); +} diff --git a/packages/core/schematics/migrations/signal-migration/src/input_detection/known_inputs.ts b/packages/core/schematics/migrations/signal-migration/src/input_detection/known_inputs.ts new file mode 100644 index 00000000000..412640f79fd --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/input_detection/known_inputs.ts @@ -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(); + + /** Known container classes of inputs. */ + private _allClasses = new Set(); + /** Maps classes to their directive info. */ + private _classToDirectiveInfo = new Map(); + + /** Whether the given input exists. */ + has(descr: Pick): 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): 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; + } +} diff --git a/packages/core/schematics/migrations/signal-migration/src/input_detection/nodes_to_input.ts b/packages/core/schematics/migrations/signal-migration/src/input_detection/nodes_to_input.ts new file mode 100644 index 00000000000..91a2038dac4 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/input_detection/nodes_to_input.ts @@ -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; +} diff --git a/packages/core/schematics/migrations/signal-migration/src/utils/input_id.ts b/packages/core/schematics/migrations/signal-migration/src/utils/input_id.ts new file mode 100644 index 00000000000..04904f9f5a8 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/utils/input_id.ts @@ -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 || ''; + } else { + className = node.parent.name?.text ?? ''; + } + + 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).key !== undefined && + (v as Partial).node !== undefined + ); +}