Merge remote-tracking branch 'origin/main' into fix/lru-cache-zero-max-crash

This commit is contained in:
Gary Magyar 2026-03-06 09:02:50 +00:00
commit 2868da5ddb
10 changed files with 1003 additions and 45 deletions

View file

@ -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
*/

View file

@ -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.
// ============================================================================
/**

View file

@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.3.6",
"version": "1.3.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.3.6",
"version": "1.3.9",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.3.6",
"version": "1.3.9",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",

View file

@ -28,38 +28,92 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
/**
* Generate the full GitNexus context content.
*
* Design principles (learned from real agent behavior):
* - AGENTS.md is the ROUTER — it tells the agent WHICH skill to read
* - Skills contain the actual workflows — AGENTS.md does NOT duplicate them
* - Bold **IMPORTANT** block + "Skills — Read First" heading — agents skip soft suggestions
* - One-line quick start (read context resource) gives agents an entry point
* - Tools/Resources sections are labeled "Reference" — agents treat them as lookup, not workflow
*
* Design principles (learned from real agent behavior and industry research):
* - Inline critical workflows — skills are skipped 56% of the time (Vercel eval data)
* - Use RFC 2119 language (MUST, NEVER, ALWAYS) — models follow imperative rules
* - Three-tier boundaries (Always/When/Never) — proven to change model behavior
* - Keep under 120 lines — adherence degrades past 150 lines
* - Exact tool commands with parameters — vague directives get ignored
* - Self-review checklist — forces model to verify its own work
*/
function generateGitNexusContent(projectName: string, stats: RepoStats): string {
return `${GITNEXUS_START_MARKER}
# GitNexus MCP
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows).
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
## Always Start Here
> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
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**
## Always Do
> If step 1 warns the index is stale, run \`npx gitnexus analyze\` in the terminal first.
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`gitnexus_impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`.
## Skills
## When Debugging
| 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\` |
1. \`gitnexus_query({query: "<error or symptom>"})\` — find execution flows related to the issue
2. \`gitnexus_context({name: "<suspect function>"})\` — see all callers, callees, and process participation
3. \`READ gitnexus://repo/${projectName}/process/{processName}\` — trace the full execution flow step by step
4. For regressions: \`gitnexus_detect_changes({scope: "compare", base_ref: "main"})\` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with \`dry_run: false\`.
- **Extracting/Splitting**: MUST run \`gitnexus_context({name: "target"})\` to see all incoming/outgoing refs, then \`gitnexus_impact({target: "target", direction: "upstream"})\` to find all external callers before moving code.
- After any refactor: run \`gitnexus_detect_changes({scope: "all"})\` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running \`gitnexus_impact\` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use \`gitnexus_rename\` which understands the call graph.
- NEVER commit changes without running \`gitnexus_detect_changes()\` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| \`query\` | Find code by concept | \`gitnexus_query({query: "auth validation"})\` |
| \`context\` | 360-degree view of one symbol | \`gitnexus_context({name: "validateUser"})\` |
| \`impact\` | Blast radius before editing | \`gitnexus_impact({target: "X", direction: "upstream"})\` |
| \`detect_changes\` | Pre-commit scope check | \`gitnexus_detect_changes({scope: "staged"})\` |
| \`rename\` | Safe multi-file rename | \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` |
| \`cypher\` | Custom graph queries | \`gitnexus_cypher({query: "MATCH ..."})\` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| \`gitnexus://repo/${projectName}/context\` | Codebase overview, check index freshness |
| \`gitnexus://repo/${projectName}/clusters\` | All functional areas |
| \`gitnexus://repo/${projectName}/processes\` | All execution flows |
| \`gitnexus://repo/${projectName}/process/{name}\` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. \`gitnexus_impact\` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. \`gitnexus_detect_changes()\` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## CLI
- Re-index: \`npx gitnexus analyze\`
- Check freshness: \`npx gitnexus status\`
- Generate docs: \`npx gitnexus wiki\`
${GITNEXUS_END_MARKER}`;
}

View file

@ -357,10 +357,8 @@ export const analyzeCommand = async (
console.log('');
// ONNX Runtime registers native atexit hooks that segfault during process
// shutdown on macOS (#38) and some Linux configs (#40). Force-exit to
// bypass them when embeddings were loaded.
if (!embeddingSkipped) {
process.exit(0);
}
// KuzuDB's native module holds open handles that prevent Node from exiting.
// ONNX Runtime also registers native atexit hooks that segfault on some
// platforms (#38, #40). Force-exit to ensure clean termination.
process.exit(0);
};

View file

@ -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);
};

View file

@ -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 };
};
// ============================================================================

View file

@ -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';
@ -210,6 +210,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);

View file

@ -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',
]);
@ -279,7 +291,8 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
if (current.type === 'init_declaration' || current.type === 'deinit_declaration') {
const funcName = current.type === 'init_declaration' ? 'init' : 'deinit';
const label = 'Constructor';
return generateId(label, `${filePath}:${funcName}`);
const startLine = current.startPosition?.row ?? 0;
return generateId(label, `${filePath}:${funcName}:${startLine}`);
}
if (['function_declaration', 'function_definition', 'async_function_declaration',
@ -315,7 +328,8 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
}
if (funcName) {
return generateId(label, `${filePath}:${funcName}`);
const startLine = current.startPosition?.row ?? 0;
return generateId(label, `${filePath}:${funcName}:${startLine}`);
}
}
current = current.parent;
@ -526,6 +540,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
imports: [],
calls: [],
heritage: [],
routes: [],
fileCount: 0,
};
@ -701,6 +716,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 +1249,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 +1265,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 +1276,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 +1298,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;
}