libs/nx/src/migrations/update-dynamic-form-field-signals/update-dynamic-form-field-signals.ts
Properties |
| templateChanged | |
| Type |
boolean
|
|
Description
|
An external template ( |
| 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);
}