File

libs/nx/src/migrations/update-building-block-id-consumers/update-building-block-id-consumers.ts

Index

Properties

Properties

templateChanged
Type boolean
Description

An external template (.html) file was modified in the tree.

tsChanged
Type boolean
Description

The TypeScript source file was modified and must be written back.

import { formatFiles, getProjects, logger, Tree, visitNotIgnoredFiles } from '@nx/devkit';
import * as path from 'path';
import {
  ClassDeclaration,
  Node,
  Project,
  PropertyAccessExpression,
  PropertyDeclaration,
  QuoteKind,
  SourceFile,
  SyntaxKind
} from 'ts-morph';
import { ManualReviewItem, reportManualReviewItems, toSnippet } from '../utils/manual-review';
import {
  migrateTemplate,
  TemplateMemberSet,
  templateTextMentionsMember
} from '../utils/template-migration';
import { NxTreeFileSystemHost } from '../utils/ts-morph-tree-file-system-host';
import { findTsConfigInProjectRoot } from '../utils/ts-morph-utils';

/**
 * `AbstractBuildingBlock.id` moved from `@Input()` to a signal input in TALY v55.0.0.
 * Unlike other migrated members, `id` is often overridden by subclasses with a fixed
 * value, so this migration also handles that case:
 * 1. Rewrites a subclass's `id` override to `override readonly id = input(...)`.
 * 2. Unwraps `this.id` / `<ref>.id` reads (`x` -> `x()`) in TS and in templates
 *    (inline and external), including on variables typed as `AbstractBuildingBlock`.
 *
 * A non-override write (`someBb.id = 'x'`) has no safe rewrite and is reported for
 * manual review instead.
 */
const TARGET_MODULE = '@allianz/taly-core/building-blocks';
const TARGET_CLASS_NAMES = ['AbstractBuildingBlock', 'BuildingBlockInterface'];
const SUBCLASSABLE_TARGET_CLASS = 'AbstractBuildingBlock';
const MEMBER = 'id';

// A cheap pre-filter: only inspect files that could plausibly re-export the target
// (used to widen the scan to barrel files that don't name the class themselves).
const RE_EXPORT_PATTERN =
  /\bexport\s+(?:type\s+)?(?:\*(?:\s+as\s+[$\w]+)?|\{[^}]*\})\s*from\s*['"]/;

// Compound assignment operators (excluding the plain `=`).
const COMPOUND_ASSIGNMENT_OPERATORS = new Set<SyntaxKind>([
  SyntaxKind.PlusEqualsToken,
  SyntaxKind.MinusEqualsToken,
  SyntaxKind.AsteriskEqualsToken,
  SyntaxKind.SlashEqualsToken,
  SyntaxKind.PercentEqualsToken,
  SyntaxKind.AsteriskAsteriskEqualsToken,
  SyntaxKind.AmpersandEqualsToken,
  SyntaxKind.BarEqualsToken,
  SyntaxKind.CaretEqualsToken,
  SyntaxKind.LessThanLessThanEqualsToken,
  SyntaxKind.GreaterThanGreaterThanEqualsToken,
  SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
  SyntaxKind.BarBarEqualsToken,
  SyntaxKind.AmpersandAmpersandEqualsToken,
  SyntaxKind.QuestionQuestionEqualsToken
]);

const MANUAL_WRITE_REASON =
  "assignment to a Building Block's `id`, which is now a read-only input signal " +
  '(`InputSignal<string>`) — there is no external setter; bind `[id]` in the template ' +
  'instead, or restructure the code';

export default async function updateBuildingBlockIdConsumers(tree: Tree) {
  const projects = getProjects(tree);
  let changesApplied = false;

  const manualItems: ManualReviewItem[] = [];

  for (const [projectName, nxProject] of projects) {
    let project: Project;
    try {
      const tsConfigFilePath = findTsConfigInProjectRoot(tree, nxProject.root);

      project = new Project({
        tsConfigFilePath,
        fileSystem: new NxTreeFileSystemHost(tree),
        manipulationSettings: {
          quoteKind: QuoteKind.Single
        }
      });
    } catch (error) {
      // A project failing to construct (e.g. a malformed tsconfig) must not discard
      // the manual-review items already collected from every project processed so far.
      logger.error(
        `update-building-block-id-consumers: skipped project '${projectName}' — ${
          error instanceof Error ? error.message : String(error)
        }`
      );
      continue;
    }

    visitNotIgnoredFiles(tree, nxProject.root, (filePath) => {
      if (!filePath.endsWith('.ts')) return;

      try {
        const fileContent = tree.read(filePath, 'utf-8');
        if (fileContent === null) return;

        if (!fileContent.includes(TARGET_MODULE)) return;
        const mightBeAffected =
          TARGET_CLASS_NAMES.some((name) => fileContent.includes(name)) ||
          RE_EXPORT_PATTERN.test(fileContent);
        if (!mightBeAffected) return;

        const sourceFile = project.getSourceFile(filePath) ?? project.addSourceFileAtPath(filePath);

        const { tsChanged, templateChanged } = processFile(sourceFile, tree, filePath, manualItems);
        if (tsChanged) {
          tree.write(filePath, sourceFile.getFullText());
        }
        if (tsChanged || templateChanged) {
          changesApplied = true;
        }
      } catch (error) {
        // A single file failing to process must not abort the rest of the project —
        // otherwise every file after it is silently left unmigrated.
        logger.error(
          `update-building-block-id-consumers: skipped file '${filePath}' in project ` +
            `'${projectName}' — ${error instanceof Error ? error.message : String(error)}`
        );
      }
    });
  }

  if (changesApplied) {
    await formatFiles(tree);
  }

  reportManualReviewItems(manualItems, 'Building Block `id`');
}

interface FileChanges {
  /** The TypeScript source file was modified and must be written back. */
  tsChanged: boolean;
  /** An external template (`.html`) file was modified in the tree. */
  templateChanged: boolean;
}

function processFile(
  sourceFile: SourceFile,
  tree: Tree,
  filePath: string,
  manualItems: ManualReviewItem[]
): FileChanges {
  // A barrel re-exporting the target is not itself migratable, but it hides the
  // target from every file importing through it — report it up front.
  manualItems.push(...collectReExportReviewItems(sourceFile, filePath));
  manualItems.push(...collectElementAccessAndDestructuringReviewItems(sourceFile, filePath));

  // `BuildingBlockInterface` cannot appear in a class's `extends` clause (it's an
  // interface), so only `AbstractBuildingBlock` imports seed the subclass-chain walk.
  // Both, however, are valid types for a *reference* (`buildingBlock: BuildingBlockInterface`),
  // so both feed the instance-access name set below.
  const importedClassNames = collectImportedTargetNames(sourceFile, SUBCLASSABLE_TARGET_CLASS);
  const importedInterfaceNames = collectImportedTargetNames(sourceFile, 'BuildingBlockInterface');
  if (importedClassNames.size === 0 && importedInterfaceNames.size === 0) {
    return { tsChanged: false, templateChanged: false };
  }

  // Extend the class-name set to locally-declared subclasses of a subclass (a chain of
  // base classes defined within this same file). Cross-file chains are out of scope.
  const subclassTargetNames = collectExtendedTargetNames(sourceFile, importedClassNames);
  const instanceTargetNames = new Set([...subclassTargetNames, ...importedInterfaceNames]);

  let tsChanged = false;
  let templateChanged = false;

  for (const classDecl of sourceFile.getClasses()) {
    const extendsExpression = classDecl.getExtends();
    if (!extendsExpression) continue;

    const baseName = extendsExpression.getExpression().getText();
    if (!subclassTargetNames.has(baseName)) continue;

    tsChanged = processIdOverride(classDecl, filePath, manualItems) || tsChanged;

    // `id` re-declared as a get/set accessor, or with no initializer, has no safe
    // mechanical rewrite (see processIdOverride) and is left as-is. `this.id` inside
    // this same class still refers to that unconverted override, NOT the inherited
    // signal — rewriting it to `this.id()` would call a getter that returns a plain
    // string, throwing at runtime. Skip the unwrap in that case.
    //
    // Evaluated *after* processIdOverride so a just-converted `id = input(...)`
    // override counts as converted (and is therefore unwrapped).
    const idShadowed = isIdShadowedAndUnconverted(classDecl);
    // A class whose own `id` is shadowed-and-unconverted must also be excluded from
    // `instanceTargetNames` below — otherwise an *external* reference typed as this
    // class (e.g. `function f(bb: MyBb) { return bb.id; }`) would still get rewritten
    // to `bb.id()`, calling the plain-string override as a function and throwing.
    const className = classDecl.getName();
    if (idShadowed && className) {
      instanceTargetNames.delete(className);
    }

    tsChanged =
      transformAccesses(
        classDecl,
        (access) => !idShadowed && isThisMemberAccess(access),
        filePath,
        manualItems
      ) || tsChanged;

    // The subclass template reads the same inherited `id`, so it gets the same
    // treatment — including the shadowing skip, so the TypeScript and template sides
    // can never disagree about a given subclass.
    const templateMembers = idShadowed ? new Set<string>() : new Set([MEMBER]);
    const templateResult = processSubclassTemplate(
      classDecl,
      templateMembers,
      tree,
      filePath,
      manualItems
    );
    tsChanged = templateResult.inlineChanged || tsChanged;
    templateChanged = templateResult.externalChanged || templateChanged;
  }

  tsChanged =
    unwrapInstanceIdReads(sourceFile, instanceTargetNames, filePath, manualItems) || tsChanged;

  return { tsChanged, templateChanged };
}

/**
 * Migrates the Angular template of a Building Block subclass so `id` reads become
 * signal calls (`{{ id }}` -> `{{ id() }}`). Writes (an assignment, a two-way binding)
 * have no safe rewrite — a signal input has no external setter — and are reported for
 * manual review instead. Handles both the inline `template` (a string / no-substitution
 * template literal in the same `.ts` file) and an external `templateUrl` (a sibling
 * `.html` file).
 */
function processSubclassTemplate(
  classDecl: ClassDeclaration,
  members: Set<string>,
  tree: Tree,
  filePath: string,
  manualItems: ManualReviewItem[]
): { inlineChanged: boolean; externalChanged: boolean } {
  const result = { inlineChanged: false, externalChanged: false };

  if (members.size === 0) return result;

  // Building Block's `id` is a plain `InputSignal<string>`, so it is always empty.
  const memberSet: TemplateMemberSet = { members, plural: new Set() };

  const decoratorArgument = getComponentDecoratorObject(classDecl);
  if (!decoratorArgument) return result;

  // Inline template: `template: '...'` / `template: \`...\``.
  const inlineLiteral = getStringLikeInitializer(decoratorArgument, 'template');
  if (inlineLiteral) {
    const original = inlineLiteral.getLiteralText();
    const { text, manualItems: templateManualItems } = migrateTemplate(original, memberSet, {
      singular: 'signal input',
      plural: 'signal inputs'
    });
    if (text !== null && text !== original) {
      inlineLiteral.setLiteralValue(text);
      result.inlineChanged = true;
    }
    // Map template-local lines onto the .ts file: the literal's content starts on
    // the literal's own start line (line 1 of the template).
    const baseLine = inlineLiteral.getStartLineNumber() - 1;
    for (const item of templateManualItems) {
      manualItems.push({
        file: filePath,
        line: baseLine + item.line,
        snippet: item.snippet,
        reason: item.reason
      });
    }
  } else {
    // A substitution template literal (`\`<h1>${x}</h1> {{ id }}\``) isn't a plain
    // string, so it was never handed to migrateTemplate above — a mentioned `id`
    // read is otherwise unwrapped silently. Report it instead.
    reportInlineTemplateExpressionIfMentioned(
      decoratorArgument,
      memberSet.members,
      filePath,
      manualItems
    );
  }

  // External template: `templateUrl: './foo.component.html'`.
  const templateUrlLiteral = getStringLikeInitializer(decoratorArgument, 'templateUrl');
  if (templateUrlLiteral) {
    const templatePath = resolveTemplatePath(filePath, templateUrlLiteral.getLiteralText());
    if (tree.exists(templatePath)) {
      const original = tree.read(templatePath, 'utf-8');
      if (original !== null) {
        const { text, manualItems: templateManualItems } = migrateTemplate(original, memberSet, {
          singular: 'signal input',
          plural: 'signal inputs'
        });
        if (text !== null && text !== original) {
          tree.write(templatePath, text);
          result.externalChanged = true;
        }
        for (const item of templateManualItems) {
          manualItems.push({
            file: templatePath,
            line: item.line,
            snippet: item.snippet,
            reason: item.reason
          });
        }
      } else {
        manualItems.push({
          file: filePath,
          line: templateUrlLiteral.getStartLineNumber(),
          snippet: toSnippet(templateUrlLiteral.getText()),
          reason:
            `external template '${templateUrlLiteral.getLiteralText()}' could not be read ` +
            `at '${templatePath}' — if it reads \`id\`, unwrap the read manually`
        });
      }
    } else {
      manualItems.push({
        file: filePath,
        line: templateUrlLiteral.getStartLineNumber(),
        snippet: toSnippet(templateUrlLiteral.getText()),
        reason:
          `external template '${templateUrlLiteral.getLiteralText()}' could not be located ` +
          `at '${templatePath}' — if it reads \`id\`, unwrap the read manually`
      });
    }
  } else {
    reportDynamicTemplateUrlIfPresent(decoratorArgument, filePath, manualItems);
  }

  return result;
}

/** Returns the object-literal argument of a class's `@Component({...})` decorator. */
function getComponentDecoratorObject(classDecl: ClassDeclaration) {
  const decorator = classDecl.getDecorator('Component');
  const argument = decorator?.getArguments()[0];
  return argument?.asKind(SyntaxKind.ObjectLiteralExpression);
}

/**
 * Returns the initializer of `propertyName` when it is a plain string or a
 * no-substitution template literal (both expose `getLiteralText` / `setLiteralValue`).
 * Substitution template literals (`\`...${x}...\``) are skipped — an inline template
 * that interpolates TS values cannot be rewritten as a single literal.
 */
function getStringLikeInitializer(
  objectLiteral: ReturnType<typeof getComponentDecoratorObject>,
  propertyName: string
) {
  const property = objectLiteral?.getProperty(propertyName)?.asKind(SyntaxKind.PropertyAssignment);
  const initializer = property?.getInitializer();
  if (!initializer) return undefined;

  const stringLiteral = initializer.asKind(SyntaxKind.StringLiteral);
  if (stringLiteral) return stringLiteral;

  return initializer.asKind(SyntaxKind.NoSubstitutionTemplateLiteral);
}

/**
 * Reports the `template:` property when it's a substitution template literal
 * (`` `<h1>${x}</h1> {{ id }}` ``) mentioning one of `members` — {@link
 * getStringLikeInitializer} can't return it (there's no single literal to rewrite),
 * so without this check the mention is never migrated NOR reported.
 */
function reportInlineTemplateExpressionIfMentioned(
  objectLiteral: ReturnType<typeof getComponentDecoratorObject>,
  members: Set<string>,
  filePath: string,
  manualItems: ManualReviewItem[]
): void {
  const property = objectLiteral?.getProperty('template')?.asKind(SyntaxKind.PropertyAssignment);
  const templateExpression = property?.getInitializer()?.asKind(SyntaxKind.TemplateExpression);
  if (!templateExpression) return;
  if (!templateTextMentionsMember(templateExpression.getText(), members)) return;

  manualItems.push({
    file: filePath,
    line: templateExpression.getStartLineNumber(),
    snippet: toSnippet(templateExpression.getText()),
    reason:
      'inline `template` is a substitution template literal (contains `${...}`) mentioning ' +
      '`id` — it cannot be rewritten automatically; unwrap the read manually'
  });
}

/**
 * Reports the `templateUrl:` property when it's a substitution template literal
 * (a dynamically built path) — {@link getStringLikeInitializer} can't return it, so the
 * external template file can never be resolved or checked for an `id` read; without
 * this check the mention is never migrated NOR reported.
 */
function reportDynamicTemplateUrlIfPresent(
  objectLiteral: ReturnType<typeof getComponentDecoratorObject>,
  filePath: string,
  manualItems: ManualReviewItem[]
): void {
  const property = objectLiteral?.getProperty('templateUrl')?.asKind(SyntaxKind.PropertyAssignment);
  const templateExpression = property?.getInitializer()?.asKind(SyntaxKind.TemplateExpression);
  if (!templateExpression) return;

  manualItems.push({
    file: filePath,
    line: templateExpression.getStartLineNumber(),
    snippet: toSnippet(templateExpression.getText()),
    reason:
      '`templateUrl` is a substitution template literal (contains `${...}`) — the external ' +
      'template file cannot be resolved automatically; check it manually for an `id` read'
  });
}

/** Resolves a `templateUrl` relative to the component file, as a tree path. */
function resolveTemplatePath(componentFilePath: string, templateUrl: string): string {
  const directory = path.dirname(componentFilePath);
  return path.join(directory, templateUrl).split(path.sep).join('/');
}

/**
 * Maps every local name that refers to `className` (imported from {@link TARGET_MODULE})
 * onto itself. Both import forms a consumer can use are covered — a named import
 * (honouring an alias) and a namespace import (which binds the qualified name, e.g.
 * `bb.AbstractBuildingBlock`).
 */
function collectImportedTargetNames(sourceFile: SourceFile, className: string): Set<string> {
  const names = new Set<string>();

  for (const importDecl of sourceFile.getImportDeclarations()) {
    if (importDecl.getModuleSpecifierValue() !== TARGET_MODULE) continue;

    for (const namedImport of importDecl.getNamedImports()) {
      if (namedImport.getName() !== className) continue;
      names.add(namedImport.getAliasNode()?.getText() ?? namedImport.getName());
    }

    const namespaceImport = importDecl.getNamespaceImport();
    if (namespaceImport) {
      names.add(`${namespaceImport.getText()}.${className}`);
    }
  }

  return names;
}

/**
 * True when `id` is re-declared in `classDecl` (a get/set accessor, or a property with
 * no initializer) but was NOT converted to `input(...)` — i.e. `this.id` inside this
 * class still resolves to the unconverted override, not the inherited signal.
 */
function isIdShadowedAndUnconverted(classDecl: ClassDeclaration): boolean {
  const idAccessor = classDecl.getGetAccessor(MEMBER) ?? classDecl.getSetAccessor(MEMBER);
  if (idAccessor) return true;

  const idProperty = classDecl.getProperty(MEMBER);
  if (!idProperty) return false;

  const initializer = idProperty.getInitializer();
  if (!initializer) return true;

  const initializerCall = initializer.asKind(SyntaxKind.CallExpression);
  const calleeText = initializerCall?.getExpression().getText();
  return !(calleeText === 'input' || calleeText?.startsWith('input.'));
}

/**
 * Extends `initialNames` with every class declared in this file that (transitively)
 * extends one of them — so `class B extends A {}` / `class C extends B {}`, with `A`
 * imported from the target module, all resolve to "is a Building Block" even though
 * only `A` is actually imported.
 */
function collectExtendedTargetNames(
  sourceFile: SourceFile,
  initialNames: Set<string>
): Set<string> {
  const targetNames = new Set(initialNames);

  let changed = true;
  while (changed) {
    changed = false;
    for (const classDecl of sourceFile.getClasses()) {
      const name = classDecl.getName();
      if (!name || targetNames.has(name)) continue;

      const extendsExpression = classDecl.getExtends();
      if (!extendsExpression) continue;

      const baseName = extendsExpression.getExpression().getText();
      if (targetNames.has(baseName)) {
        targetNames.add(name);
        changed = true;
      }
    }
  }

  return targetNames;
}

/**
 * Reports an element-access read (`block['id']`) and an object-destructuring read
 * (`const { id } = block`) — neither is a `PropertyAccessExpression`, so
 * {@link transformAccesses} never sees them; without this check they are silently
 * left as-is, binding the getter *function* instead of its value after the migration.
 * There is no safe mechanical rewrite for either form (an element-access key may be
 * dynamic; a destructured binding loses its receiver entirely), so both are only ever
 * reported for manual review.
 */
function collectElementAccessAndDestructuringReviewItems(
  container: Node,
  filePath: string
): ManualReviewItem[] {
  const items: ManualReviewItem[] = [];

  for (const access of container.getDescendantsOfKind(SyntaxKind.ElementAccessExpression)) {
    const argument = access.getArgumentExpression();
    const literal =
      argument?.asKind(SyntaxKind.StringLiteral) ??
      argument?.asKind(SyntaxKind.NoSubstitutionTemplateLiteral);
    if (!literal || literal.getLiteralText() !== MEMBER) continue;

    items.push({
      file: filePath,
      line: access.getStartLineNumber(),
      snippet: toSnippet((access.getParent() ?? access).getText()),
      reason:
        'element-access read of `id`, now a signal input — this cannot be mechanically ' +
        "rewritten; unwrap it manually (e.g. `x['id']` -> `x['id']()`)"
    });
  }

  for (const bindingElement of container.getDescendantsOfKind(SyntaxKind.BindingElement)) {
    if (!bindingElement.getParentIfKind(SyntaxKind.ObjectBindingPattern)) continue;

    const name = (bindingElement.getPropertyNameNode() ?? bindingElement.getNameNode()).getText();
    if (name !== MEMBER) continue;

    items.push({
      file: filePath,
      line: bindingElement.getStartLineNumber(),
      snippet: toSnippet(bindingElement.getText()),
      reason:
        'object-destructuring read of `id`, now a signal input — this cannot be ' +
        'mechanically rewritten; unwrap it manually'
    });
  }

  return items;
}

/**
 * Reports every re-export of `AbstractBuildingBlock`, so the one shape this
 * migration knowingly cannot follow (a consumer importing it through a local
 * barrel rather than the entry point directly) is visible instead of silent.
 */
function collectReExportReviewItems(sourceFile: SourceFile, filePath: string): ManualReviewItem[] {
  const items: ManualReviewItem[] = [];

  for (const exportDecl of sourceFile.getExportDeclarations()) {
    const moduleValue = exportDecl.getModuleSpecifierValue();
    if (moduleValue !== TARGET_MODULE) continue;

    const reExportedNames = exportDecl.isNamespaceExport()
      ? TARGET_CLASS_NAMES
      : TARGET_CLASS_NAMES.filter((name) =>
          exportDecl.getNamedExports().some((namedExport) => namedExport.getName() === name)
        );
    if (reExportedNames.length === 0) continue;

    items.push({
      file: filePath,
      line: exportDecl.getStartLineNumber(),
      snippet: toSnippet(exportDecl.getText()),
      reason:
        `re-exports ${reExportedNames.join(', ')} — files importing it through this file ` +
        `(instead of directly from '${TARGET_MODULE}') were NOT migrated; review them and ` +
        'unwrap `.id` reads / rewrite `id` overrides manually'
    });
  }

  return items;
}

/**
 * Rewrites a subclass's own `id` override (a plain class property with an
 * initializer) to `override readonly id = input(<initializer>)`, adding the
 * `input` import if needed. An `id` re-declared as a get/set accessor, or with no
 * initializer, has no safe mechanical rewrite and is reported instead.
 */
function processIdOverride(
  classDecl: ClassDeclaration,
  filePath: string,
  manualItems: ManualReviewItem[]
): boolean {
  const idProperty = classDecl.getProperty(MEMBER);
  if (idProperty) {
    return rewriteIdProperty(idProperty, filePath, manualItems);
  }

  const idAccessor = classDecl.getGetAccessor(MEMBER) ?? classDecl.getSetAccessor(MEMBER);
  if (idAccessor) {
    manualItems.push({
      file: filePath,
      line: idAccessor.getStartLineNumber(),
      snippet: toSnippet(idAccessor.getText()),
      reason:
        "overrides `id` with a get/set accessor — AbstractBuildingBlock's `id` is now " +
        "an `InputSignal<string>`; replace this accessor with `override readonly id = input('...')`"
    });
  }

  return false;
}

function rewriteIdProperty(
  property: PropertyDeclaration,
  filePath: string,
  manualItems: ManualReviewItem[]
): boolean {
  const initializer = property.getInitializer();

  if (!initializer) {
    manualItems.push({
      file: filePath,
      line: property.getStartLineNumber(),
      snippet: toSnippet(property.getText()),
      reason:
        "overrides `id` with no initializer — AbstractBuildingBlock's `id` is now a signal " +
        "input; provide a default via `override readonly id = input('...')`"
    });
    return false;
  }

  // Already migrated: `id = input('...')` or `id = input.required(...)`.
  const initializerCall = initializer.asKind(SyntaxKind.CallExpression);
  const calleeText = initializerCall?.getExpression().getText();
  if (calleeText === 'input' || calleeText?.startsWith('input.')) {
    return false;
  }

  const initializerText = initializer.getText();
  property.getDecorator('Input')?.remove();
  property.replaceWithText(`override readonly ${MEMBER} = input(${initializerText});`);

  ensureNamedImport(property.getSourceFile(), '@angular/core', 'input');

  return true;
}

/** Adds a named import to an existing import from `moduleSpecifier`, or a new one. */
function ensureNamedImport(sourceFile: SourceFile, moduleSpecifier: string, name: string): void {
  const existing = sourceFile.getImportDeclaration(
    (decl) => decl.getModuleSpecifierValue() === moduleSpecifier
  );

  if (existing) {
    const alreadyImported = existing
      .getNamedImports()
      .some((namedImport) => namedImport.getName() === name);
    if (!alreadyImported) {
      existing.addNamedImport(name);
    }
    return;
  }

  sourceFile.addImportDeclaration({
    moduleSpecifier,
    namedImports: [name]
  });
}

/**
 * Rewrites `<ref>.id` accesses where `ref` is a variable, parameter or property
 * whose declared type is `AbstractBuildingBlock` or a locally-known subclass of it.
 * Matching is resolved per access against the receiver's *actual* declaration in
 * scope (via its symbol), not by identifier name.
 */
function unwrapInstanceIdReads(
  sourceFile: SourceFile,
  targetNames: Set<string>,
  filePath: string,
  manualItems: ManualReviewItem[]
): boolean {
  const isTargetByDeclaration = new Map<Node, boolean>();

  const isInstanceIdAccess = (access: PropertyAccessExpression): boolean => {
    if (access.getName() !== MEMBER) return false;

    const declaration = getReceiverDeclaration(access);
    if (!declaration) return false;

    if (isTargetByDeclaration.has(declaration)) {
      return isTargetByDeclaration.get(declaration) as boolean;
    }

    const isTarget = resolveDeclarationIsTarget(declaration, targetNames);
    isTargetByDeclaration.set(declaration, isTarget);
    return isTarget;
  };

  return transformAccesses(sourceFile, isInstanceIdAccess, filePath, manualItems);
}

/**
 * Shared transform loop. First records every matching write access (reported for
 * manual review), then repeatedly unwraps the remaining read accesses (`x` -> `x()`),
 * re-querying the AST after each mutation so we never touch stale nodes.
 */
function transformAccesses(
  container: Node,
  matches: (access: PropertyAccessExpression) => boolean,
  filePath: string,
  manualItems: ManualReviewItem[]
): boolean {
  for (const access of container.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
    if (!matches(access)) continue;
    if (classifyAccess(access) === 'manual-write') {
      manualItems.push({
        file: filePath,
        line: access.getStartLineNumber(),
        snippet: toSnippet((access.getParent() ?? access).getText()),
        reason: MANUAL_WRITE_REASON
      });
    }
  }

  let fileChanged = false;
  for (;;) {
    const nextAccess = container
      .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
      .find((access) => matches(access) && classifyAccess(access) === 'read');

    if (!nextAccess) break;

    nextAccess.replaceWithText(`${nextAccess.getText()}()`);
    fileChanged = true;
  }

  return fileChanged;
}

function isThisMemberAccess(access: PropertyAccessExpression): boolean {
  return access.getExpression().getKind() === SyntaxKind.ThisKeyword && access.getName() === MEMBER;
}

type AccessAction = 'read' | 'none' | 'manual-write';

function classifyAccess(access: PropertyAccessExpression): AccessAction {
  // Already unwrapped: `x.id()`.
  const callParent = access.getParentIfKind(SyntaxKind.CallExpression);
  if (callParent && callParent.getExpression() === access) return 'none';

  // Assignment target: `x.id = v` / `x.id += v` etc. — read-only, no safe rewrite.
  const binaryParent = access.getParentIfKind(SyntaxKind.BinaryExpression);
  if (binaryParent && binaryParent.getLeft() === access) {
    const operator = binaryParent.getOperatorToken().getKind();
    if (operator === SyntaxKind.EqualsToken || COMPOUND_ASSIGNMENT_OPERATORS.has(operator)) {
      return 'manual-write';
    }
  }

  if (isDestructuringAssignmentTarget(access)) return 'manual-write';
  if (access.getParentIfKind(SyntaxKind.DeleteExpression)) return 'manual-write';
  if (access.getParentIfKind(SyntaxKind.PostfixUnaryExpression)) return 'manual-write';

  const prefixParent = access.getParentIfKind(SyntaxKind.PrefixUnaryExpression);
  if (prefixParent) {
    const operator = prefixParent.getOperatorToken();
    if (operator === SyntaxKind.PlusPlusToken || operator === SyntaxKind.MinusMinusToken) {
      return 'manual-write';
    }
  }

  return 'read';
}

/**
 * True when `access` is the assignment target of a destructuring pattern —
 * `[x.id] = arr` or `({ a: x.id } = obj)`.
 */
function isDestructuringAssignmentTarget(access: PropertyAccessExpression): boolean {
  let node: Node = access;
  let parent = node.getParent();
  while (parent) {
    if (Node.isArrayLiteralExpression(parent) || Node.isObjectLiteralExpression(parent)) {
      const literalParent = parent.getParent();
      if (
        literalParent &&
        Node.isBinaryExpression(literalParent) &&
        literalParent.getOperatorToken().getKind() === SyntaxKind.EqualsToken &&
        literalParent.getLeft() === parent
      ) {
        return true;
      }
      return false;
    }
    if (
      Node.isPropertyAssignment(parent) ||
      Node.isShorthandPropertyAssignment(parent) ||
      Node.isSpreadAssignment(parent) ||
      Node.isSpreadElement(parent) ||
      Node.isBindingElement(parent)
    ) {
      node = parent;
      parent = node.getParent();
      continue;
    }
    return false;
  }
  return false;
}

/**
 * Resolves the declaration that a simple instance access's receiver binds to in
 * scope. Handles `foo.id` (receiver is the identifier `foo`) and `this.bar.id`
 * (receiver is the `this.bar` property access).
 */
function getReceiverDeclaration(access: PropertyAccessExpression): Node | undefined {
  const receiver = access.getExpression();

  let nameNode: Node | undefined;
  if (receiver.getKind() === SyntaxKind.Identifier) {
    nameNode = receiver;
  } else if (
    Node.isPropertyAccessExpression(receiver) &&
    receiver.getExpression().getKind() === SyntaxKind.ThisKeyword
  ) {
    nameNode = receiver.getNameNode();
  }

  if (!nameNode) return undefined;

  const declaration = nameNode.getSymbol()?.getDeclarations()?.[0];
  if (!declaration) return undefined;

  if (
    Node.isParameterDeclaration(declaration) ||
    Node.isVariableDeclaration(declaration) ||
    Node.isPropertyDeclaration(declaration)
  ) {
    return declaration;
  }

  return undefined;
}

/**
 * Resolves whether a declaration's *explicit type annotation* refers to
 * `AbstractBuildingBlock` (or a locally-known subclass). Matching is by type-name
 * text (not the type checker), decomposing unions/intersections/parentheses so any
 * class-name constituent is considered. Types with no explicit annotation
 * (inferred) cannot be matched textually and return `false`.
 */
function resolveDeclarationIsTarget(declaration: Node, targetNames: Set<string>): boolean {
  if (
    !Node.isParameterDeclaration(declaration) &&
    !Node.isVariableDeclaration(declaration) &&
    !Node.isPropertyDeclaration(declaration)
  ) {
    return false;
  }

  const typeNode = declaration.getTypeNode();
  if (!typeNode) return false;

  return collectTypeReferenceNames(typeNode).some((name) => targetNames.has(name));
}

function collectTypeReferenceNames(typeNode: Node): string[] {
  if (Node.isParenthesizedTypeNode(typeNode)) {
    return collectTypeReferenceNames(typeNode.getTypeNode());
  }
  if (Node.isUnionTypeNode(typeNode) || Node.isIntersectionTypeNode(typeNode)) {
    return typeNode.getTypeNodes().flatMap((node) => collectTypeReferenceNames(node));
  }
  if (Node.isTypeReference(typeNode)) {
    return [typeNode.getTypeName().getText()];
  }
  return [];
}

results matching ""

    No results matching ""