File

libs/nx/src/migrations/update-dynamic-form-field-signals/update-dynamic-form-field-signals.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, Tree, visitNotIgnoredFiles } from '@nx/devkit';
import * as path from 'path';
import {
  ClassDeclaration,
  Node,
  Project,
  PropertyAccessExpression,
  QuoteKind,
  SourceFile,
  SyntaxKind
} from 'ts-morph';
import { NxTreeFileSystemHost } from '../utils/ts-morph-tree-file-system-host';
import { findTsConfigInProjectRoot } from '../utils/ts-morph-utils';
import { ManualReviewItem, reportManualReviewItems, toSnippet } from './manual-review';
import { migrateTemplate, TemplateMemberSet } from './template-migration';

/**
 * Describes a Dynamic Form field component whose public/protected members were
 * migrated from plain properties to signals in TALY v53.0.0.
 *
 * - `writableMembers` became `signal(...)` / `WritableSignal` -> reads need to be
 *   unwrapped (`this.x` -> `this.x()`) and writes converted (`this.x = v` -> `this.x.set(v)`).
 * - `readonlyMembers` became `computed(...)` -> reads are unwrapped, but writes cannot
 *   be migrated automatically (a computed signal has no setter) and are left untouched
 *   so the developer resolves them explicitly.
 */
interface TargetComponent {
  module: string;
  className: string;
  writableMembers: string[];
  readonlyMembers: string[];
}

const TARGET_COMPONENTS: TargetComponent[] = [
  {
    module: '@allianz/taly-core/dynamic-form/circle-toggle-group',
    className: 'DfCircleToggleGroupComponent',
    writableMembers: [],
    readonlyMembers: ['circleToggles', 'rowJustify', 'columnSpan', 'optionsLeftAlignInExpert']
  },
  {
    module: '@allianz/taly-core/dynamic-form/tile',
    className: 'DfTileComponent',
    writableMembers: [],
    readonlyMembers: ['tiles']
  },
  {
    module: '@allianz/taly-core/dynamic-form/date',
    className: 'DfDateComponent',
    writableMembers: ['minDate', 'maxDate'],
    readonlyMembers: []
  },
  {
    module: '@allianz/taly-core/dynamic-form/phone-input',
    className: 'DfPhoneInputComponent',
    writableMembers: ['countryNames'],
    readonlyMembers: ['selectedCountryCode']
  }
];

// Methods that only exist on a signal. If a member access is immediately followed
// by one of these, the code was already migrated and must be left as-is (idempotency).
const SIGNAL_METHODS = new Set(['set', 'update', 'asReadonly']);

// Compound assignment operators (excluding the plain `=`). A write through one of
// these cannot be mechanically rewritten to `.set(...)`, so it is skipped.
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
]);

/**
 * Outcome of classifying a single member access:
 * - `read` / `write`  — will be rewritten.
 * - `none`            — nothing to do (already migrated, or an unrelated position).
 * - `manual-<reason>` — a write we deliberately skip because it has no safe
 *                       mechanical rewrite; reported to the user for manual fixup.
 */
type MemberAction =
  | 'read'
  | 'write'
  | 'none'
  | 'manual-computed-write'
  | 'manual-compound-assignment'
  | 'manual-chained-assignment'
  | 'manual-increment';

// Human-readable reasons for each manual-review action, shared by both passes.
const MANUAL_REASONS: Record<string, string> = {
  'manual-computed-write':
    'assignment to a computed (read-only) signal — a computed has no setter; resolve manually',
  'manual-compound-assignment':
    'compound assignment to a signal — read, combine, then call `.set(...)` manually',
  'manual-chained-assignment':
    'chained assignment to a signal — `.set(...)` returns void; split into separate statements',
  'manual-increment': 'increment/decrement of a signal — use `.set(x() + 1)` manually'
};

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

  // Places we could not migrate automatically, surfaced to the user at the end.
  const manualItems: ManualReviewItem[] = [];

  for (const [, nxProject] of projects) {
    const tsConfigFilePath = findTsConfigInProjectRoot(tree, nxProject.root);

    // The NxTreeFileSystemHost redirects ts-morph's filesystem access to the virtual tree.
    const project = new Project({
      tsConfigFilePath,
      fileSystem: new NxTreeFileSystemHost(tree),
      manipulationSettings: {
        quoteKind: QuoteKind.Single
      }
    });

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

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

      // Cheap pre-filter: only inspect files that import one of the target entry points
      // and mention at least one of the target component classes.
      const mightBeAffected =
        fileContent.includes('@allianz/taly-core/dynamic-form/') &&
        TARGET_COMPONENTS.some((target) => fileContent.includes(target.className));
      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;
      }
    });
  }

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

  reportManualReviewItems(manualItems);
}

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 {
  // Map each locally-bound import name (respecting aliases) to its target component.
  const localNameToTarget = new Map<string, TargetComponent>();

  for (const importDecl of sourceFile.getImportDeclarations()) {
    const target = TARGET_COMPONENTS.find(
      (candidate) => candidate.module === importDecl.getModuleSpecifierValue()
    );
    if (!target) continue;

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

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

  let tsChanged = false;
  let templateChanged = false;

  // Pass 1 — subclass access: `this.<member>` inside a class that extends a target.
  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;

    // Template of a subclass component references inherited members by name, so
    // the same member set applies. Inline templates live in the same .ts file
    // (tsChanged); external templates are written to their own .html file.
    const templateResult = processSubclassTemplate(classDecl, target, tree, filePath, manualItems);
    tsChanged = templateResult.inlineChanged || tsChanged;
    templateChanged = templateResult.externalChanged || templateChanged;
  }

  // Pass 2 — instance access: `ref.<member>` where `ref` is typed as a target component.
  tsChanged =
    processInstanceAccess(sourceFile, localNameToTarget, filePath, manualItems) || tsChanged;

  return { tsChanged, templateChanged };
}

/**
 * Resolves the migrated members visible to a subclass. A member re-declared in
 * the subclass shadows the base-class member and must not be touched, so it is
 * filtered out of both the writable and the combined set.
 */
function resolveSubclassMembers(
  classDecl: ClassDeclaration,
  target: TargetComponent
): { writable: Set<string>; all: Set<string> } {
  const ownProperties = new Set(classDecl.getProperties().map((prop) => prop.getName()));

  const writable = new Set(target.writableMembers.filter((member) => !ownProperties.has(member)));
  const all = new Set(
    [...target.writableMembers, ...target.readonlyMembers].filter(
      (member) => !ownProperties.has(member)
    )
  );

  return { writable, all };
}

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

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

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

/**
 * Migrates the Angular template of a subclass component so member reads become
 * signal calls (`{{ x }}` -> `{{ x() }}`) and writes on writable members become
 * `.set(...)`. Handles both the inline `template` (a string/template literal in
 * the same `.ts` file) and an external `templateUrl` (a sibling `.html` file).
 */
function processSubclassTemplate(
  classDecl: ClassDeclaration,
  target: TargetComponent,
  tree: Tree,
  filePath: string,
  manualItems: ManualReviewItem[]
): { inlineChanged: boolean; externalChanged: boolean } {
  const result = { inlineChanged: false, externalChanged: false };

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

  const readonly = new Set([...all].filter((member) => !writable.has(member)));
  const members: TemplateMemberSet = { writable, readonly };

  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, members);
    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
      });
    }
  }

  // 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, members);
        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
          });
        }
      }
    }
  }

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

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

/**
 * Rewrites `ref.<member>` accesses where `ref` is a variable, parameter or property
 * whose declared type is one of the target components. This covers the cases the
 * subclass pass cannot see, e.g. a `@ViewChild(DfDateComponent) field!: DfDateComponent`
 * or a function parameter `(field: DfDateComponent)`.
 *
 * Matching is resolved per access against the receiver's *actual* declaration in
 * scope (via its symbol), not by identifier name. This means two references that
 * happen to share a name but are typed differently — e.g. `(field: DfDateComponent)`
 * in one function and `(field: { minDate: Date })` in another — are handled
 * independently, and only the one truly typed as a target component is rewritten.
 */
function processInstanceAccess(
  sourceFile: SourceFile,
  localNameToTarget: Map<string, TargetComponent>,
  filePath: string,
  manualItems: ManualReviewItem[]
): boolean {
  // Resolve (and cache) the target component a given access's receiver is typed as.
  // Keyed by the receiver declaration node so repeated accesses of the same
  // reference resolve its symbol only once.
  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 typeText = getDeclarationTypeText(declaration);
    const target = typeText ? localNameToTarget.get(typeText) ?? null : null;
    targetByDeclaration.set(declaration, target);
    return target;
  };

  return transformAccesses(
    sourceFile,
    (access) => {
      const target = resolveTarget(access);
      if (!target) return false;
      return (
        target.writableMembers.includes(access.getName()) ||
        target.readonlyMembers.includes(access.getName())
      );
    },
    // static writability is unused here; resolved per access against the receiver.
    undefined,
    (access) => resolveTarget(access)?.writableMembers.includes(access.getName()) ?? false,
    filePath,
    manualItems
  );
}

/**
 * Shared transform loop. First records every matching access that cannot be migrated
 * automatically (so it can be reported for manual review), then repeatedly rewrites the
 * remaining read/write accesses (read -> `x()`, write -> `x.set(v)`), re-querying the AST
 * after each mutation so we never touch stale nodes. Rewritten accesses classify as
 * 'none' afterwards and manual accesses are never rewritten, so the loop terminates.
 *
 * `isWritableStatic` is used when writability is the same for every match (subclass pass);
 * `isWritableDynamic` resolves writability per-access (instance pass, where it depends on
 * which reference is being accessed). Exactly one of the two is provided.
 */
function transformAccesses(
  container: Node,
  matches: (access: PropertyAccessExpression) => boolean,
  isWritableStatic: Set<string> | undefined,
  isWritableDynamic: ((access: PropertyAccessExpression) => boolean) | undefined,
  filePath: string,
  manualItems: ManualReviewItem[]
): boolean {
  const isWritable = (access: PropertyAccessExpression): boolean =>
    isWritableStatic
      ? isWritableStatic.has(access.getName())
      : isWritableDynamic?.(access) ?? false;

  // Pass 1 (non-mutating): collect accesses we cannot migrate automatically.
  for (const access of container.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
    if (!matches(access)) continue;
    const action = classifyAccess(access, isWritable);
    if (action.startsWith('manual-')) {
      manualItems.push({
        file: filePath,
        line: access.getStartLineNumber(),
        snippet: toSnippet((access.getParent() ?? access).getText()),
        reason: MANUAL_REASONS[action]
      });
    }
  }

  // Pass 2 (mutating): rewrite reads/writes only.
  let fileChanged = false;
  for (;;) {
    const nextAccess = container
      .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
      .find((access) => {
        if (!matches(access)) return false;
        const action = classifyAccess(access, isWritable);
        return action === 'read' || action === 'write';
      });

    if (!nextAccess) break;

    const action = classifyAccess(nextAccess, isWritable);

    if (action === 'write') {
      const binaryExpression = nextAccess.getParentIfKind(SyntaxKind.BinaryExpression);
      const rightHandSide = binaryExpression?.getRight().getText();
      if (binaryExpression && rightHandSide !== undefined) {
        binaryExpression.replaceWithText(`${nextAccess.getText()}.set(${rightHandSide})`);
        fileChanged = true;
      }
    } else if (action === 'read') {
      nextAccess.replaceWithText(`${nextAccess.getText()}()`);
      fileChanged = true;
    }
  }

  return fileChanged;
}

/**
 * Resolves the declaration that a simple instance access's receiver binds to in
 * scope. Handles `foo.member` (receiver is the identifier `foo`) and
 * `this.bar.member` (receiver is the `this.bar` property access). Returns the
 * variable / parameter / property declaration the receiver refers to, or
 * `undefined` for anything more complex (chained calls, index access, etc.).
 *
 * Resolving via the symbol — rather than matching on the identifier text — is what
 * makes the pass scope-aware: two `field`s declared in different scopes resolve to
 * their own declarations, so only the one typed as a target component is matched.
 */
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
  ) {
    // `this.<ref>.<member>` — resolve the `<ref>` property.
    nameNode = receiver.getNameNode();
  }

  if (!nameNode) return undefined;

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

  // Only bindings that carry an explicit type annotation can be matched textually.
  if (
    Node.isParameterDeclaration(declaration) ||
    Node.isVariableDeclaration(declaration) ||
    Node.isPropertyDeclaration(declaration)
  ) {
    return declaration;
  }

  return undefined;
}

/** Returns the text of a declaration's explicit type annotation, if any. */
function getDeclarationTypeText(declaration: Node): string | undefined {
  if (
    Node.isParameterDeclaration(declaration) ||
    Node.isVariableDeclaration(declaration) ||
    Node.isPropertyDeclaration(declaration)
  ) {
    return declaration.getTypeNode()?.getText();
  }
  return undefined;
}

function isThisMemberAccess(access: PropertyAccessExpression, members: Set<string>): boolean {
  return (
    access.getExpression().getKind() === SyntaxKind.ThisKeyword && members.has(access.getName())
  );
}

function classifyAccess(
  access: PropertyAccessExpression,
  isWritable: (access: PropertyAccessExpression) => boolean
): MemberAction {
  // Already unwrapped: `this.x()`
  const callParent = access.getParentIfKind(SyntaxKind.CallExpression);
  if (callParent && callParent.getExpression() === access) return 'none';

  // Already migrated signal call: `this.x.set(...)` / `.update(...)` / `.asReadonly()`
  const propertyAccessParent = access.getParentIfKind(SyntaxKind.PropertyAccessExpression);
  if (
    propertyAccessParent &&
    propertyAccessParent.getExpression() === access &&
    SIGNAL_METHODS.has(propertyAccessParent.getName())
  ) {
    return 'none';
  }

  // Assignment target: `this.x = v` (write) or `this.x += v` (unmigratable -> skip)
  const binaryParent = access.getParentIfKind(SyntaxKind.BinaryExpression);
  if (binaryParent && binaryParent.getLeft() === access) {
    const operator = binaryParent.getOperatorToken().getKind();
    if (operator === SyntaxKind.EqualsToken) {
      // A write to a computed (read-only) member has no setter.
      if (!isWritable(access)) return 'manual-computed-write';
      // Chained assignment `this.x = this.y = v` — the right-hand side is itself an
      // assignment. Converting to `this.x.set(this.y.set(v))` is wrong because
      // `.set()` returns void, so skip and let the developer split it.
      const rhs = binaryParent.getRight().asKind(SyntaxKind.BinaryExpression);
      if (rhs && isAssignmentOperator(rhs.getOperatorToken().getKind())) {
        return 'manual-chained-assignment';
      }
      // This access is itself the right-hand side of an outer assignment
      // (`outer = this.x = v`) — same void-return problem.
      const grandParent = binaryParent.getParentIfKind(SyntaxKind.BinaryExpression);
      if (
        grandParent &&
        grandParent.getRight() === binaryParent &&
        isAssignmentOperator(grandParent.getOperatorToken().getKind())
      ) {
        return 'manual-chained-assignment';
      }
      return 'write';
    }
    if (COMPOUND_ASSIGNMENT_OPERATORS.has(operator)) {
      // Only writable members would otherwise be rewritten; a computed member here
      // is still a manual case, but the compound-operator reason is the actionable one.
      return 'manual-compound-assignment';
    }
  }

  // Increment / decrement: `this.x++`, `--this.x` -> cannot unwrap, skip.
  if (access.getParentIfKind(SyntaxKind.PostfixUnaryExpression)) return 'manual-increment';
  const prefixParent = access.getParentIfKind(SyntaxKind.PrefixUnaryExpression);
  if (prefixParent) {
    const operator = prefixParent.getOperatorToken();
    if (operator === SyntaxKind.PlusPlusToken || operator === SyntaxKind.MinusMinusToken) {
      return 'manual-increment';
    }
  }

  return 'read';
}

/** True for `=` and every compound assignment operator (`+=`, `??=`, …). */
function isAssignmentOperator(operator: SyntaxKind): boolean {
  return operator === SyntaxKind.EqualsToken || COMPOUND_ASSIGNMENT_OPERATORS.has(operator);
}

results matching ""

    No results matching ""