diff --git a/AGENTS.md b/AGENTS.md index aad422632..f27f6caa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,16 @@ +# AI Agent Rules + # GitNexus MCP -This project is indexed by GitNexus as **GitnexusV2** (1444 symbols, 3700 relationships, 111 execution flows). +This project is indexed by GitNexus as **GitNexus** (1348 symbols, 3492 relationships, 104 execution flows). + +GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search. ## Always Start Here +For any task involving code understanding, debugging, impact analysis, or refactoring, you must: + 1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness 2. **Match your task to a skill below** and **read that skill file** 3. **Follow the skill's workflow and checklist** @@ -15,11 +21,44 @@ This project is indexed by GitNexus as **GitnexusV2** (1444 symbols, 3700 relati | Task | Read this skill file | |------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/refactoring/SKILL.md` | + +## Tools Reference + +| Tool | What it gives you | +|------|-------------------| +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +|----------|---------| +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/CLAUDE.md b/CLAUDE.md index aad422632..7986daec7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,14 @@ # GitNexus MCP -This project is indexed by GitNexus as **GitnexusV2** (1444 symbols, 3700 relationships, 111 execution flows). +This project is indexed by GitNexus as **GitNexus** (1348 symbols, 3492 relationships, 104 execution flows). + +GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search. ## Always Start Here +For any task involving code understanding, debugging, impact analysis, or refactoring, you must: + 1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness 2. **Match your task to a skill below** and **read that skill file** 3. **Follow the skill's workflow and checklist** @@ -15,11 +19,44 @@ This project is indexed by GitNexus as **GitnexusV2** (1444 symbols, 3700 relati | Task | Read this skill file | |------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/refactoring/SKILL.md` | + +## Tools Reference + +| Tool | What it gives you | +|------|-------------------| +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +|----------|---------| +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/README.md b/README.md index 6a236f47e..119173b0f 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,13 @@ # GitNexus -⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus. -
- -**Building nervous system for agent context.** +**Building git for agent context.** Indexes any codebase into a knowledge graph — every dependency, call chain, cluster, and execution flow — then exposes it through smart tools so AI agents never miss code. +[](https://www.npmjs.com/package/gitnexus) +[](https://polyformproject.org/licenses/noncommercial/1.0.0/) @@ -89,12 +72,6 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up > **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context. -### Community Integrations - -| Agent | Install | Source | -|-------|---------|--------| -| [pi](https://pi.dev) | `pi install npm:pi-gitnexus` | [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | - If you prefer manual configuration: **Claude Code** (full support — MCP + skills + hooks): @@ -320,7 +297,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas ### Supported Languages -TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust, PHP, Swift +TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP --- @@ -482,7 +459,7 @@ The wiki generator reads the indexed graph structure, groups files into modules - [X] Wiki Generation, Multi-File Rename, Git-Diff Impact Analysis - [X] Process-Grouped Search, 360-Degree Context, Claude Code Hooks -- [X] Multi-Repo MCP, Zero-Config Setup, 11 Language Support +- [X] Multi-Repo MCP, Zero-Config Setup, 9 Language Support - [X] Community Detection, Process Detection, Confidence Scoring - [X] Hybrid Search, Vector Index 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 67f773574..15f681fad 100644 --- a/gitnexus-web/src/core/ingestion/framework-detection.ts +++ b/gitnexus-web/src/core/ingestion/framework-detection.ts @@ -319,7 +319,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 7ab36ad46..9b6dfc3f4 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. @@ -456,3 +456,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/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 722f73c8b..ae0e7026f 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -8,7 +8,7 @@ import { ASTCache } from './ast-cache.js'; import { findSiblingChild, getLanguageFromFilename, yieldToEventLoop } from './utils.js'; import { detectFrameworkFromAST } from './framework-detection.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; @@ -16,6 +16,7 @@ export interface WorkerExtractedData { imports: ExtractedImport[]; calls: ExtractedCall[]; heritage: ExtractedHeritage[]; + routes: ExtractedRoute[]; } const DEFINITION_CAPTURE_KEYS = [ @@ -215,7 +216,7 @@ const processParsingWithWorkers = async ( if (lang) parseableFiles.push({ path: file.path, content: file.content }); } - if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [] }; + if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [], routes: [] }; const total = files.length; @@ -231,6 +232,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({ @@ -251,11 +253,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 8a7d9d0ee..f067eaccc 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, buildImportResolutionContext } 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'; @@ -201,6 +201,10 @@ export const runPipelineFromRepo = async ( if (chunkWorkerData.heritage.length > 0) { await processHeritageFromExtracted(graph, chunkWorkerData.heritage, symbolTable); } + // Routes — resolve immediately (Laravel route→controller CALLS edges) + if (chunkWorkerData.routes && chunkWorkerData.routes.length > 0) { + await processRoutesFromExtracted(graph, chunkWorkerData.routes, symbolTable, importMap); + } } else { await processImports(graph, chunkFiles, astCache, importMap, undefined, repoPath, allPaths); sequentialChunkPaths.push(chunkPaths); diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 036693d76..2e9890b7b 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -80,6 +80,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[]; @@ -87,6 +98,7 @@ export interface ParseWorkerResult { imports: ExtractedImport[]; calls: ExtractedCall[]; heritage: ExtractedHeritage[]; + routes: ExtractedRoute[]; fileCount: number; } @@ -260,10 +272,10 @@ 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', - // Kotlin (function_declaration already included above via JS/TS) - 'anonymous_function', 'lambda_literal', + // Kotlin + 'lambda_literal', // PHP - 'anonymous_function_creation_expression', + 'anonymous_function', // Swift initializers/deinitializers 'init_declaration', 'deinit_declaration', ]); @@ -526,6 +538,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n imports: [], calls: [], heritage: [], + routes: [], fileCount: 0, }; @@ -701,6 +714,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, @@ -862,6 +1247,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); + } } }; @@ -872,7 +1263,7 @@ const processFileGroup = ( /** Accumulated result across sub-batches */ let accumulated: ParseWorkerResult = { nodes: [], relationships: [], symbols: [], - imports: [], calls: [], heritage: [], fileCount: 0, + imports: [], calls: [], heritage: [], routes: [], fileCount: 0, }; let cumulativeProcessed = 0; @@ -883,6 +1274,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => { target.imports.push(...src.imports); target.calls.push(...src.calls); target.heritage.push(...src.heritage); + target.routes.push(...src.routes); target.fileCount += src.fileCount; }; @@ -904,7 +1296,7 @@ parentPort!.on('message', (msg: any) => { if (msg && msg.type === 'flush') { parentPort!.postMessage({ type: 'result', data: accumulated }); // Reset for potential reuse - accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], fileCount: 0 }; + accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], routes: [], fileCount: 0 }; cumulativeProcessed = 0; return; }