mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge branch 'pr-133'
This commit is contained in:
commit
6915a9350b
9 changed files with 1011 additions and 54 deletions
53
AGENTS.md
53
AGENTS.md
|
|
@ -1,10 +1,16 @@
|
|||
# AI Agent Rules
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# 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
|
||||
```
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
|
|
|||
51
CLAUDE.md
51
CLAUDE.md
|
|
@ -1,10 +1,14 @@
|
|||
<!-- gitnexus:start -->
|
||||
# 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
|
||||
```
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
|
|
|||
35
README.md
35
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.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/19809" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19809" alt="abhigyanpatwari%2FGitNexus | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
<a href="https://trendshift.io/repositories/19809" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/19809" alt="abhigyanpatwari%2FGitNexus | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
|
||||
<h2>Join the official Discord to discuss ideas, issues etc!</h2>
|
||||
|
||||
<a href="https://discord.gg/AAsRVT6fGb">
|
||||
<img src="https://img.shields.io/discord/1477255801545429032?color=5865F2&logo=discord&logoColor=white" alt="Discord"/>
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/gitnexus">
|
||||
<img src="https://img.shields.io/npm/v/gitnexus.svg" alt="npm version"/>
|
||||
</a>
|
||||
<a href="https://polyformproject.org/licenses/noncommercial/1.0.0/">
|
||||
<img src="https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg" alt="License: PolyForm Noncommercial"/>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
**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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue