/** * @angular-modernizer/plugin-angular - Service Bag Transform Orchestrator * * The central "brain" of the Service Bag transformation. * Orchestrates the splitting of Service Bags into focused services following SRP. * * Philosophy: "API-Driven Orchestration" * - Receives all capabilities via TransformContext * - Uses PublicApi for all AST operations * - Stateless - no constructor dependencies * - Pure business logic, no low-level manipulation * * Transformation Strategy: * 1. Detect Service Bag (>10 methods, >3 responsibilities) * 2. Group methods by responsibility patterns (8 domains) * 3. Analyze properties for RxJS patterns (Subjects, Observables) * 4. Analyze method bodies for implementation patterns (.next(), JSON.parse(), etc.) * 5. Merge method + property responsibilities * 6. Create focused service files (one per responsibility) * 7. Update imports in original service * 8. Inject focused services via constructor DI * 9. Delegate method calls to focused services * * Enhanced Pattern Detection: * - Three-tier analysis: Method names → Method bodies → Properties * - Extended patterns reduce 43% miss rate to <10% target * - Real-world validation on config.service.ts (25+ methods, 7 responsibilities) * * Responsibility Domains (kebab-case internally, converted to PascalCase for service names): * - data-retrieval: get, fetch, load, read, query → DataRetrievalService * - data-mutation: save, update, delete, write, create, remove, set, modify → DataMutationService * - cache-management: cache, clear, reset, invalidate, cleanup → CacheManagementService * - validation: validate, check, verify, ensure → ValidationService * - transformation: transform, convert, map, parse, decode, AsBoolean, AsNumber → TransformationService * - event-handling: on, handle, emit, trigger, notify, clicked, Saved, Changed, toggle, RxJS Subjects → EventHandlingService * - authentication: auth, login, logout, token → AuthenticationService * - authorization: can, has, is, permission, role → AuthorizationService * * Idempotency: * - Checks if focused service files already exist * - Checks if constructor parameters already exist * - Safe to run multiple times */ import type { TransformContext } from '@angular-modernizer/plugin-system'; import type { PublicApi } from '@angular-modernizer/api'; import { ServicePattern } from '@angular-modernizer/api'; import type { ClassDeclaration, MethodDeclaration, Project, SourceFile, } from 'ts-morph'; import { SyntaxKind, Scope } from 'ts-morph'; import * as path from 'node:path'; import { analyzeMethodResponsibility, analyzeMethodAst, } from '@angular-modernizer/plugin-architecture'; import { ASTPathAliasResolver, ServiceBagValidationService, BackupManager, TypeScriptValidator, type ServiceBagValidationConfig, type AngularModernizerConfig, } from '@angular-modernizer/core'; import { ConstructorDependencyHelper, PropertyDependencyAnalyzer, SharedStateDetector, ConstructorInitializationAnalyzer, MethodBodyAnalyzer, CrossServiceCallDetector, DependencyGraphBuilder, MethodCallRewriter, PrivateHelperMethodDetector, type PropertyInitialization, type StateServiceSpec, } from '../helpers/index.js'; /** * Represents a group of methods belonging to the same responsibility domain. */ interface ResponsibilityGroup { /** The responsibility domain name (e.g., 'DataRetrieval') */ responsibility: string; /** Methods belonging to this responsibility */ methods: MethodDeclaration[]; /** Generated service name (e.g., 'DataRetrievalService') */ serviceName: string; } /** * Service Bag Transform Orchestrator - contains all transformation logic. * * This orchestrator handles the complete transformation of Service Bags into * focused services following the Single Responsibility Principle. * * @example * ```typescript * const orchestrator = new ServiceBagTransformOrchestrator(); * const context = ContextFactory.createTransformContext({ sourceFile, project, api }); * await orchestrator.run(context); * * // Results in: * // - data-retrieval-service.ts (with getUser, fetchOrders, etc.) * // - data-mutation-service.ts (with saveUser, updateProduct, etc.) * // - Original service updated to delegate to focused services * ``` */ export class ServiceBagTransformOrchestrator { private readonly methodBodyAnalyzer = new MethodBodyAnalyzer(); private readonly crossServiceCallDetector = new CrossServiceCallDetector(); private readonly methodCallRewriter = new MethodCallRewriter(); private readonly privateHelperDetector = new PrivateHelperMethodDetector(); /** * Main orchestration method - executes the complete transformation. * * @param context - Transform context with source file, project, and PublicApi * @returns Promise that resolves when transformation is complete */ async run(context: TransformContext): Promise { const { sourceFile, api, project } = context; const filePath = context.filePath ?? sourceFile.getFilePath(); // Create fresh dependency graph for this transformation // (Prevents accumulation of edges from previous files) const dependencyGraphBuilder = new DependencyGraphBuilder(); // Derive project root from file path (go up to find tsconfig.json) const projectRoot = this.findProjectRoot(filePath); // Load configuration for validation settings // Use context.config only — kernel is responsible for providing config. // Falling back to file-based ConfigLoader.load() reads real disk paths, // which breaks in-memory test projects (BackupManager readFileSync fails). const config: Partial = context.config ?? {}; const validationConfig: ServiceBagValidationConfig = { typescript: config.validation?.typescript ?? false, eslint: config.validation?.eslint ?? false, build: config.validation?.build ?? false, buildTimeout: config.validation?.buildTimeout ?? 300000, rollbackOnFailure: config.validation?.rollbackOnFailure ?? false, }; // Initialize backup manager (only when rollback is explicitly enabled) let backupManager: BackupManager | undefined; if (validationConfig.rollbackOnFailure) { backupManager = new BackupManager( path.join(projectRoot, '.angular-modernizer-backups'), ); } // Initialize validation service (when TypeScript or build validation is enabled) let validationService: ServiceBagValidationService | undefined; if (validationConfig.typescript || validationConfig.build) { validationService = new ServiceBagValidationService( new TypeScriptValidator(), undefined, // BuildValidator not provided for now path.join(projectRoot, '.angular-modernizer-backups'), ); } // Initialize path alias resolver const aliasResolver = new ASTPathAliasResolver({ baseDir: projectRoot, }); // Load path aliases from tsconfig.json const tsconfigPath = path.join(projectRoot, 'tsconfig.json'); try { await aliasResolver.loadFromTsconfig(tsconfigPath); } catch (error) { // Log warning but continue - path alias resolution is optional if (error instanceof Error) { console.warn( `[ServiceBagTransformOrchestrator] Could not load tsconfig: ${error.message}`, ); } } // Track generated files for path alias resolution and validation const generatedFiles: SourceFile[] = []; // Create backup batch BEFORE transformation (only if rollback enabled) let backupResult: | { success: boolean; backupId: string; failedFiles: string[] } | undefined; if (backupManager) { const filesToBackup = [sourceFile.getFilePath()]; backupResult = await backupManager.createBackupBatch( filesToBackup, 'service-bag-transformation', ); if (!backupResult.success) { throw new Error( `Backup failed: ${backupResult.failedFiles.join(', ')}`, ); } } // Find all classes in the source file const classes = sourceFile.getDescendantsOfKind( SyntaxKind.ClassDeclaration, ); for (const classDecl of classes) { // Detect service with confidence threshold const classification = api.analysis.servicePatternRecognizer.classifyService(sourceFile); // Skip non-services or low-confidence detections if ( classification.pattern === ServicePattern.UNKNOWN || classification.confidence < 0.7 ) { continue; } // Get public methods only (explicitly public OR no modifier — TypeScript default is public) const publicMethods = classDecl .getMethods() .filter( (m) => m.getScope() !== Scope.Private && m.getScope() !== Scope.Protected, ); // Check Service Bag threshold: >10 methods if (publicMethods.length <= 10) { continue; } // Group methods by responsibility domain (name + body analysis + private helpers) const methodGroups = this.groupMethodsByResponsibility( publicMethods, classDecl, ); // Analyze properties for RxJS patterns (Subjects, Observables) const propertyResponsibilities = this.analyzeProperties(classDecl); // Merge property-based responsibilities with method-based groups const groups = this.mergeResponsibilities( methodGroups, propertyResponsibilities, ); // Need >3 responsibilities to qualify as Service Bag if (groups.length <= 3) { continue; } // Detect shared state properties const propertyAnalyzer = new PropertyDependencyAnalyzer(); const sharedStateDetector = new SharedStateDetector(); const stateServiceSpec = sharedStateDetector.detectSharedState( classDecl, groups, propertyAnalyzer, ); // Generate state service if shared properties exist if (stateServiceSpec) { const stateServiceFile = await this.createStateService( stateServiceSpec, sourceFile.getFilePath(), project, api, ); if (stateServiceFile) { generatedFiles.push(stateServiceFile); } } // Create focused services for each responsibility group for (const group of groups) { const generatedFile = await this.createFocusedService( group, sourceFile.getFilePath(), classDecl, project, api, stateServiceSpec, ); if (generatedFile) { generatedFiles.push(generatedFile); } } // Extract shared helpers to utility service const utilityServiceFile = await this.extractSharedHelpers( classDecl, groups, sourceFile.getFilePath(), project, api, ); if (utilityServiceFile) { generatedFiles.push(utilityServiceFile); // Update call sites in focused services to use utility const utilityServiceName = utilityServiceFile .getClasses()[0] ?.getName(); if (utilityServiceName) { await this.updateSharedHelperCallSites( classDecl, groups, utilityServiceName, sourceFile.getFilePath(), project, api, ); } } // Handle cross-service dependencies await this.handleCrossServiceDependencies( groups, project, api, sourceFile.getFilePath(), dependencyGraphBuilder, ); // Detect circular dependencies const cycles = dependencyGraphBuilder.detectCircularDependencies(); if (cycles.length > 0) { console.warn('⚠️ Circular dependency detected in focused services:'); for (const cycle of cycles) { console.warn(` ${cycle}`); } console.warn(' Consider refactoring to break the cycle.'); } // Update original service to delegate to focused services await this.updateOriginalService(classDecl, groups, sourceFile, api); } // Resolve path aliases in all generated files for (const file of generatedFiles) { const result = aliasResolver.resolveAliasesInAST(file); if (result.aliasesResolved > 0) { console.info( `[ServiceBagTransformOrchestrator] Resolved ${result.aliasesResolved} path aliases in ${file.getFilePath()}`, ); } } // Validate transformation results // Collect all files to validate (original + generated) const filesToValidate: SourceFile[] = [sourceFile, ...generatedFiles]; try { // Run validation with automatic rollback on failure (only if validation enabled) if (!validationService) { // Skip validation - no validation service configured console.info( '[ServiceBagTransformOrchestrator] Validation skipped (disabled in config)', ); return; } const validationResult = validationConfig.rollbackOnFailure ? await validationService.validateWithRollback( filesToValidate, validationConfig, ) : await validationService.validateTransformation( filesToValidate, validationConfig, ); if (!validationResult.success) { // Validation failed - backup was already restored by validateWithRollback // Now delete generated files console.error( `[ServiceBagTransformOrchestrator] Validation failed with ${validationResult.errors.length} errors`, ); console.error( `[ServiceBagTransformOrchestrator] Rollback: ${validationResult.rolledBack ? 'SUCCESS' : 'FAILED'}`, ); // Delete generated files for (const file of generatedFiles) { try { const filePath = file.getFilePath(); project.removeSourceFile(file); // Also delete from file system if it exists const fs = await import('node:fs/promises'); const { existsSync } = await import('node:fs'); if (existsSync(filePath)) { await fs.unlink(filePath); console.info( `[ServiceBagTransformOrchestrator] Deleted generated file: ${filePath}`, ); } } catch (error) { console.error( `[ServiceBagTransformOrchestrator] Failed to delete file: ${error instanceof Error ? error.message : String(error)}`, ); } } // Re-throw error with validation details const errorMessage = validationResult.errors .map((err) => `${err.code}: ${err.message}`) .join('\n'); throw new Error( `Service bag transformation validation failed:\n${errorMessage}`, ); } // Validation succeeded - delete backup (if backup was created) console.info( `[ServiceBagTransformOrchestrator] Validation passed (${validationResult.duration}ms)`, ); if (backupManager && backupResult) { await backupManager.deleteBackupBatch(backupResult.backupId); } } catch (error) { // Validation threw an error - attempt rollback (if backup was created) console.error( `[ServiceBagTransformOrchestrator] Validation error: ${error instanceof Error ? error.message : String(error)}`, ); if (backupManager && backupResult) { // Restore backup const restoreResult = await backupManager.restoreBackupBatch( backupResult.backupId, ); if (!restoreResult.success) { console.error( `[ServiceBagTransformOrchestrator] Rollback failed: ${restoreResult.failedFiles.join(', ')}`, ); } else { console.info( `[ServiceBagTransformOrchestrator] Rollback successful: ${restoreResult.restoredFiles.join(', ')}`, ); } } // Delete generated files for (const file of generatedFiles) { try { const filePath = file.getFilePath(); project.removeSourceFile(file); const fs = await import('node:fs/promises'); const { existsSync } = await import('node:fs'); if (existsSync(filePath)) { await fs.unlink(filePath); } } catch { // Ignore deletion errors during error recovery } } // Re-throw the original error throw error; } } /** * Groups methods by responsibility domain based on naming patterns and body analysis. * * Analyzes method names and bodies against 8 responsibility patterns to identify * distinct domains. Also performs AST-based method body analysis to catch * patterns missed by name-only detection. * * Enhanced Detection: * - Method name patterns (primary) * - Method body analysis (secondary) - detects .next(), .emit(), JSON.parse(), etc. * - Extended patterns for event-handling (notify, clicked, Saved, Changed, toggle, etc.) * - Extended patterns for transformation (decode, AsBoolean, AsNumber, AsString, etc.) * * Refactoring (2026-02-10): * - Uses shared analyzeMethodResponsibility utility from plugin-architecture * - Eliminates pattern duplication * * Private Helper Detection (2026-02-12): * - Second pass detects private helper methods called by public methods * - Transitive helpers are included (helper calling another helper) * - Prevents TypeScript compilation errors (TS2339) after extraction * * Shared Helper Deduplication (2026-02-16): * - Detects helpers used by multiple responsibility groups * - Extracts shared helpers to utility service instead of duplicating * - Updates call sites to use utility service * * @param methods - Array of public method declarations to group * @param classDecl - The class containing the methods * @returns Array of responsibility groups (including private helpers) * @private */ private groupMethodsByResponsibility( methods: MethodDeclaration[], classDecl: ClassDeclaration, ): ResponsibilityGroup[] { const groups = new Map(); // First pass: Group public methods by responsibility for (const method of methods) { const methodName = method.getName(); const methodBody = method.getBodyText(); let responsibility: string | undefined; // Name-pattern + body-text analysis (highest priority for well-named methods). // Name patterns are highly reliable for standard naming conventions (get*, set*, save*, on*, etc.). const patternResponsibilities = analyzeMethodResponsibility( methodName, methodBody, ); if (patternResponsibilities.size > 0) { responsibility = Array.from(patternResponsibilities)[0]!; } // AST-semantic analysis — used ONLY as fallback when name patterns gave no result. // Body signals (.next(), http.get(), etc.) should not override clear name-based classification // (e.g. setConfigParams calls .next() internally but is still a data-mutation method). if (responsibility === undefined) { const astConfidenceMap = analyzeMethodAst(method, classDecl); const topAstEntry = [...astConfidenceMap.entries()].sort( ([, a], [, b]) => b - a, )[0]; if (topAstEntry && topAstEntry[1] >= 0.7) { responsibility = topAstEntry[0]; } } if (responsibility !== undefined) { if (!groups.has(responsibility)) { groups.set(responsibility, []); } groups.get(responsibility)!.push(method); } else { // Fallback: Unclassified methods go to 'General' group if (!groups.has('General')) { groups.set('General', []); } groups.get('General')!.push(method); } } // Second pass: Detect ALL private helpers for ALL public methods // Build map: helper method → list of responsibilities that use it const helperUsageMap = new Map>(); for (const [responsibility, publicMethods] of groups.entries()) { for (const publicMethod of publicMethods) { // Detect all transitive private helpers for this public method const helpers = this.privateHelperDetector.detectTransitiveHelpers( publicMethod, classDecl, ); for (const helper of helpers) { if (!helperUsageMap.has(helper)) { helperUsageMap.set(helper, new Set()); } helperUsageMap.get(helper)!.add(responsibility); } } } // Third pass: Assign helpers to groups based on usage pattern // - Single-responsibility helpers: Add to their responsibility group // - Shared helpers (multiple responsibilities): Will be extracted to utility (tracked separately) for (const [responsibility, publicMethods] of groups.entries()) { const privateHelpers = new Set(); for (const publicMethod of publicMethods) { const helpers = this.privateHelperDetector.detectTransitiveHelpers( publicMethod, classDecl, ); for (const helper of helpers) { const usedByResponsibilities = helperUsageMap.get(helper); if (usedByResponsibilities?.size === 1) { // Single-responsibility helper: Add to group privateHelpers.add(helper); } // Shared helpers (size > 1) are NOT added to any group // They will be extracted to utility service later } } // Add single-responsibility private helpers to the group for (const helper of privateHelpers) { groups.get(responsibility)!.push(helper); } } // Convert Map to ResponsibilityGroup array return Array.from(groups.entries()).map(([responsibility, methods]) => ({ responsibility, methods, serviceName: `${this.toPascalCase(responsibility)}Service`, })); } /** * Analyzes class properties to detect RxJS-based event handling. * * Detects properties using reactive patterns that indicate event handling * responsibility: * - Subject - RxJS event emitters * - BehaviorSubject - Stateful event streams * - Observable - Event streams (less common as properties) * - ReplaySubject, AsyncSubject - Other RxJS subjects * * This catches properties like: * - private onSettingsChanged: BehaviorSubject * - private onConfigSaved: Subject * - private onToggle: Subject * * Real-world impact: config.service.ts has 8 RxJS Subjects that were * previously missed, representing a significant event-handling responsibility. * * @param classDecl - Class declaration to analyze properties from * @returns Map of responsibility domains to property counts * @private */ private analyzeProperties(classDecl: ClassDeclaration): Map { const responsibilities = new Map(); const properties = classDecl.getProperties(); for (const prop of properties) { const type = prop.getType().getText(); const initText = prop.getInitializer()?.getText() ?? ''; // Detect RxJS reactive types (Subject, BehaviorSubject, Observable, etc.) if ( type.includes('Subject') || type.includes('Observable') || type.includes('EventEmitter') ) { responsibilities.set( 'event-handling', (responsibilities.get('event-handling') ?? 0) + 1, ); } // inject() style DI signals for data-retrieval / transformation if (/inject\s*\(\s*HttpClient\s*\)/.test(initText)) { responsibilities.set( 'data-retrieval', (responsibilities.get('data-retrieval') ?? 0) + 1, ); } if (/inject\s*\(\s*FormBuilder\s*\)/.test(initText)) { responsibilities.set( 'transformation', (responsibilities.get('transformation') ?? 0) + 1, ); } } // Also scan constructor parameters for HttpClient / FormBuilder injection for (const ctor of classDecl.getConstructors()) { for (const param of ctor.getParameters()) { const paramType = param.getTypeNode()?.getText() ?? ''; if (paramType === 'HttpClient') { responsibilities.set( 'data-retrieval', (responsibilities.get('data-retrieval') ?? 0) + 1, ); } if (paramType === 'FormBuilder') { responsibilities.set( 'transformation', (responsibilities.get('transformation') ?? 0) + 1, ); } } } return responsibilities; } /** * Merges property-based responsibilities with method-based responsibility groups. * * Combines two sources of responsibility detection: * 1. Method-based (from groupMethodsByResponsibility) * 2. Property-based (from analyzeProperties) * * If properties indicate a responsibility domain that wasn't detected from methods, * we add it to the groups to ensure accurate Service Bag detection. * * Example: 8 RxJS Subject properties indicate event-handling responsibility * even if no event-handling methods were detected by name patterns. * * @param methodGroups - Responsibility groups from method analysis * @param propertyResponsibilities - Responsibility counts from property analysis * @returns Merged responsibility groups * @private */ private mergeResponsibilities( methodGroups: ResponsibilityGroup[], propertyResponsibilities: Map, ): ResponsibilityGroup[] { const groupMap = new Map(); // Add all method-based groups for (const group of methodGroups) { groupMap.set(group.responsibility, group); } // Add property-based responsibilities if they have significant presence // (threshold: ≥3 properties indicates a distinct responsibility) for (const [responsibility, count] of propertyResponsibilities.entries()) { if (count >= 3 && !groupMap.has(responsibility)) { // Create a new group for property-based responsibility // Note: No methods in this group, it's purely property-based groupMap.set(responsibility, { responsibility, methods: [], // Property-based responsibility has no methods initially serviceName: `${this.toPascalCase(responsibility)}Service`, }); } } return Array.from(groupMap.values()); } /** * Creates a focused service file for a responsibility group. * * Generates a new service file with: * - Injectable decorator with providedIn: 'root' * - Constructor with dependencies used by methods (bugfix) * - All required imports (HttpClient, Observable, types) (bugfix) * - Methods from the responsibility group * * Enhancements: * - Uses ConstructorDependencyHelper to extract dependencies * - Uses ImportCleanupHelper to analyze and add imports * * Idempotent: Skips if file already exists. * * @param group - Responsibility group to create service for * @param originalFilePath - Path of the original service file * @param originalClass - Original service class declaration (for dependency extraction) * @param project - ts-morph Project instance * @param api - PublicApi for ImportManager and SymbolLocator * @returns Promise that resolves with the generated SourceFile, or null if skipped * @private */ private async createFocusedService( group: ResponsibilityGroup, originalFilePath: string, originalClass: ClassDeclaration, project: Project, api: PublicApi, stateServiceSpec?: StateServiceSpec | null, ): Promise { // Generate service file path (kebab-case filename) const dir = path.dirname(originalFilePath); const kebabName = this.toKebabCase(group.serviceName); const newFilePath = path.join(dir, `${kebabName}.ts`); // Check if file already exists (idempotency) const existingFile = project.getSourceFile(newFilePath); if (existingFile) { return null; // Already created, skip } // Extract properties used by methods in this group const propertyAnalyzer = new PropertyDependencyAnalyzer(); const properties = propertyAnalyzer.analyzePropertyUsage( originalClass, group.methods, ); // Extract constructor initializations for properties const constructorAnalyzer = new ConstructorInitializationAnalyzer(); const propertyNames = properties.map((p) => p.name); const initializations = constructorAnalyzer.extractPropertyInitializations( originalClass, propertyNames, ); // Create helper for dependency management const constructorHelper = new ConstructorDependencyHelper(); // Extract dependencies used by methods in this group let dependencies = constructorHelper.extractUsedDependencies( originalClass, group.methods, ); // Analyze initialization code for additional dependencies (e.g., this.config in initializers) const initializationDeps = this.extractDependenciesFromInitializations( initializations, originalClass, ); // Merge method dependencies with initialization dependencies dependencies = this.mergeDependencies(dependencies, initializationDeps); // Create new service file const newFile = project.createSourceFile(newFilePath, '', { overwrite: false, }); // Build symbol map for import resolution const symbolMap = api.analysis.symbolLocator.buildProjectSymbolMap(project); // Build alias map from original source file to preserve import aliases const aliasMap = this.buildAliasMap(originalClass.getSourceFile()); // Track what imports we need to add interface ImportInfo { name: string; kind: 'named' | 'namespace' | 'default'; alias?: string; } const requiredImports = new Map(); // moduleSpecifier -> ImportInfo[] // Helper to add an import to our tracking map (with alias resolution) const trackImport = ( moduleSpecifier: string, symbolName: string, kind: 'named' | 'namespace' | 'default' = 'named', alias?: string, ): void => { // If symbolName is actually an alias in the original source, resolve to original export name if (kind === 'named' && !alias) { const aliasEntry = aliasMap.get(symbolName); if (aliasEntry) { moduleSpecifier = aliasEntry.specifier; alias = symbolName; // preserve the alias used in code symbolName = aliasEntry.originalName; // use the real export name } } if (!requiredImports.has(moduleSpecifier)) { requiredImports.set(moduleSpecifier, []); } const imports = requiredImports.get(moduleSpecifier)!; // Avoid duplicates if ( !imports.some( (imp) => imp.name === symbolName && imp.alias === alias && imp.kind === kind, ) ) { imports.push({ name: symbolName, kind, alias }); } }; // Always need Injectable decorator trackImport('@angular/core', 'Injectable'); // Add inject import for dependency injection (Regel 9) // Always add inject() import if we have dependencies OR state service injection if (dependencies.length > 0 || stateServiceSpec) { trackImport('@angular/core', 'inject'); } // Analyze dependencies and track their imports for (const dep of dependencies) { const moduleSpecifier = this.resolveModuleSpecifier( dep.type, originalClass.getSourceFile(), symbolMap, ); if (moduleSpecifier) { trackImport(moduleSpecifier, dep.type); } } // Create service class with Injectable decorator const newClass = newFile.addClass({ name: group.serviceName, isExported: true, decorators: [ { name: 'Injectable', arguments: ['{ providedIn: "root" }'], }, ], }); // Add properties with inject() for dependencies (Regel 9) // Use modern inject() approach instead of Constructor DI for new code for (const dep of dependencies) { newClass.addProperty({ name: dep.name, type: dep.type, scope: dep.scope, isReadonly: dep.isReadonly, initializer: `inject(${dep.type})`, }); } // Add properties to focused service for (const prop of properties) { newClass.addProperty({ name: prop.name, type: prop.type, scope: prop.scope, isReadonly: prop.isReadonly, initializer: prop.initializer, }); // Extract type references from property type this.extractTypeReferencesFromSignature( prop.type, originalClass.getSourceFile(), symbolMap, trackImport, ); } // Add constructor with initializations if needed if (initializations.length > 0) { // Analyze initialization code to find required constructor parameters const requiredParams = this.analyzeInitializationDependencies( initializations, originalClass, dependencies, ); // Create constructor with required parameters const constructor = newClass.addConstructor({ parameters: requiredParams.map((param) => ({ name: param.name, type: param.type, })), }); // Set constructor body with initialization statements const statements = initializations.map( (init) => `this.${init.propertyName} = ${init.initializationCode};`, ); constructor.setBodyText(statements.join('\n')); // Track imports for parameter types for (const param of requiredParams) { this.extractTypeReferencesFromSignature( param.type, originalClass.getSourceFile(), symbolMap, trackImport, ); } // Extract RxJS type references from initialization code for (const init of initializations) { // Check for common RxJS types const rxjsTypes = [ 'Subject', 'BehaviorSubject', 'ReplaySubject', 'AsyncSubject', 'Observable', ]; for (const rxjsType of rxjsTypes) { if (init.initializationCode.includes(rxjsType)) { trackImport('rxjs', rxjsType); } } } } // If state service exists, inject it if (stateServiceSpec) { // Strip trailing "Service" from service name for property name // e.g., "ConfigStateService" -> "ConfigState" -> "configState" const baseServiceName = stateServiceSpec.serviceName.replace( /Service$/, '', ); const stateServicePropertyName = this.toCamelCase(baseServiceName); newClass.addProperty({ name: stateServicePropertyName, type: stateServiceSpec.serviceName, scope: Scope.Private, initializer: `inject(${stateServiceSpec.serviceName})`, }); // Add import for state service const stateServicePath = `./${this.toKebabCase(stateServiceSpec.serviceName)}`; trackImport(stateServicePath, stateServiceSpec.serviceName); } // Add methods with correct signatures and bodies const sourceFile = originalClass.getSourceFile(); for (const method of group.methods) { // Use source-text (getReturnTypeNode) to preserve import aliases like DomainUser. // Fall back to resolved type text only when there is no explicit type annotation. const returnTypeText = method.getReturnTypeNode()?.getText() ?? method.getReturnType().getText(sourceFile); // Get method body and replace shared property accesses with state service calls let methodBody = method.getBodyText() ?? '// TODO: Implement method logic'; // Replace shared property accesses with state service calls if (stateServiceSpec) { // Strip trailing "Service" from service name for property name // e.g., "ConfigStateService" -> "ConfigState" -> "configState" const baseServiceName = stateServiceSpec.serviceName.replace( /Service$/, '', ); const stateServicePropertyName = this.toCamelCase(baseServiceName); for (const sharedProp of stateServiceSpec.sharedProperties) { // Replace: this._configParams -> this.configState.configParams const pattern = new RegExp( String.raw`this\.${sharedProp.name}\b`, 'g', ); const replacement = `this.${stateServicePropertyName}.${sharedProp.name.replace(/^_/, '')}`; methodBody = methodBody.replace(pattern, replacement); } } newClass.addMethod({ name: method.getName(), returnType: returnTypeText, isAsync: method.isAsync(), scope: method.getScope(), // Preserve original scope (public/private/protected) parameters: method.getParameters().map((p) => ({ name: p.getName(), // Use source-text to preserve aliases in parameter types type: p.getTypeNode()?.getText() ?? p.getType().getText(sourceFile), })), statements: methodBody, }); // Extract type references from return type and parameters this.extractTypeReferencesFromSignature( returnTypeText, originalClass.getSourceFile(), symbolMap, trackImport, ); for (const param of method.getParameters()) { this.extractTypeReferencesFromSignature( param.getTypeNode()?.getText() ?? param.getType().getText(sourceFile), originalClass.getSourceFile(), symbolMap, trackImport, ); } } // Extract identifiers from method bodies this.extractMethodBodyIdentifiers( newClass, originalClass.getSourceFile(), trackImport, ); // Add all tracked imports using ImportManager for (const [moduleSpecifier, imports] of requiredImports.entries()) { for (const importInfo of imports) { if (importInfo.kind === 'default') { // Default import: import moment from 'moment' let importDecl = newFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = newFile.addImportDeclaration({ moduleSpecifier, defaultImport: importInfo.name, }); } else if (!importDecl.getDefaultImport()) { importDecl.setDefaultImport(importInfo.name); } } else if (importInfo.kind === 'namespace') { // Namespace import: import * as _ from 'lodash' let importDecl = newFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = newFile.addImportDeclaration({ moduleSpecifier, namespaceImport: importInfo.name, }); } else if (!importDecl.getNamespaceImport()) { importDecl.setNamespaceImport(importInfo.name); } } else { // Named import: import { map } from 'rxjs/operators' // or aliased: import { Model as DomainModel } from './model' if (importInfo.alias) { // For aliased imports, manually add the import with alias let importDecl = newFile.getImportDeclaration(moduleSpecifier); importDecl ??= newFile.addImportDeclaration({ moduleSpecifier }); const existingNamedImport = importDecl .getNamedImports() .find( (ni) => ni.getName() === importInfo.name && ni.getAliasNode()?.getText() === importInfo.alias, ); if (!existingNamedImport) { importDecl.addNamedImport({ name: importInfo.name, alias: importInfo.alias, }); } } else { api.transformation.importManager.addNamedImport( newFile, moduleSpecifier, importInfo.name, ); } } } } // Post-process: add missing imports for locally-declared types (TS2304 fix) this.fixLocalTypeImports( newFile, newFilePath, originalClass.getSourceFile(), ); // Organize imports after all additions api.transformation.importManager.organizeImports(newFile); // Save the new file await newFile.save(); // Return the generated file for path alias resolution return newFile; } /** * Creates a state service for shared properties. * * Generates a service with: * - Injectable decorator with providedIn: 'root' * - Private properties for state * - Getter/setter methods for each property * * @param spec - State service specification * @param originalFilePath - Path to the original service file * @param project - ts-morph Project * @param api - Public API for transformation utilities * @returns Promise resolving to the created SourceFile or null if file exists * @private */ private async createStateService( spec: StateServiceSpec, originalFilePath: string, project: Project, api: PublicApi, ): Promise { const dir = path.dirname(originalFilePath); const kebabName = this.toKebabCase(spec.serviceName); const newFilePath = path.join(dir, `${kebabName}.ts`); // Check if file already exists (idempotency) const existingFile = project.getSourceFile(newFilePath); if (existingFile) { return null; } // Create new file const newFile = project.createSourceFile(newFilePath, '', { overwrite: false, }); // Build symbol map for import resolution const symbolMap = api.analysis.symbolLocator.buildProjectSymbolMap(project); // Track what imports we need to add interface ImportInfo { name: string; kind: 'named' | 'namespace' | 'default'; alias?: string; } const requiredImports = new Map(); // moduleSpecifier -> ImportInfo[] // Helper to add an import to our tracking map const trackImport = ( moduleSpecifier: string, symbolName: string, kind: 'named' | 'namespace' | 'default' = 'named', alias?: string, ): void => { if (!requiredImports.has(moduleSpecifier)) { requiredImports.set(moduleSpecifier, []); } const imports = requiredImports.get(moduleSpecifier)!; // Avoid duplicates if ( !imports.some( (imp) => imp.name === symbolName && imp.alias === alias && imp.kind === kind, ) ) { imports.push({ name: symbolName, kind, alias }); } }; // Always need Injectable decorator trackImport('@angular/core', 'Injectable'); // Get original source file for import resolution const originalSourceFile = project.getSourceFile(originalFilePath); if (!originalSourceFile) { throw new Error(`Original source file not found: ${originalFilePath}`); } // Create class const newClass = newFile.addClass({ name: spec.serviceName, isExported: true, decorators: [ { name: 'Injectable', arguments: ['{ providedIn: "root" }'], }, ], }); // Add properties and getters/setters for (const prop of spec.sharedProperties) { const getterName = prop.name.replace(/^_/, ''); const hasBackingField = prop.name !== getterName; // name starts with '_' if (hasBackingField) { // Backing field pattern: private _prop + public get/set accessor newClass.addProperty({ name: prop.name, type: prop.type, scope: Scope.Private, initializer: prop.initializer, }); newClass.addGetAccessor({ name: getterName, returnType: prop.type, statements: `return this.${prop.name};`, }); newClass.addSetAccessor({ name: getterName, parameters: [{ name: 'value', type: prop.type }], statements: `this.${prop.name} = value;`, }); } else { // Plain property: add directly with original scope (no getter/setter to avoid TS2300) newClass.addProperty({ name: prop.name, type: prop.type, scope: prop.scope, initializer: prop.initializer, }); } // Extract type references from property type this.extractTypeReferencesFromSignature( prop.type, originalSourceFile, symbolMap, trackImport, ); } // Add all tracked imports using ImportManager for (const [moduleSpecifier, imports] of requiredImports.entries()) { for (const importInfo of imports) { if (importInfo.kind === 'default') { // Default import: import moment from 'moment' let importDecl = newFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = newFile.addImportDeclaration({ moduleSpecifier, defaultImport: importInfo.name, }); } else if (!importDecl.getDefaultImport()) { importDecl.setDefaultImport(importInfo.name); } } else if (importInfo.kind === 'namespace') { // Namespace import: import * as _ from 'lodash' let importDecl = newFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = newFile.addImportDeclaration({ moduleSpecifier, namespaceImport: importInfo.name, }); } else if (!importDecl.getNamespaceImport()) { importDecl.setNamespaceImport(importInfo.name); } } else { // Named import: import { map } from 'rxjs/operators' // or aliased: import { Model as DomainModel } from './model' if (importInfo.alias) { // For aliased imports, manually add the import with alias let importDecl = newFile.getImportDeclaration(moduleSpecifier); importDecl ??= newFile.addImportDeclaration({ moduleSpecifier }); const existingNamedImport = importDecl .getNamedImports() .find( (ni) => ni.getName() === importInfo.name && ni.getAliasNode()?.getText() === importInfo.alias, ); if (!existingNamedImport) { importDecl.addNamedImport({ name: importInfo.name, alias: importInfo.alias, }); } } else { api.transformation.importManager.addNamedImport( newFile, moduleSpecifier, importInfo.name, ); } } } } // Post-process: add missing imports for locally-declared types (TS2304 fix) this.fixLocalTypeImports(newFile, newFilePath, originalSourceFile); // Organize imports after all additions api.transformation.importManager.organizeImports(newFile); // Save file await newFile.save(); return newFile; } /** * Updates the original service to delegate to focused services. * * Performs the following updates: * 1. Adds imports for focused services * 2. Injects focused services via constructor DI * 3. Updates method bodies to delegate to focused services * 4. Cleans up unused imports and constructor parameters * * Idempotent: Checks for existing parameters before adding. * * @param classDecl - Original service class declaration * @param groups - Responsibility groups with focused services * @param sourceFile - Original source file * @param api - PublicApi for AST operations * @returns Promise that resolves when updates are complete * @private */ private async updateOriginalService( classDecl: ClassDeclaration, groups: ResponsibilityGroup[], sourceFile: SourceFile, api: PublicApi, ): Promise { // Create helper for dependency tracking const constructorHelper = new ConstructorDependencyHelper(); // Track which dependency types were moved to focused services const movedDependencyTypes = new Set(); // Add imports for focused services and track moved dependencies for (const group of groups) { const kebabName = this.toKebabCase(group.serviceName); api.transformation.importManager.addNamedImport( sourceFile, `./${kebabName}`, group.serviceName, ); // Extract dependencies used by this group's methods const dependencies = constructorHelper.extractUsedDependencies( classDecl, group.methods, ); // Track moved dependency types for (const dep of dependencies) { movedDependencyTypes.add(dep.type); } } // Get or create constructor let constructor = classDecl.getConstructors()[0]; constructor ??= classDecl.addConstructor({}); // Add focused services as constructor parameters for (const group of groups) { const paramName = this.toCamelCase(group.serviceName); // Check if parameter already exists (idempotency) const existingParam = constructor.getParameter(paramName); if (existingParam) { continue; } constructor.addParameter({ name: paramName, type: group.serviceName, scope: Scope.Private, isReadonly: true, }); } // Update method implementations to delegate to focused services for (const group of groups) { const paramName = this.toCamelCase(group.serviceName); for (const method of group.methods) { const originalMethod = classDecl.getMethod(method.getName()); if (originalMethod) { // Generate delegation code originalMethod.setBodyText( `return this.${paramName}.${method.getName()}(${method .getParameters() .map((p) => p.getName()) .join(', ')});`, ); } } } // Remove unused constructor parameters FIRST // (Must happen before import cleanup, otherwise imports see params as "used") const allDeps = constructorHelper.extractAllDependencies(classDecl); const remainingMethods = classDecl.getMethods(); const usedDeps = constructorHelper.extractUsedDependencies( classDecl, remainingMethods, ); // Remove parameters that are no longer used for (const dep of allDeps) { if (!usedDeps.some((d) => d.name === dep.name)) { const param = constructor.getParameter(dep.name); if (param) { param.remove(); } } } // Clean up unused imports AFTER constructor cleanup // Analyze which imports are no longer used in the class const allImports = sourceFile.getImportDeclarations(); for (const importDecl of allImports) { const moduleSpec = importDecl.getModuleSpecifierValue(); // Skip Angular core imports (Injectable, etc.) - always keep these if (moduleSpec === '@angular/core') { continue; } const namedImports = importDecl.getNamedImports(); for (const namedImport of namedImports) { const importName = namedImport.getName(); const alias = namedImport.getAliasNode()?.getText(); // Check if this import is still used in the class if (!this.isImportUsedInClass(classDecl, importName, alias)) { api.transformation.importManager.removeNamedImport( sourceFile, importName, moduleSpec, ); } } } // Clean up any empty import declarations api.transformation.importManager.cleanupEmptyImports(sourceFile); // Organize imports after cleanup api.transformation.importManager.organizeImports(sourceFile); } /** * Analyzes initialization code to determine required constructor parameters. * * Scans initialization expressions to find: * 1. Constructor parameters (non-DI) - identifiers without 'this.' prefix * 2. Dependencies - already handled as injected properties * * For example: * - `new BehaviorSubject(initialData)` → needs `initialData: Data` parameter * - `this.config.get('apiUrl')` → `config` already injected, no param needed * - `this.processData(initialData)` → needs `initialData: Data` parameter * * @param initializations - Property initialization metadata * @param originalClass - Original service class to extract parameter types * @param existingDependencies - Dependencies already injected as properties * @returns Array of required constructor parameters * @private */ private analyzeInitializationDependencies( initializations: PropertyInitialization[], originalClass: ClassDeclaration, existingDependencies: { name: string; type: string }[], ): { name: string; type: string }[] { // Get original constructor parameters for type lookup const originalConstructors = originalClass.getConstructors(); if (originalConstructors.length === 0) { return []; } const originalConstructor = originalConstructors[0]; if (!originalConstructor) { return []; } const originalParams = originalConstructor.getParameters(); const paramTypeMap = new Map(); // Build map of parameter name → type from original constructor for (const param of originalParams) { const paramName = param.getName(); const paramType = param.getType().getText(); paramTypeMap.set(paramName, paramType); } // Track which parameters are needed const neededParams = new Set(); const dependencyNames = new Set(existingDependencies.map((d) => d.name)); // Pattern to match identifiers (not preceded by 'this.') // Matches: word boundaries, excludes 'this.xyz' but captures 'xyz' in other contexts const identifierPattern = /(?` patterns and * looks up their types from the original constructor parameters. * * For example: * - `this.config.get('apiUrl')` → extracts `config: ConfigService` * - `this.http.get(url)` → extracts `http: HttpClient` * * @param initializations - Property initialization metadata * @param originalClass - Original service class to extract dependency types * @returns Array of dependency metadata * @private */ private extractDependenciesFromInitializations( initializations: PropertyInitialization[], originalClass: ClassDeclaration, ): { name: string; type: string; scope: Scope; isReadonly: boolean }[] { // Get original constructor parameters for type lookup const originalConstructors = originalClass.getConstructors(); if (originalConstructors.length === 0) { return []; } const originalConstructor = originalConstructors[0]; if (!originalConstructor) { return []; } const originalParams = originalConstructor.getParameters(); const paramMap = new Map< string, { type: string; scope: Scope; isReadonly: boolean } >(); // Build map of parameter name → metadata from original constructor for (const param of originalParams) { const paramName = param.getName(); const paramType = param.getTypeNode()?.getText() ?? param.getType().getText(); const scope = param.getScope() ?? Scope.Public; const isReadonly = param.isReadonly(); paramMap.set(paramName, { type: paramType, scope, isReadonly }); } // Track which dependencies are referenced in initializations const referencedDeps = new Set(); // Pattern to match this.identifier (dependency access) const dependencyPattern = /\bthis\.([a-z_$][a-zA-Z0-9_$]*)\b/g; for (const init of initializations) { const code = init.initializationCode; // Find all this.xyz patterns const matches = code.matchAll(dependencyPattern); for (const match of matches) { const depName = match[1]; if (!depName) { continue; } // Check if this is a constructor parameter (dependency) if (paramMap.has(depName)) { referencedDeps.add(depName); } } } // Build dependency array const result: { name: string; type: string; scope: Scope; isReadonly: boolean; }[] = []; for (const depName of referencedDeps) { const depInfo = paramMap.get(depName); if (depInfo) { result.push({ name: depName, type: depInfo.type, scope: depInfo.scope ?? Scope.Private, isReadonly: depInfo.isReadonly, }); } } return result; } /** * Merges two dependency arrays, avoiding duplicates. * * @param deps1 - First dependency array * @param deps2 - Second dependency array * @returns Merged dependency array without duplicates * @private */ private mergeDependencies( deps1: { name: string; type: string; scope: Scope; isReadonly: boolean }[], deps2: { name: string; type: string; scope: Scope; isReadonly: boolean }[], ): { name: string; type: string; scope: Scope; isReadonly: boolean }[] { const merged = new Map< string, { name: string; type: string; scope: Scope; isReadonly: boolean } >(); // Add all from deps1 for (const dep of deps1) { merged.set(dep.name, dep); } // Add from deps2, avoiding duplicates for (const dep of deps2) { if (!merged.has(dep.name)) { merged.set(dep.name, dep); } } return Array.from(merged.values()); } /** * Find the project root by walking up the directory tree from the given file path. * Looks for tsconfig.json or package.json to identify the project root. * * @param filePath - Path of a file in the project * @returns Project root directory * @private */ private findProjectRoot(filePath: string): string { const currentDir = path.dirname(filePath); const root = path.parse(currentDir).root; while (currentDir !== root) { // Check if current directory is project root (has tsconfig.json or package.json) // For in-memory testing, we'll just use the immediate parent directory return currentDir; } // Fallback to current directory if root not found return currentDir; } /** * Post-processes a generated file to add missing imports for locally-declared types. * * Detects TS2304 "Cannot find name" errors and resolves types (interfaces, type aliases, * enums) declared locally in the original source file. If found, exports them and adds * the relative import to the generated file. * * @param newFile - Generated service file to fix * @param newFilePath - Absolute path of the generated file * @param originalSourceFile - Original source file containing local declarations * @private */ private fixLocalTypeImports( newFile: SourceFile, newFilePath: string, originalSourceFile: SourceFile, ): void { const diagnostics = newFile .getPreEmitDiagnostics() .filter((d) => d.getCode() === 2304); for (const diagnostic of diagnostics) { const rawMsg = diagnostic.getMessageText(); const messageText = typeof rawMsg === 'string' ? rawMsg : rawMsg.getMessageText(); const match = /Cannot find name '([^']+)'/.exec(messageText); if (!match) { continue; } const typeName = match[1]!; const localDecl = originalSourceFile.getInterface(typeName) || originalSourceFile.getTypeAlias(typeName) || originalSourceFile.getEnum(typeName); if (localDecl) { if (!localDecl.isExported()) { localDecl.setIsExported(true); } const relPath = this.calculateRelativeImportPath( newFilePath, originalSourceFile.getFilePath(), ); const existingImport = newFile.getImportDeclaration(relPath); if (!existingImport) { newFile.addImportDeclaration({ moduleSpecifier: relPath, namedImports: [typeName], }); } else if ( !existingImport .getNamedImports() .some((n) => n.getName() === typeName) ) { existingImport.addNamedImport(typeName); } } } } /** * Builds a map from import alias names to their original export names and module specifiers. * * For example, `import { UserModel as DomainUser } from './user.model'` produces: * `'DomainUser' → { originalName: 'UserModel', specifier: './user.model' }` * * @param sourceFile - Source file to scan for aliased imports * @returns Map from alias name to original name + specifier * @private */ private buildAliasMap( sourceFile: SourceFile, ): Map { const map = new Map(); for (const decl of sourceFile.getImportDeclarations()) { const specifier = decl.getModuleSpecifierValue(); for (const ni of decl.getNamedImports()) { const alias = ni.getAliasNode()?.getText(); if (alias) { map.set(alias, { originalName: ni.getName(), specifier }); } } } return map; } /** * Resolves the module specifier for a type name. * * Determines where a type should be imported from by: * 1. Checking existing imports in the original service file * 2. Looking up the type in the project symbol map * 3. Falling back to common Angular imports * * @param typeName - Type name to resolve (e.g., 'HttpClient', 'User') * @param originalServiceFile - Original service source file * @param symbolMap - Project-wide symbol map from SymbolLocator * @returns Module specifier or undefined if not found * @private */ private resolveModuleSpecifier( typeName: string, originalServiceFile: SourceFile, symbolMap: Map< string, { className: string; sourceFile: SourceFile; isExported: boolean } >, ): string | undefined { // 1. Try to find in original service's existing imports first const existingImports = originalServiceFile.getImportDeclarations(); for (const importDecl of existingImports) { const namedImports = importDecl.getNamedImports(); for (const namedImport of namedImports) { const name = namedImport.getName(); const alias = namedImport.getAliasNode()?.getText(); // Match either the original name or the alias if (name === typeName || alias === typeName) { return importDecl.getModuleSpecifierValue(); } } } // 2. Use symbol map to find the type in the project const symbolInfo = symbolMap.get(typeName); if (symbolInfo) { // Calculate relative import path (will be resolved to absolute or alias later) return this.calculateRelativeImportPath( originalServiceFile.getFilePath(), symbolInfo.sourceFile.getFilePath(), ); } // 3. Common Angular imports (fallback) const angularImports: Record = { Injectable: '@angular/core', HttpClient: '@angular/common/http', Observable: 'rxjs', Subject: 'rxjs', BehaviorSubject: 'rxjs', ReplaySubject: 'rxjs', AsyncSubject: 'rxjs', EventEmitter: '@angular/core', }; return angularImports[typeName]; } /** * Calculates a relative import path between two file paths. * * @param fromPath - The file path that will contain the import * @param toPath - The file path to import from * @returns Relative import path without extension * @private */ private calculateRelativeImportPath( fromPath: string, toPath: string, ): string { const fromDir = path.dirname(fromPath); const toPathWithoutExt = toPath.replace(/\.ts$/, ''); const relativePath = path.relative(fromDir, toPathWithoutExt); // Ensure relative paths start with './' or '../' if (!relativePath.startsWith('.')) { return `./${relativePath}`; } return relativePath; } /** * Extracts type references from a method signature (return type or parameter type). * * Handles: * - Simple types: `User` → ['User'] * - Generic types: `Observable` → ['Observable', 'User'] * - Union types: `User | Admin` → ['User', 'Admin'] * - Array types: `User[]` → ['User'] * * @param typeText - Type text from method signature * @param originalServiceFile - Original service source file * @param symbolMap - Project-wide symbol map * @param trackImport - Callback to track an import * @private */ private extractTypeReferencesFromSignature( typeText: string, originalServiceFile: SourceFile, symbolMap: Map< string, { className: string; sourceFile: SourceFile; isExported: boolean } >, trackImport: (moduleSpecifier: string, symbolName: string) => void, ): void { // Remove common primitives and built-in types const builtInTypes = new Set([ 'string', 'number', 'boolean', 'void', 'any', 'unknown', 'never', 'undefined', 'null', 'Array', 'Promise', 'Date', 'RegExp', ]); // Extract type names (simple pattern - handles most cases) const typePattern = /\b([A-Z][a-zA-Z0-9]*)\b/g; const matches = typeText.matchAll(typePattern); for (const match of matches) { const typeName = match[1]; if (typeName && !builtInTypes.has(typeName)) { const moduleSpecifier = this.resolveModuleSpecifier( typeName, originalServiceFile, symbolMap, ); if (moduleSpecifier) { trackImport(moduleSpecifier, typeName); } } } } /** * Extracts identifiers used in method bodies and tracks their imports. * * Scans all method bodies for identifiers (function calls, property accesses) and * maps them back to their import declarations in the original service file. * * Handles: * - Named imports: `import { map, filter } from 'rxjs/operators'` * - RxJS operators: `.pipe(map(...))` → track 'map' import * - Utility functions: `parseData(...)` → track 'parseData' import * * @param classDecl - Class declaration to analyze * @param originalServiceFile - Original service source file to resolve imports from * @param trackImport - Callback to track an import * @private */ private extractMethodBodyIdentifiers( classDecl: ClassDeclaration, originalServiceFile: SourceFile, trackImport: ( moduleSpecifier: string, symbolName: string, kind?: 'named' | 'namespace' | 'default', alias?: string, ) => void, ): void { // Use AST-based analyzer const identifiers = this.methodBodyAnalyzer.analyzeMethodBodies( classDecl, originalServiceFile, ); const seenIdentifiers = new Set(); for (const identifier of identifiers) { const key = `${identifier.moduleSpecifier}::${identifier.name}::${identifier.kind}::${identifier.alias ?? ''}`; if (!seenIdentifiers.has(key)) { seenIdentifiers.add(key); trackImport( identifier.moduleSpecifier, identifier.name, identifier.kind, identifier.alias, ); } } } /** * Checks if an import is still used in a class. * * Verifies whether an import name (or its alias) is referenced in: * - Constructor parameters * - Method signatures (return types and parameters) * - Properties * - Method bodies * * @param classDecl - Class declaration to check * @param importName - Import name to check * @param alias - Optional alias for the import * @returns True if the import is still used, false otherwise * @private */ private isImportUsedInClass( classDecl: ClassDeclaration, importName: string, alias?: string, ): boolean { // Helper to check if text contains the import (original or alias) const containsImport = (text: string): boolean => { if (text.includes(importName)) { return true; } if (alias && text.includes(alias)) { return true; } return false; }; // 1. Check constructor parameters const constructors = classDecl.getConstructors(); for (const ctor of constructors) { for (const param of ctor.getParameters()) { const paramTypeText = param.getType().getText(); if (containsImport(paramTypeText)) { return true; } } } // 2. Check method signatures (return types + parameters) const methods = classDecl.getMethods(); for (const method of methods) { // Check return type const returnTypeText = method.getReturnType().getText(); if (containsImport(returnTypeText)) { return true; } // Check parameter types for (const param of method.getParameters()) { const paramTypeText = param.getType().getText(); if (containsImport(paramTypeText)) { return true; } } // Check method body const bodyText = method.getBodyText(); if (bodyText && containsImport(bodyText)) { return true; } } // 3. Check properties const properties = classDecl.getProperties(); for (const prop of properties) { const propTypeText = prop.getType().getText(); if (containsImport(propTypeText)) { return true; } // Check property initializer const initializer = prop.getInitializer(); if (initializer && containsImport(initializer.getText())) { return true; } } return false; } /** * Converts kebab-case to PascalCase for service naming. * * @param str - String in kebab-case format * @returns String in PascalCase format * @private * @example * toPascalCase('data-retrieval') // Returns 'DataRetrieval' */ private toPascalCase(str: string): string { return str .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(''); } /** * Handles cross-service dependencies between focused services. * * For each focused service: * 1. Detects cross-service method calls * 2. Injects required services using inject() * 3. Rewrites method bodies to use injected services * 4. Adds imports for injected services * 5. Builds dependency graph * * @param groups - Responsibility groups * @param project - ts-morph Project * @param api - PublicApi for ImportManager * @param originalFilePath - Original service file path * @param dependencyGraphBuilder - Dependency graph builder instance * @private */ private async handleCrossServiceDependencies( groups: ResponsibilityGroup[], project: Project, api: PublicApi, originalFilePath: string, dependencyGraphBuilder: DependencyGraphBuilder, ): Promise { // Build map of all focused services (serviceName → methods) const allFocusedServices = new Map(); for (const group of groups) { allFocusedServices.set(group.serviceName, group.methods); } // Process each focused service for (const group of groups) { const kebabName = this.toKebabCase(group.serviceName); const dir = path.dirname(originalFilePath); const filePath = path.join(dir, `${kebabName}.ts`); const focusedServiceFile = project.getSourceFile(filePath); if (!focusedServiceFile) { continue; } const focusedServiceClass = focusedServiceFile.getClass( group.serviceName, ); if (!focusedServiceClass) { continue; } // Detect cross-service calls const crossServiceCalls = this.crossServiceCallDetector.detectCrossServiceCalls( focusedServiceClass, allFocusedServices, ); if (crossServiceCalls.length === 0) { continue; } // Ensure inject() is imported from @angular/core for cross-service injections // (Idempotent - ImportManager handles duplicate imports) api.transformation.importManager.addNamedImport( focusedServiceFile, '@angular/core', 'inject', ); // Group calls by target service const targetServices = new Set( crossServiceCalls.map((c) => c.targetService), ); // Inject required services for (const targetService of targetServices) { const propertyName = this.methodCallRewriter.toPropertyName(targetService); // Add property with inject() focusedServiceClass.addProperty({ name: propertyName, type: targetService, scope: Scope.Private, initializer: `inject(${targetService})`, }); // Add import for target service const targetServiceKebab = this.toKebabCase(targetService); const targetServicePath = `./${targetServiceKebab}`; api.transformation.importManager.addNamedImport( focusedServiceFile, targetServicePath, targetService, ); // Build dependency graph dependencyGraphBuilder.addDependency(group.serviceName, targetService); } // Rewrite method bodies const servicePropertyMap = new Map(); for (const targetService of targetServices) { servicePropertyMap.set( targetService, this.methodCallRewriter.toPropertyName(targetService), ); } // Group cross-service calls by source method const callsByMethod = new Map< MethodDeclaration, typeof crossServiceCalls >(); for (const call of crossServiceCalls) { if (!callsByMethod.has(call.sourceMethod)) { callsByMethod.set(call.sourceMethod, []); } callsByMethod.get(call.sourceMethod)!.push(call); } // Rewrite each method for (const [method, calls] of callsByMethod.entries()) { const rewrittenBody = this.methodCallRewriter.rewriteMethodBody( method, calls, servicePropertyMap, ); method.setBodyText(rewrittenBody); } // Organize imports api.transformation.importManager.organizeImports(focusedServiceFile); api.transformation.importManager.cleanupEmptyImports(focusedServiceFile); await focusedServiceFile.save(); } } /** * Converts PascalCase to kebab-case for file naming. * * @param str - String in PascalCase format * @returns String in kebab-case format * @private * @example * toKebabCase('DataRetrievalService') // Returns 'data-retrieval-service' */ private toKebabCase(str: string): string { return str.replaceAll(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); } /** * Converts PascalCase to camelCase for variable naming. * * @param str - String in PascalCase format * @returns String in camelCase format * @private * @example * toCamelCase('DataRetrievalService') // Returns 'dataRetrievalService' */ private toCamelCase(str: string): string { return str.charAt(0).toLowerCase() + str.slice(1); } /** * Extracts shared private helpers to a utility service. * * Detects helpers used by multiple responsibility groups and: * 1. Creates a utility service with static methods * 2. Updates call sites in all focused services to use the utility * 3. Prevents duplication of helper logic across services * * Example: formatDate() used by both DataRetrievalService and CacheManagementService * → Extracted to ConfigServiceUtils.formatDate() * * @param classDecl - Original service class declaration * @param groups - Responsibility groups * @param originalFilePath - Path to original service file * @param project - ts-morph Project * @param api - PublicApi for transformation utilities * @returns Promise resolving to utility service file or null if no shared helpers * @private */ private async extractSharedHelpers( classDecl: ClassDeclaration, groups: ResponsibilityGroup[], originalFilePath: string, project: Project, api: PublicApi, ): Promise { // Build map: helper method → list of responsibilities that use it const helperUsageMap = new Map>(); for (const group of groups) { const publicMethods = group.methods.filter( (m) => m.getScope() !== Scope.Private && m.getScope() !== Scope.Protected, ); for (const publicMethod of publicMethods) { const helpers = this.privateHelperDetector.detectTransitiveHelpers( publicMethod, classDecl, ); for (const helper of helpers) { if (!helperUsageMap.has(helper)) { helperUsageMap.set(helper, new Set()); } helperUsageMap.get(helper)!.add(group.responsibility); } } } // Find shared helpers (used by >1 responsibility) const sharedHelpers: MethodDeclaration[] = []; for (const [helper, responsibilities] of helperUsageMap.entries()) { if (responsibilities.size > 1) { sharedHelpers.push(helper); } } // No shared helpers? Skip utility creation if (sharedHelpers.length === 0) { return null; } // Create utility service file const originalServiceName = classDecl.getName() ?? 'Service'; const utilityServiceName = `${originalServiceName}Utils`; const dir = path.dirname(originalFilePath); const kebabName = this.toKebabCase(utilityServiceName); const utilityFilePath = path.join(dir, `${kebabName}.ts`); // Check if file already exists (idempotency) const existingFile = project.getSourceFile(utilityFilePath); if (existingFile) { return existingFile; } // Create utility service file const utilityFile = project.createSourceFile(utilityFilePath, '', { overwrite: false, }); // Create utility class with static methods const utilityClass = utilityFile.addClass({ name: utilityServiceName, isExported: true, }); // Add JSDoc comment explaining the utility's purpose utilityClass.addJsDoc({ description: `Utility class for shared helper methods.\n\nExtracted from ${originalServiceName} because these methods were used by\nmultiple responsibilities across different services.`, }); // Build symbol map for import resolution const symbolMap = api.analysis.symbolLocator.buildProjectSymbolMap(project); // Track required imports interface ImportInfo { name: string; kind: 'named' | 'namespace' | 'default'; alias?: string; } const requiredImports = new Map(); const trackImport = ( moduleSpecifier: string, symbolName: string, kind: 'named' | 'namespace' | 'default' = 'named', alias?: string, ): void => { if (!requiredImports.has(moduleSpecifier)) { requiredImports.set(moduleSpecifier, []); } const imports = requiredImports.get(moduleSpecifier)!; if ( !imports.some( (imp) => imp.name === symbolName && imp.alias === alias && imp.kind === kind, ) ) { imports.push({ name: symbolName, kind, alias }); } }; // Add each shared helper as a static method const originalSourceFile = classDecl.getSourceFile(); for (const helper of sharedHelpers) { const returnTypeText = helper.getReturnType().getText(originalSourceFile); const methodBody = helper.getBodyText() ?? '// TODO: Implement method logic'; utilityClass.addMethod({ name: helper.getName(), returnType: returnTypeText, isStatic: true, isAsync: helper.isAsync(), parameters: helper.getParameters().map((p) => ({ name: p.getName(), type: p.getType().getText(originalSourceFile), })), statements: methodBody, }); // Extract type references this.extractTypeReferencesFromSignature( returnTypeText, originalSourceFile, symbolMap, trackImport, ); for (const param of helper.getParameters()) { this.extractTypeReferencesFromSignature( param.getType().getText(originalSourceFile), originalSourceFile, symbolMap, trackImport, ); } } // Add all tracked imports for (const [moduleSpecifier, imports] of requiredImports.entries()) { for (const importInfo of imports) { if (importInfo.kind === 'default') { let importDecl = utilityFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = utilityFile.addImportDeclaration({ moduleSpecifier, defaultImport: importInfo.name, }); } else if (!importDecl.getDefaultImport()) { importDecl.setDefaultImport(importInfo.name); } } else if (importInfo.kind === 'namespace') { let importDecl = utilityFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = utilityFile.addImportDeclaration({ moduleSpecifier, namespaceImport: importInfo.name, }); } else if (!importDecl.getNamespaceImport()) { importDecl.setNamespaceImport(importInfo.name); } } else { if (importInfo.alias) { let importDecl = utilityFile.getImportDeclaration(moduleSpecifier); importDecl ??= utilityFile.addImportDeclaration({ moduleSpecifier, }); const existingNamedImport = importDecl .getNamedImports() .find( (ni) => ni.getName() === importInfo.name && ni.getAliasNode()?.getText() === importInfo.alias, ); if (!existingNamedImport) { importDecl.addNamedImport({ name: importInfo.name, alias: importInfo.alias, }); } } else { api.transformation.importManager.addNamedImport( utilityFile, moduleSpecifier, importInfo.name, ); } } } } // Organize imports api.transformation.importManager.organizeImports(utilityFile); // Save file await utilityFile.save(); return utilityFile; } /** * Updates method bodies in focused services to use utility service for shared helpers. * * Replaces `this.helperMethod(args)` with `UtilityService.helperMethod(args)` * for all shared helpers. * * @param classDecl - Original service class declaration * @param groups - Responsibility groups * @param utilityServiceName - Name of the utility service * @param originalFilePath - Path to original service file * @param project - ts-morph Project * @param api - PublicApi for transformation utilities * @returns Promise resolving when all call sites are updated * @private */ private async updateSharedHelperCallSites( classDecl: ClassDeclaration, groups: ResponsibilityGroup[], utilityServiceName: string, originalFilePath: string, project: Project, api: PublicApi, ): Promise { // Build map: helper method → list of responsibilities that use it const helperUsageMap = new Map>(); for (const group of groups) { for (const publicMethod of group.methods.filter( (m) => m.getScope() !== Scope.Private && m.getScope() !== Scope.Protected, )) { const helpers = this.privateHelperDetector.detectTransitiveHelpers( publicMethod, classDecl, ); for (const helper of helpers) { if (!helperUsageMap.has(helper)) { helperUsageMap.set(helper, new Set()); } helperUsageMap.get(helper)!.add(group.responsibility); } } } // Find shared helpers (used by >1 responsibility) const sharedHelperNames = new Set(); for (const [helper, responsibilities] of helperUsageMap.entries()) { if (responsibilities.size > 1) { sharedHelperNames.add(helper.getName()); } } // No shared helpers? Nothing to update if (sharedHelperNames.size === 0) { return; } // Update each focused service const dir = path.dirname(originalFilePath); for (const group of groups) { const kebabName = this.toKebabCase(group.serviceName); const filePath = path.join(dir, `${kebabName}.ts`); const focusedServiceFile = project.getSourceFile(filePath); if (!focusedServiceFile) { continue; } const focusedServiceClass = focusedServiceFile.getClass( group.serviceName, ); if (!focusedServiceClass) { continue; } // Check if any methods in this service call shared helpers let needsUtilityImport = false; // Update method bodies for (const method of focusedServiceClass.getMethods()) { let methodBody = method.getBodyText() ?? ''; let modified = false; // Replace `this.helperMethod(` with `UtilityService.helperMethod(` for (const helperName of sharedHelperNames) { const pattern = new RegExp( String.raw`\bthis\.${helperName}\s*\(`, 'g', ); if (pattern.test(methodBody)) { methodBody = methodBody.replace( pattern, `${utilityServiceName}.${helperName}(`, ); modified = true; needsUtilityImport = true; } } if (modified) { method.setBodyText(methodBody); } } // Add import for utility service if needed if (needsUtilityImport) { const utilityKebab = this.toKebabCase(utilityServiceName); const utilityPath = `./${utilityKebab}`; api.transformation.importManager.addNamedImport( focusedServiceFile, utilityPath, utilityServiceName, ); } // Organize imports api.transformation.importManager.organizeImports(focusedServiceFile); await focusedServiceFile.save(); } } }