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
JavaScript
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 : ${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'
});
}
}
}
}