libs/nx/src/migrations/update-building-block-id-consumers/update-building-block-id-consumers.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, 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 [];
}