libs/nx/src/migrations/update-signal-input-output-consumers/update-signal-input-output-consumers.ts
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.
Properties |
|
| className | |
| Type |
string
|
| members | |
| Type |
string[]
|
| module | |
| Type |
string
|
| outputs (Optional) | |
| Type |
string[]
|
|
Description
|
|
| 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 [];
}