File

libs/nx/src/migrations/update-signal-input-output-consumers/update-signal-input-output-consumers.ts

Description

Describes a published component/directive whose plain @Input()/@Output() members were replaced with input()/output() in TALY v55.0.0.

members are members that changed shape: a consumer's read (x.foo) must become a call (x.foo()). writeOnlyMembers are the rarer case of a member that is still read the same way (e.g. a getter computed from a renamed signal input) but lost its external setter — for those, reads are left untouched and only a write is reported.

Every member (of either kind) is now read-only from the outside — a signal input has no external setter — so any assignment (x.foo = v, x.foo += v, …) has no safe mechanical rewrite and is reported for manual review instead.

Index

Properties

Properties

className
Type string
members
Type string[]
module
Type string
outputs (Optional)
Type string[]
Description

@Output() members that became output(). Reads (x.foo.subscribe(...)) keep working unchanged, so these are never unwrapped — but OutputEmitterRef dropped the rest of the RxJS Subject surface EventEmitter had (.pipe(), .next(), .complete(), .asObservable()); a consumer chaining one of those has no safe mechanical rewrite and is reported for manual review instead (see REMOVED_SUBJECT_METHODS).

writeOnlyMembers (Optional)
Type string[]
import { formatFiles, getProjects, logger, Tree, visitNotIgnoredFiles } from '@nx/devkit';
import * as path from 'path';
import {
  ClassDeclaration,
  Node,
  Project,
  PropertyAccessExpression,
  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';

/**
 * Describes a published component/directive whose plain `@Input()`/`@Output()`
 * members were replaced with `input()`/`output()` in TALY v55.0.0.
 *
 * `members` are members that changed *shape*: a consumer's read (`x.foo`) must
 * become a call (`x.foo()`). `writeOnlyMembers` are the rarer case of a member that
 * is *still read the same way* (e.g. a getter computed from a renamed signal input)
 * but lost its external setter — for those, reads are left untouched and only a
 * write is reported.
 *
 * Every member (of either kind) is now read-only from the outside — a signal input
 * has no external setter — so any assignment (`x.foo = v`, `x.foo += v`, …) has no
 * safe mechanical rewrite and is reported for manual review instead.
 */
interface TargetComponent {
  module: string;
  className: string;
  members: string[];
  writeOnlyMembers?: string[];
  /**
   * `@Output()` members that became `output()`. Reads (`x.foo.subscribe(...)`) keep
   * working unchanged, so these are never unwrapped — but `OutputEmitterRef` dropped
   * the rest of the RxJS `Subject` surface `EventEmitter` had (`.pipe()`, `.next()`,
   * `.complete()`, `.asObservable()`); a consumer chaining one of those has no safe
   * mechanical rewrite and is reported for manual review instead (see
   * {@link REMOVED_SUBJECT_METHODS}).
   */
  outputs?: string[];
}

/**
 * Only components reachable by a consumer through a published entry point are
 * listed — a member a consumer can never import cannot appear in a consumer's code.
 */
const TARGET_COMPONENTS: TargetComponent[] = [
  {
    module: '@allianz/taly-core/frame',
    className: 'FrameComponent',
    members: [
      'chromeless',
      'title',
      'logoSrc',
      'sidebar',
      'spinner',
      'spinnerConstrainedToFrame',
      'headerLogoLinkUrl',
      'footerConfig',
      'stageConfig',
      'globalSidebarConfig',
      'navigationConfig',
      'offerCodeStateKey',
      'hasJumpNavigationMenu'
    ],
    // `centered` stayed a plain getter (now computed from the renamed `centeredInput`
    // signal) — reads are unchanged, only the previously-real setter is gone.
    writeOnlyMembers: ['centered']
  },
  {
    module: '@allianz/taly-core/frame',
    className: 'NotificationComponent',
    members: ['context', 'closable'],
    outputs: ['closeNotification']
  },
  {
    module: '@allianz/taly-core/frame',
    className: 'SpinnerComponent',
    members: ['label']
  },
  {
    module: '@allianz/taly-core/frame',
    className: 'TalyFrameSmallPrintMarkerDirective',
    members: ['talyFrameSmallPrint']
  },
  {
    module: '@allianz/taly-core/monaco-editor',
    className: 'EditorComponent',
    members: ['insideNg', 'options', 'model']
  },
  {
    module: '@allianz/taly-core/monaco-editor',
    className: 'DiffEditorComponent',
    members: ['insideNg', 'options', 'originalModel', 'modifiedModel']
  },
  {
    module: '@allianz/taly-core/building-blocks',
    className: 'PlaceholderComponent',
    members: ['title', 'purpose', 'expectedState', 'isCompleted']
  },
  {
    module: '@allianz/taly-core/ui',
    className: 'SummaryPanelComponent',
    members: ['id', 'title', 'expanded', 'variant']
  },
  {
    module: '@allianz/taly-core/ui',
    className: 'ValidationErrorsComponent',
    members: ['isInputDateYear', 'controlErrors', 'errorMessages', 'appearance']
  },
  {
    module: '@allianz/taly-core/ui',
    className: 'TalyInternalHeadlineComponent',
    members: ['type', 'subline']
  },
  {
    module: '@allianz/taly-acl/angular',
    className: 'AclTagDirective',
    members: ['hint', 'transient', 'tag']
  },
  {
    module: '@allianz/taly-acl/angular',
    className: 'AclIconComponent',
    members: ['name', 'title', 'size']
  },
  {
    module: '@allianz/taly-acl/angular',
    className: 'AclTagHintComponent',
    members: ['contentShown', 'givenAclTag']
  },
  {
    module: '@allianz/taly-core/devtools',
    className: 'ShowroomHeaderComponent',
    members: ['debugToolsToggleVisible']
  },
  {
    module: '@allianz/taly-core/devtools',
    className: 'BuildingBlockDebugger',
    members: ['exampleState', 'exampleResources', 'buildingBlockInput', 'buildingBlock']
  },
  {
    module: '@allianz/taly-core/building-blocks',
    className: 'AbstractBuildingBlock',
    members: [],
    outputs: ['completed']
  }
];

const TARGET_MODULE_PREFIXES = [
  '@allianz/taly-core/frame',
  '@allianz/taly-core/monaco-editor',
  '@allianz/taly-core/building-blocks',
  '@allianz/taly-core/ui',
  '@allianz/taly-acl/angular',
  '@allianz/taly-core/devtools'
];

/** Every `members` name across {@link TARGET_COMPONENTS}, used to flag an element-access
 * read (`x['foo']`) or object-destructuring read (`const { foo } = x`) — neither is a
 * `PropertyAccessExpression`, so {@link transformAccesses} never sees them; they are
 * reported for manual review instead since there is no safe way to determine the
 * receiver's type mechanically for these forms. */
const ALL_MEMBER_NAMES = new Set(TARGET_COMPONENTS.flatMap((target) => target.members));

// `EventEmitter` (RxJS `Subject`) methods that `OutputEmitterRef` does not expose.
const REMOVED_SUBJECT_METHODS = new Set(['pipe', 'next', 'complete', 'asObservable']);

const RE_EXPORT_PATTERN =
  /\bexport\s+(?:type\s+)?(?:\*(?:\s+as\s+[$\w]+)?|\{[^}]*\})\s*from\s*['"]/;

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 signal input, which is now read-only from the outside — there is no ' +
  'external setter; bind it in the template instead, or restructure the code';

/**
 * Nx migration entry point: walks every project's non-ignored `.ts` files, rewrites
 * consumer accesses of the migrated components' members, and reports anything that
 * can't be mechanically rewritten for manual review.
 *
 * The **template of a subclass** of a migrated component is covered too: an inline
 * `template: '...'` and an external `templateUrl: './x.html'` are both rewritten so a
 * `{{ title }}` read becomes `{{ title() }}` (see
 * {@link ../utils/template-migration#migrateTemplate}). This
 * matters more than a TypeScript read: the member is now a getter *function*, so an
 * un-unwrapped template read renders the function source / evaluates as always-truthy
 * without any compile error.
 */
export default async function updateSignalInputOutputConsumers(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-signal-input-output-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 (!TARGET_MODULE_PREFIXES.some((prefix) => fileContent.includes(prefix))) return;
        const mightBeAffected =
          TARGET_COMPONENTS.some((target) => fileContent.includes(target.className)) ||
          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-signal-input-output-consumers: skipped file '${filePath}' in project ` +
            `'${projectName}' — ${error instanceof Error ? error.message : String(error)}`
        );
      }
    });
  }

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

  reportManualReviewItems(manualItems, 'signal-inputs/outputs');
}

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;
}

/** Applies the re-export review, subclass, and instance-access transforms to a single file. */
function processFile(
  sourceFile: SourceFile,
  tree: Tree,
  filePath: string,
  manualItems: ManualReviewItem[]
): FileChanges {
  manualItems.push(...collectReExportReviewItems(sourceFile, filePath));
  manualItems.push(
    ...collectElementAccessAndDestructuringReviewItems(sourceFile, ALL_MEMBER_NAMES, filePath)
  );

  const localNameToTarget = collectLocalTargetReferences(sourceFile);
  if (localNameToTarget.size === 0) return { tsChanged: false, templateChanged: false };

  let tsChanged = false;
  let templateChanged = false;

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

    const baseName = extendsExpression.getExpression().getText();
    const target = localNameToTarget.get(baseName);
    if (!target) continue;

    tsChanged = processSubclass(classDecl, target, filePath, manualItems) || tsChanged;

    // The subclass template reads the same inherited members by name, so the same
    // member set applies. An inline template lives in this same .ts file (tsChanged);
    // an external template is written to its own .html file.
    const templateResult = processSubclassTemplate(classDecl, target, tree, filePath, manualItems);
    tsChanged = templateResult.inlineChanged || tsChanged;
    templateChanged = templateResult.externalChanged || templateChanged;
  }

  tsChanged =
    processInstanceAccess(sourceFile, localNameToTarget, filePath, manualItems) || tsChanged;

  return { tsChanged, templateChanged };
}

/**
 * Maps every local name that refers to a target component onto that component, for
 * both a named import (honouring an alias) and a namespace import (which binds the
 * qualified name, e.g. `frame.FrameComponent`).
 */
function collectLocalTargetReferences(sourceFile: SourceFile): Map<string, TargetComponent> {
  const localNameToTarget = new Map<string, TargetComponent>();

  for (const importDecl of sourceFile.getImportDeclarations()) {
    const moduleValue = importDecl.getModuleSpecifierValue();
    const targetsForModule = TARGET_COMPONENTS.filter(
      (candidate) => candidate.module === moduleValue
    );
    if (targetsForModule.length === 0) continue;

    for (const namedImport of importDecl.getNamedImports()) {
      const importedName = namedImport.getName();
      const target = targetsForModule.find((candidate) => candidate.className === importedName);
      if (!target) continue;
      const localName = namedImport.getAliasNode()?.getText() ?? importedName;
      localNameToTarget.set(localName, target);
    }

    const namespaceImport = importDecl.getNamespaceImport();
    if (namespaceImport) {
      const namespaceName = namespaceImport.getText();
      for (const target of targetsForModule) {
        localNameToTarget.set(`${namespaceName}.${target.className}`, target);
      }
    }
  }

  return localNameToTarget;
}

/**
 * Reports an element-access read (`block['id']`) and an object-destructuring read
 * (`const { id } = block`) whose name matches a migrated member. 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,
  memberNames: Set<string>,
  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) continue;

    const name = literal.getLiteralText();
    if (!memberNames.has(name)) continue;

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

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

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

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

  return items;
}

/**
 * Reports every re-export of a target component, so 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 === undefined) continue;

    const targetsForModule = TARGET_COMPONENTS.filter(
      (candidate) => candidate.module === moduleValue
    );
    if (targetsForModule.length === 0) continue;

    const reExported = exportDecl.isNamespaceExport()
      ? targetsForModule
      : targetsForModule.filter((candidate) =>
          exportDecl
            .getNamedExports()
            .some((namedExport) => namedExport.getName() === candidate.className)
        );
    if (reExported.length === 0) continue;

    const classNames = reExported.map((target) => target.className).join(', ');
    items.push({
      file: filePath,
      line: exportDecl.getStartLineNumber(),
      snippet: toSnippet(exportDecl.getText()),
      reason:
        `re-exports migrated component(s) ${classNames} — files importing them through ` +
        'this file (instead of directly from the entry point) were NOT migrated; review ' +
        'them manually'
    });
  }

  return items;
}

/** All member names (read-write and write-only) that belong to a target. */
function allMemberNames(target: TargetComponent): Set<string> {
  return new Set([...target.members, ...(target.writeOnlyMembers ?? [])]);
}

/** A member re-declared in the subclass shadows the base-class member. */
function resolveSubclassMembers(
  classDecl: ClassDeclaration,
  target: TargetComponent
): { unwrappable: Set<string>; all: Set<string>; outputs: Set<string> } {
  const ownMembers = new Set<string>([
    ...classDecl.getProperties().map((prop) => prop.getName()),
    ...classDecl.getGetAccessors().map((accessor) => accessor.getName()),
    ...classDecl.getSetAccessors().map((accessor) => accessor.getName()),
    ...classDecl.getMethods().map((method) => method.getName())
  ]);

  const unwrappable = new Set(target.members.filter((member) => !ownMembers.has(member)));
  const all = new Set([...allMemberNames(target)].filter((member) => !ownMembers.has(member)));
  const outputs = new Set((target.outputs ?? []).filter((member) => !ownMembers.has(member)));

  return { unwrappable, all, outputs };
}

function processSubclass(
  classDecl: ClassDeclaration,
  target: TargetComponent,
  filePath: string,
  manualItems: ManualReviewItem[]
): boolean {
  const { unwrappable, all, outputs } = resolveSubclassMembers(classDecl, target);

  manualItems.push(
    ...collectOutputMisuseReviewItems(
      classDecl,
      (access) => isThisMemberAccess(access, outputs),
      filePath
    )
  );

  if (all.size === 0) return false;

  return transformAccesses(
    classDecl,
    (access) => isThisMemberAccess(access, all),
    unwrappable,
    filePath,
    manualItems
  );
}

/**
 * Migrates the Angular template of a subclass component so migrated member reads
 * become signal calls (`{{ title }}` -> `{{ title() }}`). 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).
 *
 * Only the `unwrappable` members are passed on — exactly the set the TypeScript-side
 * transform unwraps, so the two sides can never disagree about a given subclass:
 * - a member the subclass re-declares shadows the base member and is excluded;
 * - a write-only member (e.g. `centered`) is still read the same way, so a template read
 *   of it stays correct and must not be unwrapped;
 * - an output is bound as an event (`(closeNotification)="close()"`), never read, so it
 *   is irrelevant here.
 */
function processSubclassTemplate(
  classDecl: ClassDeclaration,
  target: TargetComponent,
  tree: Tree,
  filePath: string,
  manualItems: ManualReviewItem[]
): { inlineChanged: boolean; externalChanged: boolean } {
  const result = { inlineChanged: false, externalChanged: false };

  const { unwrappable } = resolveSubclassMembers(classDecl, target);
  if (unwrappable.size === 0) return result;

  // `plural` doesn't apply to signal inputs/outputs; always empty here.
  const memberSet: TemplateMemberSet = { members: unwrappable, 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 member
    // 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 a migrated member, unwrap the read(s) 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 a migrated member, unwrap the read(s) 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 ' +
      'a migrated member — it cannot be rewritten automatically; unwrap the read(s) 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 migrated member reads;
 * 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 migrated member reads'
  });
}

/** 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('/');
}

/**
 * Reports a call to a removed RxJS `Subject` method (`.pipe()`, `.next()`,
 * `.complete()`, `.asObservable()`) on an output that changed from `EventEmitter` to
 * `output()`. `OutputEmitterRef` only keeps `.subscribe()`/`.emit()`, so such a call no
 * longer compiles, and there is no clean mechanical rewrite to fall back to — this is
 * report-only, never rewritten.
 */
function collectOutputMisuseReviewItems(
  container: Node,
  isOutputAccess: (access: PropertyAccessExpression) => boolean,
  filePath: string
): ManualReviewItem[] {
  const items: ManualReviewItem[] = [];

  for (const access of container.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
    if (!REMOVED_SUBJECT_METHODS.has(access.getName())) continue;

    const receiver = access.getExpression();
    if (!Node.isPropertyAccessExpression(receiver)) continue;
    if (!isOutputAccess(receiver)) continue;

    items.push({
      file: filePath,
      line: access.getStartLineNumber(),
      snippet: toSnippet((access.getParent() ?? access).getText()),
      reason:
        `calls '.${access.getName()}()' on an output that changed from EventEmitter to ` +
        "OutputEmitterRef, which only keeps '.subscribe()'/'.emit()' — this call no " +
        'longer compiles; use .subscribe() instead, or restructure the code'
    });
  }

  return items;
}

/**
 * Rewrites `ref.<member>` accesses where `ref` is a variable, parameter or property
 * whose declared type is one of the target components.
 */
function processInstanceAccess(
  sourceFile: SourceFile,
  localNameToTarget: Map<string, TargetComponent>,
  filePath: string,
  manualItems: ManualReviewItem[]
): boolean {
  const targetByDeclaration = new Map<Node, TargetComponent | null>();

  const resolveTarget = (access: PropertyAccessExpression): TargetComponent | null => {
    const declaration = getReceiverDeclaration(access);
    if (!declaration) return null;

    if (targetByDeclaration.has(declaration)) {
      return targetByDeclaration.get(declaration) ?? null;
    }

    const target = resolveDeclarationTarget(declaration, localNameToTarget);
    targetByDeclaration.set(declaration, target);
    return target;
  };

  manualItems.push(
    ...collectOutputMisuseReviewItems(
      sourceFile,
      (access) => (resolveTarget(access)?.outputs ?? []).includes(access.getName()),
      filePath
    )
  );

  return transformAccesses(
    sourceFile,
    (access) => {
      const target = resolveTarget(access);
      if (!target) return false;
      return allMemberNames(target).has(access.getName());
    },
    (access) => new Set(resolveTarget(access)?.members ?? []),
    filePath,
    manualItems
  );
}

type UnwrappableResolver = Set<string> | ((access: PropertyAccessExpression) => Set<string>);

/** Normalizes an `UnwrappableResolver` into the set of unwrappable member names for a given access. */
function resolveUnwrappable(
  resolver: UnwrappableResolver,
  access: PropertyAccessExpression
): Set<string> {
  return typeof resolver === 'function' ? resolver(access) : resolver;
}

/**
 * Shared transform loop. First records every matching write access (reported for
 * manual review, regardless of whether the member is unwrappable), then repeatedly
 * unwraps the remaining read accesses on unwrappable members (`x` -> `x()`),
 * re-querying the AST after each mutation so we never touch stale nodes.
 */
function transformAccesses(
  container: Node,
  matches: (access: PropertyAccessExpression) => boolean,
  unwrappable: UnwrappableResolver,
  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) &&
          resolveUnwrappable(unwrappable, access).has(access.getName()) &&
          classifyAccess(access) === 'read'
      );

    if (!nextAccess) break;

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

  return fileChanged;
}

/** True when `access` is a `this.<member>` access on one of the given members. */
function isThisMemberAccess(access: PropertyAccessExpression, members: Set<string>): boolean {
  return (
    access.getExpression().getKind() === SyntaxKind.ThisKeyword && members.has(access.getName())
  );
}

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

/**
 * Classifies a property access as a mechanically-rewritable read, a write requiring
 * manual review, or neither (e.g. a call expression).
 */
function classifyAccess(access: PropertyAccessExpression): AccessAction {
  const callParent = access.getParentIfKind(SyntaxKind.CallExpression);
  if (callParent && callParent.getExpression() === access) return 'none';

  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` appears as a target inside an array/object destructuring
 * assignment (e.g. `[x.foo] = ...` or `({ bar: x.foo } = ...)`).
 */
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 (parameter, variable, or property) backing an access's
 * receiver, so its declared type can be checked against the target components.
 */
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;
}

/** Matches a receiver declaration's type annotation against the known target components. */
function resolveDeclarationTarget(
  declaration: Node,
  localNameToTarget: Map<string, TargetComponent>
): TargetComponent | null {
  if (
    !Node.isParameterDeclaration(declaration) &&
    !Node.isVariableDeclaration(declaration) &&
    !Node.isPropertyDeclaration(declaration)
  ) {
    return null;
  }

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

  for (const name of collectTypeReferenceNames(typeNode)) {
    const target = localNameToTarget.get(name);
    if (target) return target;
  }
  return null;
}

/**
 * Flattens a type node into the type-reference names it could resolve to, unwrapping
 * parentheses, unions, and intersections.
 */
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 ""