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

657 lines (656 loc) 84 kB
#!/usr/bin/env node import { Project, SyntaxKind, Node } from 'ts-morph'; import { parse as parseHTML } from 'node-html-parser'; import { digraph, toDot, attribute } from 'ts-graphviz'; // Removed Style import import * as fs from 'fs'; import * as path from 'path'; import glob from 'fast-glob'; import { execSync } from 'child_process'; class AngularFlowAnalyzer { // Helper to convert kebab-case to PascalCase for component names kebabToPascalCase(filename) { // Get the base name without any extension let name = path.basename(filename, path.extname(filename)); // Remove common Angular "type" suffixes if they appear after a dot (e.g., app.component -> app) // Also handles cases like 'my-feature.component' becoming 'MyFeature' // And removes suffixes if they are part of the kebab name before a dot, e.g., 'my-component-name.routes' -> 'MyComponentName' 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)$/, ''); // Convert kebab-case (e.g., 'my-awesome-feature') to PascalCase return name .split('-') .filter(part => part.length > 0) // Filter out empty strings from multiple hyphens like 'a--b' .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join(''); } constructor(angularProjectPath) { this.routes = []; this.flows = []; this.menus = []; this.processedRouteObjects = new Set(); // Track processed route object literals this.processedLazyLoads = new Set(); // To track lazy loads already processed this.angularProjectPath = angularProjectPath; // Initialize ts-morph project this.project = new Project({ tsConfigFilePath: path.join(this.angularProjectPath, 'tsconfig.json') }); } async analyze() { console.log('🚀 Script execution started...'); console.log('🔍 Starting Angular project analysis...'); await this.addSourceFiles(); console.log(`올 Total TypeScript source files loaded: ${this.project.getSourceFiles().length}`); // Enhanced Log console.log('📍 Analyzing routing modules (initial pass)...'); await this.analyzeRoutingModules('/'); console.log('🔎 Analyzing template files and TS for navigation...'); await this.analyzeSourceFilesForNavigation(); console.log('📊 Generating flow diagram...'); if (this.routes.length === 0 && this.flows.length === 0) { console.warn('⚠️ No routes or flows were extracted. The generated DOT file will be empty or minimal.'); } await this.generateGraph(); } async addSourceFiles() { // Add all TypeScript files const tsFiles = await glob('**/*.ts', { cwd: this.angularProjectPath, ignore: ['node_modules/**', 'dist/**'] }); // Add all HTML template files const htmlFiles = await glob('**/*.html', { cwd: this.angularProjectPath, ignore: ['node_modules/**', 'dist/**'] }); this.project.addSourceFilesAtPaths(tsFiles.map(f => path.join(this.angularProjectPath, f))); } async analyzeRoutingModules(parentPathForThisContext, sourceFilesToSearch) { let filesToProcess; if (sourceFilesToSearch) { filesToProcess = sourceFilesToSearch; console.log(` [AnalyzeRoutes] Specific scan for ${filesToProcess.length} file(s) with parent context: '${parentPathForThisContext}'`); } else { // INITIAL CALL - only find and process app.config.ts or app.module.ts console.log(` [AnalyzeRoutes] Initial scan for top-level routing configuration. Parent context for this scan: '${parentPathForThisContext}' (this should be '/')`); if (parentPathForThisContext !== '/') { console.warn(` ⚠️ Initial scan for top-level routes was called with parent context '${parentPathForThisContext}' instead of '/'. This might lead to incorrect root paths.`); } const appConfigTs = this.project.getSourceFile(sf => /app\.config\.ts$/.test(sf.getFilePath())); const appModuleTs = this.project.getSourceFile(sf => /app\.module\.ts$/.test(sf.getFilePath())); // More general top-level module patterns if app.module.ts is not standard const otherTopLevelModules = this.project.getSourceFiles().filter(sf => /src\/app\/[^\/]+\.module\.ts$/.test(sf.getFilePath()) && sf !== appModuleTs); filesToProcess = []; if (appConfigTs) { console.log(` Found app.config.ts: ${appConfigTs.getFilePath()}`); filesToProcess.push(appConfigTs); } // If app.config.ts (with provideRouter) exists, it's usually the main source of routes. // If not, app.module.ts (with RouterModule.forRoot) is the next candidate. if (appModuleTs && filesToProcess.length === 0) { console.log(` Found app.module.ts: ${appModuleTs.getFilePath()}`); filesToProcess.push(appModuleTs); } else if (appModuleTs && filesToProcess.length > 0) { console.log(` app.config.ts already found, app.module.ts (${appModuleTs.getFilePath()}) will be scanned if it contains RouterModule.forRoot/forChild, but app.config.ts usually takes precedence for provideRouter.`); // Potentially add appModuleTs too if it might contain RouterModule and routes not in app.config.ts // However, this can lead to conflicts if routes are duplicated. For now, prioritize one. } if (filesToProcess.length === 0 && otherTopLevelModules.length > 0) { console.log(` No app.config.ts or app.module.ts found. Checking other potential top-level modules in src/app/ (e.g., main.ts might import one).`); // This is a heuristic: pick the first one or a common one like 'core.module.ts' // A more robust solution would trace imports from main.ts // For now, let's be cautious to avoid too many false positives for the root context. // filesToProcess.push(otherTopLevelModules[0]); // console.log(` Using ${otherTopLevelModules[0].getFilePath()} as a potential top-level module.`); } if (filesToProcess.length === 0) { console.warn(" ⚠️ No clear top-level routing configuration file (app.config.ts, app.module.ts, or other obvious top-level module in src/app) found for initial route scan. Analysis might be incomplete or start from an arbitrary point if any other file defines routes with root-like paths."); // As a last resort, if absolutely no top-level config is found, the old behavior of scanning many files might be needed, // but it's prone to the context errors we're trying to solve. So, we'll proceed with an empty set for now if none are explicitly found. } } for (const sourceFile of filesToProcess) { const filePath = sourceFile.getFilePath(); // For the initial scan, parentPathForThisContext will be '/', which is correct for app.config/app.module. // For subsequent scans (from loadChildren), parentPathForThisContext is the crucial parent's fullPath. console.log(` [AnalyzeRoutes] Processing file: ${filePath} using parent context: '${parentPathForThisContext}'`); this.extractRoutes(sourceFile, parentPathForThisContext); } } extractRoutes(sourceFile, parentPathForThisFileContext) { console.log(` [ExtractRoutes] SourceFile: ${sourceFile.getFilePath()}, Using ParentContextPath: '${parentPathForThisFileContext}'`); const processedArrayLiterals = new Set(); // Track arrays processed in this specific call to extractRoutes // Priority 1: Find calls to provideRouter, RouterModule.forRoot, RouterModule.forChild 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'); }); for (const call of routingFunctionCalls) { if (call.getArguments().length > 0) { const firstArg = call.getArguments()[0]; if (Node.isArrayLiteralExpression(firstArg)) { // console.log(` Found direct ArrayLiteral in ${call.getExpression().getText()} in ${sourceFile.getFilePath()}`); this.processRouteArrayLiteral(firstArg, parentPathForThisFileContext, sourceFile); processedArrayLiterals.add(firstArg); } else if (Node.isIdentifier(firstArg)) { const identifierName = firstArg.getText(); // console.log(` Found Identifier '${identifierName}' in ${call.getExpression().getText()} in ${sourceFile.getFilePath()}. Attempting to resolve...`); try { const definitions = firstArg.getDefinitionNodes(); for (const def of definitions) { if (Node.isVariableDeclaration(def)) { const initializer = def.getInitializer(); if (initializer && Node.isArrayLiteralExpression(initializer)) { // console.log(` Resolved '${identifierName}' to an ArrayLiteral in the same file.`); 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(` Identifier '${identifierName}' is imported from ${importSourceFile.getFilePath()}. Analyzing imported file with context '${parentPathForThisFileContext}'.`); this.project.addSourceFileAtPathIfExists(importSourceFile.getFilePath()); this.project.resolveSourceFileDependencies(); // Call analyzeRoutingModules for the imported file, ensuring it gets the correct parent context. // This is better than directly calling extractRoutes here, as analyzeRoutingModules handles specific file processing. this.analyzeRoutingModules(parentPathForThisFileContext, [importSourceFile]); // We assume analyzeRoutingModules will set its own flag or handle processing, so don't set mainRoutesArrayFoundAndProcessed here. } else if (importSourceFile === sourceFile) { const localDeclarations = sourceFile.getVariableDeclarations().filter(vd => vd.getName() === identifierName); for (const localDec of localDeclarations) { const initializer = localDec.getInitializer(); if (initializer && Node.isArrayLiteralExpression(initializer)) { this.processRouteArrayLiteral(initializer, parentPathForThisFileContext, sourceFile); processedArrayLiterals.add(initializer); break; } } } } } } catch (error) { console.error(` Error resolving identifier '${identifierName}' in ${sourceFile.getFilePath()}: ${error}`); } } } } // ---- START NEW LOGIC TO PARSE NGMODULE IMPORTS ---- if (sourceFile.getFilePath().endsWith('.module.ts')) { console.log(` [ExtractRoutes - NgModuleScan] File ${sourceFile.getFilePath()} is a module. Scanning @NgModule imports.`); const ngModuleDecorators = sourceFile.getDescendantsOfKind(SyntaxKind.Decorator).filter(d => d.getName() === 'NgModule'); for (const decorator of ngModuleDecorators) { 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)) { console.log(` [ExtractRoutes - NgModuleScan] Found imports array in @NgModule of ${sourceFile.getFilePath()}`); for (const imp of importsInitializer.getElements()) { if (Node.isIdentifier(imp)) { const importName = imp.getText(); console.log(` [ExtractRoutes - NgModuleScan] Found imported identifier: ${importName}`); try { const definitionNodes = imp.getDefinitionNodes(); let foundAndProcessedImport = false; // Detailed logging (can be kept or removed after debugging) if (importName === 'AppRoutingModule' || importName === 'AuthModule') { console.log(` [ExtractRoutes - NgModuleScan - DETAIL] For '${importName}', found ${definitionNodes.length} definition nodes:`); definitionNodes.forEach((dn, index) => { console.log(` [ExtractRoutes - NgModuleScan - DETAIL] DefNode #${index}: Kind='${dn.getKindName()}', File='${dn.getSourceFile().getFilePath()}'`); if (Node.isImportSpecifier(dn)) { const importDecl = dn.getImportDeclaration(); console.log(` [ExtractRoutes - NgModuleScan - DETAIL] ImportSpecifier details: ModuleSpecifier='${importDecl.getModuleSpecifierValue()}', ImportedFromFile='${importDecl.getModuleSpecifierSourceFile()?.getFilePath() || 'COULD NOT RESOLVE SPECIFIER SOURCE FILE'}'`); } }); } for (const defNode of definitionNodes) { const definitionSourceFile = defNode.getSourceFile(); // Check if the definition node (e.g., ClassDeclaration) is in a DIFFERENT file if (definitionSourceFile && definitionSourceFile.getFilePath() !== sourceFile.getFilePath()) { // We're interested if this definition from another file is a class (typical for Angular modules) // or other relevant top-level declarations that might contain routes. if (Node.isClassDeclaration(defNode) || Node.isFunctionDeclaration(defNode) /* potentially add other kinds if needed */) { console.log(` [ExtractRoutes - NgModuleScan] ACTION: '${importName}' definition (Kind: ${defNode.getKindName()}) found in different file '${definitionSourceFile.getFilePath()}'. Scheduling analysis.`); this.project.addSourceFileAtPathIfExists(definitionSourceFile.getFilePath()); this.project.resolveSourceFileDependencies(); // Analyze this newly discovered source file this.analyzeRoutingModules(parentPathForThisFileContext, [definitionSourceFile]); foundAndProcessedImport = true; break; // Found the relevant cross-file module declaration } } else if (Node.isImportSpecifier(defNode)) { // This handles cases where the definition IS an import specifier itself, // which might still be useful if the above direct ClassDeclaration check fails for some reason. const importDeclaration = defNode.getImportDeclaration(); const importedSourceFileViaSpecifier = importDeclaration.getModuleSpecifierSourceFile(); if (importedSourceFileViaSpecifier && importedSourceFileViaSpecifier.getFilePath() !== sourceFile.getFilePath()) { console.log(` [ExtractRoutes - NgModuleScan] ACTION (via ImportSpecifier): ${importName} is imported from ${importedSourceFileViaSpecifier.getFilePath()}. Scheduling analysis.`); this.project.addSourceFileAtPathIfExists(importedSourceFileViaSpecifier.getFilePath()); this.project.resolveSourceFileDependencies(); this.analyzeRoutingModules(parentPathForThisFileContext, [importedSourceFileViaSpecifier]); foundAndProcessedImport = true; break; } } } if (!foundAndProcessedImport) { console.log(` [ExtractRoutes - NgModuleScan] No actionable cross-file module definition found for ${importName} after checking ${definitionNodes.length} definition(s).`); definitionNodes.forEach(dn => { console.log(` [ExtractRoutes - NgModuleScan] Checked DefNode Kind for ${importName}: ${dn.getKindName()} in File: ${dn.getSourceFile().getFilePath()}`); }); } } catch (err) { console.warn(` [ExtractRoutes - NgModuleScan] Error resolving identifier ${importName} in @NgModule imports: ${err}`); } } } } } } } } // ---- END NEW LOGIC TO PARSE NGMODULE IMPORTS ---- // Fallback or General Scan: // Always run this for all files, but skip arrays already processed by the targeted scan above. // console.log(` [ExtractRoutes] Performing general scan for route arrays in ${sourceFile.getFilePath()}`); const allArrayLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression); for (const arr of allArrayLiterals) { if (processedArrayLiterals.has(arr)) { // console.log(` [ExtractRoutes] Skipping array already processed by targeted scan.`); continue; } // Basic structural check: does it look like it might contain route objects? const hasRouteLikeElements = arr.getElements().some(el => { if (Node.isObjectLiteralExpression(el)) { return el.getProperties().some(prop => Node.isPropertyAssignment(prop) && (prop.getName() === 'path' || prop.getName() === 'component' || prop.getName() === 'loadChildren' || prop.getName() === 'redirectTo' || prop.getName() === 'children')); } return false; }); if (hasRouteLikeElements) { // console.log(` [ExtractRoutes] General scan found a potential route array in ${sourceFile.getFilePath()} at line ${arr.getStartLineNumber()}. Processing it.`); this.processRouteArrayLiteral(arr, parentPathForThisFileContext, sourceFile); } else { // console.log(` [ExtractRoutes] General scan found an array in ${sourceFile.getFilePath()} at line ${arr.getStartLineNumber()} that does not appear to contain route objects.`); } } } // Helper method to process a resolved ArrayLiteralExpression containing routes processRouteArrayLiteral(routeArrayNode, parentRouteContextFullPath, sourceFile) { console.log(` [ProcessArrayLiteral] START processing array from file: ${sourceFile.getFilePath()}, ParentContext: '${parentRouteContextFullPath}', Number of elements: ${routeArrayNode.getElements().length}`); const parsedRoutes = this.parseRouteArray(routeArrayNode, parentRouteContextFullPath, sourceFile); console.log(` [ProcessArrayLiteral] FINISHED parseRouteArray. Number of parsed routes from this array: ${parsedRoutes.length}`); for (const pr of parsedRoutes) { // console.log(` [ProcessArrayLiteral] Considering parsed route: path='${pr.path}', fp='${pr.fullPath}', comp='${pr.component || 'N/A'}', children#='${pr.children?.length || 0}', redirectTo='${pr.redirectTo || 'N/A'}', loadChildren='${pr.loadChildren || 'N/A'}'`); this.addRouteToCollection(pr); } } addRouteToCollection(pr) { if (!pr || (!pr.component && !pr.loadChildren && !pr.redirectTo && (!pr.children || pr.children.length === 0) && pr.path === '')) { // console.log(` [AddRoute] Skipping route object that is effectively empty or a simple pathless placeholder: ${pr?.fullPath}`); return; } console.log(` [AddRoute] Considering to add route: path='${pr.path}', fp='${pr.fullPath}', comp='${pr.component || 'N/A'}', children#='${pr.children?.length || 0}', redirectTo='${pr.redirectTo || 'N/A'}', loadChildren='${pr.loadChildren || 'N/A'}'`); // Attempt to find an existing route by fullPath first, as this is the unique identifier for a page. const existingRouteByPathIndex = this.routes.findIndex(r => r.fullPath === pr.fullPath); if (existingRouteByPathIndex !== -1) { const existingRoute = this.routes[existingRouteByPathIndex]; // If an existing route for the same fullPath is found: // 1. If new route has a component and old one didn't, OR // 2. If new route has loadChildren and old one didn't, OR // 3. If new route has redirectTo and old one didn't, // then the new one is more specific/complete for this path. let updateReason = ''; if (pr.component && !existingRoute.component) updateReason = 'new component'; else if (pr.loadChildren && !existingRoute.loadChildren) updateReason = 'new loadChildren'; else if (pr.redirectTo && !existingRoute.redirectTo) updateReason = 'new redirectTo'; // 4. Or if new one has children and old one didn't (less common to override this way) // else if (pr.children && pr.children.length > 0 && (!existingRoute.children || existingRoute.children.length === 0)) updateReason = 'new children'; if (updateReason) { console.log(` [AddRoute] Updating existing route at ${pr.fullPath} because of ${updateReason}. Old comp: ${existingRoute.component}, New comp: ${pr.component}`); // Preserve children from the old route if the new one doesn't have them but the old one did (e.g. a grouping route being given a component) if (!pr.children && existingRoute.children && existingRoute.children.length > 0 && existingRoute.component !== pr.component) { // Be careful not to wipe out children if we are just adding a component to a path that was a parent. // This case needs very careful thought. Usually, a path is either a parent OR has a component. // For now, if a component is added, it might become a terminal route for that path. console.log(` New route for ${pr.fullPath} has new ${updateReason}, old route had children. Deciding on children preservation...`); // If the new definition has a component, it usually implies it's the endpoint for that path. // If the old one was just a grouping path, its children are effectively superseded for this specific path if a component is now defined. } this.routes[existingRouteByPathIndex] = pr; } else if (pr.component && existingRoute.component && pr.component !== existingRoute.component) { console.warn(` [AddRoute] Ambiguous route definition for ${pr.fullPath}. Existing component: ${existingRoute.component}, New component: ${pr.component}. Keeping existing.`); } else { // console.log(` [AddRoute] Existing route at ${pr.fullPath} is same or more complete. Skipping new.`); } return; // Handled existing path } // If no route exists for this fullPath, then check for existing component (for different paths) // This was the old logic, useful if a component was first defined with a generic path then a more specific one. const existingRouteByComponentIndex = this.routes.findIndex(r => r.component && pr.component && r.component === pr.component); if (existingRouteByComponentIndex !== -1) { const existingRoute = this.routes[existingRouteByComponentIndex]; if (pr.fullPath.length > existingRoute.fullPath.length || (existingRoute.path === '**' && (pr.component || pr.loadChildren))) { console.log(` [AddRoute] Replacing existing route for component ${pr.component} (old path: ${existingRoute.fullPath}) with new, more specific path: ${pr.fullPath}`); this.routes[existingRouteByComponentIndex] = pr; } else { // console.log(` [AddRoute] Existing route for component ${pr.component} (path: ${existingRoute.fullPath}) is more specific or same as new route (path: ${pr.fullPath}). Skipping new.`); } return; // Handled by component specificity } // If neither fullPath nor component matched for replacement, add as new route (final duplication check) const trulyDuplicate = this.routes.find(r => r.fullPath === pr.fullPath && ((r.component && pr.component && r.component === pr.component) || (r.loadChildren && pr.loadChildren && r.loadChildren === pr.loadChildren) || (r.redirectTo && pr.redirectTo && r.redirectTo === pr.redirectTo) || (!r.component && !pr.component && !r.loadChildren && !pr.loadChildren && !r.redirectTo && !pr.redirectTo && (r.children && r.children.length > 0) && (pr.children && pr.children.length > 0)))); if (!trulyDuplicate) { // console.log(` [AddRoute] Adding new route: ${pr.fullPath}`); this.routes.push(pr); } else { // console.log(` [AddRoute] Skipping truly duplicate route: ${pr.fullPath} (${pr.component || pr.loadChildren || pr.redirectTo || 'path-only with children'})`); } } parseRouteArray(node, parentRouteContextFullPath, sourceFile) { console.log(` [ParseRouteArray] START. ParentContext: '${parentRouteContextFullPath}', Input node kind: ${node.getKindName()}, SourceFile: ${sourceFile.getFilePath()}`); const routes = []; if (Node.isArrayLiteralExpression(node)) { node.getElements().forEach(element => { if (Node.isObjectLiteralExpression(element)) { if (this.processedRouteObjects.has(element)) { console.log(` [ParseRouteArray] Skipping already processed route object in ${sourceFile.getFilePath()} at line ${element.getStartLineNumber()}`); return; } const route = this.parseRouteObject(element, parentRouteContextFullPath, sourceFile); if (route) { console.log(` [ParseRouteArray] Successfully parsed an object. Result: path='${route.path}', fp='${route.fullPath}', comp='${route.component || 'N/A'}'`); this.processedRouteObjects.add(element); routes.push(route); if (route.loadChildren) { console.log(` [ParseRouteArray] Found loadChildren for route path: '${route.path}', fullPath: '${route.fullPath}'. Target: '${route.loadChildren}'. Scheduling lazy load.`); this.loadAndParseLazyModule(route.loadChildren, route.fullPath, sourceFile) .catch(err => { console.warn(`⚠️ Error processing lazy module ${route.loadChildren} (parent: ${route.fullPath}): ${err.message}`); }); } } else { console.log(` [ParseRouteArray] parseRouteObject returned null for an element.`); } } }); } console.log(` [ParseRouteArray] END. Returning ${routes.length} routes from this array literal.`); return routes; } parseRouteObject(node, parentRouteContextFullPath, sourceFile) { console.log(`[ParseRouteObject] START. ParentContext: '${parentRouteContextFullPath}', SourceFile: ${sourceFile.getFilePath()}, Line: ${node.getStartLineNumber()}`); const props = {}; let pathSegmentFromProps = undefined; node.getProperties().forEach(prop => { if (Node.isPropertyAssignment(prop)) { const name = prop.getNameNode().getText(); const initializer = prop.getInitializer(); console.log(` [ParseRouteObject] path: '${pathSegmentFromProps || parentRouteContextFullPath}', Found property: '${name}', Initializer Kind: ${initializer?.getKindName()}`); if (!initializer) return; switch (name) { case 'path': if (Node.isStringLiteral(initializer)) pathSegmentFromProps = initializer.getLiteralValue(); break; case 'component': if (Node.isIdentifier(initializer)) props[name] = initializer.getText(); break; case 'loadComponent': if (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) { const importCall = initializer.getDescendantsOfKind(SyntaxKind.CallExpression) .find(call => call.getExpression().getText() === 'import'); if (importCall && importCall.getArguments().length > 0) { // Attempt to get component name from .then(m => m.ComponentName) const thenPropertyAccess = importCall.getParentWhile(n => n.getKind() !== SyntaxKind.PropertyAccessExpression && n !== initializer.getBody()); if (thenPropertyAccess && Node.isPropertyAccessExpression(thenPropertyAccess)) { const standaloneCompName = thenPropertyAccess.getNameNode().getText(); console.log(` [ParseRouteObject] Extracted component name '${standaloneCompName}' from loadComponent for path '${pathSegmentFromProps || parentRouteContextFullPath}'`); props['component'] = standaloneCompName; // Store it as if it were a direct component property } else { // Fallback: try to derive from the import path if no .then(m => m.Comp) is found const importPathNode = importCall.getArguments()[0]; if (Node.isStringLiteral(importPathNode)) { const importedFileName = importPathNode.getLiteralValue().split('/').pop()?.split('.')[0]; if (importedFileName) { const derivedCompName = this.kebabToPascalCase(importedFileName); console.log(` [ParseRouteObject] No direct component access in loadComponent's .then(). Derived component name '${derivedCompName}' from import path '${importPathNode.getLiteralValue()}' for path '${pathSegmentFromProps || parentRouteContextFullPath}'`); props['component'] = derivedCompName; } } } } } break; case 'loadChildren': console.log(`[ParseRouteObject] Found 'loadChildren' property for path '${pathSegmentFromProps || parentRouteContextFullPath}'. Initializer Kind: ${initializer.getKindName()}, Text: ${initializer.getText().substring(0, 100)}...`); if (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) { const importCall = initializer.getDescendantsOfKind(SyntaxKind.CallExpression) .find(call => call.getExpression().getText() === 'import'); if (importCall && importCall.getArguments().length > 0) { const importPathNode = importCall.getArguments()[0]; if (Node.isStringLiteral(importPathNode)) { props[name] = importPathNode.getLiteralValue(); console.log(` [ParseRouteObject] Extracted loadChildren target via import() directly in function: '${props[name]}'`); } else { console.log(` [ParseRouteObject] loadChildren function's import() argument is not a StringLiteral: ${importPathNode.getKindName()}`); } } else { console.log(` [ParseRouteObject] loadChildren function does not contain a direct import() call or import() has no arguments.`); } } else if (Node.isStringLiteral(initializer)) { props[name] = initializer.getLiteralValue(); console.log(` [ParseRouteObject] Extracted loadChildren target (string literal): '${props[name]}'`); } else if (Node.isIdentifier(initializer)) { console.log(` [ParseRouteObject] loadChildren initializer is an Identifier: '${initializer.getText()}'. Attempting to resolve it.`); try { const definitions = initializer.getDefinitionNodes(); for (const def of definitions) { if (Node.isVariableDeclaration(def) || Node.isFunctionDeclaration(def) || Node.isPropertyAssignment(def)) { let functionBodyNode = undefined; if (Node.isVariableDeclaration(def)) functionBodyNode = def.getInitializer(); else if (Node.isFunctionDeclaration(def)) functionBodyNode = def; // FunctionDeclaration node itself else if (Node.isPropertyAssignment(def)) functionBodyNode = def.getInitializer(); if (functionBodyNode && (Node.isArrowFunction(functionBodyNode) || Node.isFunctionExpression(functionBodyNode) || Node.isFunctionDeclaration(functionBodyNode))) { console.log(` [ParseRouteObject] Resolved identifier '${initializer.getText()}' to a function-like structure (Kind: ${functionBodyNode.getKindName()}).`); const importCall = functionBodyNode.getDescendantsOfKind(SyntaxKind.CallExpression) .find(call => call.getExpression().getText() === 'import'); if (importCall && importCall.getArguments().length > 0) { const importPathNode = importCall.getArguments()[0]; if (Node.isStringLiteral(importPathNode)) { props[name] = importPathNode.getLiteralValue(); console.log(` [ParseRouteObject] Extracted loadChildren target via resolved identifier's import(): '${props[name]}'`); break; // Found it } } } } } if (!props[name]) { console.log(` [ParseRouteObject] Could not resolve loadChildren identifier '${initializer.getText()}' to a function with an import().`); } } catch (err) { console.error(` [ParseRouteObject] Error resolving loadChildren identifier '${initializer.getText()}': ${err}`); } } else { console.log(` [ParseRouteObject] loadChildren initializer is not a recognized type. Kind: ${initializer.getKindName()}`); } break; case 'redirectTo': if (Node.isStringLiteral(initializer)) props[name] = initializer.getLiteralValue(); break; case 'children': if (Node.isArrayLiteralExpression(initializer)) props[name] = initializer; // Store AST node break; case 'canActivate': if (Node.isArrayLiteralExpression(initializer)) props[name] = initializer; // Store AST node break; case 'data': if (Node.isObjectLiteralExpression(initializer)) props[name] = initializer; // Store AST node break; } } }); if (pathSegmentFromProps === undefined) { // It's common for a route to be just children or redirectTo, path can be optional if parent provides it or it's empty. // However, if it has no path, component, children, loadChildren, or redirectTo, it's not a useful route. if (!props['component'] && !props['children'] && !props['loadChildren'] && !props['redirectTo'] && !props['loadComponent']) { console.log(`[ParseRouteObject] Skipping object with no path and no other defining properties (comp, children, loadChildren, redirectTo) at line ${node.getStartLineNumber()} in ${sourceFile.getFilePath()}`); return null; } // If path is undefined, but other properties exist, treat path as '' for this segment. pathSegmentFromProps = ''; } const route = { path: pathSegmentFromProps, fullPath: '' }; console.log(`[ParseRouteObject] pathSegmentFromProps: '${pathSegmentFromProps}'`); // Calculate fullPath for the current route object if (parentRouteContextFullPath === '/' && route.path === '') { // True root of the application route.fullPath = '/'; if (!this.routes.some(r => r.isRoot)) { // Mark as root only if no other root exists route.isRoot = true; } } else if (parentRouteContextFullPath === '/') { // Direct child of the application root route.fullPath = `/${route.path}`; } else { // Nested route route.fullPath = route.path === '' ? parentRouteContextFullPath : `${parentRouteContextFullPath}/${route.path}`; } // Normalize fullPath: remove double slashes, then remove trailing slash unless it's just "/" route.fullPath = route.fullPath.replace(/\/\//g, '/'); if (route.fullPath !== '/' && route.fullPath.endsWith('/')) { route.fullPath = route.fullPath.slice(0, -1); } // Ensure fullPath is at least '/' if it somehow became empty (e.g. parent '/', path '') if (route.fullPath === '') route.fullPath = '/'; console.log(`[ParseRouteObject] Calculated fullPath: '${route.fullPath}', componentFromProps: '${props['component'] || 'N/A'}'`); // Assign other properties if (props['component']) route.component = props['component']; if (props['redirectTo']) route.redirectTo = props['redirectTo']; if (props['loadChildren']) route.loadChildren = props['loadChildren']; if (props['canActivate'] && Node.isArrayLiteralExpression(props['canActivate'])) { route.guards = props['canActivate'].getElements().map(el => el.getText()); } if (props['data'] && Node.isObjectLiteralExpression(props['data'])) { route.data = {}; props['data'].getProperties().forEach(dataProp => { if (Node.isPropertyAssignment(dataProp)) { const dataKey = dataProp.getNameNode().getText(); const dataValueNode = dataProp.getInitializer(); if (dataValueNode) { route.data[dataKey] = Node.isStringLiteral(dataValueNode) ? dataValueNode.getLiteralValue() : dataValueNode.getText(); } } }); } // IMPORTANT: If there are children, parse them using the *newly calculated and finalized* route.fullPath // This is critical: children's full paths are relative to their immediate parent's full path. if (props['children'] && Node.isArrayLiteralExpression(props['children'])) { // console.log(` Parsing children for ${route.fullPath} (parent context was ${parentRouteContextFullPath}, path segment ${route.path})`); const childRoutes = this.parseRouteArray(props['children'], route.fullPath, sourceFile); // Assign to route.children for structural integrity if needed elsewhere, but also add them to the main collection. route.children = childRoutes; for (const childRoute of childRoutes) { this.addRouteToCollection(childRoute); } } console.log(`[ParseRouteObject] END. Returning route object for path: '${route.fullPath}'`); return route; } async loadAndParseLazyModule(modulePathString, parentRouteFullPathForLazyModule, originatingSourceFile) { let resolvedModulePath = modulePathString; let exportName = undefined; // For cases like './module#ExportedRoutesArray' console.log(` [LoadAndParseLazy] For modulePath: '${modulePathString}', OriginatingFile: ${originatingSourceFile.getFilePath()}, Using ParentRoutePathForLazy: '${parentRouteFullPathForLazyModule}'`); // Avoid re-processing if this exact lazy load (path + parent context) was already triggered // This is a simple guard, might need more sophisticated caching if complex scenarios arise const lazyLoadSignature = `${modulePathString}#${parentRouteFullPathForLazyModule}`; if (this.processedLazyLoads.has(lazyLoadSignature)) { // console.log(` Skipping already processed lazy load: ${lazyLoadSignature}`); return; } if (resolvedModulePath.includes('#')) { [resolvedModulePath, exportName] = resolvedModulePath.split('#'); } // console.log(` Attempting to lazy load: Path='${resolvedModulePath}', Export='${exportName}', ParentRoutePath='${parentRouteFullPathForLazyModule}'`); // Resolve the module path relative to the originating source file's directory const baseDir = path.dirname(originatingSourceFile.getFilePath()); let absolutePathToTry = path.resolve(baseDir, resolvedModulePath); // Check for common extensions or if it's a directory const extensionsToTry = ['', '.ts', '.module.ts', '.routes.ts']; let foundPath = undefined; for (const ext of extensionsToTry) { let currentTry = absolutePathToTry + ext; if (fs.existsSync(currentTry) && fs.lstatSync(currentTry).isFile()) { foundPath = currentTry; break; } } if (!foundPath && fs.existsSync(absolutePathToTry) && fs.lstatSync(absolutePathToTry).isDirectory()) { // If it's a directory, look for common routing file patterns inside it // This is a simplified heuristic. Angular's resolution can be more complex (e.g. package.json main/module fields) const defaultFiles = ['index.ts', `${path.basename(absolutePathToTry)}.routes.ts`, `${path.basename(absolutePathToTry)}.module.ts`]; for (const defaultFile of defaultFiles) { let currentTry = path.join(absolutePathToTry, defaultFile); if (fs.existsSync(currentTry) && fs.lstatSync(currentTry).isFile()) { foundPath = currentTry; // console.log(` Lazy path resolved to directory, found default file: ${foundPath}`); break; } } } if (!foundPath) { // Fallback: Try resolving from project 'src' or 'src/app' if not found relative to current file // This helps with paths like 'app/features/my-feature/my-feature.module' const fallbackBasePaths = [path.join(this.angularProjectPath, 'src'), path.join(this.angularProjectPath, 'src/app')]; for (const basePath of fallbackBasePaths) { absolutePathToTry = path.resolve(basePath, resolvedModulePath.startsWith('./') ? resolvedModulePath.substring(2) : resolvedModulePath); for (const ext of extensionsToTry) { let currentTry = absolutePathToTry + ext; if (fs.existsSync(currentTry) && fs.lstatSync(currentTry).isFile()) { foundPath = currentTry; break; } } if (foundPath) break; if (!foundPath && fs.existsSync(absolutePathToTry) && fs.lstatSync(absolutePathToTry).isDirectory()) { const defaultFiles = ['index.ts', `${path.basename(absolutePathToTry)}.routes.ts`, `${path.basename(absolutePathToTry)}.module.ts`];