/** * @angular-modernizer/plugin-angular - Interface Duplication Analysis Rule * * Detects two kinds of type-system duplication across a TypeScript project: * * 1. **Interface family duplication** — groups of 3+ interfaces sharing the same * conceptual suffix (e.g. DialogRequest, Config, Data) whose property sets have * non-zero Jaccard similarity. One violation is emitted per group. * * 2. **ID type mixing** — functions or methods with 2+ parameters whose names end * with `Id` and whose declared type is `string` (i.e. unbranded string IDs). * * Skips `.d.ts` declaration files and anything under `node_modules/`. * * @example * ```typescript * const rule = new InterfaceDuplicationAnalysisRule(); * const results = await rule.analyze(context); * ``` */ import type { InterfaceDeclaration, SourceFile } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; // --------------------------------------------------------------------------- // Suffix pattern definitions // --------------------------------------------------------------------------- interface SuffixPattern { readonly regex: RegExp; readonly violationType: string; } const SUFFIX_PATTERNS: readonly SuffixPattern[] = [ { 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' }, ]; const DEFAULT_MIN_SIMILAR_INSTANCES = 3; // --------------------------------------------------------------------------- // Internal types // --------------------------------------------------------------------------- interface ScopeConfig { type?: 'project' | 'folder' | 'feature'; paths?: string[]; } interface RuleConfig { minSimilarInstances?: number; /** Minimum number of properties that must be shared by all interfaces in a group. * Default 2 — eliminates noise from single generic-property coincidences (id, name, date…). */ minCommonProperties?: number; /** Minimum Jaccard similarity score (0–1) required to report a group. * Default 0 — no similarity threshold. Set to e.g. 0.1 to suppress large families * whose members share only one or two generic properties out of many unique ones. */ minSimilarityScore?: number; scope?: ScopeConfig; } interface InterfaceInfo { readonly name: string; readonly properties: ReadonlySet; readonly filePath: string; readonly line: number; readonly hasExtends: boolean; readonly violationType: string; readonly suffixKey: string; } // --------------------------------------------------------------------------- // Rule // --------------------------------------------------------------------------- export class InterfaceDuplicationAnalysisRule implements AnalysisRule { public readonly id = 'angular:interface-duplication'; public readonly name = 'Interface Duplication'; public readonly description = 'Detects duplicate interface families (DialogRequest, Config, Data, etc.) that can be ' + 'consolidated into a generic base interface or discriminated union, and flags methods ' + 'with multiple unbranded string ID parameters'; public readonly severity = 'warning'; public readonly category = 'angular-typescript'; public readonly tags = [ 'angular', 'typescript', 'duplication', 'interface', 'type-safety', ]; async analyze(context: AnalysisContext): Promise { const config = (context.config ?? {}) as RuleConfig; const minSimilarInstances = config.minSimilarInstances ?? DEFAULT_MIN_SIMILAR_INSTANCES; const minCommonProperties = config.minCommonProperties ?? 2; const minSimilarityScore = config.minSimilarityScore ?? 0; const sourceFiles = this.getSourceFiles(context, config.scope); const interfaceInfos = this.collectInterfaces(sourceFiles); return [ ...this.detectInterfaceDuplication( interfaceInfos, minSimilarInstances, minCommonProperties, minSimilarityScore, ), ...this.detectIdTypeMixing(sourceFiles), ]; } // --------------------------------------------------------------------------- // Source file selection // --------------------------------------------------------------------------- private getSourceFiles( context: AnalysisContext, scope?: ScopeConfig, ): SourceFile[] { return context.project.getSourceFiles().filter((sf) => { const fp = sf.getFilePath(); if (fp.endsWith('.d.ts')) { return false; } if (fp.includes('/node_modules/')) { return false; } if (scope?.type === 'folder' && scope.paths?.length) { return scope.paths.some((p) => fp.startsWith(p)); } return true; }); } // --------------------------------------------------------------------------- // Interface collection // --------------------------------------------------------------------------- private collectInterfaces(sourceFiles: SourceFile[]): InterfaceInfo[] { const infos: InterfaceInfo[] = []; for (const sf of sourceFiles) { for (const iface of sf.getInterfaces()) { const name = iface.getName(); const match = this.matchSuffix(name); if (!match) { continue; } infos.push({ name, properties: this.getPropertyNames(iface), filePath: sf.getFilePath(), line: iface.getStartLineNumber(), hasExtends: iface.getExtends().length > 0, violationType: match.violationType, suffixKey: match.suffixKey, }); } } return infos; } private matchSuffix( name: string, ): { violationType: string; suffixKey: string } | null { for (const pat of SUFFIX_PATTERNS) { const m = name.match(pat.regex); if (m) { return { violationType: pat.violationType, suffixKey: m[0] }; } } return null; } /** * Returns all property names for an interface, including inherited ones. * Uses the TypeScript type system (`getType().getProperties()`) for accuracy * and falls back to own properties only if type resolution fails. */ 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; } // --------------------------------------------------------------------------- // Interface duplication detection // --------------------------------------------------------------------------- private detectInterfaceDuplication( infos: InterfaceInfo[], minSimilarInstances: number, minCommonProperties: number, minSimilarityScore: number, ): AnalysisResult[] { const results: AnalysisResult[] = []; // Step 1: Group by (suffixKey :: violationType) const byKey = new Map(); for (const info of infos) { const key = `${info.suffixKey}::${info.violationType}`; let bucket = byKey.get(key); if (!bucket) { bucket = []; byKey.set(key, bucket); } bucket.push(info); } for (const [, bucket] of byKey) { if (bucket.length < minSimilarInstances) { continue; } // Step 2: Build property → interfaces map. // We look for SUBGROUPS within the suffix group that share at least one // property — the whole-group intersection is too strict for large families // (e.g. 37 DialogRequest interfaces with zero global common properties but // several subsets of 3–5 that all share `title` or `clientId`). const propToInfos = new Map(); for (const info of bucket) { for (const prop of info.properties) { let list = propToInfos.get(prop); if (!list) { list = []; propToInfos.set(prop, list); } list.push(info); } } // Step 3: For each property shared by N+ interfaces form a subgroup. // Deduplicate by sorted interface-name key so we emit one violation per // unique set of interfaces regardless of how many shared properties led to it. const reportedGroups = new Set(); for (const [, subgroup] of propToInfos) { if (subgroup.length < minSimilarInstances) { continue; } const groupKey = subgroup .map((i) => i.name) .sort() .join('\0'); if (reportedGroups.has(groupKey)) { continue; } reportedGroups.add(groupKey); const propertySets = subgroup.map((i) => i.properties); const propertyIntersection = this.intersect(propertySets); const propertyUnion = this.union(propertySets); // Guard: intersection must be non-empty (always true here, but be safe) if (propertyIntersection.size === 0) { continue; } if (propertyIntersection.size < minCommonProperties) { continue; } const similarityScore = propertyUnion.size === 0 ? 0 : propertyIntersection.size / propertyUnion.size; if (similarityScore < minSimilarityScore) { continue; } const hasExtends = subgroup.some((i) => i.hasExtends); const suggestedRefactoring = hasExtends ? 'base-interface-generic' : 'branded-type-consolidation'; const primary = subgroup[0]!; results.push({ ruleId: this.id, filePath: primary.filePath, line: primary.line, message: `Found ${subgroup.length} similar interfaces with shared properties ` + `[${[...propertyIntersection].join(', ')}] ` + `(${subgroup.map((i) => i.name).join(', ')}). ` + `Consider consolidating into a base type.`, metadata: { violationType: primary.violationType, suffixKey: primary.suffixKey, interfaceNames: subgroup.map((i) => i.name), commonProperties: [...propertyIntersection], similarityScore, suggestedRefactoring, }, }); } } return results; } // --------------------------------------------------------------------------- // ID type mixing detection // --------------------------------------------------------------------------- private detectIdTypeMixing(sourceFiles: SourceFile[]): AnalysisResult[] { const results: AnalysisResult[] = []; for (const sf of sourceFiles) { const filePath = sf.getFilePath(); // Class methods for (const classDecl of sf.getClasses()) { for (const method of classDecl.getMethods()) { const idParams = method .getParameters() .filter( (p) => p.getName().endsWith('Id') && p.getTypeNode()?.getText() === 'string', ); if (idParams.length >= 2) { results.push({ ruleId: this.id, filePath, line: method.getStartLineNumber(), message: `Method '${method.getName()}' has ${idParams.length} unbranded string ID parameters ` + `(${idParams.map((p) => p.getName()).join(', ')}). Use branded types to prevent mix-ups.`, metadata: { violationType: 'id-type-mixing', paramNames: idParams.map((p) => p.getName()), }, }); } } } // Standalone functions for (const fn of sf.getFunctions()) { const idParams = fn .getParameters() .filter( (p) => p.getName().endsWith('Id') && p.getTypeNode()?.getText() === 'string', ); if (idParams.length >= 2) { results.push({ ruleId: this.id, filePath, line: fn.getStartLineNumber(), message: `Function '${fn.getName() ?? ''}' has ${idParams.length} unbranded string ID parameters ` + `(${idParams.map((p) => p.getName()).join(', ')}). Use branded types to prevent mix-ups.`, metadata: { violationType: 'id-type-mixing', paramNames: idParams.map((p) => p.getName()), }, }); } } } return results; } // --------------------------------------------------------------------------- // Set utilities // --------------------------------------------------------------------------- private intersect(sets: ReadonlySet[]): Set { if (sets.length === 0) { return new Set(); } let result = new Set(sets[0]); for (let i = 1; i < sets.length; i++) { const current = sets[i]!; result = new Set([...result].filter((x) => current.has(x))); } return result; } private union(sets: ReadonlySet[]): Set { const result = new Set(); for (const s of sets) { for (const x of s) { result.add(x); } } return result; } }