From a7fc9d2f88bfaab88c4bd7163121db76e110c517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCne=C5=9F=20Bizim?= Date: Fri, 27 Feb 2026 11:17:36 +0300 Subject: [PATCH] feat(laravel): add route detection and route group support Parse Route::* calls from PHP AST using a procedural walk that tracks group nesting state (middleware, prefix, controller cascade). Creates CALLS edges from route files to controller methods. Supported patterns: - Route::get/post/put/patch/delete/any/match with [Controller::class, 'method'] - Route::resource / Route::apiResource (expanded to individual actions) - Invokable controllers (Controller::class -> __invoke) - String syntax ('Controller@method') - Route::middleware()->group() fluent chains (arbitrary depth) - Route::prefix()->name()->group() chains - Route::controller(X::class)->group() shared controller - Route::group(['middleware' => ...], fn) array API - Nested groups with full middleware/prefix cascade Implementation notes: - Uses anonymous_function node type (php_only grammar, not anonymous_function_creation_expression) - File paths from workers are relative; check startsWith('routes/') not includes('/routes/') - Edges: File -> Method with reason='laravel-route', confidence 0.9 (import-resolved) - Mirrored to gitnexus-web inline in processCalls (no worker layer) --- .../src/core/ingestion/call-processor.ts | 433 ++++++++++++++++++ .../src/core/ingestion/framework-detection.ts | 3 +- gitnexus/src/core/ingestion/call-processor.ts | 73 ++- .../src/core/ingestion/framework-detection.ts | 3 +- .../src/core/ingestion/parsing-processor.ts | 9 +- gitnexus/src/core/ingestion/pipeline.ts | 14 +- .../core/ingestion/workers/parse-worker.ts | 393 +++++++++++++++- 7 files changed, 920 insertions(+), 8 deletions(-) diff --git a/gitnexus-web/src/core/ingestion/call-processor.ts b/gitnexus-web/src/core/ingestion/call-processor.ts index 2b71c5aaa..ce105ba00 100644 --- a/gitnexus-web/src/core/ingestion/call-processor.ts +++ b/gitnexus-web/src/core/ingestion/call-processor.ts @@ -216,6 +216,58 @@ export const processCalls = async ( }); }); + // Extract Laravel routes from route files via procedural AST walk + if (language === 'php' && (file.path.includes('/routes/') || file.path.startsWith('routes/')) && file.path.endsWith('.php')) { + const extractedRoutes = extractLaravelRoutes(tree, file.path); + for (const route of extractedRoutes) { + if (!route.controllerName || !route.methodName) continue; + + const controllerDefs = symbolTable.lookupFuzzy(route.controllerName); + if (controllerDefs.length === 0) continue; + + const routeImportedFiles = importMap.get(route.filePath); + let controllerDef = controllerDefs[0]; + let conf = controllerDefs.length === 1 ? 0.7 : 0.5; + + if (routeImportedFiles) { + for (const def of controllerDefs) { + if (routeImportedFiles.has(def.filePath)) { + controllerDef = def; + conf = 0.9; + break; + } + } + } + + const methodId = symbolTable.lookupExact(controllerDef.filePath, route.methodName); + const routeSourceId = generateId('File', route.filePath); + + if (!methodId) { + const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`); + const routeRelId = generateId('CALLS', `${routeSourceId}:route->${guessedId}`); + graph.addRelationship({ + id: routeRelId, + sourceId: routeSourceId, + targetId: guessedId, + type: 'CALLS', + confidence: conf * 0.8, + reason: 'laravel-route', + }); + continue; + } + + const routeRelId = generateId('CALLS', `${routeSourceId}:route->${methodId}`); + graph.addRelationship({ + id: routeRelId, + sourceId: routeSourceId, + targetId: methodId, + type: 'CALLS', + confidence: conf, + reason: 'laravel-route', + }); + } + } + // Cleanup if re-parsed if (wasReparsed) { tree.delete(); @@ -223,6 +275,387 @@ export const processCalls = async ( } }; +// ============================================================================ +// Laravel Route Extraction (procedural AST walk) +// ============================================================================ + +interface ExtractedRoute { + filePath: string; + httpMethod: string; + routePath: string | null; + controllerName: string | null; + methodName: string | null; + middleware: string[]; + prefix: string | null; + lineNumber: number; +} + +interface RouteGroupContext { + middleware: string[]; + prefix: string | null; + controller: string | null; +} + +const ROUTE_HTTP_METHODS = new Set([ + 'get', 'post', 'put', 'patch', 'delete', 'options', 'any', 'match', +]); + +const ROUTE_RESOURCE_METHODS = new Set(['resource', 'apiResource']); + +const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy']; +const API_RESOURCE_ACTIONS = ['index', 'store', 'show', 'update', 'destroy']; + +function isRouteStaticCall(node: any): boolean { + if (node.type !== 'scoped_call_expression') return false; + const obj = node.childForFieldName?.('object') ?? node.children?.[0]; + return obj?.text === 'Route'; +} + +function getCallMethodName(node: any): string | null { + const nameNode = node.childForFieldName?.('name') ?? + node.children?.find((c: any) => c.type === 'name'); + return nameNode?.text ?? null; +} + +function getArguments(node: any): any { + return node.children?.find((c: any) => c.type === 'arguments') ?? null; +} + +function findClosureBody(argsNode: any): any | null { + if (!argsNode) return null; + for (const child of argsNode.children ?? []) { + if (child.type === 'argument') { + for (const inner of child.children ?? []) { + if (inner.type === 'anonymous_function' || + inner.type === 'arrow_function') { + return inner.childForFieldName?.('body') ?? + inner.children?.find((c: any) => c.type === 'compound_statement'); + } + } + } + if (child.type === 'anonymous_function' || + child.type === 'arrow_function') { + return child.childForFieldName?.('body') ?? + child.children?.find((c: any) => c.type === 'compound_statement'); + } + } + return null; +} + +function findDescendant(node: any, type: string): any { + if (node.type === type) return node; + for (const child of (node.children ?? [])) { + const found = findDescendant(child, type); + if (found) return found; + } + return null; +} + +function extractStringContent(node: any): string | null { + if (!node) return null; + const content = node.children?.find((c: any) => c.type === 'string_content'); + if (content) return content.text; + if (node.type === 'string_content') return node.text; + return null; +} + +function extractFirstStringArg(argsNode: any): string | null { + if (!argsNode) return null; + for (const child of argsNode.children ?? []) { + const target = child.type === 'argument' ? child.children?.[0] : child; + if (!target) continue; + if (target.type === 'string' || target.type === 'encapsed_string') { + return extractStringContent(target); + } + } + return null; +} + +function extractMiddlewareArg(argsNode: any): string[] { + if (!argsNode) return []; + for (const child of argsNode.children ?? []) { + const target = child.type === 'argument' ? child.children?.[0] : child; + if (!target) continue; + if (target.type === 'string' || target.type === 'encapsed_string') { + const val = extractStringContent(target); + return val ? [val] : []; + } + if (target.type === 'array_creation_expression') { + const items: string[] = []; + for (const el of target.children ?? []) { + if (el.type === 'array_element_initializer') { + const str = el.children?.find((c: any) => c.type === 'string' || c.type === 'encapsed_string'); + const val = str ? extractStringContent(str) : null; + if (val) items.push(val); + } + } + return items; + } + } + return []; +} + +function extractClassArg(argsNode: any): string | null { + if (!argsNode) return null; + for (const child of argsNode.children ?? []) { + const target = child.type === 'argument' ? child.children?.[0] : child; + if (target?.type === 'class_constant_access_expression') { + return target.children?.find((c: any) => c.type === 'name')?.text ?? null; + } + } + return null; +} + +function extractControllerTarget(argsNode: any): { controller: string | null; method: string | null } { + if (!argsNode) return { controller: null, method: null }; + + const args: any[] = []; + for (const child of argsNode.children ?? []) { + if (child.type === 'argument') args.push(child.children?.[0]); + else if (child.type !== '(' && child.type !== ')' && child.type !== ',') args.push(child); + } + + const handlerNode = args[1]; + if (!handlerNode) return { controller: null, method: null }; + + if (handlerNode.type === 'array_creation_expression') { + let controller: string | null = null; + let method: string | null = null; + const elements: any[] = []; + for (const el of handlerNode.children ?? []) { + if (el.type === 'array_element_initializer') elements.push(el); + } + if (elements[0]) { + const classAccess = findDescendant(elements[0], 'class_constant_access_expression'); + if (classAccess) { + controller = classAccess.children?.find((c: any) => c.type === 'name')?.text ?? null; + } + } + if (elements[1]) { + const str = findDescendant(elements[1], 'string'); + method = str ? extractStringContent(str) : null; + } + return { controller, method }; + } + + if (handlerNode.type === 'string' || handlerNode.type === 'encapsed_string') { + const text = extractStringContent(handlerNode); + if (text?.includes('@')) { + const [controller, method] = text.split('@'); + return { controller, method }; + } + } + + if (handlerNode.type === 'class_constant_access_expression') { + const controller = handlerNode.children?.find((c: any) => c.type === 'name')?.text ?? null; + return { controller, method: '__invoke' }; + } + + return { controller: null, method: null }; +} + +interface ChainedRouteCall { + isRouteFacade: boolean; + terminalMethod: string; + attributes: { method: string; argsNode: any }[]; + terminalArgs: any; + node: any; +} + +function unwrapRouteChain(node: any): ChainedRouteCall | null { + if (node.type !== 'member_call_expression') return null; + + const terminalMethod = getCallMethodName(node); + if (!terminalMethod) return null; + + const terminalArgs = getArguments(node); + const attributes: { method: string; argsNode: any }[] = []; + + let current = node.children?.[0]; + + while (current) { + if (current.type === 'member_call_expression') { + const method = getCallMethodName(current); + const args = getArguments(current); + if (method) attributes.unshift({ method, argsNode: args }); + current = current.children?.[0]; + } else if (current.type === 'scoped_call_expression') { + const obj = current.childForFieldName?.('object') ?? current.children?.[0]; + if (obj?.text !== 'Route') return null; + + const method = getCallMethodName(current); + const args = getArguments(current); + if (method) attributes.unshift({ method, argsNode: args }); + + return { isRouteFacade: true, terminalMethod, attributes, terminalArgs, node }; + } else { + break; + } + } + + return null; +} + +function parseArrayGroupArgs(argsNode: any): RouteGroupContext { + const ctx: RouteGroupContext = { middleware: [], prefix: null, controller: null }; + if (!argsNode) return ctx; + + for (const child of argsNode.children ?? []) { + const target = child.type === 'argument' ? child.children?.[0] : child; + if (target?.type === 'array_creation_expression') { + for (const el of target.children ?? []) { + if (el.type !== 'array_element_initializer') continue; + const children = el.children ?? []; + const arrowIdx = children.findIndex((c: any) => c.type === '=>'); + if (arrowIdx === -1) continue; + const key = extractStringContent(children[arrowIdx - 1]); + const val = children[arrowIdx + 1]; + if (key === 'middleware') { + if (val?.type === 'string') { + const s = extractStringContent(val); + if (s) ctx.middleware.push(s); + } else if (val?.type === 'array_creation_expression') { + for (const item of val.children ?? []) { + if (item.type === 'array_element_initializer') { + const str = item.children?.find((c: any) => c.type === 'string'); + const s = str ? extractStringContent(str) : null; + if (s) ctx.middleware.push(s); + } + } + } + } else if (key === 'prefix') { + ctx.prefix = extractStringContent(val) ?? null; + } else if (key === 'controller') { + if (val?.type === 'class_constant_access_expression') { + ctx.controller = val.children?.find((c: any) => c.type === 'name')?.text ?? null; + } + } + } + } + } + return ctx; +} + +function extractLaravelRoutes(tree: any, filePath: string): ExtractedRoute[] { + const routes: ExtractedRoute[] = []; + + function resolveStack(stack: RouteGroupContext[]): { middleware: string[]; prefix: string | null; controller: string | null } { + const middleware: string[] = []; + let prefix: string | null = null; + let controller: string | null = null; + for (const ctx of stack) { + middleware.push(...ctx.middleware); + if (ctx.prefix) prefix = prefix ? `${prefix}/${ctx.prefix}`.replace(/\/+/g, '/') : ctx.prefix; + if (ctx.controller) controller = ctx.controller; + } + return { middleware, prefix, controller }; + } + + function emitRoute( + httpMethod: string, + argsNode: any, + lineNumber: number, + groupStack: RouteGroupContext[], + chainAttrs: { method: string; argsNode: any }[], + ) { + const effective = resolveStack(groupStack); + + for (const attr of chainAttrs) { + if (attr.method === 'middleware') effective.middleware.push(...extractMiddlewareArg(attr.argsNode)); + if (attr.method === 'prefix') { + const p = extractFirstStringArg(attr.argsNode); + if (p) effective.prefix = effective.prefix ? `${effective.prefix}/${p}` : p; + } + if (attr.method === 'controller') { + const cls = extractClassArg(attr.argsNode); + if (cls) effective.controller = cls; + } + } + + const routePath = extractFirstStringArg(argsNode); + + if (ROUTE_RESOURCE_METHODS.has(httpMethod)) { + const target = extractControllerTarget(argsNode); + const actions = httpMethod === 'apiResource' ? API_RESOURCE_ACTIONS : RESOURCE_ACTIONS; + for (const action of actions) { + routes.push({ + filePath, httpMethod, routePath, + controllerName: target.controller ?? effective.controller, + methodName: action, + middleware: [...effective.middleware], + prefix: effective.prefix, + lineNumber, + }); + } + } else { + const target = extractControllerTarget(argsNode); + routes.push({ + filePath, httpMethod, routePath, + controllerName: target.controller ?? effective.controller, + methodName: target.method, + middleware: [...effective.middleware], + prefix: effective.prefix, + lineNumber, + }); + } + } + + function walk(node: any, groupStack: RouteGroupContext[]) { + if (isRouteStaticCall(node)) { + const method = getCallMethodName(node); + if (method && (ROUTE_HTTP_METHODS.has(method) || ROUTE_RESOURCE_METHODS.has(method))) { + emitRoute(method, getArguments(node), node.startPosition.row, groupStack, []); + return; + } + if (method === 'group') { + const argsNode = getArguments(node); + const groupCtx = parseArrayGroupArgs(argsNode); + const body = findClosureBody(argsNode); + if (body) { + groupStack.push(groupCtx); + walkChildren(body, groupStack); + groupStack.pop(); + } + return; + } + } + + const chain = unwrapRouteChain(node); + if (chain) { + if (chain.terminalMethod === 'group') { + const groupCtx: RouteGroupContext = { middleware: [], prefix: null, controller: null }; + for (const attr of chain.attributes) { + if (attr.method === 'middleware') groupCtx.middleware.push(...extractMiddlewareArg(attr.argsNode)); + if (attr.method === 'prefix') groupCtx.prefix = extractFirstStringArg(attr.argsNode); + if (attr.method === 'controller') groupCtx.controller = extractClassArg(attr.argsNode); + } + const body = findClosureBody(chain.terminalArgs); + if (body) { + groupStack.push(groupCtx); + walkChildren(body, groupStack); + groupStack.pop(); + } + return; + } + if (ROUTE_HTTP_METHODS.has(chain.terminalMethod) || ROUTE_RESOURCE_METHODS.has(chain.terminalMethod)) { + emitRoute(chain.terminalMethod, chain.terminalArgs, node.startPosition.row, groupStack, chain.attributes); + return; + } + } + + walkChildren(node, groupStack); + } + + function walkChildren(node: any, groupStack: RouteGroupContext[]) { + for (const child of node.children ?? []) { + walk(child, groupStack); + } + } + + walk(tree.rootNode, []); + return routes; +} + /** * Resolution result with confidence scoring */ diff --git a/gitnexus-web/src/core/ingestion/framework-detection.ts b/gitnexus-web/src/core/ingestion/framework-detection.ts index e529649ca..cd63c2a31 100644 --- a/gitnexus-web/src/core/ingestion/framework-detection.ts +++ b/gitnexus-web/src/core/ingestion/framework-detection.ts @@ -277,7 +277,8 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null } // ============================================================================ -// FUTURE: AST-BASED PATTERNS (for Phase 3) +// PARTIALLY IMPLEMENTED: Route::* detection via procedural AST walk in parse-worker/call-processor +// Remaining: NestJS, Express, FastAPI, Flask, Spring, etc. // ============================================================================ /** diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index b3766df2b..c501f4c98 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -7,7 +7,7 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename, yieldToEventLoop } from './utils.js'; -import type { ExtractedCall } from './workers/parse-worker.js'; +import type { ExtractedCall, ExtractedRoute } from './workers/parse-worker.js'; /** * Node types that represent function/method definitions across languages. @@ -376,3 +376,74 @@ export const processCallsFromExtracted = async ( onProgress?.(totalFiles, totalFiles); }; + +/** + * Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods. + */ +export const processRoutesFromExtracted = async ( + graph: KnowledgeGraph, + extractedRoutes: ExtractedRoute[], + symbolTable: SymbolTable, + importMap: ImportMap, + onProgress?: (current: number, total: number) => void +) => { + for (let i = 0; i < extractedRoutes.length; i++) { + const route = extractedRoutes[i]; + if (i % 50 === 0) { + onProgress?.(i, extractedRoutes.length); + await yieldToEventLoop(); + } + + if (!route.controllerName || !route.methodName) continue; + + // Resolve controller class in symbol table + const controllerDefs = symbolTable.lookupFuzzy(route.controllerName); + if (controllerDefs.length === 0) continue; + + // Prefer import-resolved match + const importedFiles = importMap.get(route.filePath); + let controllerDef = controllerDefs[0]; + let confidence = controllerDefs.length === 1 ? 0.7 : 0.5; + + if (importedFiles) { + for (const def of controllerDefs) { + if (importedFiles.has(def.filePath)) { + controllerDef = def; + confidence = 0.9; + break; + } + } + } + + // Find the method on the controller + const methodId = symbolTable.lookupExact(controllerDef.filePath, route.methodName); + const sourceId = generateId('File', route.filePath); + + if (!methodId) { + // Construct method ID manually + const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`); + const relId = generateId('CALLS', `${sourceId}:route->${guessedId}`); + graph.addRelationship({ + id: relId, + sourceId, + targetId: guessedId, + type: 'CALLS', + confidence: confidence * 0.8, + reason: 'laravel-route', + }); + continue; + } + + const relId = generateId('CALLS', `${sourceId}:route->${methodId}`); + graph.addRelationship({ + id: relId, + sourceId, + targetId: methodId, + type: 'CALLS', + confidence, + reason: 'laravel-route', + }); + } + + onProgress?.(extractedRoutes.length, extractedRoutes.length); +}; diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 7c334b956..b4ae9c328 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -277,7 +277,8 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null } // ============================================================================ -// FUTURE: AST-BASED PATTERNS (for Phase 3) +// PARTIALLY IMPLEMENTED: Route::* detection via procedural AST walk in parse-worker/call-processor +// Remaining: NestJS, Express, FastAPI, Flask, Spring, etc. // ============================================================================ /** diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index e7bb344a4..648be0398 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -7,7 +7,7 @@ import { SymbolTable } from './symbol-table.js'; import { ASTCache } from './ast-cache.js'; import { getLanguageFromFilename, yieldToEventLoop } from './utils.js'; import { WorkerPool } from './workers/worker-pool.js'; -import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage } from './workers/parse-worker.js'; +import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; @@ -15,6 +15,7 @@ export interface WorkerExtractedData { imports: ExtractedImport[]; calls: ExtractedCall[]; heritage: ExtractedHeritage[]; + routes: ExtractedRoute[]; } // ============================================================================ @@ -140,7 +141,7 @@ const processParsingWithWorkers = async ( } } - if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [] }; + if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [], routes: [] }; const total = files.length; @@ -157,6 +158,7 @@ const processParsingWithWorkers = async ( const allImports: ExtractedImport[] = []; const allCalls: ExtractedCall[] = []; const allHeritage: ExtractedHeritage[] = []; + const allRoutes: ExtractedRoute[] = []; for (const result of chunkResults) { for (const node of result.nodes) { graph.addNode({ @@ -177,11 +179,12 @@ const processParsingWithWorkers = async ( allImports.push(...result.imports); allCalls.push(...result.calls); allHeritage.push(...result.heritage); + allRoutes.push(...result.routes); } // Final progress onFileProgress?.(total, total, 'done'); - return { imports: allImports, calls: allCalls, heritage: allHeritage }; + return { imports: allImports, calls: allCalls, heritage: allHeritage, routes: allRoutes }; }; // ============================================================================ diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index d87aebf52..96e3fe25e 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -2,7 +2,7 @@ import { createKnowledgeGraph } from '../graph/graph.js'; import { processStructure } from './structure-processor.js'; import { processParsing } from './parsing-processor.js'; import { processImports, processImportsFromExtracted, createImportMap } from './import-processor.js'; -import { processCalls, processCallsFromExtracted } from './call-processor.js'; +import { processCalls, processCallsFromExtracted, processRoutesFromExtracted } from './call-processor.js'; import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js'; import { processCommunities } from './community-processor.js'; import { processProcesses } from './process-processor.js'; @@ -176,6 +176,18 @@ export const runPipelineFromRepo = async ( }); } + // Route detection (Laravel) — after calls, before heritage + if (workerData?.routes && workerData.routes.length > 0) { + onProgress({ + phase: 'calls', + percent: 91, + message: 'Resolving Laravel routes...', + stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount }, + }); + + await processRoutesFromExtracted(graph, workerData.routes, symbolTable, importMap); + } + onProgress({ phase: 'heritage', percent: 92, diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index f4541d0d2..f74e90f32 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -69,6 +69,17 @@ export interface ExtractedHeritage { kind: string; } +export interface ExtractedRoute { + filePath: string; + httpMethod: string; + routePath: string | null; + controllerName: string | null; + methodName: string | null; + middleware: string[]; + prefix: string | null; + lineNumber: number; +} + export interface ParseWorkerResult { nodes: ParsedNode[]; relationships: ParsedRelationship[]; @@ -76,6 +87,7 @@ export interface ParseWorkerResult { imports: ExtractedImport[]; calls: ExtractedCall[]; heritage: ExtractedHeritage[]; + routes: ExtractedRoute[]; fileCount: number; } @@ -220,7 +232,7 @@ const FUNCTION_NODE_TYPES = new Set([ 'function_definition', 'async_function_declaration', 'async_arrow_function', 'method_declaration', 'constructor_declaration', 'local_function_statement', 'function_item', 'impl_item', - 'anonymous_function_creation_expression', // PHP anonymous functions + 'anonymous_function', // PHP anonymous functions ]); /** Walk up AST to find enclosing function, return its generateId or null for top-level */ @@ -360,6 +372,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n imports: [], calls: [], heritage: [], + routes: [], fileCount: 0, }; @@ -527,6 +540,378 @@ function extractEloquentRelationDescription(methodNode: any): string | null { return null; } +// ============================================================================ +// Laravel Route Extraction (procedural AST walk) +// ============================================================================ + +interface RouteGroupContext { + middleware: string[]; + prefix: string | null; + controller: string | null; +} + +const ROUTE_HTTP_METHODS = new Set([ + 'get', 'post', 'put', 'patch', 'delete', 'options', 'any', 'match', +]); + +const ROUTE_RESOURCE_METHODS = new Set(['resource', 'apiResource']); + +const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy']; +const API_RESOURCE_ACTIONS = ['index', 'store', 'show', 'update', 'destroy']; + +/** Check if node is a scoped_call_expression with object 'Route' */ +function isRouteStaticCall(node: any): boolean { + if (node.type !== 'scoped_call_expression') return false; + const obj = node.childForFieldName?.('object') ?? node.children?.[0]; + return obj?.text === 'Route'; +} + +/** Get the method name from a scoped_call_expression or member_call_expression */ +function getCallMethodName(node: any): string | null { + const nameNode = node.childForFieldName?.('name') ?? + node.children?.find((c: any) => c.type === 'name'); + return nameNode?.text ?? null; +} + +/** Get the arguments node from a call expression */ +function getArguments(node: any): any { + return node.children?.find((c: any) => c.type === 'arguments') ?? null; +} + +/** Find the closure body inside arguments */ +function findClosureBody(argsNode: any): any | null { + if (!argsNode) return null; + for (const child of argsNode.children ?? []) { + if (child.type === 'argument') { + for (const inner of child.children ?? []) { + if (inner.type === 'anonymous_function' || + inner.type === 'arrow_function') { + return inner.childForFieldName?.('body') ?? + inner.children?.find((c: any) => c.type === 'compound_statement'); + } + } + } + if (child.type === 'anonymous_function' || + child.type === 'arrow_function') { + return child.childForFieldName?.('body') ?? + child.children?.find((c: any) => c.type === 'compound_statement'); + } + } + return null; +} + +/** Extract first string argument from arguments node */ +function extractFirstStringArg(argsNode: any): string | null { + if (!argsNode) return null; + for (const child of argsNode.children ?? []) { + const target = child.type === 'argument' ? child.children?.[0] : child; + if (!target) continue; + if (target.type === 'string' || target.type === 'encapsed_string') { + return extractStringContent(target); + } + } + return null; +} + +/** Extract middleware from arguments — handles string or array */ +function extractMiddlewareArg(argsNode: any): string[] { + if (!argsNode) return []; + for (const child of argsNode.children ?? []) { + const target = child.type === 'argument' ? child.children?.[0] : child; + if (!target) continue; + if (target.type === 'string' || target.type === 'encapsed_string') { + const val = extractStringContent(target); + return val ? [val] : []; + } + if (target.type === 'array_creation_expression') { + const items: string[] = []; + for (const el of target.children ?? []) { + if (el.type === 'array_element_initializer') { + const str = el.children?.find((c: any) => c.type === 'string' || c.type === 'encapsed_string'); + const val = str ? extractStringContent(str) : null; + if (val) items.push(val); + } + } + return items; + } + } + return []; +} + +/** Extract Controller::class from arguments */ +function extractClassArg(argsNode: any): string | null { + if (!argsNode) return null; + for (const child of argsNode.children ?? []) { + const target = child.type === 'argument' ? child.children?.[0] : child; + if (target?.type === 'class_constant_access_expression') { + return target.children?.find((c: any) => c.type === 'name')?.text ?? null; + } + } + return null; +} + +/** Extract controller class name from arguments: [Controller::class, 'method'] or 'Controller@method' */ +function extractControllerTarget(argsNode: any): { controller: string | null; method: string | null } { + if (!argsNode) return { controller: null, method: null }; + + const args: any[] = []; + for (const child of argsNode.children ?? []) { + if (child.type === 'argument') args.push(child.children?.[0]); + else if (child.type !== '(' && child.type !== ')' && child.type !== ',') args.push(child); + } + + // Second arg is the handler + const handlerNode = args[1]; + if (!handlerNode) return { controller: null, method: null }; + + // Array syntax: [UserController::class, 'index'] + if (handlerNode.type === 'array_creation_expression') { + let controller: string | null = null; + let method: string | null = null; + const elements: any[] = []; + for (const el of handlerNode.children ?? []) { + if (el.type === 'array_element_initializer') elements.push(el); + } + if (elements[0]) { + const classAccess = findDescendant(elements[0], 'class_constant_access_expression'); + if (classAccess) { + controller = classAccess.children?.find((c: any) => c.type === 'name')?.text ?? null; + } + } + if (elements[1]) { + const str = findDescendant(elements[1], 'string'); + method = str ? extractStringContent(str) : null; + } + return { controller, method }; + } + + // String syntax: 'UserController@index' + if (handlerNode.type === 'string' || handlerNode.type === 'encapsed_string') { + const text = extractStringContent(handlerNode); + if (text?.includes('@')) { + const [controller, method] = text.split('@'); + return { controller, method }; + } + } + + // Class reference: UserController::class (invokable controller) + if (handlerNode.type === 'class_constant_access_expression') { + const controller = handlerNode.children?.find((c: any) => c.type === 'name')?.text ?? null; + return { controller, method: '__invoke' }; + } + + return { controller: null, method: null }; +} + +interface ChainedRouteCall { + isRouteFacade: boolean; + terminalMethod: string; + attributes: { method: string; argsNode: any }[]; + terminalArgs: any; + node: any; +} + +/** + * Unwrap a chained call like Route::middleware('auth')->prefix('api')->group(fn) + */ +function unwrapRouteChain(node: any): ChainedRouteCall | null { + if (node.type !== 'member_call_expression') return null; + + const terminalMethod = getCallMethodName(node); + if (!terminalMethod) return null; + + const terminalArgs = getArguments(node); + const attributes: { method: string; argsNode: any }[] = []; + + let current = node.children?.[0]; + + while (current) { + if (current.type === 'member_call_expression') { + const method = getCallMethodName(current); + const args = getArguments(current); + if (method) attributes.unshift({ method, argsNode: args }); + current = current.children?.[0]; + } else if (current.type === 'scoped_call_expression') { + const obj = current.childForFieldName?.('object') ?? current.children?.[0]; + if (obj?.text !== 'Route') return null; + + const method = getCallMethodName(current); + const args = getArguments(current); + if (method) attributes.unshift({ method, argsNode: args }); + + return { isRouteFacade: true, terminalMethod, attributes, terminalArgs, node }; + } else { + break; + } + } + + return null; +} + +/** Parse Route::group(['middleware' => ..., 'prefix' => ...], fn) array syntax */ +function parseArrayGroupArgs(argsNode: any): RouteGroupContext { + const ctx: RouteGroupContext = { middleware: [], prefix: null, controller: null }; + if (!argsNode) return ctx; + + for (const child of argsNode.children ?? []) { + const target = child.type === 'argument' ? child.children?.[0] : child; + if (target?.type === 'array_creation_expression') { + for (const el of target.children ?? []) { + if (el.type !== 'array_element_initializer') continue; + const children = el.children ?? []; + const arrowIdx = children.findIndex((c: any) => c.type === '=>'); + if (arrowIdx === -1) continue; + const key = extractStringContent(children[arrowIdx - 1]); + const val = children[arrowIdx + 1]; + if (key === 'middleware') { + if (val?.type === 'string') { + const s = extractStringContent(val); + if (s) ctx.middleware.push(s); + } else if (val?.type === 'array_creation_expression') { + for (const item of val.children ?? []) { + if (item.type === 'array_element_initializer') { + const str = item.children?.find((c: any) => c.type === 'string'); + const s = str ? extractStringContent(str) : null; + if (s) ctx.middleware.push(s); + } + } + } + } else if (key === 'prefix') { + ctx.prefix = extractStringContent(val) ?? null; + } else if (key === 'controller') { + if (val?.type === 'class_constant_access_expression') { + ctx.controller = val.children?.find((c: any) => c.type === 'name')?.text ?? null; + } + } + } + } + } + return ctx; +} + +function extractLaravelRoutes(tree: any, filePath: string): ExtractedRoute[] { + const routes: ExtractedRoute[] = []; + + function resolveStack(stack: RouteGroupContext[]): { middleware: string[]; prefix: string | null; controller: string | null } { + const middleware: string[] = []; + let prefix: string | null = null; + let controller: string | null = null; + for (const ctx of stack) { + middleware.push(...ctx.middleware); + if (ctx.prefix) prefix = prefix ? `${prefix}/${ctx.prefix}`.replace(/\/+/g, '/') : ctx.prefix; + if (ctx.controller) controller = ctx.controller; + } + return { middleware, prefix, controller }; + } + + function emitRoute( + httpMethod: string, + argsNode: any, + lineNumber: number, + groupStack: RouteGroupContext[], + chainAttrs: { method: string; argsNode: any }[], + ) { + const effective = resolveStack(groupStack); + + for (const attr of chainAttrs) { + if (attr.method === 'middleware') effective.middleware.push(...extractMiddlewareArg(attr.argsNode)); + if (attr.method === 'prefix') { + const p = extractFirstStringArg(attr.argsNode); + if (p) effective.prefix = effective.prefix ? `${effective.prefix}/${p}` : p; + } + if (attr.method === 'controller') { + const cls = extractClassArg(attr.argsNode); + if (cls) effective.controller = cls; + } + } + + const routePath = extractFirstStringArg(argsNode); + + if (ROUTE_RESOURCE_METHODS.has(httpMethod)) { + const target = extractControllerTarget(argsNode); + const actions = httpMethod === 'apiResource' ? API_RESOURCE_ACTIONS : RESOURCE_ACTIONS; + for (const action of actions) { + routes.push({ + filePath, httpMethod, routePath, + controllerName: target.controller ?? effective.controller, + methodName: action, + middleware: [...effective.middleware], + prefix: effective.prefix, + lineNumber, + }); + } + } else { + const target = extractControllerTarget(argsNode); + routes.push({ + filePath, httpMethod, routePath, + controllerName: target.controller ?? effective.controller, + methodName: target.method, + middleware: [...effective.middleware], + prefix: effective.prefix, + lineNumber, + }); + } + } + + function walk(node: any, groupStack: RouteGroupContext[]) { + // Case 1: Simple Route::get(...), Route::post(...), etc. + if (isRouteStaticCall(node)) { + const method = getCallMethodName(node); + if (method && (ROUTE_HTTP_METHODS.has(method) || ROUTE_RESOURCE_METHODS.has(method))) { + emitRoute(method, getArguments(node), node.startPosition.row, groupStack, []); + return; + } + if (method === 'group') { + const argsNode = getArguments(node); + const groupCtx = parseArrayGroupArgs(argsNode); + const body = findClosureBody(argsNode); + if (body) { + groupStack.push(groupCtx); + walkChildren(body, groupStack); + groupStack.pop(); + } + return; + } + } + + // Case 2: Fluent chain — Route::middleware(...)->group(...) or Route::middleware(...)->get(...) + const chain = unwrapRouteChain(node); + if (chain) { + if (chain.terminalMethod === 'group') { + const groupCtx: RouteGroupContext = { middleware: [], prefix: null, controller: null }; + for (const attr of chain.attributes) { + if (attr.method === 'middleware') groupCtx.middleware.push(...extractMiddlewareArg(attr.argsNode)); + if (attr.method === 'prefix') groupCtx.prefix = extractFirstStringArg(attr.argsNode); + if (attr.method === 'controller') groupCtx.controller = extractClassArg(attr.argsNode); + } + const body = findClosureBody(chain.terminalArgs); + if (body) { + groupStack.push(groupCtx); + walkChildren(body, groupStack); + groupStack.pop(); + } + return; + } + if (ROUTE_HTTP_METHODS.has(chain.terminalMethod) || ROUTE_RESOURCE_METHODS.has(chain.terminalMethod)) { + emitRoute(chain.terminalMethod, chain.terminalArgs, node.startPosition.row, groupStack, chain.attributes); + return; + } + } + + // Default: recurse into children + walkChildren(node, groupStack); + } + + function walkChildren(node: any, groupStack: RouteGroupContext[]) { + for (const child of node.children ?? []) { + walk(child, groupStack); + } + } + + walk(tree.rootNode, []); + return routes; +} + const processFileGroup = ( files: ParseWorkerInput[], language: SupportedLanguages, @@ -674,6 +1059,12 @@ const processFileGroup = ( reason: '', }); } + + // Extract Laravel routes from route files via procedural AST walk + if (language === SupportedLanguages.PHP && (file.path.includes('/routes/') || file.path.startsWith('routes/')) && file.path.endsWith('.php')) { + const extractedRoutes = extractLaravelRoutes(tree, file.path); + result.routes.push(...extractedRoutes); + } } };