/** * InterfaceDuplicationTransformOrchestrator * * Thick orchestrator for the interface-duplication-transform rule. * Detects groups of 3+ similar interfaces and consolidates them into: * - A base interface containing the common properties * - One branded type alias per original interface * (const with `unique symbol` for runtime checks) * - A type guard per branded type * - Updated cross-references across the project * * Same-file groups: base interface written to the same file as the original interfaces. * Cross-file groups: base interface written to a dedicated shared models file at * `src/app/libs/shared/models/base/base-{suffix}.model.ts` (configurable via * `baseModelsPath` config option). Each original file receives an import of the * base interface. * * Brand design: the `unique symbol` const is emitted for each branded type. * The type alias itself does NOT use a computed property name (which would trigger * "type literal computed property must be a literal or unique symbol" TS errors in * some in-memory project configurations). The runtime type guard checks the symbol * property via `(req as Record)[BRAND] === true`. * * Three Guarantees: * 1. Idempotent — detects violations on each run; exits immediately when * no groups are found (safe to run multiple times) * 2. Atomic — snapshot + rollback on any failure, including newly * created base files (removed on rollback) * 3. Reversible — project must compile cleanly after each successful run */ import * as path from 'node:path'; import type { TransformContext } from '@angular-modernizer/plugin-system'; import type { PublicApi } from '@angular-modernizer/api'; import type { InterfaceDeclaration, Project, SourceFile } from 'ts-morph'; import { SyntaxKind, Node, DiagnosticCategory } from 'ts-morph'; // --------------------------------------------------------------------------- // Internal types // --------------------------------------------------------------------------- interface PropertyInfo { readonly name: string; /** Exact TypeScript type text as written in the source, e.g. "string", "number | null", "RecordStateDto" */ readonly typeText: string; /** Whether the property is optional (has `?` modifier) */ readonly optional: boolean; } interface DuplicationGroup { readonly suffixKey: string; readonly interfaces: InterfaceDeclaration[]; readonly commonProperties: PropertyInfo[]; /** * Absolute path of the file where the base interface should be written. * Same-file groups: the source file containing the interfaces. * Cross-file groups: a dedicated shared models file in libs/shared/models/base/. */ readonly baseFilePath: string; } interface BrandedTypeSpec { /** Original interface name, e.g. "IFooDialogRequest" */ readonly oldName: string; /** New name without "I" prefix, e.g. "FooDialogRequest" */ readonly newName: string; /** SCREAMING_SNAKE brand const name, e.g. "FOO_DIALOG_REQUEST_BRAND" */ readonly brandConstName: string; /** Properties unique to this interface (not in commonProperties) */ readonly uniqueProperties: PropertyInfo[]; readonly sourceFile: SourceFile; } /** * All data needed to apply a group transformation — extracted from live AST nodes * before any replaceWithText() calls invalidate those references. */ interface GroupTransformData { readonly baseInterfaceName: string; readonly commonProperties: PropertyInfo[]; /** * Absolute path of the file where the base interface will be written. * For same-file groups this equals each spec's sourceFile path. * For cross-file groups this is the shared models file path. */ readonly baseFilePath: string; readonly specs: BrandedTypeSpec[]; } // --------------------------------------------------------------------------- // Suffix pattern definitions (shared with analysis rule) // --------------------------------------------------------------------------- const SUFFIX_PATTERNS: readonly { regex: RegExp; violationType: string }[] = [ { regex: /DialogRequest$/, violationType: 'dialog-request-duplication' }, { regex: /DialogConfig$/, violationType: 'dialog-request-duplication' }, { regex: /DialogOptions$/, violationType: 'dialog-request-duplication' }, { regex: /DialogParams$/, violationType: 'dialog-request-duplication' }, { regex: /Config$/, violationType: 'config-duplication' }, { regex: /Data$/, violationType: 'data-interface-duplication' }, { regex: /Dto$/, violationType: 'data-interface-duplication' }, { regex: /Payload$/, violationType: 'data-interface-duplication' }, { regex: /Response$/, violationType: 'data-interface-duplication' }, { regex: /Result$/, violationType: 'data-interface-duplication' }, ]; function matchSuffix(name: string): string | null { for (const p of SUFFIX_PATTERNS) { const m = name.match(p.regex); if (m) { return m[0]; } } return null; } // --------------------------------------------------------------------------- // Orchestrator // --------------------------------------------------------------------------- export class InterfaceDuplicationTransformOrchestrator { async run(context: TransformContext): Promise { const { project, config } = context; const cfg = config; const minSimilarInstances = (cfg['minSimilarInstances'] as number | undefined) ?? 3; const minCommonProperties = (cfg['minCommonProperties'] as number | undefined) ?? 2; const baseModelsRelPath = (cfg['baseModelsPath'] as string | undefined) ?? 'src/app/libs/shared/models/base'; const projectRoot = this.detectProjectRoot(project); const baseModelsAbsPath = path.isAbsolute(baseModelsRelPath) ? baseModelsRelPath : path.join(projectRoot, baseModelsRelPath); // Step 0: Idempotency — detect groups; exit if nothing to do const groups = this.detectGroups( project, minSimilarInstances, minCommonProperties, baseModelsAbsPath, ); if (groups.length === 0) { return; } // Step 1: Pre-compute all group data while AST node references are still valid. // replaceWithText() invalidates InterfaceDeclaration node refs; reading must // happen before any mutation. The same interface can appear in multiple // subgroups (new subgroup algorithm) — we extract all data here so transforms // can re-fetch by name (safe even after prior replacements). const allGroupData = this.prepareAllGroupData(groups); // Step 2: Atomicity — snapshot affected files; track newly created base files. const preExistingErrorKeys = new Set( project .getPreEmitDiagnostics() .filter((d) => d.getCategory() === DiagnosticCategory.Error) .map((d) => { const msg = d.getMessageText(); const text = typeof msg === 'string' ? msg : msg.getMessageText(); return `${d.getCode()}:${text}`; }), ); const snapshots = new Map(); const newlyCreatedPaths = new Set(); for (const data of allGroupData) { const existingBase = project.getSourceFile(data.baseFilePath); if (existingBase) { if (!snapshots.has(data.baseFilePath)) { snapshots.set(data.baseFilePath, existingBase.getFullText()); } } else { newlyCreatedPaths.add(data.baseFilePath); } for (const spec of data.specs) { const sfPath = spec.sourceFile.getFilePath(); if (!snapshots.has(sfPath)) { snapshots.set(sfPath, spec.sourceFile.getFullText()); } } } // Augment the pre-existing error fingerprint set with renamed-type variants. // Error messages like "not assignable to type 'ITileConfig'" become // "not assignable to type 'TileConfig'" after the rename. Without this, those // pre-existing errors would be counted as NEW errors introduced by the transform. for (const data of allGroupData) { for (const spec of data.specs) { const oldPat = new RegExp(String.raw`\b${spec.oldName}\b`, 'g'); for (const key of [...preExistingErrorKeys]) { if (key.includes(spec.oldName)) { preExistingErrorKeys.add(key.replace(oldPat, spec.newName)); } } } } try { // Step 3: Apply transforms group by group using only strings + SourceFile refs. const allSpecs: BrandedTypeSpec[] = []; for (const data of allGroupData) { this.applyGroupTransform(data, project); allSpecs.push(...data.specs); } // Step 4: Cross-reference resolution this.resolveAllCrossReferences(allSpecs, project); // Step 5: Validate TypeScript compilation — only count errors NEW to this transform const newErrors = project .getPreEmitDiagnostics() .filter((d) => d.getCategory() === DiagnosticCategory.Error) .filter((d) => { const msg = d.getMessageText(); const text = typeof msg === 'string' ? msg : msg.getMessageText(); return !preExistingErrorKeys.has(`${d.getCode()}:${text}`); }); if (newErrors.length > 0) { const msgs = newErrors .slice(0, 3) .map((d) => { const msg = d.getMessageText(); return typeof msg === 'string' ? msg : msg.getMessageText(); }) .join('; '); throw new Error( `TypeScript validation failed (${newErrors.length} new errors): ${msgs}`, ); } } catch (err) { // Rollback: restore all snapshotted files; delete any newly created base files for (const [filePath, originalText] of snapshots) { const sf = project.getSourceFile(filePath); if (sf) { sf.replaceWithText(originalText); } } for (const newPath of newlyCreatedPaths) { const sf = project.getSourceFile(newPath); if (sf) { project.removeSourceFile(sf); } } throw err; } } // --------------------------------------------------------------------------- // Group detection // --------------------------------------------------------------------------- private detectGroups( project: Project, minSimilarInstances: number, minCommonProperties: number, baseModelsAbsPath: string, ): DuplicationGroup[] { const sourceFiles = project.getSourceFiles().filter((sf) => { const fp = sf.getFilePath(); return !fp.endsWith('.d.ts') && !fp.includes('/node_modules/'); }); // Step 1: Group interfaces by their suffix key const byKey = new Map(); for (const sf of sourceFiles) { for (const iface of sf.getInterfaces()) { const key = matchSuffix(iface.getName()); if (!key) { continue; } let bucket = byKey.get(key); if (!bucket) { bucket = []; byKey.set(key, bucket); } bucket.push(iface); } } // Step 2: Within each suffix group find SUBGROUPS sharing at least one property. // A whole-group intersection is too strict when a suffix has many unrelated // interfaces (e.g. 37 DialogRequest with zero global common properties but // several clusters of 3–5 sharing `title`, `clientId`, etc.). const groups: DuplicationGroup[] = []; for (const [key, bucket] of byKey) { if (bucket.length < minSimilarInstances) { continue; } // Build property → [interfaces that declare it] const propToIfaces = new Map(); for (const iface of bucket) { for (const propName of this.getPropertyNames(iface)) { let list = propToIfaces.get(propName); if (!list) { list = []; propToIfaces.set(propName, list); } list.push(iface); } } // For each property shared by N+ interfaces, form a subgroup const reportedKeys = new Set(); for (const [, rawSubgroup] of propToIfaces) { if (rawSubgroup.length < minSimilarInstances) { continue; } // Deduplicate by reference identity — `propToIfaces` can accumulate the same // node multiple times when it appears in many buckets (e.g. IFooResponse appears // in both `ID` and `Comment` property lists, giving a subgroup of length 3 that // is actually just one unique interface repeated). const subgroup = [...new Set(rawSubgroup)]; if (subgroup.length < minSimilarInstances) { continue; } const groupKey = subgroup .map((i) => i.getName()) .sort() .join('\0'); if (reportedKeys.has(groupKey)) { continue; } reportedKeys.add(groupKey); // Skip groups where any interface extends something OUTSIDE this group. // External inheritance means removing the parent interface or base class would // break the extends chain (e.g. `ITileConfig extends IConfigName` — IConfigName // is NOT being transformed, so `TileConfig = IBaseTileConfig & {...}` would lose // the structural contract with IConfigName consumers). // Interfaces extending ModelBase (a class) are also excluded: class methods // cannot be typed accurately as interface properties. const groupNames = new Set(subgroup.map((i) => i.getName())); const hasExternalExtends = subgroup.some((iface) => iface .getExtends() .some((ext) => !groupNames.has(ext.getExpression().getText())), ); if (hasExternalExtends) { continue; } // Skip groups where any interface extends another interface IN the same group. // These are inheritance chains (e.g. IEntityChangedData extends IChangedData where // both are in the group), not genuine duplication. The shared properties exist // by design through the extends relationship. Transforming them into branded type // aliases breaks the inheritance contract and introduces new TS errors at call // sites that assign object literals to the derived interface type. const hasInternalExtends = subgroup.some((iface) => iface .getExtends() .some((ext) => groupNames.has(ext.getExpression().getText())), ); if (hasInternalExtends) { continue; } const propertySets = subgroup.map((i) => this.getPropertyNames(i)); const commonNames = this.intersect(propertySets); if (commonNames.size === 0) { continue; } if (commonNames.size < minCommonProperties) { continue; } // For each candidate common property, verify that ALL interfaces agree on // the exact type text AND that the type is resolvable (not `unknown`). // A property typed `unknown` is a method or class member that cannot be // accurately represented as an interface property — including it in the // base interface would break any call sites. // A property whose type text differs across interfaces (e.g. `actionType: // EditType` vs `actionType: null`) is also excluded since extracting it // would break assignability for the stricter consumer. const commonProperties: PropertyInfo[] = []; for (const name of commonNames) { const infos = subgroup.map((iface) => this.getFullPropertyInfoMap(iface).get(name), ); if (infos.some((i) => !i || i.typeText === 'unknown')) { continue; } // method or unresolvable — skip const firstType = infos[0]!.typeText; const firstOptional = infos[0]!.optional; // Type text must agree across all interfaces, AND optionality must agree. // Mixing required/optional on the same property changes structural semantics // (e.g. { title: string } required vs { title?: string } optional); extracting // such a property to a base interface would silently alter the type contract. if (!infos.every((i) => i!.typeText === firstType)) { continue; } if (!infos.every((i) => i!.optional === firstOptional)) { continue; } commonProperties.push({ name, typeText: firstType, optional: firstOptional, }); } if (commonProperties.length < minCommonProperties) { continue; } // Determine whether this group spans multiple source files. const firstFilePath = subgroup[0]!.getSourceFile().getFilePath(); const isCrossFile = !subgroup.every( (i) => i.getSourceFile().getFilePath() === firstFilePath, ); const baseFilePath = isCrossFile ? path.join( baseModelsAbsPath, `base-${this.toKebabCase(key)}.model.ts`, ) : firstFilePath; groups.push({ suffixKey: key, interfaces: subgroup, commonProperties, baseFilePath, }); } } return groups; } // --------------------------------------------------------------------------- // Group transformation — two-pass design // --------------------------------------------------------------------------- /** * Pass 1 (read-only): Extract all data from live AST nodes for every group. * Must be called before any replaceWithText() mutations. After mutation, * stored InterfaceDeclaration references become "forgotten" and cannot be read. * * Base interface names are unique per group. Multiple groups that share the * same suffix (e.g. several distinct `Data` subgroups) receive disambiguated * names: first group → `IBaseData`, second → `IBaseData2`, third → `IBaseData3`. * This prevents the second group from reusing the base interface created for * the first and losing its own common properties. */ private prepareAllGroupData( groups: DuplicationGroup[], ): GroupTransformData[] { const usedBaseNames = new Set(); return groups.map((group) => { // Derive a readable, unique base interface name from the first interface in the group. // e.g. IAgreementQualificationData (suffix=Data) → IBaseAgreementQualificationData const firstName = group.interfaces[0]!.getName(); const withoutI = firstName.startsWith('I') ? firstName.slice(1) : firstName; const stem = withoutI.endsWith(group.suffixKey) ? withoutI.slice(0, withoutI.length - group.suffixKey.length) : withoutI; let candidate = `IBase${stem}${group.suffixKey}`; let n = 2; while (usedBaseNames.has(candidate)) { candidate = `IBase${stem}${group.suffixKey}${n++}`; } usedBaseNames.add(candidate); return { baseInterfaceName: candidate, commonProperties: group.commonProperties, baseFilePath: group.baseFilePath, specs: this.buildSpecs(group), }; }); } /** * Pass 2 (mutating): Apply a single group's transform using only strings and * SourceFile refs (both remain valid after prior replaceWithText() calls). * Re-fetches each interface by name — returns early if already replaced by * a prior group (idempotency guard in replaceInterfaceWithBrandedType). * * For cross-file groups: creates the base file if needed and injects an import * of the base interface into each spec's source file. * Type guards always go in spec.sourceFile to avoid circular imports between * the base file and the spec files. */ private applyGroupTransform( data: GroupTransformData, project: Project, ): void { // Get or create the base interface file let baseFile = project.getSourceFile(data.baseFilePath); baseFile ??= project.createSourceFile(data.baseFilePath, ''); // For cross-file groups: the base interface is written to a NEW shared file that // has no existing imports. Any custom type referenced in the common properties // (e.g. `OperationResult`, `AssignmentTypeViewModel[]`) must be imported. // We copy those imports from the spec files to the base file, preserving module // specifiers (path aliases like `@enterprise-shared/...` are kept as-is; relative // paths are rebased from the spec file's directory to the base file's directory). const isCrossFile = data.specs.some( (s) => s.sourceFile.getFilePath() !== data.baseFilePath, ); if (isCrossFile) { this.ensureCommonPropertyTypeImports( baseFile, data.commonProperties, data.specs.map((s) => s.sourceFile), ); } this.insertBaseInterface( baseFile, data.baseInterfaceName, data.commonProperties, ); for (const spec of data.specs) { // For cross-file groups: inject an import of the base interface into the spec file if (spec.sourceFile.getFilePath() !== data.baseFilePath) { this.ensureBaseImport( spec.sourceFile, data.baseInterfaceName, data.baseFilePath, ); } this.replaceInterfaceWithBrandedType(spec, data.baseInterfaceName); } for (const spec of data.specs) { // Type guards always in the same file as the branded type alias. // Putting guards in the base file would require importing the brand consts and // type aliases from the spec files, creating a circular dependency. this.insertTypeGuard(spec, spec.sourceFile); } } private buildSpecs(group: DuplicationGroup): BrandedTypeSpec[] { const commonSet = new Set(group.commonProperties.map((p) => p.name)); return group.interfaces.map((iface) => { const oldName = iface.getName(); const newName = oldName.startsWith('I') ? oldName.slice(1) : oldName; const brandConstName = this.toBrandConstName(newName); const fullInfoMap = this.getFullPropertyInfoMap(iface); const allPropNames = this.getPropertyNames(iface); const uniqueProperties: PropertyInfo[] = [...allPropNames] .filter((p) => !commonSet.has(p)) .map((name) => { const info = fullInfoMap.get(name); return { name, typeText: info?.typeText ?? 'unknown', optional: info?.optional ?? false, }; }); return { oldName, newName, brandConstName, uniqueProperties, sourceFile: iface.getSourceFile(), }; }); } /** FooDialogRequest → FOO_DIALOG_REQUEST_BRAND */ private toBrandConstName(newName: string): string { return ( newName .replaceAll(/([A-Z])/g, '_$1') .toUpperCase() .replace(/^_/, '') + '_BRAND' ); } /** * For cross-file groups: ensures the base interface file has imports for every * custom type referenced in the common properties. * * For each non-built-in type name found in property type text, we scan the spec * source files for an existing named import of that type and copy it to the base * file. Relative `./` module specifiers are re-based from the spec file's * directory to the base file's directory. Path-alias specifiers (e.g. * `@enterprise-shared/...`) are used as-is — they are project-wide and resolve the * same regardless of which file they appear in. */ private ensureCommonPropertyTypeImports( baseFile: SourceFile, commonProperties: PropertyInfo[], specFiles: SourceFile[], ): void { // TypeScript/JavaScript built-in identifiers that need no import const BUILT_INS = new Set([ 'string', 'number', 'boolean', 'null', 'undefined', 'any', 'unknown', 'void', 'never', 'symbol', 'object', 'bigint', 'Date', 'Array', 'Map', 'Set', 'Promise', 'Record', 'Partial', 'Required', 'Readonly', 'Pick', 'Omit', 'Extract', 'Exclude', 'NonNullable', 'ReturnType', 'InstanceType', 'Parameters', 'ConstructorParameters', 'ThisType', ]); for (const prop of commonProperties) { // Extract every identifier token from the type text (e.g. "Foo | Bar[]" → ["Foo","Bar"]) const typeNames = (prop.typeText.match(/[A-Za-z_]\w*/g) ?? []).filter( (n) => !BUILT_INS.has(n) && /^[A-Z]/.test(n), ); for (const typeName of typeNames) { // Skip if the base file already has this import const alreadyImported = baseFile .getImportDeclarations() .some((d) => d.getNamedImports().some((ni) => ni.getName() === typeName), ); if (alreadyImported) { continue; } // Find the import in any spec file for (const specFile of specFiles) { const importDecl = specFile .getImportDeclarations() .find((d) => d.getNamedImports().some((ni) => ni.getName() === typeName), ); if (!importDecl) { continue; } let specifier = importDecl.getModuleSpecifierValue(); // Re-base relative paths from the spec file's directory to the base file's directory if (specifier.startsWith('.')) { const absPath = path.resolve( path.dirname(specFile.getFilePath()), specifier, ); specifier = path .relative(path.dirname(baseFile.getFilePath()), absPath) .split(path.sep) .join('/'); if (!specifier.startsWith('.')) { specifier = './' + specifier; } } // Add or augment the import in the base file const existingImport = baseFile .getImportDeclarations() .find((d) => d.getModuleSpecifierValue() === specifier); if (existingImport) { if ( !existingImport .getNamedImports() .some((ni) => ni.getName() === typeName) ) { existingImport.addNamedImport(typeName); } } else { baseFile.addImportDeclaration({ namedImports: [typeName], moduleSpecifier: specifier, }); } break; // found the import — no need to check other spec files } } } } private insertBaseInterface( sf: SourceFile, name: string, commonProperties: PropertyInfo[], ): void { if (sf.getInterface(name)) { return; } // idempotent const props = commonProperties .map((p) => ` ${p.name}${p.optional ? '?' : ''}: ${p.typeText};`) .join('\n'); sf.insertText(0, `export interface ${name} {\n${props}\n}\n\n`); } /** * Ensures that `specFile` has an import of `baseInterfaceName` from `baseFilePath`. * Used for cross-file groups where the base interface lives in a shared models file. */ private ensureBaseImport( specFile: SourceFile, baseInterfaceName: string, baseFilePath: string, ): void { const from = path.dirname(specFile.getFilePath()); // Compute relative path, strip .ts extension, normalise separators let relPath = path.relative(from, baseFilePath.replace(/\.ts$/, '')); relPath = relPath.split(path.sep).join('/'); if (!relPath.startsWith('.')) { relPath = './' + relPath; } const existing = specFile .getImportDeclarations() .find((decl) => decl.getModuleSpecifierValue() === relPath); if (existing) { const has = existing .getNamedImports() .some((ni) => ni.getName() === baseInterfaceName); if (!has) { existing.addNamedImport(baseInterfaceName); } } else { specFile.addImportDeclaration({ namedImports: [baseInterfaceName], moduleSpecifier: relPath, }); } } private replaceInterfaceWithBrandedType( spec: BrandedTypeSpec, baseInterfaceName: string, ): void { const iface = spec.sourceFile.getInterface(spec.oldName); if (!iface) { return; } // already replaced (idempotent) const uniqueBlock = spec.uniqueProperties.length > 0 ? ` & {\n${spec.uniqueProperties.map((p) => ` ${p.name}${p.optional ? '?' : ''}: ${p.typeText};`).join('\n')}\n}` : ''; // NOTE: The brand const is declared with `unique symbol` for runtime type checks. // We do NOT use `[typeof BRAND]` in the type literal because ts-morph's in-memory // TypeScript instance does not reliably resolve the unique symbol type after // replaceWithText(). The runtime guard uses `Record` casting instead. const symbolDecl = `export const ${spec.brandConstName}: unique symbol = Symbol('${spec.newName}');\n`; const typeAlias = `export type ${spec.newName} = ${baseInterfaceName}${uniqueBlock};\n`; iface.replaceWithText(symbolDecl + typeAlias); } private insertTypeGuard(spec: BrandedTypeSpec, sf: SourceFile): void { const guardName = `is${spec.newName}`; if (sf.getFunction(guardName)) { return; } // idempotent const guard = `\nexport function ${guardName}(req: unknown): req is ${spec.newName} {\n` + ` return typeof req === 'object' && req !== null &&\n` + ` (req as Record)[${spec.brandConstName}] === true;\n` + `}\n`; sf.addStatements(guard); } // --------------------------------------------------------------------------- // Cross-reference resolution // --------------------------------------------------------------------------- private resolveAllCrossReferences( specs: BrandedTypeSpec[], project: Project, ): void { // Deduplicate: an interface that appears in multiple groups produces multiple specs // with the same (oldName, sourceFilePath) pair. Rename propagation is idempotent // but expensive; more importantly, running it multiple times can obscure logic. const processed = new Set(); for (const spec of specs) { const key = `${spec.oldName}@${spec.sourceFile.getFilePath()}`; if (processed.has(key)) { continue; } processed.add(key); // BFS propagation handles imports + re-exports through barrel chains. // Returns the set of files whose import/export specifiers were actually updated // (plus the source file itself). Only files in this set should have their // TypeReference nodes renamed — updating type references in files that import // the same name from a DIFFERENT (unrelated) source file would break them. const affectedPaths = this.propagateRename( spec.oldName, spec.newName, spec.sourceFile.getFilePath(), project, ); // Update TypeReference nodes only in files that were part of the propagation. for (const sf of project.getSourceFiles()) { if (!affectedPaths.has(sf.getFilePath())) { continue; } this.updateAllTypeReferences(sf, spec.oldName, spec.newName); } } } /** * BFS propagation of an interface rename through import/re-export chains. * * Starting from `startFilePath` (where `oldName` was replaced by `newName`), * walks the export graph to find every file that references `oldName` and * renames each occurrence. * * Returns the set of file paths that were actually modified (plus `startFilePath`). * The caller uses this set to limit TypeReference updates — files NOT in this set * may have their own (unrelated) declaration or import of `oldName` from a * completely different source, and their type references must not be renamed. * * Handles three patterns per file: * A) `export * from 'providing'` — file transitively re-exports newName; * no text change needed, but add to providing set for downstream scanning. * B) `export { oldName } from 'providing'` — explicit named re-export; rename. * C) `import { oldName } from 'providing'` — named import; rename. * D) `export { oldName }` (no module specifier) — re-export of local binding * (e.g. barrel that imported then re-exported); rename after C updates import. */ private propagateRename( oldName: string, newName: string, startFilePath: string, project: Project, ): Set { // Files that now export `newName` (explicitly or via export *) const providingPaths = new Set([startFilePath]); // Files whose import/export specifiers were actually renamed (need TypeRef updates) const affectedPaths = new Set([startFilePath]); let changed = true; while (changed) { changed = false; for (const sf of project.getSourceFiles()) { const sfPath = sf.getFilePath(); // A: `export * from 'providing'` — transitively provides newName, no text edit needed if (!providingPaths.has(sfPath)) { for (const decl of sf.getExportDeclarations()) { if (decl.getNamedExports().length > 0) { continue; } // skip named re-exports if (decl.getNamespaceExport()) { continue; } // skip `export * as ns` const resolved = decl.getModuleSpecifierSourceFile(); if (resolved && providingPaths.has(resolved.getFilePath())) { providingPaths.add(sfPath); changed = true; break; } } } // B: `export { oldName } from 'providing'` for (const decl of sf.getExportDeclarations()) { if (!decl.hasModuleSpecifier()) { continue; } const resolved = decl.getModuleSpecifierSourceFile(); if (!resolved || !providingPaths.has(resolved.getFilePath())) { continue; } for (const ne of decl.getNamedExports()) { if (ne.getName() === oldName) { ne.setName(newName); affectedPaths.add(sfPath); if (!providingPaths.has(sfPath)) { providingPaths.add(sfPath); changed = true; } } } } // C: `import { oldName } from 'providing'` for (const decl of sf.getImportDeclarations()) { const resolved = decl.getModuleSpecifierSourceFile(); if (!resolved || !providingPaths.has(resolved.getFilePath())) { continue; } for (const ni of decl.getNamedImports()) { if (ni.getName() === oldName) { ni.setName(newName); affectedPaths.add(sfPath); } } } // D: `export { oldName }` (no module specifier — re-exports a local binding) for (const decl of sf.getExportDeclarations()) { if (decl.hasModuleSpecifier()) { continue; } for (const ne of decl.getNamedExports()) { if (ne.getName() === oldName) { ne.setName(newName); affectedPaths.add(sfPath); if (!providingPaths.has(sfPath)) { providingPaths.add(sfPath); changed = true; } } } } } } return affectedPaths; } /** * Updates every TypeReference node in `sf` whose type name is exactly `oldName`. * * A single TypeReference traversal covers all cases where the name is used as a * type: parameter types, variable/property declarations, return types, array element * types, union/intersection members, generic type arguments, type aliases, type * assertions (`as X`), conditional type constraints, and more. * * Nodes are collected up-front and processed in reverse document order so that * earlier text positions remain valid while later ones are rewritten. * * The caller is responsible for only invoking this on files that were confirmed to * be part of the rename propagation chain (i.e., files returned by `propagateRename`). * This function adds a last-resort guard: skip if the file still has a LOCAL * declaration of `oldName` — renaming usages there would create broken references. */ private updateAllTypeReferences( sf: SourceFile, oldName: string, newName: string, ): void { // Guard: file has a local (non-imported) declaration of oldName. // The local interface/type was not renamed, so its usages must keep the old name. if ( sf.getInterface(oldName) !== undefined || sf.getTypeAlias(oldName) !== undefined ) { return; } const refs = sf .getDescendantsOfKind(SyntaxKind.TypeReference) .filter((tr) => tr.getTypeName().getText() === oldName) .reverse(); // reverse so later positions don't shift earlier ones for (const ref of refs) { ref.replaceWithText(newName); } } // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- /** * Detects the project root by looking for the `src/app` pattern in source file * paths (standard Angular project layout). Falls back to common path prefix. */ private detectProjectRoot(project: Project): string { const paths = project .getSourceFiles() .filter((sf) => !sf.getFilePath().includes('/node_modules/')) .map((sf) => sf.getFilePath()); if (paths.length === 0) { return '/'; } // Angular project heuristic: look for /src/app/ in any source file path for (const p of paths) { const idx = p.indexOf('/src/app/'); if (idx !== -1) { return p.substring(0, idx); } } // Fallback: common path prefix of all source files let common = path.dirname(paths[0]!); for (const p of paths.slice(1)) { while (common !== '/' && !p.startsWith(common + '/')) { common = path.dirname(common); } } return common; } /** "DialogRequest" → "dialog-request", "Dto" → "dto", "Config" → "config" */ private toKebabCase(str: string): string { return str .replaceAll(/([A-Z])/g, (c) => '-' + c.toLowerCase()) .replace(/^-/, ''); } /** * Returns a map of ALL property names (own + inherited) to their type info (type text + optionality). * * Own properties are resolved from direct PropertySignature declarations. * Inherited properties (from `extends`) are resolved by following the type * symbol's declarations up the extends chain via the TypeScript type system. * Falls back to `{ typeText: 'unknown', optional: false }` only when the declaration cannot be found. */ private getFullPropertyInfoMap( iface: InterfaceDeclaration, ): Map { const map = new Map(); // 1. Own properties — fast, always reliable for (const prop of iface.getProperties()) { map.set(prop.getName(), { typeText: prop.getTypeNode()?.getText() ?? 'unknown', optional: prop.hasQuestionToken(), }); } // 2. Inherited properties via type system try { for (const sym of iface.getType().getProperties()) { const name = sym.getName(); if (map.has(name)) { continue; } // own declaration takes precedence for (const decl of sym.getDeclarations()) { if (Node.isPropertySignature(decl)) { const typeText = decl.getTypeNode()?.getText(); if (typeText) { map.set(name, { typeText, optional: decl.hasQuestionToken() }); break; } } } } } catch { // type resolution failed; whatever own props we found are sufficient } return map; } private getPropertyNames(iface: InterfaceDeclaration): Set { const names = new Set(); try { for (const sym of iface.getType().getProperties()) { names.add(sym.getName()); } } catch { for (const prop of iface.getProperties()) { names.add(prop.getName()); } } return names; } private intersect(sets: Set[]): Set { if (sets.length === 0) { return new Set(); } let result = new Set(sets[0]); for (let i = 1; i < sets.length; i++) { const cur = sets[i]!; result = new Set([...result].filter((x) => cur.has(x))); } return result; } }