UNPKG

userpravah

Version:

UserPravah is an extensible, framework-agnostic tool for analyzing user flows and navigation patterns in web applications. It supports multiple frameworks (Angular, React) and output formats (DOT/Graphviz, JSON) with a plugin-based architecture for easy e

723 lines (722 loc) 41.8 kB
import { Project, SyntaxKind, Node } from 'ts-morph'; import { parse as parseHTML } from 'node-html-parser'; import * as fs from 'fs'; import * as path from 'path'; import glob from 'fast-glob'; import { BasicPatternCollector } from '../../core/pattern-collector.interface.js'; export class AngularAdapter { constructor(projectPath, patternCollector) { this.frameworkName = 'Angular'; this.routes = []; this.flows = []; this.menus = []; this.processedRouteObjects = new Set(); this.processedLazyLoads = new Set(); this.angularProjectPath = projectPath; this.project = new Project({ tsConfigFilePath: path.join(this.angularProjectPath, 'tsconfig.json'), }); this.patternCollector = patternCollector || new BasicPatternCollector(); } getDiscoveredPatterns() { const patternsByType = {}; this.patternCollector.getAllPatterns().forEach((p) => { if (!patternsByType[p.type]) { patternsByType[p.type] = []; } patternsByType[p.type].push(p); }); return patternsByType; } recordPattern(type, file, details, lineNumber) { this.patternCollector.addPattern({ type, file, lineNumber, details, framework: this.frameworkName, }); } kebabToPascalCase(filename) { let name = path.basename(filename, path.extname(filename)); name = name.replace(/\.(component|service|module|pipe|directive|guard|routes|page|config|store|effects|reducer|action|model|interface|enum|util|helper|constant|schema|validator|interceptor|resolver|adapter|facade|query|command|event|subscriber|listener|dto|vo|entity|repository|provider|factory|builder|handler|operator|stream|source|sink|transform|aggregator|projector|saga|orchestrator|coordinator|mediator|gateway|client|proxy|stub|mock|dummy|fake|spec|test|e2e|stories|bench)$/, ''); return name .split('-') .filter(part => part.length > 0) .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join(''); } async addSourceFiles() { const tsFiles = await glob('**/*.ts', { cwd: this.angularProjectPath, ignore: ['node_modules/**', 'dist/**'], absolute: true }); this.project.addSourceFilesAtPaths(tsFiles); console.log(`[AngularAdapter] Total TypeScript source files initially added: ${this.project.getSourceFiles().length}`); } async analyzeProject(projectPath) { if (projectPath !== this.angularProjectPath) { this.angularProjectPath = projectPath; this.project = new Project({ tsConfigFilePath: path.join(this.angularProjectPath, 'tsconfig.json') }); } this.routes = []; this.flows = []; this.menus = []; this.processedRouteObjects.clear(); this.processedLazyLoads.clear(); this.patternCollector.clearPatterns(); // Crucial: clear patterns for this adapter's run console.log('🚀 [AngularAdapter] Script execution started...'); await this.addSourceFiles(); console.log('📍 [AngularAdapter] Analyzing routing modules (initial pass)...'); await this.analyzeRoutingModules('/'); console.log('🔎 [AngularAdapter] Analyzing template files and TS for navigation...'); await this.analyzeSourceFilesForNavigation(); console.log('📊 [AngularAdapter] Consolidating analysis results...'); const processedRoutes = this.routes.map(r => ({ id: this.cleanRoutePathForId(r.fullPath), originalPath: r.fullPath, displayName: r.component ? this.deriveDisplayNameFromComponent(r.component) : this.deriveDisplayNameFromPath(r.fullPath), pathDepth: r.fullPath.split('/').filter(Boolean).length, category: this.getNodeCategory(r.fullPath), component: r.component, importance: 0, isRoot: r.isRoot, guards: r.guards, })); const processedFlows = this.flows.map(f => { let sourceNodeId = ''; const fromRouteByComponent = this.routes.find(r => r.component === f.from); if (fromRouteByComponent) { sourceNodeId = this.cleanRoutePathForId(fromRouteByComponent.fullPath); } else if (f.from.startsWith('/')) { sourceNodeId = this.cleanRoutePathForId(f.from); } else { sourceNodeId = `component:${this.kebabToPascalCase(f.from)}`; } let targetNodeId = this.cleanRoutePathForId(f.to); const targetRoute = this.findMatchingRouteForPath(f.to, processedRoutes); if (targetRoute) { targetNodeId = targetRoute.id; } return { sourceNodeId: sourceNodeId, targetNodeId: targetNodeId, type: f.type, condition: f.condition, label: f.condition, // Default label to condition, can be overridden by formatter }; }); const validProcessedFlows = processedFlows.filter(f => processedRoutes.some(r => r.id === f.sourceNodeId) && (processedRoutes.some(r => r.id === f.targetNodeId) || this.isWildcardOrRedirectTarget(f.targetNodeId))); return { routes: processedRoutes, flows: validProcessedFlows, menus: this.menus, }; } findMatchingRouteForPath(pathToMatch, availableRoutes) { let matchedRoute = availableRoutes.find(r => r.originalPath === pathToMatch); if (matchedRoute) return matchedRoute; for (const route of availableRoutes) { if (route.originalPath.includes(':')) { const patternText = route.originalPath.replace(/:[^\\/]+/g, '[^/]+'); const regex = new RegExp(`^${patternText}$`); if (regex.test(pathToMatch)) return route; } } return undefined; } isWildcardOrRedirectTarget(targetId) { return targetId.includes('**'); } cleanRoutePathForId(path) { if (!path) return 'undefined_path'; return path.replace(/:([^\/]+)/g, '_$1_').replace(/\//g, '__').replace(/\*/g, 'wildcard'); } deriveDisplayNameFromPath(routePath) { if (!routePath || routePath === '/') return 'Root'; const lastSegment = routePath.split('/').filter(Boolean).pop() || 'Unnamed Segment'; return lastSegment.replace(/^[:*]/, '').split(/[-_]/).map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); } deriveDisplayNameFromComponent(componentName) { if (!componentName) return 'Unnamed Component'; let displayName = componentName.replace(/Component$/, ''); return displayName.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase()).trim(); } getNodeCategory(path) { const segments = path.split('/').filter(Boolean); if (segments.length === 0 || path === '/') return 'root'; if (segments[0].toLowerCase() === 'auth' || segments[0].toLowerCase() === 'login') return 'auth'; return segments[0].toLowerCase(); } async analyzeRoutingModules(parentPathForThisContext, sourceFilesToSearch) { let filesToProcess; if (sourceFilesToSearch) { filesToProcess = sourceFilesToSearch; } else { const appConfigTs = this.project.getSourceFile(sf => /app\.config\.ts$/.test(sf.getFilePath())); const appModuleTs = this.project.getSourceFile(sf => /app\.module\.ts$/.test(sf.getFilePath())); filesToProcess = []; if (appConfigTs) { this.recordPattern('TopLevelConfig', appConfigTs.getFilePath(), { type: 'app.config.ts' }); filesToProcess.push(appConfigTs); } if (appModuleTs && filesToProcess.length === 0) { this.recordPattern('TopLevelConfig', appModuleTs.getFilePath(), { type: 'app.module.ts' }); filesToProcess.push(appModuleTs); } // Simplified: In a real app, might check other top-level modules or main.ts if (filesToProcess.length === 0) { this.recordPattern('AnalysisWarning', 'Project', { message: 'No top-level routing config (app.config.ts/app.module.ts) found' }); } } for (const sourceFile of filesToProcess) { // console.log(` [AngularAdapter - AnalyzeRoutes] Processing file: ${sourceFile.getFilePath()} with context: '${parentPathForThisContext}'`); this.extractRoutes(sourceFile, parentPathForThisContext); } } extractRoutes(sourceFile, parentPathForThisFileContext) { const sfPath = sourceFile.getFilePath(); const processedArrayLiterals = new Set(); const routingFunctionCalls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).filter(call => { const exprText = call.getExpression().getText(); return exprText.endsWith('provideRouter') || exprText.endsWith('RouterModule.forRoot') || exprText.endsWith('RouterModule.forChild'); }); if (routingFunctionCalls.length > 0) { this.recordPattern('RoutingAPIUsage', sfPath, { functions: routingFunctionCalls.map(c => c.getExpression().getText()), count: routingFunctionCalls.length }, routingFunctionCalls[0].getStartLineNumber()); } for (const call of routingFunctionCalls) { const firstArg = call.getArguments()[0]; if (Node.isArrayLiteralExpression(firstArg)) { this.processRouteArrayLiteral(firstArg, parentPathForThisFileContext, sourceFile); processedArrayLiterals.add(firstArg); } else if (Node.isIdentifier(firstArg)) { const identifierName = firstArg.getText(); try { firstArg.getDefinitionNodes().forEach(def => { if (Node.isVariableDeclaration(def)) { const initializer = def.getInitializer(); if (initializer && Node.isArrayLiteralExpression(initializer)) { this.processRouteArrayLiteral(initializer, parentPathForThisFileContext, sourceFile); processedArrayLiterals.add(initializer); } } else if (Node.isImportSpecifier(def) || Node.isExportSpecifier(def) || Node.isNamespaceImport(def) || Node.isExportAssignment(def)) { const importSourceFile = def.getSourceFile(); if (importSourceFile && importSourceFile !== sourceFile) { // console.log(` [AngularAdapter - ExtractRoutes] Identifier '${identifierName}' imported from ${importSourceFile.getFilePath()}. Analyzing...`); this.project.addSourceFileAtPathIfExists(importSourceFile.getFilePath()); this.project.resolveSourceFileDependencies(); this.analyzeRoutingModules(parentPathForThisFileContext, [importSourceFile]); } } }); } catch (error) { this.recordPattern('Error', sfPath, { message: `Error resolving identifier '${identifierName}' for routes: ${error.message}`, identifier: identifierName, }, firstArg.getStartLineNumber()); } } } if (sfPath.endsWith('.module.ts')) { this.recordPattern('NgModuleScan', sfPath, { status: 'Started' }); sourceFile.getDescendantsOfKind(SyntaxKind.Decorator).filter(d => d.getName() === 'NgModule').forEach(decorator => { const decoratorArg = decorator.getArguments()[0]; if (decoratorArg && Node.isObjectLiteralExpression(decoratorArg)) { const importsProperty = decoratorArg.getProperty('imports'); if (importsProperty && Node.isPropertyAssignment(importsProperty)) { const importsInitializer = importsProperty.getInitializer(); if (importsInitializer && Node.isArrayLiteralExpression(importsInitializer)) { importsInitializer.getElements().filter(Node.isIdentifier).forEach(impIdentifier => { const importName = impIdentifier.getText(); try { impIdentifier.getDefinitionNodes().forEach(defNode => { const definitionSourceFile = defNode.getSourceFile(); if (definitionSourceFile && definitionSourceFile.getFilePath() !== sfPath) { if (Node.isClassDeclaration(defNode) || Node.isFunctionDeclaration(defNode)) { this.recordPattern('NgModuleImport', sfPath, { importedModule: importName, importedFromFile: definitionSourceFile.getFilePath(), action: 'ScheduledAnalysis' }, impIdentifier.getStartLineNumber()); this.project.addSourceFileAtPathIfExists(definitionSourceFile.getFilePath()); this.project.resolveSourceFileDependencies(); this.analyzeRoutingModules(parentPathForThisFileContext, [definitionSourceFile]); } } else if (Node.isImportSpecifier(defNode)) { const importDeclaration = defNode.getImportDeclaration(); const importedSourceFileViaSpecifier = importDeclaration.getModuleSpecifierSourceFile(); if (importedSourceFileViaSpecifier && importedSourceFileViaSpecifier.getFilePath() !== sfPath) { this.recordPattern('NgModuleImport', sfPath, { importedModule: importName, importedFromFile: importedSourceFileViaSpecifier.getFilePath(), via: 'ImportSpecifier', action: 'ScheduledAnalysis' }, impIdentifier.getStartLineNumber()); this.project.addSourceFileAtPathIfExists(importedSourceFileViaSpecifier.getFilePath()); this.project.resolveSourceFileDependencies(); this.analyzeRoutingModules(parentPathForThisFileContext, [importedSourceFileViaSpecifier]); } } }); } catch (err) { this.recordPattern('Error', sfPath, { message: `Error resolving identifier '${importName}' in @NgModule: ${err.message}`, identifier: importName, }, impIdentifier.getStartLineNumber()); } }); } } } }); } sourceFile.getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression).forEach(arr => { if (processedArrayLiterals.has(arr)) return; const hasRouteLikeElements = arr.getElements().some(el => Node.isObjectLiteralExpression(el) && el.getProperties().some(prop => Node.isPropertyAssignment(prop) && ['path', 'component', 'loadChildren', 'redirectTo', 'children'].includes(prop.getName()))); if (hasRouteLikeElements) { this.processRouteArrayLiteral(arr, parentPathForThisFileContext, sourceFile); } }); } processRouteArrayLiteral(routeArrayNode, parentRouteContextFullPath, sourceFile) { const parsedRoutes = this.parseRouteArray(routeArrayNode, parentRouteContextFullPath, sourceFile); for (const pr of parsedRoutes) { this.addRouteToCollection(pr, sourceFile.getFilePath(), routeArrayNode.getStartLineNumber()); } } addRouteToCollection(pr, sourceFilePath, sourceLine) { if (!pr || (!pr.component && !pr.loadChildren && !pr.redirectTo && (!pr.children || pr.children.length === 0) && pr.path === '')) return; const existingRouteIdx = this.routes.findIndex(r => r.fullPath === pr.fullPath); if (existingRouteIdx !== -1) { const existing = this.routes[existingRouteIdx]; let reason = ''; if (pr.component && !existing.component) reason = 'new component'; else if (pr.loadChildren && !existing.loadChildren) reason = 'new loadChildren'; else if (pr.redirectTo && !existing.redirectTo) reason = 'new redirectTo'; if (reason) { this.recordPattern('RouteUpdated', sourceFilePath, { path: pr.fullPath, reason, oldComp: existing.component, newComp: pr.component }, sourceLine); this.routes[existingRouteIdx] = pr; } else if (pr.component && existing.component && pr.component !== existing.component) { this.recordPattern('AmbiguousRoute', sourceFilePath, { path: pr.fullPath, oldComp: existing.component, newComp: pr.component, kept: 'existing' }, sourceLine); } return; } const existingCompIdx = this.routes.findIndex(r => r.component && pr.component && r.component === pr.component); if (existingCompIdx !== -1) { const existing = this.routes[existingCompIdx]; if (pr.fullPath.length > existing.fullPath.length || (existing.path === '**' && (pr.component || pr.loadChildren))) { this.recordPattern('RouteReplacedBySpecificity', sourceFilePath, { component: pr.component, oldPath: existing.fullPath, newPath: pr.fullPath }, sourceLine); this.routes[existingCompIdx] = pr; } return; } if (!this.routes.find(r => r.fullPath === pr.fullPath && r.component === pr.component && r.loadChildren === pr.loadChildren && r.redirectTo === pr.redirectTo && ((r.children && pr.children && r.children.length === pr.children.length) || (!r.children && !pr.children)))) { this.recordPattern('RouteAdded', sourceFilePath, { fullPath: pr.fullPath, component: pr.component, loadChildren: pr.loadChildren, redirectTo: pr.redirectTo }, sourceLine); this.routes.push(pr); } } parseRouteArray(node, parentRouteContextFullPath, sourceFile) { const currentRoutes = []; if (Node.isArrayLiteralExpression(node)) { node.getElements().filter(Node.isObjectLiteralExpression).forEach(element => { if (this.processedRouteObjects.has(element)) return; const route = this.parseRouteObject(element, parentRouteContextFullPath, sourceFile); if (route) { this.processedRouteObjects.add(element); currentRoutes.push(route); if (route.loadChildren) { this.recordPattern('LazyLoadDetected', sourceFile.getFilePath(), { parentFullPath: route.fullPath, loadChildrenPath: route.loadChildren, status: 'Scheduled' }, element.getStartLineNumber()); this.loadAndParseLazyModule(route.loadChildren, route.fullPath, sourceFile) .catch(err => { this.recordPattern('Error', sourceFile.getFilePath(), { message: `Error processing lazy module ${route.loadChildren}: ${err.message}`, parentFullPath: route.fullPath, context: 'LazyLoadProcessing' }, element.getStartLineNumber()); }); } } }); } return currentRoutes; } parseRouteObject(node, parentRouteContextFullPath, sourceFile) { const sfPath = sourceFile.getFilePath(); const line = node.getStartLineNumber(); const props = {}; let pathSegment; node.getProperties().filter(Node.isPropertyAssignment).forEach(prop => { const name = prop.getNameNode().getText(); const init = prop.getInitializer(); if (!init) return; switch (name) { case 'path': if (Node.isStringLiteral(init)) pathSegment = init.getLiteralValue(); break; case 'component': if (Node.isIdentifier(init)) props[name] = init.getText(); break; case 'loadComponent': if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) { this.recordPattern('LoadComponentUsage', sfPath, { path: parentRouteContextFullPath, type: 'Function' }, line); const importCall = init.getDescendantsOfKind(SyntaxKind.CallExpression).find(c => c.getExpression().getText() === 'import'); if (importCall?.getArguments()[0]) { const access = importCall.getParentWhile(n => n.getKind() !== SyntaxKind.PropertyAccessExpression && n !== init.getBody()); if (access && Node.isPropertyAccessExpression(access)) props['component'] = access.getNameNode().getText(); else if (Node.isStringLiteral(importCall.getArguments()[0])) { const importFile = importCall.getArguments()[0].asKind(SyntaxKind.StringLiteral).getLiteralValue().split('/').pop()?.split('.')[0]; if (importFile) props['component'] = this.kebabToPascalCase(importFile); } } } break; case 'loadChildren': let lcVal; if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) { this.recordPattern('LoadChildrenUsage', sfPath, { path: parentRouteContextFullPath, type: 'Function' }, line); const importCall = init.getDescendantsOfKind(SyntaxKind.CallExpression).find(c => c.getExpression().getText() === 'import'); if (importCall?.getArguments()[0] && Node.isStringLiteral(importCall.getArguments()[0])) lcVal = importCall.getArguments()[0].asKind(SyntaxKind.StringLiteral).getLiteralValue(); } else if (Node.isStringLiteral(init)) { this.recordPattern('LoadChildrenUsage', sfPath, { path: parentRouteContextFullPath, type: 'StringLiteral' }, line); lcVal = init.getLiteralValue(); } else if (Node.isIdentifier(init)) { this.recordPattern('LoadChildrenUsage', sfPath, { path: parentRouteContextFullPath, type: 'Identifier', identifierName: init.getText() }, line); // Simplified: In a real scenario, resolve identifier, for now, this might not extract the path // lcVal = resolveIdentifierToImportString(init); } if (lcVal) props[name] = lcVal; break; case 'redirectTo': if (Node.isStringLiteral(init)) props[name] = init.getLiteralValue(); break; case 'children': if (Node.isArrayLiteralExpression(init)) props[name] = init; break; case 'canActivate': if (Node.isArrayLiteralExpression(init)) props[name] = init.getElements().map(el => el.getText()); this.recordPattern('GuardUsage', sfPath, { guardType: 'canActivate', guards: props[name], path: parentRouteContextFullPath }, line); break; case 'data': if (Node.isObjectLiteralExpression(init)) props[name] = {}; break; // Simplified, could parse deeply } }); if (pathSegment === undefined && !props['component'] && !props['children'] && !props['loadChildren'] && !props['redirectTo'] && !props['loadComponent']) return null; pathSegment = pathSegment ?? ''; const route = { path: pathSegment, fullPath: '' }; if (parentRouteContextFullPath === '/' && route.path === '') { route.fullPath = '/'; if (!this.routes.some(r => r.isRoot)) route.isRoot = true; } else route.fullPath = parentRouteContextFullPath === '/' ? `/${route.path}` : (route.path === '' ? parentRouteContextFullPath : `${parentRouteContextFullPath}/${route.path}`); route.fullPath = route.fullPath.replace(/\/\//g, '/'); if (route.fullPath !== '/' && route.fullPath.endsWith('/')) route.fullPath = route.fullPath.slice(0, -1); if (route.fullPath === '') route.fullPath = '/'; Object.assign(route, props); this.recordPattern('RouteObjectParsed', sfPath, { path: route.path, fullPath: route.fullPath, component: route.component, loadChildren: route.loadChildren, redirectTo: route.redirectTo, hasChildren: !!props['children'], guards: route.guards }, line); if (props['children'] && Node.isArrayLiteralExpression(props['children'])) { const childRoutes = this.parseRouteArray(props['children'], route.fullPath, sourceFile); route.children = childRoutes; // Assign for internal structure for (const cr of childRoutes) this.addRouteToCollection(cr, sfPath, props['children'].getStartLineNumber()); } return route; } async loadAndParseLazyModule(modulePathString, parentRouteFullPathForLazyModule, originatingSourceFile) { const sfPath = originatingSourceFile.getFilePath(); const line = originatingSourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral).find(s => s.getLiteralValue() === modulePathString)?.getStartLineNumber(); const lazyLoadSignature = `${modulePathString}#${parentRouteFullPathForLazyModule}`; if (this.processedLazyLoads.has(lazyLoadSignature)) { this.recordPattern('LazyLoadSkipped', sfPath, { modulePath: modulePathString, parentRoute: parentRouteFullPathForLazyModule, reason: 'AlreadyProcessed' }, line); return; } let resolvedModulePath = modulePathString.includes('#') ? modulePathString.split('#')[0] : modulePathString; const baseDir = path.dirname(sfPath); let foundPath; const extensionsToTry = ['', '.ts', '.module.ts', '.routes.ts']; const checkPath = (p) => { for (const ext of extensionsToTry) if (fs.existsSync(p + ext) && fs.lstatSync(p + ext).isFile()) return p + ext; if (fs.existsSync(p) && fs.lstatSync(p).isDirectory()) for (const defaultFile of ['index.ts', `${path.basename(p)}.routes.ts`, `${path.basename(p)}.module.ts`]) { const currentTry = path.join(p, defaultFile); if (fs.existsSync(currentTry) && fs.lstatSync(currentTry).isFile()) return currentTry; } return undefined; }; foundPath = checkPath(path.resolve(baseDir, resolvedModulePath)); if (!foundPath) for (const fallbackBasePath of [path.join(this.angularProjectPath, 'src'), path.join(this.angularProjectPath, 'src/app')]) { foundPath = checkPath(path.resolve(fallbackBasePath, resolvedModulePath.startsWith('./') ? resolvedModulePath.substring(2) : resolvedModulePath)); if (foundPath) break; } if (!foundPath) { this.recordPattern('LazyLoadResolutionFailure', sfPath, { modulePath: modulePathString, parentRoute: parentRouteFullPathForLazyModule, triedFrom: baseDir }, line); return; } this.recordPattern('LazyLoadResolved', sfPath, { modulePath: modulePathString, resolvedPath: foundPath, parentRoute: parentRouteFullPathForLazyModule }, line); const lazyLoadedSourceFile = this.project.addSourceFileAtPathIfExists(foundPath) || this.project.getSourceFile(foundPath); if (lazyLoadedSourceFile) { this.project.resolveSourceFileDependencies(); if (foundPath.endsWith('.module.ts')) { const moduleDir = path.dirname(foundPath); const baseModuleName = path.basename(foundPath, '.module.ts'); const patterns = [`${baseModuleName}-routing.module.ts`, `${baseModuleName}.routing.module.ts`, `${baseModuleName}.routes.ts`]; let routingFileFound = false; for (const pattern of patterns) { const routingFilePath = path.join(moduleDir, pattern); if (fs.existsSync(routingFilePath)) { const routingSourceFile = this.project.addSourceFileAtPathIfExists(routingFilePath) || this.project.getSourceFile(routingFilePath); if (routingSourceFile) { this.project.resolveSourceFileDependencies(); await this.analyzeRoutingModules(parentRouteFullPathForLazyModule, [routingSourceFile]); routingFileFound = true; break; } } } if (!routingFileFound) await this.analyzeRoutingModules(parentRouteFullPathForLazyModule, [lazyLoadedSourceFile]); } else await this.analyzeRoutingModules(parentRouteFullPathForLazyModule, [lazyLoadedSourceFile]); } else this.recordPattern('Error', sfPath, { message: `Could not load source file for lazy module ${foundPath}`, context: 'LazyLoadProcessing' }, line); this.processedLazyLoads.add(lazyLoadSignature); } async analyzeSourceFilesForNavigation() { for (const sourceFile of this.project.getSourceFiles()) { const sfPath = sourceFile.getFilePath(); this.extractProgrammaticNavigation(sourceFile); if (sfPath.endsWith('.component.ts')) { const componentClass = sourceFile.getClasses()[0]; if (componentClass) { let templatePath = null, templateContent = null, isInlineTemplate = false, decoratorLine; const decorator = componentClass.getDecorator('Component'); if (decorator) { decoratorLine = decorator.getStartLineNumber(); const decoratorArgs = decorator.getArguments(); if (decoratorArgs.length > 0 && Node.isObjectLiteralExpression(decoratorArgs[0])) { const metadata = decoratorArgs[0]; const templateUrlProp = metadata.getProperty('templateUrl'); if (templateUrlProp && Node.isPropertyAssignment(templateUrlProp)) { const initializer = templateUrlProp.getInitializer(); if (initializer && Node.isStringLiteral(initializer)) { const relativePath = initializer.getLiteralValue(); templatePath = path.resolve(path.dirname(sfPath), relativePath); this.recordPattern('TemplateUrl', sfPath, { component: componentClass.getName(), templateUrl: relativePath, resolvedPath: templatePath }, templateUrlProp.getStartLineNumber()); } } if (!templatePath) { const templateProp = metadata.getProperty('template'); if (templateProp && Node.isPropertyAssignment(templateProp)) { const initializer = templateProp.getInitializer(); if (initializer && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer))) { templateContent = initializer.getLiteralText(); isInlineTemplate = true; this.recordPattern('InlineTemplate', sfPath, { component: componentClass.getName(), length: templateContent.length }, templateProp.getStartLineNumber()); } } } } } else this.recordPattern('MissingComponentDecorator', sfPath, { className: componentClass.getName() }, componentClass.getStartLineNumber()); if (templatePath) { if (fs.existsSync(templatePath)) templateContent = fs.readFileSync(templatePath, 'utf-8'); else { this.recordPattern('TemplateNotFound', sfPath, { component: componentClass.getName(), templateUrl: templatePath }, decoratorLine); templateContent = null; } } if (templateContent) { const fromComponentName = componentClass.getName() || this.kebabToPascalCase(path.basename(sfPath)); this.extractTemplateNavigation(templateContent, isInlineTemplate ? sfPath : templatePath, fromComponentName, isInlineTemplate ? decoratorLine : undefined); } } } } } extractProgrammaticNavigation(sourceFile) { const sfPath = sourceFile.getFilePath(); sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression) .filter(call => call.getExpression().getText().match(/router\.(navigate|navigateByUrl)/)) .forEach(call => { const callLine = call.getStartLineNumber(); const flow = this.parseNavigationCall(call, sfPath); if (flow) { this.recordPattern('ProgrammaticNavigation', sfPath, { from: flow.from, to: flow.to, type: call.getExpression().getText().endsWith('navigateByUrl') ? 'navigateByUrl' : 'navigate', rawTarget: call.getArguments()[0]?.getText() }, callLine); this.flows.push(flow); } else { this.recordPattern('ProgrammaticNavigationFailed', sfPath, { rawCall: call.getText() }, callLine); } }); } parseNavigationCall(callNode, filePath) { const callLine = callNode.getStartLineNumber(); const navArgs = callNode.getArguments(); if (navArgs.length === 0) return null; const targetPathNode = navArgs[0]; let targetPath; if (Node.isArrayLiteralExpression(targetPathNode)) { let segments = []; targetPathNode.getElements().forEach(elNode => { if (Node.isStringLiteral(elNode)) segments.push(elNode.getLiteralValue()); else { const elText = elNode.getText(); segments.push(elText.toLowerCase().includes('id') ? ':id' : `:${elText.replace(/[^a-zA-Z0-9_]/g, '')}`); } }); if (segments.length > 0) { let builtPath = ""; if (segments[0].startsWith('/')) builtPath = segments.join('/').replace(/\/\//g, '/'); else builtPath = segments.map(s => s.replace(/^\/+|\/+$/g, '')).filter(s => s).join('/'); targetPath = builtPath.replace(/\/\//g, '/'); } } else if (Node.isStringLiteral(targetPathNode)) targetPath = targetPathNode.getLiteralValue(); if (targetPath === undefined) return null; let fromContextIdentifier = callNode.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)?.getNameNode()?.getText() || this.kebabToPascalCase(path.basename(filePath, path.extname(filePath)).replace(/\.(component|service|guard)$/, '')); let finalTargetPath = targetPath; if (!targetPath.startsWith('/')) { const currentRoute = this.routes.find(r => r.component === fromContextIdentifier); if (currentRoute?.fullPath) { try { const ensuredBasePath = currentRoute.fullPath.startsWith('/') ? currentRoute.fullPath : '/' + currentRoute.fullPath; finalTargetPath = new URL(targetPath, `http://dummy.com${ensuredBasePath}`).pathname; } catch (e) { this.recordPattern('RelativePathResolutionError', filePath, { targetPath, base: currentRoute.fullPath, error: e.message }, callLine); } } else { this.recordPattern('RelativePathResolutionMissingContext', filePath, { fromContextIdentifier, targetPath }, callLine); } } finalTargetPath = finalTargetPath.replace(/\/\//g, '/'); if (finalTargetPath !== '/' && finalTargetPath.endsWith('/')) finalTargetPath = finalTargetPath.slice(0, -1); if (finalTargetPath === '') finalTargetPath = '/'; let condition; const matchedTargetRoute = this.routes.find(r => r.fullPath === finalTargetPath || (r.fullPath.includes(':') && finalTargetPath && new RegExp(`^${r.fullPath.replace(/:[^\\/]+/g, '[^/]+')}$`).test(finalTargetPath))); if (matchedTargetRoute?.guards && matchedTargetRoute.guards.length > 0) { condition = matchedTargetRoute.guards.join(', '); this.recordPattern('NavigationWithGuards', filePath, { from: fromContextIdentifier, to: finalTargetPath, guards: condition }, callLine); } return { from: fromContextIdentifier, to: finalTargetPath, type: 'dynamic', condition }; } extractTemplateNavigation(content, templateFilePath, fromComponentName, templateLine) { const root = parseHTML(content); const routerLinks = root.querySelectorAll('[routerLink]'); if (routerLinks.length > 0) { this.recordPattern('RouterLinkUsage', templateFilePath, { component: fromComponentName, count: routerLinks.length, links: routerLinks.map(link => link.getAttribute('routerLink')) }, templateLine); } for (const link of routerLinks) { const routePath = link.getAttribute('routerLink'); if (routePath) { let normalizedToPath = routePath.trim(); if (!normalizedToPath.startsWith('/')) { this.recordPattern('RelativeRouterLink', templateFilePath, { component: fromComponentName, routerLink: normalizedToPath, details: "Kept as relative" }); } else if (normalizedToPath !== '/' && normalizedToPath.endsWith('/')) { normalizedToPath = normalizedToPath.slice(0, -1); } if (normalizedToPath === '') normalizedToPath = '/'; this.flows.push({ from: fromComponentName, to: normalizedToPath, type: 'static' }); } } } }