mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
merge: resolve conflicts with main for PHP/Laravel support
Merge main into feat/php-laravel-support, resolving conflicts in: - csv-generator.ts: add description column to streaming CSV architecture - kuzu-adapter.ts: add description to COPY queries and insert/merge ops - schema.ts: add description STRING to all code element tables, FROM Method TO Property - parse-worker.ts: integrate PHP built-ins and Eloquent extraction with sub-batch worker - import-processor.ts: integrate PHP PSR-4 resolution with ImportResolutionContext - package-lock.json: regenerate from main's 1.3.3 base with tree-sitter-php Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
258eeef915
68 changed files with 2137 additions and 1451 deletions
19
.claude-plugin/marketplace.json
Normal file
19
.claude-plugin/marketplace.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "gitnexus-marketplace",
|
||||
"owner": {
|
||||
"name": "GitNexus",
|
||||
"email": "nico@gitnexus.dev"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Code intelligence powered by a knowledge graph — execution flows, blast radius, and semantic search",
|
||||
"homepage": "https://github.com/nicosxt/gitnexus"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.2.11",
|
||||
"source": "./gitnexus-claude-plugin",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"WebFetch(domain:cursor.com)",
|
||||
"WebFetch(domain:composio.dev)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(claude rename:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm link:*)",
|
||||
"Bash(gitnexus --version:*)",
|
||||
"Bash(gitnexus --help:*)",
|
||||
"Bash(npm ls:*)",
|
||||
"Bash(gitnexus augment:*)",
|
||||
"Bash(node -e \"\nconst { augment } = await import\\(''./gitnexus/dist/core/augmentation/engine.js''\\);\ntry {\n const r = await augment\\(''setup'', process.cwd\\(\\)\\);\n console.log\\(''Result:'', r ? r.substring\\(0, 200\\) : ''null''\\);\n} catch\\(e\\) { console.error\\(''Error:'', e.message\\); }\nprocess.exit\\(0\\);\n\")",
|
||||
"Bash(cmd.exe /c \"cd /d D:\\\\Projects\\\\GitnexusV2 && gitnexus augment setup\")",
|
||||
"Bash(cmd.exe /c \"cd /d D:\\\\Projects\\\\GitnexusV2 && gitnexus status\")",
|
||||
"Bash(gh repo clone:*)",
|
||||
"Bash(claude mcp:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm view:*)",
|
||||
"Bash(npm version:*)",
|
||||
"Bash(npm pack:*)",
|
||||
"Bash(npm publish:*)",
|
||||
"Bash(npx gitnexus:*)",
|
||||
"mcp__gitnexus__list_repos",
|
||||
"mcp__gitnexus__query",
|
||||
"mcp__gitnexus__context",
|
||||
"mcp__gitnexus__impact",
|
||||
"Bash(git add:*)",
|
||||
"Bash(Glob)",
|
||||
"Bash(Bash\"\\) per new Claude Code schema\n- Rename gitnexus-hook.js → gitnexus-hook.cjs for CommonJS compatibility\n- Fix setup.ts: correct hook filename and timeout \\(8000ms instead of 10ms\\)\n- Bump to v1.1.9 and publish to npm\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git push:*)",
|
||||
"WebFetch(domain:docs.kuzudb.com)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"WebFetch(domain:read.engineerscodex.com)",
|
||||
"WebFetch(domain:towardsdatascience.com)",
|
||||
"WebFetch(domain:kilo.ai)",
|
||||
"WebFetch(domain:deepwiki.com)",
|
||||
"WebFetch(domain:turbopuffer.com)",
|
||||
"WebFetch(domain:windsurf.com)",
|
||||
"WebFetch(domain:modal.com)",
|
||||
"WebFetch(domain:www.augmentcode.com)",
|
||||
"WebFetch(domain:www.qodo.ai)",
|
||||
"WebFetch(domain:arxiv.org)",
|
||||
"WebFetch(domain:cognition.ai)",
|
||||
"WebFetch(domain:microsoft.github.io)",
|
||||
"WebFetch(domain:github.github.com)",
|
||||
"WebFetch(domain:gist.github.com)",
|
||||
"WebFetch(domain:fsoft-ai4code.github.io)",
|
||||
"mcp__gitnexus__cypher",
|
||||
"WebFetch(domain:repomix.com)",
|
||||
"WebFetch(domain:www.humanlayer.dev)",
|
||||
"WebFetch(domain:agents.md)",
|
||||
"WebFetch(domain:eclipsesource.com)",
|
||||
"WebFetch(domain:www.usefulfunctions.co.uk)",
|
||||
"WebFetch(domain:developers.googleblog.com)",
|
||||
"WebFetch(domain:www.anthropic.com)",
|
||||
"WebFetch(domain:www.driver.ai)",
|
||||
"WebFetch(domain:blog.sshh.io)",
|
||||
"WebFetch(domain:docs.qodo.ai)",
|
||||
"WebFetch(domain:smartlogic.io)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(powershell -Command:*)",
|
||||
"Bash(cmd /c \"dir /s C:\\\\Users\\\\ADMIN\\\\.cache\\\\huggingface 2>nul | findstr /i \"\"File\\(s\\)\"\"\")",
|
||||
"Bash(du:*)",
|
||||
"mcp__desktop-commander__list_directory",
|
||||
"Bash(python3 -c \":*)",
|
||||
"mcp__gitnexus__detect_changes"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"gitnexus"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,85 +1,85 @@
|
|||
---
|
||||
name: gitnexus-debugging
|
||||
description: Trace bugs through call chains using knowledge graph
|
||||
---
|
||||
|
||||
# Debugging with GitNexus
|
||||
|
||||
## When to Use
|
||||
- "Why is this function failing?"
|
||||
- "Trace where this error comes from"
|
||||
- "Who calls this method?"
|
||||
- "This endpoint returns 500"
|
||||
- Investigating bugs, errors, or unexpected behavior
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|---------|-------------------|
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
```
|
||||
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
→ Step 3: validatePayment → calls fetchRates (external)
|
||||
|
||||
4. Root cause: fetchRates calls external API without proper timeout
|
||||
```
|
||||
---
|
||||
name: gitnexus-debugging
|
||||
description: Trace bugs through call chains using knowledge graph
|
||||
---
|
||||
|
||||
# Debugging with GitNexus
|
||||
|
||||
## When to Use
|
||||
- "Why is this function failing?"
|
||||
- "Trace where this error comes from"
|
||||
- "Who calls this method?"
|
||||
- "This endpoint returns 500"
|
||||
- Investigating bugs, errors, or unexpected behavior
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|---------|-------------------|
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
```
|
||||
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
→ Step 3: validatePayment → calls fetchRates (external)
|
||||
|
||||
4. Root cause: fetchRates calls external API without proper timeout
|
||||
```
|
||||
|
|
@ -1,75 +1,75 @@
|
|||
---
|
||||
name: gitnexus-exploring
|
||||
description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
---
|
||||
|
||||
# Exploring Codebases with GitNexus
|
||||
|
||||
## When to Use
|
||||
- "How does authentication work?"
|
||||
- "What's the project structure?"
|
||||
- "Show me the main components"
|
||||
- "Where is the database logic?"
|
||||
- Understanding code you haven't seen before
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | What you get |
|
||||
|----------|-------------|
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
```
|
||||
|
||||
## Example: "How does payment processing work?"
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
```
|
||||
---
|
||||
name: gitnexus-exploring
|
||||
description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
---
|
||||
|
||||
# Exploring Codebases with GitNexus
|
||||
|
||||
## When to Use
|
||||
- "How does authentication work?"
|
||||
- "What's the project structure?"
|
||||
- "Show me the main components"
|
||||
- "Where is the database logic?"
|
||||
- Understanding code you haven't seen before
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | What you get |
|
||||
|----------|-------------|
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
```
|
||||
|
||||
## Example: "How does payment processing work?"
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
```
|
||||
|
|
@ -1,94 +1,94 @@
|
|||
---
|
||||
name: gitnexus-impact-analysis
|
||||
description: Analyze blast radius before making code changes
|
||||
---
|
||||
|
||||
# Impact Analysis with GitNexus
|
||||
|
||||
## When to Use
|
||||
- "Is it safe to change this function?"
|
||||
- "What will break if I modify X?"
|
||||
- "Show me the blast radius"
|
||||
- "Who uses this code?"
|
||||
- Before making non-trivial code changes
|
||||
- Before committing — to understand what your changes affect
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
## Understanding Output
|
||||
|
||||
| Depth | Risk Level | Meaning |
|
||||
|-------|-----------|---------|
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Affected | Risk |
|
||||
|----------|------|
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
```
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
maxDepth: 3
|
||||
})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- loginHandler (src/auth/login.ts:42) [CALLS, 100%]
|
||||
- apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
2. READ gitnexus://repo/my-app/processes
|
||||
→ LoginFlow and TokenRefresh touch validateUser
|
||||
|
||||
3. Risk: 2 direct callers, 2 processes = MEDIUM
|
||||
```
|
||||
---
|
||||
name: gitnexus-impact-analysis
|
||||
description: Analyze blast radius before making code changes
|
||||
---
|
||||
|
||||
# Impact Analysis with GitNexus
|
||||
|
||||
## When to Use
|
||||
- "Is it safe to change this function?"
|
||||
- "What will break if I modify X?"
|
||||
- "Show me the blast radius"
|
||||
- "Who uses this code?"
|
||||
- Before making non-trivial code changes
|
||||
- Before committing — to understand what your changes affect
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
## Understanding Output
|
||||
|
||||
| Depth | Risk Level | Meaning |
|
||||
|-------|-----------|---------|
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Affected | Risk |
|
||||
|----------|------|
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
```
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
maxDepth: 3
|
||||
})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- loginHandler (src/auth/login.ts:42) [CALLS, 100%]
|
||||
- apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
2. READ gitnexus://repo/my-app/processes
|
||||
→ LoginFlow and TokenRefresh touch validateUser
|
||||
|
||||
3. Risk: 2 direct callers, 2 processes = MEDIUM
|
||||
```
|
||||
|
|
@ -1,113 +1,113 @@
|
|||
---
|
||||
name: gitnexus-refactoring
|
||||
description: Plan safe refactors using blast radius and dependency mapping
|
||||
---
|
||||
|
||||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
- "Move this to a new file"
|
||||
- Any task involving renaming, extracting, splitting, or restructuring code
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
```
|
||||
|
||||
## Risk Rules
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
|-------------|------------|
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
---
|
||||
name: gitnexus-refactoring
|
||||
description: Plan safe refactors using blast radius and dependency mapping
|
||||
---
|
||||
|
||||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
- "Move this to a new file"
|
||||
- Any task involving renaming, extracting, splitting, or restructuring code
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
```
|
||||
|
||||
## Risk Rules
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
|-------------|------------|
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -17,6 +17,8 @@ dist/
|
|||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.claude/settings.local.json
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
|
@ -41,6 +43,7 @@ coverage/
|
|||
|
||||
.env*.local
|
||||
.gitnexus
|
||||
.claude/settings.local.json
|
||||
|
||||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
|
|
|||
14
AGENTS.md
14
AGENTS.md
|
|
@ -1,9 +1,7 @@
|
|||
# AI Agent Rules
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus MCP
|
||||
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1309 symbols, 3350 relationships, 101 execution flows).
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1348 symbols, 3469 relationships, 104 execution flows).
|
||||
|
||||
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
|
||||
|
||||
|
|
@ -21,10 +19,10 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
|||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| 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` |
|
||||
| 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 Reference
|
||||
|
||||
|
|
@ -61,4 +59,4 @@ MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
|||
RETURN caller.name, caller.filePath
|
||||
```
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
<!-- gitnexus:end -->
|
||||
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -1,7 +1,7 @@
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus MCP
|
||||
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1309 symbols, 3350 relationships, 101 execution flows).
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1348 symbols, 3469 relationships, 104 execution flows).
|
||||
|
||||
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
|
||||
|
||||
|
|
@ -19,10 +19,10 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
|||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| 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` |
|
||||
| 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 Reference
|
||||
|
||||
|
|
@ -59,4 +59,4 @@ MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
|||
RETURN caller.name, caller.filePath
|
||||
```
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
<!-- gitnexus:end -->
|
||||
|
|
@ -72,6 +72,12 @@ 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):
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -164,7 +165,7 @@ def run_swebench_evaluation(results_dir: Path, run_id: str, subset: str = "lite"
|
|||
try:
|
||||
eval_output = results_dir / run_id / "swebench_eval"
|
||||
cmd = [
|
||||
"python", "-m", "swebench.harness.run_evaluation",
|
||||
sys.executable, "-m", "swebench.harness.run_evaluation",
|
||||
"--dataset_name", dataset_mapping.get(subset, subset),
|
||||
"--predictions_path", str(preds_path),
|
||||
"--max_workers", "4",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
# Claude 3.5 Haiku — fast, cheap, good baseline
|
||||
# Claude Haiku 4.5 — fast, cheap, good baseline
|
||||
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
# To use Anthropic directly, change to: anthropic/claude-3-5-haiku-20241022
|
||||
model:
|
||||
model_name: "openrouter/anthropic/claude-3.5-haiku"
|
||||
model_name: "openrouter/anthropic/claude-haiku-4.5"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
|
|
|
|||
11
eval/configs/models/minimax-m2.1.yaml
Normal file
11
eval/configs/models/minimax-m2.1.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# MiniMax M2.5 — via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
# Uses text-based model class because MiniMax doesn't support tool_calls natively.
|
||||
# The action_regex tells mini-swe-agent to parse ```bash blocks from responses.
|
||||
model:
|
||||
model_class: litellm_textbased
|
||||
model_name: "openrouter/minimax/minimax-m2.5"
|
||||
action_regex: "```(?:bash|mswea_bash_command)\\s*\\n(.*?)\\n```"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
temperature: 0
|
||||
|
|
@ -30,6 +30,10 @@ gitnexus-eval-analyze = "analysis.analyze_results:app"
|
|||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["agents", "environments", "analysis", "bridge"]
|
||||
extra-files = ["run_eval.py"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ def process_instance(
|
|||
env_class_name = env_config.pop("environment_class", "docker")
|
||||
|
||||
if env_class_name == "eval.environments.gitnexus_docker.GitNexusDockerEnvironment":
|
||||
from eval.environments.gitnexus_docker import GitNexusDockerEnvironment
|
||||
from environments.gitnexus_docker import GitNexusDockerEnvironment
|
||||
env_config["image"] = get_swebench_docker_image(instance)
|
||||
env = GitNexusDockerEnvironment(**env_config)
|
||||
else:
|
||||
|
|
@ -189,7 +189,7 @@ def process_instance(
|
|||
agent_config = dict(config.get("agent", {}))
|
||||
agent_class_name = agent_config.pop("agent_class", "eval.agents.gitnexus_agent.GitNexusAgent")
|
||||
|
||||
from eval.agents.gitnexus_agent import GitNexusAgent
|
||||
from agents.gitnexus_agent import GitNexusAgent
|
||||
traj_path = instance_dir / f"{instance_id}.traj.json"
|
||||
agent_config["output_path"] = traj_path
|
||||
agent = GitNexusAgent(model, env, **agent_config)
|
||||
|
|
@ -199,11 +199,18 @@ def process_instance(
|
|||
info = agent.run(instance["problem_statement"])
|
||||
|
||||
result["exit_status"] = info.get("exit_status")
|
||||
result["submission"] = info.get("submission", "")
|
||||
result["cost"] = agent.cost
|
||||
result["n_calls"] = agent.n_calls
|
||||
result["gitnexus_metrics"] = agent.gitnexus_metrics.to_dict()
|
||||
|
||||
# Extract git diff patch from the container (SWE-bench needs the model_patch)
|
||||
try:
|
||||
patch_output = env.execute({"command": "cd /testbed && git diff"})
|
||||
result["submission"] = patch_output.get("output", "").strip()
|
||||
except Exception as patch_err:
|
||||
logger.warning(f"[{run_id}] Failed to extract patch: {patch_err}")
|
||||
result["submission"] = info.get("submission", "")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{run_id}] Error on {instance_id}: {e}")
|
||||
result["exit_status"] = type(e).__name__
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.11",
|
||||
"author": {
|
||||
"name": "GitNexus"
|
||||
},
|
||||
"homepage": "https://github.com/nicosxt/gitnexus",
|
||||
"repository": "https://github.com/nicosxt/gitnexus"
|
||||
"repository": "https://github.com/nicosxt/gitnexus",
|
||||
"keywords": ["code-intelligence", "knowledge-graph", "mcp", "static-analysis"]
|
||||
}
|
||||
|
|
|
|||
8
gitnexus-claude-plugin/.mcp.json
Normal file
8
gitnexus-claude-plugin/.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* GitNexus Claude Code Hook
|
||||
* GitNexus Claude Code Plugin Hook
|
||||
*
|
||||
* PreToolUse handler — intercepts Grep/Glob/Bash searches
|
||||
* and augments with graph context from the GitNexus index.
|
||||
*
|
||||
* NOTE: SessionStart hooks are broken on Windows (Claude Code bug).
|
||||
* NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
|
||||
* Session context is injected via CLAUDE.md / skills instead.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
|
|
@ -101,11 +101,33 @@ function main() {
|
|||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const result = execFileSync(
|
||||
'gitnexus',
|
||||
['augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
// augment CLI writes result to stderr (KuzuDB's native module captures
|
||||
// stdout fd at OS level, making it unusable in subprocess contexts).
|
||||
let result = '';
|
||||
|
||||
// Try direct gitnexus binary first (faster if globally installed)
|
||||
try {
|
||||
const child = spawnSync(
|
||||
'gitnexus',
|
||||
['augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
if (child.status === 0 || (child.stderr && child.stderr.trim())) {
|
||||
result = child.stderr || '';
|
||||
}
|
||||
} catch { /* not on PATH */ }
|
||||
|
||||
// Fallback to npx if direct binary didn't produce output
|
||||
if (!result || !result.trim()) {
|
||||
try {
|
||||
const child = spawnSync(
|
||||
'npx',
|
||||
['-y', 'gitnexus', 'augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
result = child.stderr || '';
|
||||
} catch { /* graceful failure */ }
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
console.log(JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
#!/bin/bash
|
||||
# GitNexus PreToolUse hook for Claude Code
|
||||
# Intercepts Grep/Glob/Bash searches and augments with graph context.
|
||||
# Receives JSON on stdin with { tool_name, tool_input, cwd, ... }
|
||||
# Returns JSON with additionalContext for graph-enriched results.
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
||||
CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
|
||||
|
||||
# Extract search pattern based on tool type
|
||||
PATTERN=""
|
||||
|
||||
case "$TOOL_NAME" in
|
||||
Grep)
|
||||
PATTERN=$(echo "$INPUT" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
|
||||
;;
|
||||
Glob)
|
||||
# Glob patterns are file paths, not search terms — extract meaningful part
|
||||
RAW=$(echo "$INPUT" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
|
||||
# Strip glob syntax to get the meaningful name (e.g., "**/*.ts" → skip, "auth*.ts" → "auth")
|
||||
PATTERN=$(echo "$RAW" | sed -n 's/.*[*\/]\([a-zA-Z][a-zA-Z0-9_-]*\).*/\1/p')
|
||||
;;
|
||||
Bash)
|
||||
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
||||
# Only augment grep/rg commands
|
||||
if echo "$CMD" | grep -qE '\brg\b|\bgrep\b'; then
|
||||
# Extract pattern from rg/grep
|
||||
if echo "$CMD" | grep -qE '\brg\b'; then
|
||||
PATTERN=$(echo "$CMD" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
elif echo "$CMD" | grep -qE '\bgrep\b'; then
|
||||
PATTERN=$(echo "$CMD" | sed -n "s/.*\bgrep\s\+\(-[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Not a search tool — skip
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Skip if pattern too short or empty
|
||||
if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if we're in a GitNexus-indexed repo
|
||||
dir="${CWD:-$PWD}"
|
||||
found=false
|
||||
for i in 1 2 3 4 5; do
|
||||
if [ -d "$dir/.gitnexus" ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
parent="$(dirname "$dir")"
|
||||
[ "$parent" = "$dir" ] && break
|
||||
dir="$parent"
|
||||
done
|
||||
|
||||
if [ "$found" = false ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run gitnexus augment — must be fast (<500ms target)
|
||||
RESULT=$(cd "$CWD" && npx -y gitnexus augment "$PATTERN" 2>/dev/null)
|
||||
|
||||
if [ -n "$RESULT" ]; then
|
||||
ESCAPED=$(echo "$RESULT" | jq -Rs .)
|
||||
jq -n --argjson ctx "$ESCAPED" '{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
additionalContext: $ctx
|
||||
}
|
||||
}'
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// GitNexus SessionStart hook for Claude Code
|
||||
// Fires on session startup. Stdout is injected into Claude's context.
|
||||
// Checks if the current directory has a GitNexus index.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let dir = process.cwd();
|
||||
let found = false;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (fs.existsSync(path.join(dir, '.gitnexus'))) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write(`## GitNexus Code Intelligence
|
||||
|
||||
This codebase is indexed by GitNexus, providing a knowledge graph with execution flows, relationships, and semantic search.
|
||||
|
||||
**Available MCP Tools:**
|
||||
- \`query\` — Process-grouped code intelligence (execution flows related to a concept)
|
||||
- \`context\` — 360-degree symbol view (categorized refs, process participation)
|
||||
- \`impact\` — Blast radius analysis (what breaks if you change a symbol)
|
||||
- \`detect_changes\` — Git-diff impact analysis (what do your changes affect)
|
||||
- \`rename\` — Multi-file coordinated rename with confidence tags
|
||||
- \`cypher\` — Raw graph queries
|
||||
- \`list_repos\` — Discover indexed repos
|
||||
|
||||
**Quick Start:** READ \`gitnexus://repo/{name}/context\` for codebase overview, then use \`query\` to find execution flows.
|
||||
|
||||
**Resources:** \`gitnexus://repo/{name}/context\` (overview), \`/processes\` (execution flows), \`/schema\` (for Cypher)
|
||||
`);
|
||||
process.exit(0);
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
#!/bin/bash
|
||||
# GitNexus SessionStart hook for Claude Code
|
||||
# Fires on session startup. Stdout is injected into Claude's context.
|
||||
# Checks if the current directory has a GitNexus index.
|
||||
|
||||
dir="$PWD"
|
||||
found=false
|
||||
for i in 1 2 3 4 5; do
|
||||
if [ -d "$dir/.gitnexus" ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
parent="$(dirname "$dir")"
|
||||
[ "$parent" = "$dir" ] && break
|
||||
dir="$parent"
|
||||
done
|
||||
|
||||
if [ "$found" = false ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Inject GitNexus context — this stdout goes directly into Claude's context
|
||||
cat << 'EOF'
|
||||
## GitNexus Code Intelligence
|
||||
|
||||
This codebase is indexed by GitNexus, providing a knowledge graph with execution flows, relationships, and semantic search.
|
||||
|
||||
**Available MCP Tools:**
|
||||
- `query` — Process-grouped code intelligence (execution flows related to a concept)
|
||||
- `context` — 360-degree symbol view (categorized refs, process participation)
|
||||
- `impact` — Blast radius analysis (what breaks if you change a symbol)
|
||||
- `detect_changes` — Git-diff impact analysis (what do your changes affect)
|
||||
- `rename` — Multi-file coordinated rename with confidence tags
|
||||
- `cypher` — Raw graph queries
|
||||
- `list_repos` — Discover indexed repos
|
||||
|
||||
**Quick Start:** READ `gitnexus://repo/{name}/context` for codebase overview, then use `query` to find execution flows.
|
||||
|
||||
**Resources:** `gitnexus://repo/{name}/context` (overview), `/processes` (execution flows), `/schema` (for Cypher)
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
82
gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md
Normal file
82
gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
name: gitnexus-cli
|
||||
description: GitNexus CLI commands — index, status, clean, and wiki generation
|
||||
---
|
||||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
All commands work via `npx` — no global install required.
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
npx gitnexus status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
|
||||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
npx gitnexus clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--force` | Skip confirmation prompt |
|
||||
| `--all` | Clean all indexed repos, not just the current one |
|
||||
|
||||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
npx gitnexus wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--force` | Force full regeneration |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
npx gitnexus list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
||||
## After Indexing
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
|
||||
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||
8
gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json
Normal file
8
gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ description: Trace bugs through call chains using knowledge graph
|
|||
# Debugging with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Why is this function failing?"
|
||||
- "Trace where this error comes from"
|
||||
- "Who calls this method?"
|
||||
|
|
@ -37,17 +38,18 @@ description: Trace bugs through call chains using knowledge graph
|
|||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|---------|-------------------|
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
|
|
@ -55,6 +57,7 @@ gitnexus_query({query: "payment validation error"})
|
|||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
|
|
@ -63,6 +66,7 @@ gitnexus_context({name: "validatePayment"})
|
|||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
|||
# Exploring Codebases with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "How does authentication work?"
|
||||
- "What's the project structure?"
|
||||
- "Show me the main components"
|
||||
|
|
@ -37,16 +38,17 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
|||
|
||||
## Resources
|
||||
|
||||
| Resource | What you get |
|
||||
|----------|-------------|
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
| Resource | What you get |
|
||||
| --------------------------------------- | ------------------------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
|
|
@ -54,6 +56,7 @@ gitnexus_query({query: "payment processing"})
|
|||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
64
gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md
Normal file
64
gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
name: gitnexus-guide
|
||||
description: GitNexus quickstart — tools, resources, schema, and workflow reference
|
||||
---
|
||||
|
||||
# GitNexus Guide
|
||||
|
||||
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
|
||||
|
||||
## Always Start Here
|
||||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
||||
|
||||
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**
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
| Task | Skill to read |
|
||||
| -------------------------------------------- | ------------------- |
|
||||
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
|
||||
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
|
||||
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
|
||||
| Rename / extract / split / refactor | `gitnexus-refactoring` |
|
||||
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
|
||||
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
|
||||
|
||||
## 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
|
||||
```
|
||||
8
gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json
Normal file
8
gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ description: Analyze blast radius before making code changes
|
|||
# Impact Analysis with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Is it safe to change this function?"
|
||||
- "What will break if I modify X?"
|
||||
- "Show me the blast radius"
|
||||
|
|
@ -37,24 +38,25 @@ description: Analyze blast radius before making code changes
|
|||
|
||||
## Understanding Output
|
||||
|
||||
| Depth | Risk Level | Meaning |
|
||||
|-------|-----------|---------|
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
| Depth | Risk Level | Meaning |
|
||||
| ----- | ---------------- | ------------------------ |
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Affected | Risk |
|
||||
|----------|------|
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Affected | Risk |
|
||||
| ------------------------------ | -------- |
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
|
|
@ -72,6 +74,7 @@ gitnexus_impact({
|
|||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
|
|
@ -26,6 +27,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
|
|
@ -35,6 +37,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
|
|
@ -45,6 +48,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
|
|
@ -58,6 +62,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
|
|
@ -66,6 +71,7 @@ gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_
|
|||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
|
|
@ -73,6 +79,7 @@ gitnexus_impact({target: "validateUser", direction: "upstream"})
|
|||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
|
|
@ -81,6 +88,7 @@ gitnexus_detect_changes({scope: "all"})
|
|||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
|
@ -88,12 +96,12 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
|||
|
||||
## Risk Rules
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
|-------------|------------|
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -257,11 +257,6 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
|||
return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' };
|
||||
}
|
||||
|
||||
// Generic PHP MVC: files ending with Controller.php
|
||||
if (p.endsWith('controller.php')) {
|
||||
return { framework: 'php-mvc', entryPointMultiplier: 2.5, reason: 'php-controller-file' };
|
||||
}
|
||||
|
||||
// ========== GENERIC PATTERNS ==========
|
||||
|
||||
// Any language: index files in API folders
|
||||
|
|
@ -302,7 +297,7 @@ export const FRAMEWORK_AST_PATTERNS = {
|
|||
|
||||
// Go patterns (function signatures)
|
||||
'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP'],
|
||||
|
||||
|
||||
// PHP/Laravel
|
||||
'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete',
|
||||
'Route::resource', 'Route::apiResource', '#[Route('],
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ 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) |
|
||||
|
||||
## MCP Setup (manual)
|
||||
|
||||
If you prefer to configure manually instead of using `gitnexus setup`:
|
||||
|
|
|
|||
6
gitnexus/package-lock.json
generated
6
gitnexus/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.2.8",
|
||||
"version": "1.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.2.8",
|
||||
"version": "1.3.3",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"tree-sitter-go": "^0.21.0",
|
||||
"tree-sitter-java": "^0.21.0",
|
||||
"tree-sitter-javascript": "^0.21.0",
|
||||
"tree-sitter-php": "^0.23.0",
|
||||
"tree-sitter-php": "^0.23.12",
|
||||
"tree-sitter-python": "^0.21.0",
|
||||
"tree-sitter-rust": "^0.21.0",
|
||||
"tree-sitter-typescript": "^0.21.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.2.8",
|
||||
"version": "1.3.3",
|
||||
"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",
|
||||
|
|
@ -62,9 +62,9 @@
|
|||
"tree-sitter-go": "^0.21.0",
|
||||
"tree-sitter-java": "^0.21.0",
|
||||
"tree-sitter-javascript": "^0.21.0",
|
||||
"tree-sitter-php": "^0.23.12",
|
||||
"tree-sitter-python": "^0.21.0",
|
||||
"tree-sitter-rust": "^0.21.0",
|
||||
"tree-sitter-php": "^0.23.0",
|
||||
"tree-sitter-typescript": "^0.21.0",
|
||||
"typescript": "^5.4.5",
|
||||
"uuid": "^13.0.0"
|
||||
|
|
|
|||
82
gitnexus/skills/gitnexus-cli.md
Normal file
82
gitnexus/skills/gitnexus-cli.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
name: gitnexus-cli
|
||||
description: GitNexus CLI commands — index, status, clean, and wiki generation
|
||||
---
|
||||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
All commands work via `npx` — no global install required.
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
||||
| Flag | Effect |
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
npx gitnexus status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
|
||||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
npx gitnexus clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
||||
| Flag | Effect |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| `--force` | Skip confirmation prompt |
|
||||
| `--all` | Clean all indexed repos, not just the current one |
|
||||
|
||||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
npx gitnexus wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
|
||||
| Flag | Effect |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `--force` | Force full regeneration |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
npx gitnexus list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
||||
## After Indexing
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
|
||||
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||
|
|
@ -6,6 +6,7 @@ description: Trace bugs through call chains using knowledge graph
|
|||
# Debugging with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Why is this function failing?"
|
||||
- "Trace where this error comes from"
|
||||
- "Who calls this method?"
|
||||
|
|
@ -37,17 +38,18 @@ description: Trace bugs through call chains using knowledge graph
|
|||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|---------|-------------------|
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
|
|
@ -55,6 +57,7 @@ gitnexus_query({query: "payment validation error"})
|
|||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
|
|
@ -63,6 +66,7 @@ gitnexus_context({name: "validatePayment"})
|
|||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
|
|
@ -6,6 +6,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
|||
# Exploring Codebases with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "How does authentication work?"
|
||||
- "What's the project structure?"
|
||||
- "Show me the main components"
|
||||
|
|
@ -37,16 +38,17 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
|||
|
||||
## Resources
|
||||
|
||||
| Resource | What you get |
|
||||
|----------|-------------|
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
| Resource | What you get |
|
||||
| --------------------------------------- | ------------------------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
|
|
@ -54,6 +56,7 @@ gitnexus_query({query: "payment processing"})
|
|||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
64
gitnexus/skills/gitnexus-guide.md
Normal file
64
gitnexus/skills/gitnexus-guide.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
name: gitnexus-guide
|
||||
description: GitNexus quickstart — tools, resources, schema, and workflow reference
|
||||
---
|
||||
|
||||
# GitNexus Guide
|
||||
|
||||
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
|
||||
|
||||
## Always Start Here
|
||||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
||||
|
||||
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**
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
| Task | Skill to read |
|
||||
| -------------------------------------------- | ------------------- |
|
||||
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
|
||||
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
|
||||
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
|
||||
| Rename / extract / split / refactor | `gitnexus-refactoring` |
|
||||
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
|
||||
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
|
||||
|
||||
## 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
|
||||
```
|
||||
|
|
@ -6,6 +6,7 @@ description: Analyze blast radius before making code changes
|
|||
# Impact Analysis with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Is it safe to change this function?"
|
||||
- "What will break if I modify X?"
|
||||
- "Show me the blast radius"
|
||||
|
|
@ -37,24 +38,25 @@ description: Analyze blast radius before making code changes
|
|||
|
||||
## Understanding Output
|
||||
|
||||
| Depth | Risk Level | Meaning |
|
||||
|-------|-----------|---------|
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
| Depth | Risk Level | Meaning |
|
||||
| ----- | ---------------- | ------------------------ |
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Affected | Risk |
|
||||
|----------|------|
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Affected | Risk |
|
||||
| ------------------------------ | -------- |
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
|
|
@ -72,6 +74,7 @@ gitnexus_impact({
|
|||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
|
|
@ -6,6 +6,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
|
|
@ -26,6 +27,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
|
|
@ -35,6 +37,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
|
|
@ -45,6 +48,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
|
|
@ -58,6 +62,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
|
|
@ -66,6 +71,7 @@ gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_
|
|||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
|
|
@ -73,6 +79,7 @@ gitnexus_impact({target: "validateUser", direction: "upstream"})
|
|||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
|
|
@ -81,6 +88,7 @@ gitnexus_detect_changes({scope: "all"})
|
|||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
|
@ -88,12 +96,12 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
|||
|
||||
## Risk Rules
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
|-------------|------------|
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
|
|
@ -42,12 +42,8 @@ function generateGitNexusContent(projectName: string, stats: RepoStats): string
|
|||
|
||||
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} 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**
|
||||
|
|
@ -58,45 +54,12 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
|||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| 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
|
||||
\`\`\`
|
||||
| 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\` |
|
||||
|
||||
${GITNEXUS_END_MARKER}`;
|
||||
}
|
||||
|
|
@ -163,21 +126,29 @@ async function installSkills(repoPath: string): Promise<string[]> {
|
|||
// Skill definitions bundled with the package
|
||||
const skills = [
|
||||
{
|
||||
name: 'exploring',
|
||||
name: 'gitnexus-exploring',
|
||||
description: 'Navigate unfamiliar code using GitNexus knowledge graph',
|
||||
},
|
||||
{
|
||||
name: 'debugging',
|
||||
name: 'gitnexus-debugging',
|
||||
description: 'Trace bugs through call chains using knowledge graph',
|
||||
},
|
||||
{
|
||||
name: 'impact-analysis',
|
||||
name: 'gitnexus-impact-analysis',
|
||||
description: 'Analyze blast radius before making code changes',
|
||||
},
|
||||
{
|
||||
name: 'refactoring',
|
||||
name: 'gitnexus-refactoring',
|
||||
description: 'Plan safe refactors using blast radius and dependency mapping',
|
||||
},
|
||||
{
|
||||
name: 'gitnexus-guide',
|
||||
description: 'GitNexus quickstart — tools, resources, schema, and workflow reference',
|
||||
},
|
||||
{
|
||||
name: 'gitnexus-cli',
|
||||
description: 'GitNexus CLI commands — index, status, clean, and wiki generation',
|
||||
},
|
||||
];
|
||||
|
||||
for (const skill of skills) {
|
||||
|
|
@ -197,7 +168,7 @@ async function installSkills(repoPath: string): Promise<string[]> {
|
|||
} catch {
|
||||
// Fallback: generate minimal skill content
|
||||
skillContent = `---
|
||||
name: gitnexus-${skill.name}
|
||||
name: ${skill.name}
|
||||
description: ${skill.description}
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import v8 from 'v8';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { runPipelineFromRepo } from '../core/ingestion/pipeline.js';
|
||||
import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement, closeKuzu, createFTSIndex, loadCachedEmbeddings } from '../core/kuzu/kuzu-adapter.js';
|
||||
|
|
@ -16,6 +18,28 @@ import { generateAIContextFiles } from './ai-context.js';
|
|||
import fs from 'fs/promises';
|
||||
import { registerClaudeHook } from './claude-hooks.js';
|
||||
|
||||
const HEAP_MB = 8192;
|
||||
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
|
||||
|
||||
/** Re-exec the process with an 8GB heap if we're currently below that. */
|
||||
function ensureHeap(): boolean {
|
||||
const nodeOpts = process.env.NODE_OPTIONS || '';
|
||||
if (nodeOpts.includes('--max-old-space-size')) return false;
|
||||
|
||||
const v8Heap = v8.getHeapStatistics().heap_size_limit;
|
||||
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
|
||||
|
||||
try {
|
||||
execFileSync(process.execPath, [HEAP_FLAG, ...process.argv.slice(1)], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim() },
|
||||
});
|
||||
} catch (e: any) {
|
||||
process.exitCode = e.status ?? 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface AnalyzeOptions {
|
||||
force?: boolean;
|
||||
embeddings?: boolean;
|
||||
|
|
@ -44,6 +68,8 @@ export const analyzeCommand = async (
|
|||
inputPath?: string,
|
||||
options?: AnalyzeOptions
|
||||
) => {
|
||||
if (ensureHeap()) return;
|
||||
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
let repoPath: string;
|
||||
|
|
@ -88,19 +114,47 @@ export const analyzeCommand = async (
|
|||
|
||||
bar.start(100, 0, { phase: 'Initializing...' });
|
||||
|
||||
// Graceful SIGINT handling — clean up resources and exit
|
||||
let aborted = false;
|
||||
const sigintHandler = () => {
|
||||
if (aborted) process.exit(1); // Second Ctrl-C: force exit
|
||||
aborted = true;
|
||||
bar.stop();
|
||||
console.log('\n Interrupted — cleaning up...');
|
||||
closeKuzu().catch(() => {}).finally(() => process.exit(130));
|
||||
};
|
||||
process.on('SIGINT', sigintHandler);
|
||||
|
||||
// Route all console output through bar.log() so the bar doesn't stamp itself
|
||||
// multiple times when other code writes to stdout/stderr mid-render.
|
||||
const origLog = console.log.bind(console);
|
||||
const origWarn = console.warn.bind(console);
|
||||
const origError = console.error.bind(console);
|
||||
const barLog = (...args: any[]) => origLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
|
||||
const barLog = (...args: any[]) => {
|
||||
// Clear the bar line, print the message, then let the next bar.update redraw
|
||||
process.stdout.write('\x1b[2K\r');
|
||||
origLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
|
||||
};
|
||||
console.log = barLog;
|
||||
console.warn = barLog;
|
||||
console.error = barLog;
|
||||
|
||||
// Show elapsed seconds for phases that run longer than 3s
|
||||
// Track elapsed time per phase — both updateBar and the interval use the
|
||||
// same format so they don't flicker against each other.
|
||||
let lastPhaseLabel = 'Initializing...';
|
||||
let phaseStart = Date.now();
|
||||
|
||||
/** Update bar with phase label + elapsed seconds (shown after 3s). */
|
||||
const updateBar = (value: number, phaseLabel: string) => {
|
||||
if (phaseLabel !== lastPhaseLabel) { lastPhaseLabel = phaseLabel; phaseStart = Date.now(); }
|
||||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||||
const display = elapsed >= 3 ? `${phaseLabel} (${elapsed}s)` : phaseLabel;
|
||||
bar.update(value, { phase: display });
|
||||
};
|
||||
|
||||
// Tick elapsed seconds for phases with infrequent progress callbacks
|
||||
// (e.g. CSV streaming, FTS indexing). Uses the same display format as
|
||||
// updateBar so there's no flickering.
|
||||
const elapsedTimer = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||||
if (elapsed >= 3) {
|
||||
|
|
@ -116,7 +170,7 @@ export const analyzeCommand = async (
|
|||
|
||||
if (options?.embeddings && existingMeta && !options?.force) {
|
||||
try {
|
||||
bar.update(0, { phase: 'Caching embeddings...' });
|
||||
updateBar(0, 'Caching embeddings...');
|
||||
await initKuzu(kuzuPath);
|
||||
const cached = await loadCachedEmbeddings();
|
||||
cachedEmbeddingNodeIds = cached.embeddingNodeIds;
|
||||
|
|
@ -131,13 +185,11 @@ export const analyzeCommand = async (
|
|||
const pipelineResult = await runPipelineFromRepo(repoPath, (progress) => {
|
||||
const phaseLabel = PHASE_LABELS[progress.phase] || progress.phase;
|
||||
const scaled = Math.round(progress.percent * 0.6);
|
||||
if (phaseLabel !== lastPhaseLabel) { lastPhaseLabel = phaseLabel; phaseStart = Date.now(); }
|
||||
bar.update(scaled, { phase: phaseLabel });
|
||||
updateBar(scaled, phaseLabel);
|
||||
});
|
||||
|
||||
// ── Phase 2: KuzuDB (60–85%) ──────────────────────────────────────
|
||||
lastPhaseLabel = 'Loading into KuzuDB...'; phaseStart = Date.now();
|
||||
bar.update(60, { phase: lastPhaseLabel });
|
||||
updateBar(60, 'Loading into KuzuDB...');
|
||||
|
||||
await closeKuzu();
|
||||
const kuzuFiles = [kuzuPath, `${kuzuPath}.wal`, `${kuzuPath}.lock`];
|
||||
|
|
@ -148,17 +200,16 @@ export const analyzeCommand = async (
|
|||
const t0Kuzu = Date.now();
|
||||
await initKuzu(kuzuPath);
|
||||
let kuzuMsgCount = 0;
|
||||
const kuzuResult = await loadGraphToKuzu(pipelineResult.graph, pipelineResult.fileContents, storagePath, (msg) => {
|
||||
const kuzuResult = await loadGraphToKuzu(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
|
||||
kuzuMsgCount++;
|
||||
const progress = Math.min(84, 60 + Math.round((kuzuMsgCount / (kuzuMsgCount + 10)) * 24));
|
||||
bar.update(progress, { phase: msg });
|
||||
updateBar(progress, msg);
|
||||
});
|
||||
const kuzuTime = ((Date.now() - t0Kuzu) / 1000).toFixed(1);
|
||||
const kuzuWarnings = kuzuResult.warnings;
|
||||
|
||||
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
|
||||
lastPhaseLabel = 'Creating search indexes...'; phaseStart = Date.now();
|
||||
bar.update(85, { phase: lastPhaseLabel });
|
||||
updateBar(85, 'Creating search indexes...');
|
||||
|
||||
const t0Fts = Date.now();
|
||||
try {
|
||||
|
|
@ -174,7 +225,7 @@ export const analyzeCommand = async (
|
|||
|
||||
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
|
||||
if (cachedEmbeddings.length > 0) {
|
||||
bar.update(88, { phase: `Restoring ${cachedEmbeddings.length} cached embeddings...` });
|
||||
updateBar(88, `Restoring ${cachedEmbeddings.length} cached embeddings...`);
|
||||
const EMBED_BATCH = 200;
|
||||
for (let i = 0; i < cachedEmbeddings.length; i += EMBED_BATCH) {
|
||||
const batch = cachedEmbeddings.slice(i, i + EMBED_BATCH);
|
||||
|
|
@ -203,8 +254,7 @@ export const analyzeCommand = async (
|
|||
}
|
||||
|
||||
if (!embeddingSkipped) {
|
||||
lastPhaseLabel = 'Loading embedding model...'; phaseStart = Date.now();
|
||||
bar.update(90, { phase: lastPhaseLabel });
|
||||
updateBar(90, 'Loading embedding model...');
|
||||
const t0Emb = Date.now();
|
||||
await runEmbeddingPipeline(
|
||||
executeQuery,
|
||||
|
|
@ -212,8 +262,7 @@ export const analyzeCommand = async (
|
|||
(progress) => {
|
||||
const scaled = 90 + Math.round((progress.percent / 100) * 8);
|
||||
const label = progress.phase === 'loading-model' ? 'Loading embedding model...' : `Embedding ${progress.nodesProcessed || 0}/${progress.totalNodes || '?'}`;
|
||||
if (label !== lastPhaseLabel) { lastPhaseLabel = label; phaseStart = Date.now(); }
|
||||
bar.update(scaled, { phase: label });
|
||||
updateBar(scaled, label);
|
||||
},
|
||||
{},
|
||||
cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined,
|
||||
|
|
@ -222,14 +271,14 @@ export const analyzeCommand = async (
|
|||
}
|
||||
|
||||
// ── Phase 5: Finalize (98–100%) ───────────────────────────────────
|
||||
bar.update(98, { phase: 'Saving metadata...' });
|
||||
updateBar(98, 'Saving metadata...');
|
||||
|
||||
const meta = {
|
||||
repoPath,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
stats: {
|
||||
files: pipelineResult.fileContents.size,
|
||||
files: pipelineResult.totalFileCount,
|
||||
nodes: stats.nodes,
|
||||
edges: stats.edges,
|
||||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||||
|
|
@ -254,7 +303,7 @@ export const analyzeCommand = async (
|
|||
}
|
||||
|
||||
const aiContext = await generateAIContextFiles(repoPath, storagePath, projectName, {
|
||||
files: pipelineResult.fileContents.size,
|
||||
files: pipelineResult.totalFileCount,
|
||||
nodes: stats.nodes,
|
||||
edges: stats.edges,
|
||||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||||
|
|
@ -270,6 +319,8 @@ export const analyzeCommand = async (
|
|||
const totalTime = ((Date.now() - t0Global) / 1000).toFixed(1);
|
||||
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
|
|
@ -292,12 +343,13 @@ export const analyzeCommand = async (
|
|||
console.log(` Hooks: ${hookResult.message}`);
|
||||
}
|
||||
|
||||
// Show warnings (missing schema pairs, etc.) after the clean output
|
||||
// Show a quiet summary if some edge types needed fallback insertion
|
||||
if (kuzuWarnings.length > 0) {
|
||||
console.log(`\n Warnings (${kuzuWarnings.length}):`);
|
||||
for (const w of kuzuWarnings) {
|
||||
console.log(` ${w}`);
|
||||
}
|
||||
const totalFallback = kuzuWarnings.reduce((sum, w) => {
|
||||
const m = w.match(/\((\d+) edges\)/);
|
||||
return sum + (m ? parseInt(m[1]) : 0);
|
||||
}, 0);
|
||||
console.log(` Note: ${totalFallback} edges across ${kuzuWarnings.length} types inserted via fallback (schema will be updated in next release)`);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,25 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Raise Node heap limit for large repos (e.g. Linux kernel).
|
||||
// Must run before any heavy allocation. If already set by the user, respect it.
|
||||
if (!process.env.NODE_OPTIONS?.includes('--max-old-space-size')) {
|
||||
const execArgv = process.execArgv.join(' ');
|
||||
if (!execArgv.includes('--max-old-space-size')) {
|
||||
// Re-spawn with a larger heap (8 GB)
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
try {
|
||||
execFileSync(process.execPath, ['--max-old-space-size=8192', ...process.argv.slice(1)], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim() },
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (e: any) {
|
||||
// If the child exited with an error code, propagate it
|
||||
process.exit(e.status ?? 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { analyzeCommand } from './analyze.js';
|
||||
import { serveCommand } from './serve.js';
|
||||
|
|
|
|||
|
|
@ -22,9 +22,16 @@ interface SetupResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* The MCP server entry for all editors
|
||||
* The MCP server entry for all editors.
|
||||
* On Windows, npx must be invoked via cmd /c since it's a .cmd script.
|
||||
*/
|
||||
function getMcpEntry() {
|
||||
if (process.platform === 'win32') {
|
||||
return {
|
||||
command: 'cmd',
|
||||
args: ['/c', 'npx', '-y', 'gitnexus@latest', 'mcp'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'npx',
|
||||
args: ['-y', 'gitnexus@latest', 'mcp'],
|
||||
|
|
@ -217,7 +224,7 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
|
|||
|
||||
// ─── Skill Installation ───────────────────────────────────────────
|
||||
|
||||
const SKILL_NAMES = ['exploring', 'debugging', 'impact-analysis', 'refactoring'];
|
||||
const SKILL_NAMES = ['gitnexus-exploring', 'gitnexus-debugging', 'gitnexus-impact-analysis', 'gitnexus-refactoring', 'gitnexus-guide', 'gitnexus-cli'];
|
||||
|
||||
/**
|
||||
* Install GitNexus skills to a target directory.
|
||||
|
|
@ -233,7 +240,7 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
|
|||
const skillsRoot = path.join(__dirname, '..', '..', 'skills');
|
||||
|
||||
for (const skillName of SKILL_NAMES) {
|
||||
const skillDir = path.join(targetDir, `gitnexus-${skillName}`);
|
||||
const skillDir = path.join(targetDir, skillName);
|
||||
|
||||
try {
|
||||
// Try directory-based skill first (skills/{name}/SKILL.md)
|
||||
|
|
|
|||
|
|
@ -51,11 +51,17 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
|
|||
get nodes(){
|
||||
return Array.from(nodeMap.values())
|
||||
},
|
||||
|
||||
|
||||
get relationships(){
|
||||
return Array.from(relationshipMap.values())
|
||||
},
|
||||
|
||||
iterNodes: () => nodeMap.values(),
|
||||
iterRelationships: () => relationshipMap.values(),
|
||||
forEachNode(fn: (node: GraphNode) => void) { nodeMap.forEach(fn); },
|
||||
forEachRelationship(fn: (rel: GraphRelationship) => void) { relationshipMap.forEach(fn); },
|
||||
getNode: (id: string) => nodeMap.get(id),
|
||||
|
||||
// O(1) count getters - avoid creating arrays just for length
|
||||
get nodeCount() {
|
||||
return nodeMap.size;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,24 @@ export type NodeLabel =
|
|||
| 'Type'
|
||||
| 'CodeElement'
|
||||
| 'Community'
|
||||
| 'Process';
|
||||
| 'Process'
|
||||
// Multi-language node types
|
||||
| 'Struct'
|
||||
| 'Macro'
|
||||
| 'Typedef'
|
||||
| 'Union'
|
||||
| 'Namespace'
|
||||
| 'Trait'
|
||||
| 'Impl'
|
||||
| 'TypeAlias'
|
||||
| 'Const'
|
||||
| 'Static'
|
||||
| 'Property'
|
||||
| 'Record'
|
||||
| 'Delegate'
|
||||
| 'Annotation'
|
||||
| 'Constructor'
|
||||
| 'Template';
|
||||
|
||||
|
||||
export type NodeProperties = {
|
||||
|
|
@ -77,8 +94,19 @@ export interface GraphRelationship {
|
|||
}
|
||||
|
||||
export interface KnowledgeGraph {
|
||||
/** Returns a full array copy — prefer iterNodes() for iteration */
|
||||
nodes: GraphNode[],
|
||||
/** Returns a full array copy — prefer iterRelationships() for iteration */
|
||||
relationships: GraphRelationship[],
|
||||
/** Zero-copy iterator over nodes */
|
||||
iterNodes: () => IterableIterator<GraphNode>,
|
||||
/** Zero-copy iterator over relationships */
|
||||
iterRelationships: () => IterableIterator<GraphRelationship>,
|
||||
/** Zero-copy forEach — avoids iterator protocol overhead in hot loops */
|
||||
forEachNode: (fn: (node: GraphNode) => void) => void,
|
||||
forEachRelationship: (fn: (rel: GraphRelationship) => void) => void,
|
||||
/** Lookup a single node by id — O(1) */
|
||||
getNode: (id: string) => GraphNode | undefined,
|
||||
nodeCount: number,
|
||||
relationshipCount: number,
|
||||
addNode: (node: GraphNode) => void,
|
||||
|
|
|
|||
|
|
@ -286,39 +286,59 @@ const resolveCallTarget = (
|
|||
* Filter out common built-in functions and noise
|
||||
* that shouldn't be tracked as calls
|
||||
*/
|
||||
const isBuiltInOrNoise = (name: string): boolean => {
|
||||
const builtIns = new Set([
|
||||
// JavaScript/TypeScript built-ins
|
||||
'console', 'log', 'warn', 'error', 'info', 'debug',
|
||||
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
|
||||
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
|
||||
'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent',
|
||||
'JSON', 'parse', 'stringify',
|
||||
'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt',
|
||||
'Map', 'Set', 'WeakMap', 'WeakSet',
|
||||
'Promise', 'resolve', 'reject', 'then', 'catch', 'finally',
|
||||
'Math', 'Date', 'RegExp', 'Error',
|
||||
'require', 'import', 'export',
|
||||
'fetch', 'Response', 'Request',
|
||||
// React hooks and common functions
|
||||
'useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext',
|
||||
'useReducer', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue',
|
||||
'createElement', 'createContext', 'createRef', 'forwardRef', 'memo', 'lazy',
|
||||
// Common array/object methods
|
||||
'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex', 'some', 'every',
|
||||
'includes', 'indexOf', 'slice', 'splice', 'concat', 'join', 'split',
|
||||
'push', 'pop', 'shift', 'unshift', 'sort', 'reverse',
|
||||
'keys', 'values', 'entries', 'assign', 'freeze', 'seal',
|
||||
'hasOwnProperty', 'toString', 'valueOf',
|
||||
// Python built-ins
|
||||
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
|
||||
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
|
||||
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
|
||||
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
|
||||
]);
|
||||
/** Pre-built set (module-level singleton) to avoid re-creating per call */
|
||||
const BUILT_IN_NAMES = new Set([
|
||||
// JavaScript/TypeScript built-ins
|
||||
'console', 'log', 'warn', 'error', 'info', 'debug',
|
||||
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
|
||||
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
|
||||
'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent',
|
||||
'JSON', 'parse', 'stringify',
|
||||
'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt',
|
||||
'Map', 'Set', 'WeakMap', 'WeakSet',
|
||||
'Promise', 'resolve', 'reject', 'then', 'catch', 'finally',
|
||||
'Math', 'Date', 'RegExp', 'Error',
|
||||
'require', 'import', 'export',
|
||||
'fetch', 'Response', 'Request',
|
||||
// React hooks and common functions
|
||||
'useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext',
|
||||
'useReducer', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue',
|
||||
'createElement', 'createContext', 'createRef', 'forwardRef', 'memo', 'lazy',
|
||||
// Common array/object methods
|
||||
'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex', 'some', 'every',
|
||||
'includes', 'indexOf', 'slice', 'splice', 'concat', 'join', 'split',
|
||||
'push', 'pop', 'shift', 'unshift', 'sort', 'reverse',
|
||||
'keys', 'values', 'entries', 'assign', 'freeze', 'seal',
|
||||
'hasOwnProperty', 'toString', 'valueOf',
|
||||
// Python built-ins
|
||||
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
|
||||
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
|
||||
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
|
||||
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
|
||||
// C/C++ standard library and common kernel helpers
|
||||
'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf',
|
||||
'scanf', 'fscanf', 'sscanf',
|
||||
'malloc', 'calloc', 'realloc', 'free', 'memcpy', 'memmove', 'memset', 'memcmp',
|
||||
'strlen', 'strcpy', 'strncpy', 'strcat', 'strncat', 'strcmp', 'strncmp', 'strstr', 'strchr', 'strrchr',
|
||||
'atoi', 'atol', 'atof', 'strtol', 'strtoul', 'strtoll', 'strtoull', 'strtod',
|
||||
'sizeof', 'offsetof', 'typeof',
|
||||
'assert', 'abort', 'exit', '_exit',
|
||||
'fopen', 'fclose', 'fread', 'fwrite', 'fseek', 'ftell', 'rewind', 'fflush', 'fgets', 'fputs',
|
||||
// Linux kernel common macros/helpers (not real call targets)
|
||||
'likely', 'unlikely', 'BUG', 'BUG_ON', 'WARN', 'WARN_ON', 'WARN_ONCE',
|
||||
'IS_ERR', 'PTR_ERR', 'ERR_PTR', 'IS_ERR_OR_NULL',
|
||||
'ARRAY_SIZE', 'container_of', 'list_for_each_entry', 'list_for_each_entry_safe',
|
||||
'min', 'max', 'clamp', 'abs', 'swap',
|
||||
'pr_info', 'pr_warn', 'pr_err', 'pr_debug', 'pr_notice', 'pr_crit', 'pr_emerg',
|
||||
'printk', 'dev_info', 'dev_warn', 'dev_err', 'dev_dbg',
|
||||
'GFP_KERNEL', 'GFP_ATOMIC',
|
||||
'spin_lock', 'spin_unlock', 'spin_lock_irqsave', 'spin_unlock_irqrestore',
|
||||
'mutex_lock', 'mutex_unlock', 'mutex_init',
|
||||
'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree',
|
||||
'get', 'put',
|
||||
]);
|
||||
|
||||
return builtIns.has(name);
|
||||
};
|
||||
const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name);
|
||||
|
||||
/**
|
||||
* Fast path: resolve pre-extracted call sites from workers.
|
||||
|
|
|
|||
|
|
@ -90,12 +90,18 @@ export const processCommunities = async (
|
|||
): Promise<CommunityDetectionResult> => {
|
||||
onProgress?.('Building graph for community detection...', 0);
|
||||
|
||||
// Step 1: Build a graphology graph from the knowledge graph
|
||||
// We only include symbol nodes (Function, Class, Method) and CALLS edges
|
||||
const graph = buildGraphologyGraph(knowledgeGraph);
|
||||
|
||||
// Pre-check total symbol count to determine large-graph mode before building
|
||||
let symbolCount = 0;
|
||||
knowledgeGraph.forEachNode(node => {
|
||||
if (node.label === 'Function' || node.label === 'Class' || node.label === 'Method' || node.label === 'Interface') {
|
||||
symbolCount++;
|
||||
}
|
||||
});
|
||||
const isLarge = symbolCount > 10_000;
|
||||
|
||||
const graph = buildGraphologyGraph(knowledgeGraph, isLarge);
|
||||
|
||||
if (graph.order === 0) {
|
||||
// No nodes to cluster
|
||||
return {
|
||||
communities: [],
|
||||
memberships: [],
|
||||
|
|
@ -103,13 +109,37 @@ export const processCommunities = async (
|
|||
};
|
||||
}
|
||||
|
||||
onProgress?.(`Running Leiden algorithm on ${graph.order} nodes...`, 30);
|
||||
const nodeCount = graph.order;
|
||||
const edgeCount = graph.size;
|
||||
|
||||
// Step 2: Run Leiden algorithm for community detection
|
||||
const details = (leiden as any).detailed(graph, {
|
||||
resolution: 1.0, // Default resolution, can be tuned
|
||||
randomWalk: true,
|
||||
});
|
||||
onProgress?.(`Running Leiden on ${nodeCount} nodes, ${edgeCount} edges${isLarge ? ` (filtered from ${symbolCount} symbols)` : ''}...`, 30);
|
||||
|
||||
// Large graphs: higher resolution + capped iterations (matching Python leidenalg default of 2).
|
||||
// The first 2 iterations capture ~95%+ of modularity; additional iterations have diminishing returns.
|
||||
// Timeout: abort after 60s for pathological graph structures.
|
||||
const LEIDEN_TIMEOUT_MS = 60_000;
|
||||
let details: any;
|
||||
try {
|
||||
details = await Promise.race([
|
||||
Promise.resolve((leiden as any).detailed(graph, {
|
||||
resolution: isLarge ? 2.0 : 1.0,
|
||||
maxIterations: isLarge ? 3 : 0,
|
||||
})),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Leiden timeout')), LEIDEN_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
} catch (e: any) {
|
||||
if (e.message === 'Leiden timeout') {
|
||||
onProgress?.('Community detection timed out, using fallback...', 60);
|
||||
// Fallback: assign all nodes to community 0
|
||||
const communities: Record<string, number> = {};
|
||||
graph.forEachNode((node: string) => { communities[node] = 0; });
|
||||
details = { communities, count: 1, modularity: 0 };
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(`Found ${details.count} communities...`, 60);
|
||||
|
||||
|
|
@ -150,46 +180,49 @@ export const processCommunities = async (
|
|||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build a graphology graph containing only symbol nodes and CALLS edges
|
||||
* This is what the Leiden algorithm will cluster
|
||||
* Build a graphology graph containing only symbol nodes and clustering edges.
|
||||
* For large graphs (>10K symbols), filter out low-confidence fuzzy-global edges
|
||||
* and degree-1 nodes that add noise and massively increase Leiden runtime.
|
||||
*/
|
||||
const buildGraphologyGraph = (knowledgeGraph: KnowledgeGraph): any => {
|
||||
// Use undirected graph for Leiden - it looks at edge density, not direction
|
||||
const MIN_CONFIDENCE_LARGE = 0.5;
|
||||
|
||||
const buildGraphologyGraph = (knowledgeGraph: KnowledgeGraph, isLarge: boolean): any => {
|
||||
const graph = new (Graph as any)({ type: 'undirected', allowSelfLoops: false });
|
||||
|
||||
// Symbol types that should be clustered
|
||||
const symbolTypes = new Set<NodeLabel>(['Function', 'Class', 'Method', 'Interface']);
|
||||
|
||||
// First pass: collect which nodes participate in clustering edges
|
||||
const clusteringRelTypes = new Set(['CALLS', 'EXTENDS', 'IMPLEMENTS']);
|
||||
const connectedNodes = new Set<string>();
|
||||
const nodeDegree = new Map<string, number>();
|
||||
|
||||
knowledgeGraph.relationships.forEach(rel => {
|
||||
if (clusteringRelTypes.has(rel.type) && rel.sourceId !== rel.targetId) {
|
||||
connectedNodes.add(rel.sourceId);
|
||||
connectedNodes.add(rel.targetId);
|
||||
}
|
||||
knowledgeGraph.forEachRelationship(rel => {
|
||||
if (!clusteringRelTypes.has(rel.type) || rel.sourceId === rel.targetId) return;
|
||||
if (isLarge && rel.confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
|
||||
connectedNodes.add(rel.sourceId);
|
||||
connectedNodes.add(rel.targetId);
|
||||
nodeDegree.set(rel.sourceId, (nodeDegree.get(rel.sourceId) || 0) + 1);
|
||||
nodeDegree.set(rel.targetId, (nodeDegree.get(rel.targetId) || 0) + 1);
|
||||
});
|
||||
|
||||
// Only add nodes that have at least one clustering edge
|
||||
// Isolated nodes would just become singletons (skipped anyway)
|
||||
knowledgeGraph.nodes.forEach(node => {
|
||||
if (symbolTypes.has(node.label) && connectedNodes.has(node.id)) {
|
||||
graph.addNode(node.id, {
|
||||
name: node.properties.name,
|
||||
filePath: node.properties.filePath,
|
||||
type: node.label,
|
||||
});
|
||||
}
|
||||
knowledgeGraph.forEachNode(node => {
|
||||
if (!symbolTypes.has(node.label) || !connectedNodes.has(node.id)) return;
|
||||
// For large graphs, skip degree-1 nodes — they just become singletons or
|
||||
// get absorbed into their single neighbor's community, but cost iteration time.
|
||||
if (isLarge && (nodeDegree.get(node.id) || 0) < 2) return;
|
||||
|
||||
graph.addNode(node.id, {
|
||||
name: node.properties.name,
|
||||
filePath: node.properties.filePath,
|
||||
type: node.label,
|
||||
});
|
||||
});
|
||||
|
||||
// Add edges
|
||||
knowledgeGraph.relationships.forEach(rel => {
|
||||
if (clusteringRelTypes.has(rel.type)) {
|
||||
if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId) && rel.sourceId !== rel.targetId) {
|
||||
if (!graph.hasEdge(rel.sourceId, rel.targetId)) {
|
||||
graph.addEdge(rel.sourceId, rel.targetId);
|
||||
}
|
||||
knowledgeGraph.forEachRelationship(rel => {
|
||||
if (!clusteringRelTypes.has(rel.type)) return;
|
||||
if (isLarge && rel.confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId) && rel.sourceId !== rel.targetId) {
|
||||
if (!graph.hasEdge(rel.sourceId, rel.targetId)) {
|
||||
graph.addEdge(rel.sourceId, rel.targetId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -222,11 +255,11 @@ const createCommunityNodes = (
|
|||
|
||||
// Build node lookup for file paths
|
||||
const nodePathMap = new Map<string, string>();
|
||||
knowledgeGraph.nodes.forEach(node => {
|
||||
for (const node of knowledgeGraph.iterNodes()) {
|
||||
if (node.properties.filePath) {
|
||||
nodePathMap.set(node.id, node.properties.filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create community nodes - SKIP SINGLETONS (isolated nodes)
|
||||
const communityNodes: CommunityNode[] = [];
|
||||
|
|
|
|||
|
|
@ -8,15 +8,30 @@ export interface FileEntry {
|
|||
content: string;
|
||||
}
|
||||
|
||||
/** Lightweight entry — path + size from stat, no content in memory */
|
||||
export interface ScannedFile {
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** Path-only reference (for type signatures) */
|
||||
export interface FilePath {
|
||||
path: string;
|
||||
}
|
||||
|
||||
const READ_CONCURRENCY = 32;
|
||||
|
||||
/** Skip files larger than 512KB — they're usually generated/vendored and crash tree-sitter */
|
||||
const MAX_FILE_SIZE = 512 * 1024;
|
||||
|
||||
export const walkRepository = async (
|
||||
/**
|
||||
* Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.
|
||||
* Memory: ~10MB for 100K files vs ~1GB+ with content.
|
||||
*/
|
||||
export const walkRepositoryPaths = async (
|
||||
repoPath: string,
|
||||
onProgress?: (current: number, total: number, filePath: string) => void
|
||||
): Promise<FileEntry[]> => {
|
||||
): Promise<ScannedFile[]> => {
|
||||
const files = await glob('**/*', {
|
||||
cwd: repoPath,
|
||||
nodir: true,
|
||||
|
|
@ -24,7 +39,7 @@ export const walkRepository = async (
|
|||
});
|
||||
|
||||
const filtered = files.filter(file => !shouldIgnorePath(file));
|
||||
const entries: FileEntry[] = [];
|
||||
const entries: ScannedFile[] = [];
|
||||
let processed = 0;
|
||||
let skippedLarge = 0;
|
||||
|
||||
|
|
@ -38,8 +53,7 @@ export const walkRepository = async (
|
|||
skippedLarge++;
|
||||
return null;
|
||||
}
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
return { path: relativePath.replace(/\\/g, '/'), content };
|
||||
return { path: relativePath.replace(/\\/g, '/'), size: stat.size };
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -55,8 +69,53 @@ export const walkRepository = async (
|
|||
}
|
||||
|
||||
if (skippedLarge > 0) {
|
||||
console.warn(` Skipped ${skippedLarge} files larger than ${MAX_FILE_SIZE / 1024}KB`);
|
||||
console.warn(` Skipped ${skippedLarge} large files (>${MAX_FILE_SIZE / 1024}KB, likely generated/vendored)`);
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase 2: Read file contents for a specific set of relative paths.
|
||||
* Returns a Map for O(1) lookup. Silently skips files that fail to read.
|
||||
*/
|
||||
export const readFileContents = async (
|
||||
repoPath: string,
|
||||
relativePaths: string[],
|
||||
): Promise<Map<string, string>> => {
|
||||
const contents = new Map<string, string>();
|
||||
|
||||
for (let start = 0; start < relativePaths.length; start += READ_CONCURRENCY) {
|
||||
const batch = relativePaths.slice(start, start + READ_CONCURRENCY);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async relativePath => {
|
||||
const fullPath = path.join(repoPath, relativePath);
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
return { path: relativePath, content };
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
contents.set(result.value.path, result.value.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return contents;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy API — scans and reads everything into memory.
|
||||
* Used by sequential fallback path only.
|
||||
*/
|
||||
export const walkRepository = async (
|
||||
repoPath: string,
|
||||
onProgress?: (current: number, total: number, filePath: string) => void
|
||||
): Promise<FileEntry[]> => {
|
||||
const scanned = await walkRepositoryPaths(repoPath, onProgress);
|
||||
const contents = await readFileContents(repoPath, scanned.map(f => f.path));
|
||||
return scanned
|
||||
.filter(f => contents.has(f.path))
|
||||
.map(f => ({ path: f.path, content: contents.get(f.path)! }));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -257,13 +257,8 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
|||
return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' };
|
||||
}
|
||||
|
||||
// Generic PHP MVC: files ending with Controller.php
|
||||
if (p.endsWith('controller.php')) {
|
||||
return { framework: 'php-mvc', entryPointMultiplier: 2.5, reason: 'php-controller-file' };
|
||||
}
|
||||
|
||||
// ========== GENERIC PATTERNS ==========
|
||||
|
||||
|
||||
// Any language: index files in API folders
|
||||
if (p.includes('/api/') && (
|
||||
p.endsWith('/index.ts') || p.endsWith('/index.js') ||
|
||||
|
|
@ -302,7 +297,7 @@ export const FRAMEWORK_AST_PATTERNS = {
|
|||
|
||||
// Go patterns (function signatures)
|
||||
'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP'],
|
||||
|
||||
|
||||
// PHP/Laravel
|
||||
'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete',
|
||||
'Route::resource', 'Route::apiResource', '#[Route('],
|
||||
|
|
|
|||
|
|
@ -18,6 +18,27 @@ export type ImportMap = Map<string, Set<string>>;
|
|||
|
||||
export const createImportMap = (): ImportMap => new Map();
|
||||
|
||||
/** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */
|
||||
export interface ImportResolutionContext {
|
||||
allFilePaths: Set<string>;
|
||||
allFileList: string[];
|
||||
normalizedFileList: string[];
|
||||
suffixIndex: SuffixIndex;
|
||||
resolveCache: Map<string, string | null>;
|
||||
}
|
||||
|
||||
/** Max entries in the resolve cache. Beyond this, the cache is cleared to bound memory.
|
||||
* 100K entries ≈ 15MB — covers the most common import patterns. */
|
||||
const RESOLVE_CACHE_CAP = 100_000;
|
||||
|
||||
export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext {
|
||||
const allFileList = allPaths;
|
||||
const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/'));
|
||||
const allFilePaths = new Set(allFileList);
|
||||
const suffixIndex = buildSuffixIndex(normalizedFileList, allFileList);
|
||||
return { allFilePaths, allFileList, normalizedFileList, suffixIndex, resolveCache: new Map() };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LANGUAGE-SPECIFIC CONFIG
|
||||
// ============================================================================
|
||||
|
|
@ -309,6 +330,15 @@ const resolveImportPath = (
|
|||
if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey) ?? null;
|
||||
|
||||
const cache = (result: string | null): string | null => {
|
||||
// Evict oldest 20% when cap is reached instead of clearing all
|
||||
if (resolveCache.size >= RESOLVE_CACHE_CAP) {
|
||||
const evictCount = Math.floor(RESOLVE_CACHE_CAP * 0.2);
|
||||
const iter = resolveCache.keys();
|
||||
for (let i = 0; i < evictCount; i++) {
|
||||
const key = iter.next().value;
|
||||
if (key !== undefined) resolveCache.delete(key);
|
||||
}
|
||||
}
|
||||
resolveCache.set(cacheKey, result);
|
||||
return result;
|
||||
};
|
||||
|
|
@ -637,12 +667,13 @@ export const processImports = async (
|
|||
importMap: ImportMap,
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
repoRoot?: string,
|
||||
allPaths?: string[],
|
||||
) => {
|
||||
// Create a Set of all file paths for fast lookup during resolution
|
||||
const allFilePaths = new Set(files.map(f => f.path));
|
||||
// Use allPaths (full repo) when available for cross-chunk resolution, else fall back to chunk files
|
||||
const allFileList = allPaths ?? files.map(f => f.path);
|
||||
const allFilePaths = new Set(allFileList);
|
||||
const parser = await loadParser();
|
||||
const resolveCache = new Map<string, string | null>();
|
||||
const allFileList = files.map(f => f.path);
|
||||
// Pre-compute normalized file list once (forward slashes)
|
||||
const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/'));
|
||||
// Build suffix index for O(1) lookups
|
||||
|
|
@ -823,18 +854,15 @@ export const processImports = async (
|
|||
|
||||
export const processImportsFromExtracted = async (
|
||||
graph: KnowledgeGraph,
|
||||
files: { path: string; content: string }[],
|
||||
files: { path: string }[],
|
||||
extractedImports: ExtractedImport[],
|
||||
importMap: ImportMap,
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
repoRoot?: string,
|
||||
prebuiltCtx?: ImportResolutionContext,
|
||||
) => {
|
||||
const allFilePaths = new Set(files.map(f => f.path));
|
||||
const resolveCache = new Map<string, string | null>();
|
||||
const allFileList = files.map(f => f.path);
|
||||
const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/'));
|
||||
// Build suffix index for O(1) lookups
|
||||
const index = buildSuffixIndex(normalizedFileList, allFileList);
|
||||
const ctx = prebuiltCtx ?? buildImportResolutionContext(files.map(f => f.path));
|
||||
const { allFilePaths, allFileList, normalizedFileList, suffixIndex: index, resolveCache } = ctx;
|
||||
|
||||
let totalImportsFound = 0;
|
||||
let totalImportsResolved = 0;
|
||||
|
|
|
|||
|
|
@ -129,28 +129,25 @@ const processParsingWithWorkers = async (
|
|||
symbolTable: SymbolTable,
|
||||
astCache: ASTCache,
|
||||
workerPool: WorkerPool,
|
||||
onFileProgress?: FileProgressCallback
|
||||
onFileProgress?: FileProgressCallback,
|
||||
): Promise<WorkerExtractedData> => {
|
||||
// Filter to parseable files only
|
||||
const parseableFiles: ParseWorkerInput[] = [];
|
||||
for (const file of files) {
|
||||
const lang = getLanguageFromFilename(file.path);
|
||||
if (lang) {
|
||||
parseableFiles.push({ path: file.path, content: file.content });
|
||||
}
|
||||
if (lang) parseableFiles.push({ path: file.path, content: file.content });
|
||||
}
|
||||
|
||||
if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [] };
|
||||
|
||||
const total = files.length;
|
||||
|
||||
// Dispatch to worker pool — pool handles splitting into chunks
|
||||
// Workers send progress messages during parsing so the bar updates smoothly
|
||||
// Dispatch to worker pool — pool handles splitting into chunks and sub-batching
|
||||
const chunkResults = await workerPool.dispatch<ParseWorkerInput, ParseWorkerResult>(
|
||||
parseableFiles,
|
||||
(filesProcessed) => {
|
||||
onFileProgress?.(Math.min(filesProcessed, total), total, 'Parsing...');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Merge results from all workers into graph and symbol table
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { createKnowledgeGraph } from '../graph/graph.js';
|
||||
import { processStructure } from './structure-processor.js';
|
||||
import { processParsing } from './parsing-processor.js';
|
||||
import { processImports, processImportsFromExtracted, createImportMap } from './import-processor.js';
|
||||
import { processImports, processImportsFromExtracted, createImportMap, buildImportResolutionContext } from './import-processor.js';
|
||||
import { processCalls, processCallsFromExtracted } from './call-processor.js';
|
||||
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
|
||||
import { processCommunities } from './community-processor.js';
|
||||
|
|
@ -9,20 +9,28 @@ import { processProcesses } from './process-processor.js';
|
|||
import { createSymbolTable } from './symbol-table.js';
|
||||
import { createASTCache } from './ast-cache.js';
|
||||
import { PipelineProgress, PipelineResult } from '../../types/pipeline.js';
|
||||
import { walkRepository } from './filesystem-walker.js';
|
||||
import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
|
||||
import { getLanguageFromFilename } from './utils.js';
|
||||
import { createWorkerPool, WorkerPool } from './workers/worker-pool.js';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
/** Max bytes of source content to load per parse chunk. Each chunk's source +
|
||||
* parsed ASTs + extracted records + worker serialization overhead all live in
|
||||
* memory simultaneously, so this must be conservative. 20MB source ≈ 200-400MB
|
||||
* peak working memory per chunk after parse expansion. */
|
||||
const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB
|
||||
|
||||
/** Max AST trees to keep in LRU cache */
|
||||
const AST_CACHE_CAP = 50;
|
||||
|
||||
export const runPipelineFromRepo = async (
|
||||
repoPath: string,
|
||||
onProgress: (progress: PipelineProgress) => void
|
||||
): Promise<PipelineResult> => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const fileContents = new Map<string, string>();
|
||||
const symbolTable = createSymbolTable();
|
||||
// AST cache sized after file scan — start with a placeholder, resize after we know file count
|
||||
let astCache = createASTCache(50);
|
||||
let astCache = createASTCache(AST_CACHE_CAP);
|
||||
const importMap = createImportMap();
|
||||
|
||||
const cleanup = () => {
|
||||
|
|
@ -31,13 +39,14 @@ export const runPipelineFromRepo = async (
|
|||
};
|
||||
|
||||
try {
|
||||
// ── Phase 1: Scan paths only (no content read) ─────────────────────
|
||||
onProgress({
|
||||
phase: 'extracting',
|
||||
percent: 0,
|
||||
message: 'Scanning repository...',
|
||||
});
|
||||
|
||||
const files = await walkRepository(repoPath, (current, total, filePath) => {
|
||||
const scannedFiles = await walkRepositoryPaths(repoPath, (current, total, filePath) => {
|
||||
const scanProgress = Math.round((current / total) * 15);
|
||||
onProgress({
|
||||
phase: 'extracting',
|
||||
|
|
@ -48,179 +57,190 @@ export const runPipelineFromRepo = async (
|
|||
});
|
||||
});
|
||||
|
||||
files.forEach(f => fileContents.set(f.path, f.content));
|
||||
|
||||
// Resize AST cache to fit all files — avoids re-parsing in import/call/heritage phases
|
||||
astCache = createASTCache(files.length);
|
||||
const totalFiles = scannedFiles.length;
|
||||
|
||||
onProgress({
|
||||
phase: 'extracting',
|
||||
percent: 15,
|
||||
message: 'Repository scanned successfully',
|
||||
stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
// ── Phase 2: Structure (paths only — no content needed) ────────────
|
||||
onProgress({
|
||||
phase: 'structure',
|
||||
percent: 15,
|
||||
message: 'Analyzing project structure...',
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
stats: { filesProcessed: 0, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
const filePaths = files.map(f => f.path);
|
||||
processStructure(graph, filePaths);
|
||||
const allPaths = scannedFiles.map(f => f.path);
|
||||
processStructure(graph, allPaths);
|
||||
|
||||
onProgress({
|
||||
phase: 'structure',
|
||||
percent: 30,
|
||||
percent: 20,
|
||||
message: 'Project structure analyzed',
|
||||
stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
// ── Phase 3+4: Chunked read + parse ────────────────────────────────
|
||||
// Group parseable files into byte-budget chunks so only ~20MB of source
|
||||
// is in memory at a time. Each chunk is: read → parse → extract → free.
|
||||
|
||||
const parseableScanned = scannedFiles.filter(f => getLanguageFromFilename(f.path));
|
||||
const totalParseable = parseableScanned.length;
|
||||
|
||||
// Build byte-budget chunks
|
||||
const chunks: string[][] = [];
|
||||
let currentChunk: string[] = [];
|
||||
let currentBytes = 0;
|
||||
for (const file of parseableScanned) {
|
||||
if (currentChunk.length > 0 && currentBytes + file.size > CHUNK_BYTE_BUDGET) {
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = [];
|
||||
currentBytes = 0;
|
||||
}
|
||||
currentChunk.push(file.path);
|
||||
currentBytes += file.size;
|
||||
}
|
||||
if (currentChunk.length > 0) chunks.push(currentChunk);
|
||||
|
||||
const numChunks = chunks.length;
|
||||
|
||||
if (isDev) {
|
||||
const totalMB = parseableScanned.reduce((s, f) => s + f.size, 0) / (1024 * 1024);
|
||||
console.log(`📂 Scan: ${totalFiles} paths, ${totalParseable} parseable (${totalMB.toFixed(0)}MB), ${numChunks} chunks @ ${CHUNK_BYTE_BUDGET / (1024 * 1024)}MB budget`);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 30,
|
||||
message: 'Parsing code definitions...',
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
percent: 20,
|
||||
message: `Parsing ${totalParseable} files in ${numChunks} chunk${numChunks !== 1 ? 's' : ''}...`,
|
||||
stats: { filesProcessed: 0, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
// Create worker pool for parallel parsing, with graceful fallback
|
||||
// Create worker pool once, reuse across chunks
|
||||
let workerPool: WorkerPool | undefined;
|
||||
try {
|
||||
const workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
|
||||
workerPool = createWorkerPool(workerUrl);
|
||||
} catch (err) {
|
||||
// Worker pool creation failed (e.g., single core) — sequential fallback
|
||||
// Worker pool creation failed — sequential fallback
|
||||
}
|
||||
|
||||
let workerData: Awaited<ReturnType<typeof processParsing>> = null;
|
||||
let filesParsedSoFar = 0;
|
||||
|
||||
// AST cache sized for one chunk (sequential fallback uses it for import/call/heritage)
|
||||
const maxChunkFiles = chunks.reduce((max, c) => Math.max(max, c.length), 0);
|
||||
astCache = createASTCache(maxChunkFiles);
|
||||
|
||||
// Build import resolution context once — suffix index, file lists, resolve cache.
|
||||
// Reused across all chunks to avoid rebuilding O(files × path_depth) structures.
|
||||
const importCtx = buildImportResolutionContext(allPaths);
|
||||
const allPathObjects = allPaths.map(p => ({ path: p }));
|
||||
|
||||
// Single-pass: parse + resolve imports/calls/heritage per chunk.
|
||||
// Calls/heritage use the symbol table built so far (symbols from earlier chunks
|
||||
// are already registered). This trades ~5% cross-chunk resolution accuracy for
|
||||
// 200-400MB less memory — critical for Linux-kernel-scale repos.
|
||||
const sequentialChunkPaths: string[][] = [];
|
||||
|
||||
try {
|
||||
workerData = await processParsing(graph, files, symbolTable, astCache, (current, total, filePath) => {
|
||||
const parsingProgress = 30 + ((current / total) * 40);
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(parsingProgress),
|
||||
message: 'Parsing code definitions...',
|
||||
detail: filePath,
|
||||
stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
}, workerPool);
|
||||
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
|
||||
const chunkPaths = chunks[chunkIdx];
|
||||
|
||||
// Read content for this chunk only
|
||||
const chunkContents = await readFileContents(repoPath, chunkPaths);
|
||||
const chunkFiles = chunkPaths
|
||||
.filter(p => chunkContents.has(p))
|
||||
.map(p => ({ path: p, content: chunkContents.get(p)! }));
|
||||
|
||||
// Parse this chunk (workers or sequential fallback)
|
||||
const chunkWorkerData = await processParsing(
|
||||
graph, chunkFiles, symbolTable, astCache,
|
||||
(current, _total, filePath) => {
|
||||
const globalCurrent = filesParsedSoFar + current;
|
||||
const parsingProgress = 20 + ((globalCurrent / totalParseable) * 62);
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(parsingProgress),
|
||||
message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`,
|
||||
detail: filePath,
|
||||
stats: { filesProcessed: globalCurrent, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
},
|
||||
workerPool,
|
||||
);
|
||||
|
||||
if (chunkWorkerData) {
|
||||
// Imports
|
||||
await processImportsFromExtracted(graph, allPathObjects, chunkWorkerData.imports, importMap, undefined, repoPath, importCtx);
|
||||
// Calls — resolve immediately, then free the array
|
||||
if (chunkWorkerData.calls.length > 0) {
|
||||
await processCallsFromExtracted(graph, chunkWorkerData.calls, symbolTable, importMap);
|
||||
}
|
||||
// Heritage — resolve immediately, then free
|
||||
if (chunkWorkerData.heritage.length > 0) {
|
||||
await processHeritageFromExtracted(graph, chunkWorkerData.heritage, symbolTable);
|
||||
}
|
||||
} else {
|
||||
await processImports(graph, chunkFiles, astCache, importMap, undefined, repoPath, allPaths);
|
||||
sequentialChunkPaths.push(chunkPaths);
|
||||
}
|
||||
|
||||
filesParsedSoFar += chunkFiles.length;
|
||||
|
||||
// Clear AST cache between chunks to free memory
|
||||
astCache.clear();
|
||||
// chunkContents + chunkFiles + chunkWorkerData go out of scope → GC reclaims
|
||||
}
|
||||
} finally {
|
||||
await workerPool?.terminate();
|
||||
}
|
||||
|
||||
onProgress({
|
||||
phase: 'imports',
|
||||
percent: 70,
|
||||
message: 'Resolving imports...',
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
if (workerData) {
|
||||
// Fast path: imports already extracted by workers, just resolve paths
|
||||
await processImportsFromExtracted(graph, files, workerData.imports, importMap, (current, total) => {
|
||||
const importProgress = 70 + ((current / total) * 12);
|
||||
onProgress({
|
||||
phase: 'imports',
|
||||
percent: Math.round(importProgress),
|
||||
message: 'Resolving imports...',
|
||||
stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
}, repoPath);
|
||||
} else {
|
||||
// Fallback: full parse + resolve (sequential path)
|
||||
await processImports(graph, files, astCache, importMap, (current, total) => {
|
||||
const importProgress = 70 + ((current / total) * 12);
|
||||
onProgress({
|
||||
phase: 'imports',
|
||||
percent: Math.round(importProgress),
|
||||
message: 'Resolving imports...',
|
||||
stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
}, repoPath);
|
||||
// Sequential fallback chunks: re-read source for call/heritage resolution
|
||||
for (const chunkPaths of sequentialChunkPaths) {
|
||||
const chunkContents = await readFileContents(repoPath, chunkPaths);
|
||||
const chunkFiles = chunkPaths
|
||||
.filter(p => chunkContents.has(p))
|
||||
.map(p => ({ path: p, content: chunkContents.get(p)! }));
|
||||
astCache = createASTCache(chunkFiles.length);
|
||||
await processCalls(graph, chunkFiles, astCache, symbolTable, importMap);
|
||||
await processHeritage(graph, chunkFiles, astCache, symbolTable);
|
||||
astCache.clear();
|
||||
}
|
||||
|
||||
// Free import resolution context — suffix index + resolve cache no longer needed
|
||||
// (allPathObjects and importCtx hold ~94MB+ for large repos)
|
||||
allPathObjects.length = 0;
|
||||
importCtx.resolveCache.clear();
|
||||
(importCtx as any).suffixIndex = null;
|
||||
(importCtx as any).normalizedFileList = null;
|
||||
|
||||
if (isDev) {
|
||||
const importsCount = graph.relationships.filter(r => r.type === 'IMPORTS').length;
|
||||
console.log(`📊 Pipeline: After import phase, graph has ${importsCount} IMPORTS relationships (total: ${graph.relationshipCount})`);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
phase: 'calls',
|
||||
percent: 82,
|
||||
message: 'Tracing function calls...',
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
if (workerData) {
|
||||
// Fast path: calls already extracted by workers, just resolve targets
|
||||
await processCallsFromExtracted(graph, workerData.calls, symbolTable, importMap, (current, total) => {
|
||||
const callProgress = 82 + ((current / total) * 10);
|
||||
onProgress({
|
||||
phase: 'calls',
|
||||
percent: Math.round(callProgress),
|
||||
message: 'Tracing function calls...',
|
||||
stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Fallback: full parse + resolve (sequential path)
|
||||
await processCalls(graph, files, astCache, symbolTable, importMap, (current, total) => {
|
||||
const callProgress = 82 + ((current / total) * 10);
|
||||
onProgress({
|
||||
phase: 'calls',
|
||||
percent: Math.round(callProgress),
|
||||
message: 'Tracing function calls...',
|
||||
stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onProgress({
|
||||
phase: 'heritage',
|
||||
percent: 92,
|
||||
message: 'Extracting class inheritance...',
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
if (workerData) {
|
||||
// Fast path: heritage already extracted by workers, just resolve symbols
|
||||
await processHeritageFromExtracted(graph, workerData.heritage, symbolTable, (current, total) => {
|
||||
const heritageProgress = 88 + ((current / total) * 4);
|
||||
onProgress({
|
||||
phase: 'heritage',
|
||||
percent: Math.round(heritageProgress),
|
||||
message: 'Extracting class inheritance...',
|
||||
stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Fallback: full parse + resolve (sequential path)
|
||||
await processHeritage(graph, files, astCache, symbolTable, (current, total) => {
|
||||
const heritageProgress = 88 + ((current / total) * 4);
|
||||
onProgress({
|
||||
phase: 'heritage',
|
||||
percent: Math.round(heritageProgress),
|
||||
message: 'Extracting class inheritance...',
|
||||
stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
});
|
||||
let importsCount = 0;
|
||||
for (const r of graph.iterRelationships()) {
|
||||
if (r.type === 'IMPORTS') importsCount++;
|
||||
}
|
||||
console.log(`📊 Pipeline: graph has ${importsCount} IMPORTS, ${graph.relationshipCount} total relationships`);
|
||||
}
|
||||
|
||||
// ── Phase 5: Communities ───────────────────────────────────────────
|
||||
onProgress({
|
||||
phase: 'communities',
|
||||
percent: 92,
|
||||
percent: 82,
|
||||
message: 'Detecting code communities...',
|
||||
stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
const communityResult = await processCommunities(graph, (message, progress) => {
|
||||
const communityProgress = 92 + (progress * 0.06);
|
||||
const communityProgress = 82 + (progress * 0.10);
|
||||
onProgress({
|
||||
phase: 'communities',
|
||||
percent: Math.round(communityProgress),
|
||||
message,
|
||||
stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -253,27 +273,28 @@ export const runPipelineFromRepo = async (
|
|||
});
|
||||
});
|
||||
|
||||
// ── Phase 6: Processes ─────────────────────────────────────────────
|
||||
onProgress({
|
||||
phase: 'processes',
|
||||
percent: 98,
|
||||
percent: 94,
|
||||
message: 'Detecting execution flows...',
|
||||
stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
// Dynamic process cap based on codebase size
|
||||
const symbolCount = graph.nodes.filter(n => n.label !== 'File').length;
|
||||
let symbolCount = 0;
|
||||
graph.forEachNode(n => { if (n.label !== 'File') symbolCount++; });
|
||||
const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10)));
|
||||
|
||||
const processResult = await processProcesses(
|
||||
graph,
|
||||
communityResult.memberships,
|
||||
(message, progress) => {
|
||||
const processProgress = 98 + (progress * 0.01);
|
||||
const processProgress = 94 + (progress * 0.05);
|
||||
onProgress({
|
||||
phase: 'processes',
|
||||
percent: Math.round(processProgress),
|
||||
message,
|
||||
stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
},
|
||||
{ maxProcesses: dynamicMaxProcesses, minSteps: 3 }
|
||||
|
|
@ -317,18 +338,17 @@ export const runPipelineFromRepo = async (
|
|||
percent: 100,
|
||||
message: `Graph complete! ${communityResult.stats.totalCommunities} communities, ${processResult.stats.totalProcesses} processes detected.`,
|
||||
stats: {
|
||||
filesProcessed: files.length,
|
||||
totalFiles: files.length,
|
||||
filesProcessed: totalFiles,
|
||||
totalFiles,
|
||||
nodesCreated: graph.nodeCount
|
||||
},
|
||||
});
|
||||
|
||||
astCache.clear();
|
||||
|
||||
return { graph, fileContents, communityResult, processResult };
|
||||
return { graph, repoPath, totalFileCount: totalFiles, communityResult, processResult };
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export const processProcesses = async (
|
|||
const callsEdges = buildCallsGraph(knowledgeGraph);
|
||||
const reverseCallsEdges = buildReverseCallsGraph(knowledgeGraph);
|
||||
const nodeMap = new Map<string, GraphNode>();
|
||||
knowledgeGraph.nodes.forEach(n => nodeMap.set(n.id, n));
|
||||
for (const n of knowledgeGraph.iterNodes()) nodeMap.set(n.id, n);
|
||||
|
||||
// Step 1: Find entry points (functions that call others but have few callers)
|
||||
const entryPoints = findEntryPoints(knowledgeGraph, reverseCallsEdges, callsEdges);
|
||||
|
|
@ -221,29 +221,29 @@ const MIN_TRACE_CONFIDENCE = 0.5;
|
|||
const buildCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
|
||||
const adj = new Map<string, string[]>();
|
||||
|
||||
graph.relationships.forEach(rel => {
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) {
|
||||
if (!adj.has(rel.sourceId)) {
|
||||
adj.set(rel.sourceId, []);
|
||||
}
|
||||
adj.get(rel.sourceId)!.push(rel.targetId);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
return adj;
|
||||
};
|
||||
|
||||
const buildReverseCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
|
||||
const adj = new Map<string, string[]>();
|
||||
|
||||
graph.relationships.forEach(rel => {
|
||||
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) {
|
||||
if (!adj.has(rel.targetId)) {
|
||||
adj.set(rel.targetId, []);
|
||||
}
|
||||
adj.get(rel.targetId)!.push(rel.sourceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return adj;
|
||||
};
|
||||
|
|
@ -270,20 +270,20 @@ const findEntryPoints = (
|
|||
reasons: string[];
|
||||
}[] = [];
|
||||
|
||||
graph.nodes.forEach(node => {
|
||||
if (!symbolTypes.has(node.label)) return;
|
||||
for (const node of graph.iterNodes()) {
|
||||
if (!symbolTypes.has(node.label)) continue;
|
||||
|
||||
const filePath = node.properties.filePath || '';
|
||||
|
||||
// Skip test files entirely
|
||||
if (isTestFile(filePath)) return;
|
||||
|
||||
if (isTestFile(filePath)) continue;
|
||||
|
||||
const callers = reverseCallsEdges.get(node.id) || [];
|
||||
const callees = callsEdges.get(node.id) || [];
|
||||
|
||||
|
||||
// Must have at least 1 outgoing call to trace forward
|
||||
if (callees.length === 0) return;
|
||||
|
||||
if (callees.length === 0) continue;
|
||||
|
||||
// Calculate entry point score using new scoring system
|
||||
const { score, reasons } = calculateEntryPointScore(
|
||||
node.properties.name,
|
||||
|
|
@ -293,11 +293,11 @@ const findEntryPoints = (
|
|||
callees.length,
|
||||
filePath // Pass filePath for framework detection
|
||||
);
|
||||
|
||||
|
||||
if (score > 0) {
|
||||
entryPointCandidates.push({ id: node.id, score, reasons });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score descending and return top candidates
|
||||
const sorted = entryPointCandidates.sort((a, b) => b.score - a.score);
|
||||
|
|
@ -306,7 +306,7 @@ const findEntryPoints = (
|
|||
if (sorted.length > 0 && isDev) {
|
||||
console.log(`[Process] Top 10 entry point candidates (new scoring):`);
|
||||
sorted.slice(0, 10).forEach((c, i) => {
|
||||
const node = graph.nodes.find(n => n.id === c.id);
|
||||
const node = graph.getNode(c.id);
|
||||
const exported = node?.properties.isExported ? '✓' : '✗';
|
||||
const shortPath = node?.properties.filePath?.split('/').slice(-2).join('/') || '';
|
||||
console.log(` ${i+1}. ${node?.properties.name} [exported:${exported}] (${shortPath})`);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ interface ParsedNode {
|
|||
endLine: number;
|
||||
language: string;
|
||||
isExported: boolean;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -273,6 +274,7 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
|
|||
};
|
||||
|
||||
const BUILT_INS = new Set([
|
||||
// JavaScript/TypeScript
|
||||
'console', 'log', 'warn', 'error', 'info', 'debug',
|
||||
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
|
||||
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
|
||||
|
|
@ -291,10 +293,32 @@ const BUILT_INS = new Set([
|
|||
'push', 'pop', 'shift', 'unshift', 'sort', 'reverse',
|
||||
'keys', 'values', 'entries', 'assign', 'freeze', 'seal',
|
||||
'hasOwnProperty', 'toString', 'valueOf',
|
||||
// Python
|
||||
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
|
||||
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
|
||||
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
|
||||
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
|
||||
// C/C++ standard library
|
||||
'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf',
|
||||
'scanf', 'fscanf', 'sscanf',
|
||||
'malloc', 'calloc', 'realloc', 'free', 'memcpy', 'memmove', 'memset', 'memcmp',
|
||||
'strlen', 'strcpy', 'strncpy', 'strcat', 'strncat', 'strcmp', 'strncmp', 'strstr', 'strchr', 'strrchr',
|
||||
'atoi', 'atol', 'atof', 'strtol', 'strtoul', 'strtoll', 'strtoull', 'strtod',
|
||||
'sizeof', 'offsetof', 'typeof',
|
||||
'assert', 'abort', 'exit', '_exit',
|
||||
'fopen', 'fclose', 'fread', 'fwrite', 'fseek', 'ftell', 'rewind', 'fflush', 'fgets', 'fputs',
|
||||
// Linux kernel common macros/helpers (not real call targets)
|
||||
'likely', 'unlikely', 'BUG', 'BUG_ON', 'WARN', 'WARN_ON', 'WARN_ONCE',
|
||||
'IS_ERR', 'PTR_ERR', 'ERR_PTR', 'IS_ERR_OR_NULL',
|
||||
'ARRAY_SIZE', 'container_of', 'list_for_each_entry', 'list_for_each_entry_safe',
|
||||
'min', 'max', 'clamp', 'abs', 'swap',
|
||||
'pr_info', 'pr_warn', 'pr_err', 'pr_debug', 'pr_notice', 'pr_crit', 'pr_emerg',
|
||||
'printk', 'dev_info', 'dev_warn', 'dev_err', 'dev_dbg',
|
||||
'GFP_KERNEL', 'GFP_ATOMIC',
|
||||
'spin_lock', 'spin_unlock', 'spin_lock_irqsave', 'spin_unlock_irqrestore',
|
||||
'mutex_lock', 'mutex_unlock', 'mutex_init',
|
||||
'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree',
|
||||
'get', 'put',
|
||||
// PHP built-ins
|
||||
'echo', 'isset', 'empty', 'unset', 'list', 'array', 'compact', 'extract',
|
||||
'count', 'strlen', 'strpos', 'strrpos', 'substr', 'strtolower', 'strtoupper', 'trim',
|
||||
|
|
@ -678,15 +702,57 @@ const processFileGroup = (
|
|||
};
|
||||
|
||||
// ============================================================================
|
||||
// Worker message handler
|
||||
// Worker message handler — supports sub-batch streaming
|
||||
// ============================================================================
|
||||
|
||||
parentPort!.on('message', (files: ParseWorkerInput[]) => {
|
||||
/** Accumulated result across sub-batches */
|
||||
let accumulated: ParseWorkerResult = {
|
||||
nodes: [], relationships: [], symbols: [],
|
||||
imports: [], calls: [], heritage: [], fileCount: 0,
|
||||
};
|
||||
let cumulativeProcessed = 0;
|
||||
|
||||
const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
|
||||
target.nodes.push(...src.nodes);
|
||||
target.relationships.push(...src.relationships);
|
||||
target.symbols.push(...src.symbols);
|
||||
target.imports.push(...src.imports);
|
||||
target.calls.push(...src.calls);
|
||||
target.heritage.push(...src.heritage);
|
||||
target.fileCount += src.fileCount;
|
||||
};
|
||||
|
||||
parentPort!.on('message', (msg: any) => {
|
||||
try {
|
||||
const result = processBatch(files, (filesProcessed) => {
|
||||
parentPort!.postMessage({ type: 'progress', filesProcessed });
|
||||
});
|
||||
parentPort!.postMessage({ type: 'result', data: result });
|
||||
// Sub-batch mode: { type: 'sub-batch', files: [...] }
|
||||
if (msg && msg.type === 'sub-batch') {
|
||||
const result = processBatch(msg.files, (filesProcessed) => {
|
||||
parentPort!.postMessage({ type: 'progress', filesProcessed: cumulativeProcessed + filesProcessed });
|
||||
});
|
||||
cumulativeProcessed += result.fileCount;
|
||||
mergeResult(accumulated, result);
|
||||
// Signal ready for next sub-batch
|
||||
parentPort!.postMessage({ type: 'sub-batch-done' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush: send accumulated results
|
||||
if (msg && msg.type === 'flush') {
|
||||
parentPort!.postMessage({ type: 'result', data: accumulated });
|
||||
// Reset for potential reuse
|
||||
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], fileCount: 0 };
|
||||
cumulativeProcessed = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy single-message mode (backward compat): array of files
|
||||
if (Array.isArray(msg)) {
|
||||
const result = processBatch(msg, (filesProcessed) => {
|
||||
parentPort!.postMessage({ type: 'progress', filesProcessed });
|
||||
});
|
||||
parentPort!.postMessage({ type: 'result', data: result });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
parentPort!.postMessage({ type: 'error', error: message });
|
||||
|
|
|
|||
|
|
@ -4,29 +4,33 @@ import os from 'node:os';
|
|||
export interface WorkerPool {
|
||||
/**
|
||||
* Dispatch items across workers. Items are split into chunks (one per worker),
|
||||
* each worker processes its chunk, and results are concatenated back in order.
|
||||
*
|
||||
* @param onProgress - Called with cumulative files processed across all workers
|
||||
* each worker processes its chunk via sub-batches to limit peak memory,
|
||||
* and results are concatenated back in order.
|
||||
*/
|
||||
dispatch<TInput, TResult>(items: TInput[], onProgress?: (filesProcessed: number) => void): Promise<TResult[]>;
|
||||
|
||||
/**
|
||||
* Terminate all workers. Must be called when done.
|
||||
*/
|
||||
/** Terminate all workers. Must be called when done. */
|
||||
terminate(): Promise<void>;
|
||||
|
||||
/** Number of workers in the pool */
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Max files to send to a worker in a single postMessage.
|
||||
* Keeps structured-clone memory bounded per sub-batch.
|
||||
*/
|
||||
const SUB_BATCH_SIZE = 1500;
|
||||
|
||||
/** Per sub-batch timeout. If a single sub-batch takes longer than this,
|
||||
* likely a pathological file (e.g. minified 50MB JS). Fail fast. */
|
||||
const SUB_BATCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Create a pool of worker threads.
|
||||
*
|
||||
* @param workerUrl - URL to the worker script (use `new URL('./parse-worker.js', import.meta.url)`)
|
||||
* @param poolSize - Number of workers (defaults to cpus - 1, minimum 1)
|
||||
*/
|
||||
export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool => {
|
||||
const size = poolSize ?? Math.max(1, os.cpus().length - 1);
|
||||
const size = poolSize ?? Math.min(8, Math.max(1, os.cpus().length - 1));
|
||||
const workers: Worker[] = [];
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
|
|
@ -36,35 +40,51 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
|||
const dispatch = <TInput, TResult>(items: TInput[], onProgress?: (filesProcessed: number) => void): Promise<TResult[]> => {
|
||||
if (items.length === 0) return Promise.resolve([]);
|
||||
|
||||
// Split items into one chunk per worker
|
||||
const chunkSize = Math.ceil(items.length / size);
|
||||
const chunks: TInput[][] = [];
|
||||
for (let i = 0; i < items.length; i += chunkSize) {
|
||||
chunks.push(items.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
// Track per-worker progress for cumulative reporting
|
||||
const workerProgress = new Array(chunks.length).fill(0);
|
||||
|
||||
// Send one chunk to each worker, collect results
|
||||
const promises = chunks.map((chunk, i) => {
|
||||
const worker = workers[i];
|
||||
return new Promise<TResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let subBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
if (subBatchTimer) clearTimeout(subBatchTimer);
|
||||
worker.removeListener('message', handler);
|
||||
worker.removeListener('error', errorHandler);
|
||||
worker.removeListener('exit', exitHandler);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Worker ${i} timed out after 5 minutes (chunk: ${chunk.length} items). Worker may have crashed or is processing too much data.`));
|
||||
const resetSubBatchTimer = () => {
|
||||
if (subBatchTimer) clearTimeout(subBatchTimer);
|
||||
subBatchTimer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Worker ${i} sub-batch timed out after ${SUB_BATCH_TIMEOUT_MS / 1000}s (chunk: ${chunk.length} items).`));
|
||||
}
|
||||
}, SUB_BATCH_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
let subBatchIdx = 0;
|
||||
|
||||
const sendNextSubBatch = () => {
|
||||
const start = subBatchIdx * SUB_BATCH_SIZE;
|
||||
if (start >= chunk.length) {
|
||||
worker.postMessage({ type: 'flush' });
|
||||
return;
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
const subBatch = chunk.slice(start, start + SUB_BATCH_SIZE);
|
||||
subBatchIdx++;
|
||||
resetSubBatchTimer();
|
||||
worker.postMessage({ type: 'sub-batch', files: subBatch });
|
||||
};
|
||||
|
||||
const handler = (msg: any) => {
|
||||
if (settled) return;
|
||||
|
|
@ -74,8 +94,9 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
|||
const total = workerProgress.reduce((a, b) => a + b, 0);
|
||||
onProgress(total);
|
||||
}
|
||||
} else if (msg && msg.type === 'sub-batch-done') {
|
||||
sendNextSubBatch();
|
||||
} else if (msg && msg.type === 'error') {
|
||||
// Error reported by worker via postMessage
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Worker ${i} error: ${msg.error}`));
|
||||
|
|
@ -84,7 +105,6 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
|||
cleanup();
|
||||
resolve(msg.data);
|
||||
} else {
|
||||
// Legacy: treat any non-typed message as result
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(msg);
|
||||
|
|
@ -92,25 +112,21 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
|||
};
|
||||
|
||||
const errorHandler = (err: any) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
if (!settled) { settled = true; cleanup(); reject(err); }
|
||||
};
|
||||
|
||||
const exitHandler = (code: number) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Worker ${i} exited unexpectedly with code ${code}. This usually indicates an out-of-memory crash or native addon failure.`));
|
||||
reject(new Error(`Worker ${i} exited with code ${code}. Likely OOM or native addon failure.`));
|
||||
}
|
||||
};
|
||||
|
||||
worker.on('message', handler);
|
||||
worker.once('error', errorHandler);
|
||||
worker.once('exit', exitHandler);
|
||||
worker.postMessage(chunk);
|
||||
sendNextSubBatch();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,315 +1,342 @@
|
|||
/**
|
||||
* CSV Generator for KuzuDB Hybrid Schema
|
||||
*
|
||||
* Generates separate CSV files for each node table and one relation CSV.
|
||||
* This enables efficient bulk loading via COPY FROM for hybrid schema.
|
||||
*
|
||||
*
|
||||
* Streams CSV rows directly to disk files in a single pass over graph nodes.
|
||||
* File contents are lazy-read from disk per-node to avoid holding the entire
|
||||
* repo in RAM. Rows are buffered (FLUSH_EVERY) before writing to minimize
|
||||
* per-row Promise overhead.
|
||||
*
|
||||
* RFC 4180 Compliant:
|
||||
* - Fields containing commas, double quotes, or newlines are enclosed in double quotes
|
||||
* - Double quotes within fields are escaped by doubling them ("")
|
||||
* - All fields are consistently quoted for safety with code content
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { createWriteStream, WriteStream } from 'fs';
|
||||
import path from 'path';
|
||||
import { KnowledgeGraph, GraphNode, NodeLabel } from '../graph/types.js';
|
||||
import { NODE_TABLES, NodeTableName } from './schema.js';
|
||||
import { NodeTableName } from './schema.js';
|
||||
|
||||
/** Flush buffered rows to disk every N rows */
|
||||
const FLUSH_EVERY = 500;
|
||||
|
||||
// ============================================================================
|
||||
// CSV ESCAPE UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sanitize string to ensure valid UTF-8 and safe CSV content for KuzuDB
|
||||
* Removes or replaces invalid characters that would break CSV parsing.
|
||||
*
|
||||
* Critical: KuzuDB's native CSV parser on Windows can misinterpret \r\n
|
||||
* inside quoted fields. We normalize all line endings to \n only.
|
||||
*/
|
||||
const sanitizeUTF8 = (str: string): string => {
|
||||
return str
|
||||
.replace(/\r\n/g, '\n') // Normalize Windows line endings first
|
||||
.replace(/\r/g, '\n') // Normalize remaining \r to \n
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control chars except \t \n
|
||||
.replace(/[\uD800-\uDFFF]/g, '') // Remove surrogate pairs (invalid standalone)
|
||||
.replace(/[\uFFFE\uFFFF]/g, ''); // Remove BOM and special chars
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
||||
.replace(/[\uD800-\uDFFF]/g, '')
|
||||
.replace(/[\uFFFE\uFFFF]/g, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* RFC 4180 compliant CSV field escaping
|
||||
* ALWAYS wraps in double quotes for safety with code content
|
||||
*/
|
||||
const escapeCSVField = (value: string | number | undefined | null): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return '""';
|
||||
}
|
||||
if (value === undefined || value === null) return '""';
|
||||
let str = String(value);
|
||||
str = sanitizeUTF8(str);
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape a numeric value (no quotes needed for numbers)
|
||||
*/
|
||||
const escapeCSVNumber = (value: number | undefined | null, defaultValue: number = -1): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return String(defaultValue);
|
||||
}
|
||||
if (value === undefined || value === null) return String(defaultValue);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// CONTENT EXTRACTION
|
||||
// CONTENT EXTRACTION (lazy — reads from disk on demand)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if content looks like binary data
|
||||
*/
|
||||
const isBinaryContent = (content: string): boolean => {
|
||||
if (!content || content.length === 0) return false;
|
||||
const sample = content.slice(0, 1000);
|
||||
let nonPrintable = 0;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const code = sample.charCodeAt(i);
|
||||
if ((code < 9) || (code > 13 && code < 32) || code === 127) {
|
||||
nonPrintable++;
|
||||
}
|
||||
if ((code < 9) || (code > 13 && code < 32) || code === 127) nonPrintable++;
|
||||
}
|
||||
return (nonPrintable / sample.length) > 0.1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract code content for a node
|
||||
* LRU content cache — avoids re-reading the same source file for every
|
||||
* symbol defined in it. Sized generously so most files stay cached during
|
||||
* the single-pass node iteration.
|
||||
*/
|
||||
const extractContent = (
|
||||
class FileContentCache {
|
||||
private cache = new Map<string, string>();
|
||||
private accessOrder: string[] = [];
|
||||
private maxSize: number;
|
||||
private repoPath: string;
|
||||
|
||||
constructor(repoPath: string, maxSize: number = 3000) {
|
||||
this.repoPath = repoPath;
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
async get(relativePath: string): Promise<string> {
|
||||
if (!relativePath) return '';
|
||||
const cached = this.cache.get(relativePath);
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const fullPath = path.join(this.repoPath, relativePath);
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
this.set(relativePath, content);
|
||||
return content;
|
||||
} catch {
|
||||
this.set(relativePath, '');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private set(key: string, value: string) {
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
const oldest = this.accessOrder.shift();
|
||||
if (oldest) this.cache.delete(oldest);
|
||||
}
|
||||
this.cache.set(key, value);
|
||||
this.accessOrder.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const extractContent = async (
|
||||
node: GraphNode,
|
||||
fileContents: Map<string, string>
|
||||
): string => {
|
||||
contentCache: FileContentCache
|
||||
): Promise<string> => {
|
||||
const filePath = node.properties.filePath;
|
||||
const content = fileContents.get(filePath);
|
||||
|
||||
const content = await contentCache.get(filePath);
|
||||
if (!content) return '';
|
||||
if (node.label === 'Folder') return '';
|
||||
if (isBinaryContent(content)) return '[Binary file - content not stored]';
|
||||
|
||||
// For File nodes, return content (limited)
|
||||
|
||||
if (node.label === 'File') {
|
||||
const MAX_FILE_CONTENT = 10000;
|
||||
if (content.length > MAX_FILE_CONTENT) {
|
||||
return content.slice(0, MAX_FILE_CONTENT) + '\n... [truncated]';
|
||||
}
|
||||
return content;
|
||||
return content.length > MAX_FILE_CONTENT
|
||||
? content.slice(0, MAX_FILE_CONTENT) + '\n... [truncated]'
|
||||
: content;
|
||||
}
|
||||
|
||||
// For code elements, extract the relevant lines with context
|
||||
|
||||
const startLine = node.properties.startLine;
|
||||
const endLine = node.properties.endLine;
|
||||
|
||||
if (startLine === undefined || endLine === undefined) return '';
|
||||
|
||||
|
||||
const lines = content.split('\n');
|
||||
const contextLines = 2;
|
||||
const start = Math.max(0, startLine - contextLines);
|
||||
const end = Math.min(lines.length - 1, endLine + contextLines);
|
||||
|
||||
const start = Math.max(0, startLine - 2);
|
||||
const end = Math.min(lines.length - 1, endLine + 2);
|
||||
const snippet = lines.slice(start, end + 1).join('\n');
|
||||
const MAX_SNIPPET = 5000;
|
||||
if (snippet.length > MAX_SNIPPET) {
|
||||
return snippet.slice(0, MAX_SNIPPET) + '\n... [truncated]';
|
||||
}
|
||||
return snippet;
|
||||
return snippet.length > MAX_SNIPPET
|
||||
? snippet.slice(0, MAX_SNIPPET) + '\n... [truncated]'
|
||||
: snippet;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// CSV GENERATION RESULT TYPE
|
||||
// BUFFERED CSV WRITER
|
||||
// ============================================================================
|
||||
|
||||
export interface CSVData {
|
||||
nodes: Map<NodeTableName, string>;
|
||||
relCSV: string; // Single relation CSV with from,to,type,confidence,reason columns
|
||||
class BufferedCSVWriter {
|
||||
private ws: WriteStream;
|
||||
private buffer: string[] = [];
|
||||
rows = 0;
|
||||
|
||||
constructor(filePath: string, header: string) {
|
||||
this.ws = createWriteStream(filePath, 'utf-8');
|
||||
// Large repos flush many times — raise listener cap to avoid MaxListenersExceededWarning
|
||||
this.ws.setMaxListeners(50);
|
||||
this.buffer.push(header);
|
||||
}
|
||||
|
||||
addRow(row: string) {
|
||||
this.buffer.push(row);
|
||||
this.rows++;
|
||||
if (this.buffer.length >= FLUSH_EVERY) {
|
||||
return this.flush();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
flush(): Promise<void> {
|
||||
if (this.buffer.length === 0) return Promise.resolve();
|
||||
const chunk = this.buffer.join('\n') + '\n';
|
||||
this.buffer.length = 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
const ok = this.ws.write(chunk);
|
||||
if (ok) resolve();
|
||||
else this.ws.once('drain', resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async finish(): Promise<void> {
|
||||
await this.flush();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws.end(() => resolve());
|
||||
this.ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NODE CSV GENERATORS
|
||||
// STREAMING CSV GENERATION — SINGLE PASS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate CSV for File nodes
|
||||
* Headers: id,name,filePath,content
|
||||
*/
|
||||
const generateFileCSV = (nodes: GraphNode[], fileContents: Map<string, string>): string => {
|
||||
const headers = ['id', 'name', 'filePath', 'content'];
|
||||
const rows: string[] = [headers.join(',')];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.label !== 'File') continue;
|
||||
// Skip duplicates
|
||||
if (seenIds.has(node.id)) continue;
|
||||
seenIds.add(node.id);
|
||||
|
||||
const content = extractContent(node, fileContents);
|
||||
rows.push([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVField(content),
|
||||
].join(','));
|
||||
}
|
||||
|
||||
return rows.join('\n');
|
||||
};
|
||||
export interface StreamedCSVResult {
|
||||
nodeFiles: Map<NodeTableName, { csvPath: string; rows: number }>;
|
||||
relCsvPath: string;
|
||||
relRows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSV for Folder nodes
|
||||
* Headers: id,name,filePath
|
||||
* Stream all CSV data directly to disk files.
|
||||
* Iterates graph nodes exactly ONCE — routes each node to the right writer.
|
||||
* File contents are lazy-read from disk with a generous LRU cache.
|
||||
*/
|
||||
const generateFolderCSV = (nodes: GraphNode[]): string => {
|
||||
const headers = ['id', 'name', 'filePath'];
|
||||
const rows: string[] = [headers.join(',')];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.label !== 'Folder') continue;
|
||||
rows.push([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
].join(','));
|
||||
}
|
||||
|
||||
return rows.join('\n');
|
||||
};
|
||||
export const streamAllCSVsToDisk = async (
|
||||
graph: KnowledgeGraph,
|
||||
repoPath: string,
|
||||
csvDir: string,
|
||||
): Promise<StreamedCSVResult> => {
|
||||
// Remove stale CSVs from previous crashed runs, then recreate
|
||||
try { await fs.rm(csvDir, { recursive: true, force: true }); } catch {}
|
||||
await fs.mkdir(csvDir, { recursive: true });
|
||||
|
||||
/**
|
||||
* Generate CSV for code element nodes (Function, Class, Interface, Method, CodeElement)
|
||||
* Headers: id,name,filePath,startLine,endLine,isExported,content,description
|
||||
*/
|
||||
const generateCodeElementCSV = (
|
||||
nodes: GraphNode[],
|
||||
label: NodeLabel,
|
||||
fileContents: Map<string, string>
|
||||
): string => {
|
||||
const headers = ['id', 'name', 'filePath', 'startLine', 'endLine', 'isExported', 'content', 'description'];
|
||||
const rows: string[] = [headers.join(',')];
|
||||
// We open ~30 concurrent write-streams; raise process limit to suppress
|
||||
// MaxListenersExceededWarning (restored after all streams finish).
|
||||
const prevMax = process.getMaxListeners();
|
||||
process.setMaxListeners(prevMax + 40);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.label !== label) continue;
|
||||
const content = extractContent(node, fileContents);
|
||||
rows.push([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
node.properties.isExported ? 'true' : 'false',
|
||||
escapeCSVField(content),
|
||||
escapeCSVField((node.properties as any).description || ''),
|
||||
].join(','));
|
||||
const contentCache = new FileContentCache(repoPath);
|
||||
|
||||
// Create writers for every node type up-front
|
||||
const fileWriter = new BufferedCSVWriter(path.join(csvDir, 'file.csv'), 'id,name,filePath,content');
|
||||
const folderWriter = new BufferedCSVWriter(path.join(csvDir, 'folder.csv'), 'id,name,filePath');
|
||||
const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content,description';
|
||||
const functionWriter = new BufferedCSVWriter(path.join(csvDir, 'function.csv'), codeElementHeader);
|
||||
const classWriter = new BufferedCSVWriter(path.join(csvDir, 'class.csv'), codeElementHeader);
|
||||
const interfaceWriter = new BufferedCSVWriter(path.join(csvDir, 'interface.csv'), codeElementHeader);
|
||||
const methodWriter = new BufferedCSVWriter(path.join(csvDir, 'method.csv'), codeElementHeader);
|
||||
const codeElemWriter = new BufferedCSVWriter(path.join(csvDir, 'codeelement.csv'), codeElementHeader);
|
||||
const communityWriter = new BufferedCSVWriter(path.join(csvDir, 'community.csv'), 'id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount');
|
||||
const processWriter = new BufferedCSVWriter(path.join(csvDir, 'process.csv'), 'id,label,heuristicLabel,processType,stepCount,communities,entryPointId,terminalId');
|
||||
|
||||
// Multi-language node types share the same CSV shape (no isExported column)
|
||||
const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description';
|
||||
const MULTI_LANG_TYPES = ['Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl',
|
||||
'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module'] as const;
|
||||
const multiLangWriters = new Map<string, BufferedCSVWriter>();
|
||||
for (const t of MULTI_LANG_TYPES) {
|
||||
multiLangWriters.set(t, new BufferedCSVWriter(path.join(csvDir, `${t.toLowerCase()}.csv`), multiLangHeader));
|
||||
}
|
||||
|
||||
return rows.join('\n');
|
||||
};
|
||||
const codeWriterMap: Record<string, BufferedCSVWriter> = {
|
||||
'Function': functionWriter,
|
||||
'Class': classWriter,
|
||||
'Interface': interfaceWriter,
|
||||
'Method': methodWriter,
|
||||
'CodeElement': codeElemWriter,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate CSV for Community nodes (from Leiden algorithm)
|
||||
* Headers: id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount
|
||||
*/
|
||||
const generateCommunityCSV = (nodes: GraphNode[]): string => {
|
||||
const headers = ['id', 'label', 'heuristicLabel', 'keywords', 'description', 'enrichedBy', 'cohesion', 'symbolCount'];
|
||||
const rows: string[] = [headers.join(',')];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.label !== 'Community') continue;
|
||||
|
||||
// Handle keywords array - convert to KuzuDB array format
|
||||
const keywords = (node.properties as any).keywords || [];
|
||||
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`;
|
||||
|
||||
rows.push([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''), // label is stored in name
|
||||
escapeCSVField(node.properties.heuristicLabel || ''),
|
||||
keywordsStr, // Array format for KuzuDB
|
||||
escapeCSVField((node.properties as any).description || ''),
|
||||
escapeCSVField((node.properties as any).enrichedBy || 'heuristic'),
|
||||
escapeCSVNumber(node.properties.cohesion, 0),
|
||||
escapeCSVNumber(node.properties.symbolCount, 0),
|
||||
].join(','));
|
||||
}
|
||||
|
||||
return rows.join('\n');
|
||||
};
|
||||
const seenFileIds = new Set<string>();
|
||||
|
||||
/**
|
||||
* Generate CSV for Process nodes
|
||||
* Headers: id,label,heuristicLabel,processType,stepCount,communities,entryPointId,terminalId
|
||||
*/
|
||||
const generateProcessCSV = (nodes: GraphNode[]): string => {
|
||||
const headers = ['id', 'label', 'heuristicLabel', 'processType', 'stepCount', 'communities', 'entryPointId', 'terminalId'];
|
||||
const rows: string[] = [headers.join(',')];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.label !== 'Process') continue;
|
||||
|
||||
// Handle communities array (string[])
|
||||
const communities = (node.properties as any).communities || [];
|
||||
const communitiesStr = `[${communities.map((c: string) => `'${c.replace(/'/g, "''")}'`).join(',')}]`;
|
||||
|
||||
rows.push([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''), // label stores name
|
||||
escapeCSVField((node.properties as any).heuristicLabel || ''),
|
||||
escapeCSVField((node.properties as any).processType || ''),
|
||||
escapeCSVNumber((node.properties as any).stepCount, 0),
|
||||
escapeCSVField(communitiesStr), // Needs CSV escaping because it contains commas!
|
||||
escapeCSVField((node.properties as any).entryPointId || ''),
|
||||
escapeCSVField((node.properties as any).terminalId || ''),
|
||||
].join(','));
|
||||
}
|
||||
|
||||
return rows.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate CSV for multi-language node tables (Struct, Enum, Trait, Property, etc.)
|
||||
* These use CODE_ELEMENT_BASE schema: id,name,filePath,startLine,endLine,content,description
|
||||
*/
|
||||
const generateMultiLangNodeCSV = (
|
||||
nodes: GraphNode[],
|
||||
label: string,
|
||||
fileContents: Map<string, string>
|
||||
): string => {
|
||||
const headers = ['id', 'name', 'filePath', 'startLine', 'endLine', 'content', 'description'];
|
||||
const rows: string[] = [headers.join(',')];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.label !== label) continue;
|
||||
const content = extractContent(node, fileContents);
|
||||
rows.push([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
escapeCSVField(content),
|
||||
escapeCSVField((node.properties as any).description || ''),
|
||||
].join(','));
|
||||
// --- SINGLE PASS over all nodes ---
|
||||
for (const node of graph.iterNodes()) {
|
||||
switch (node.label) {
|
||||
case 'File': {
|
||||
if (seenFileIds.has(node.id)) break;
|
||||
seenFileIds.add(node.id);
|
||||
const content = await extractContent(node, contentCache);
|
||||
await fileWriter.addRow([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVField(content),
|
||||
].join(','));
|
||||
break;
|
||||
}
|
||||
case 'Folder':
|
||||
await folderWriter.addRow([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
].join(','));
|
||||
break;
|
||||
case 'Community': {
|
||||
const keywords = (node.properties as any).keywords || [];
|
||||
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`;
|
||||
await communityWriter.addRow([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.heuristicLabel || ''),
|
||||
keywordsStr,
|
||||
escapeCSVField((node.properties as any).description || ''),
|
||||
escapeCSVField((node.properties as any).enrichedBy || 'heuristic'),
|
||||
escapeCSVNumber(node.properties.cohesion, 0),
|
||||
escapeCSVNumber(node.properties.symbolCount, 0),
|
||||
].join(','));
|
||||
break;
|
||||
}
|
||||
case 'Process': {
|
||||
const communities = (node.properties as any).communities || [];
|
||||
const communitiesStr = `[${communities.map((c: string) => `'${c.replace(/'/g, "''")}'`).join(',')}]`;
|
||||
await processWriter.addRow([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField((node.properties as any).heuristicLabel || ''),
|
||||
escapeCSVField((node.properties as any).processType || ''),
|
||||
escapeCSVNumber((node.properties as any).stepCount, 0),
|
||||
escapeCSVField(communitiesStr),
|
||||
escapeCSVField((node.properties as any).entryPointId || ''),
|
||||
escapeCSVField((node.properties as any).terminalId || ''),
|
||||
].join(','));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Code element nodes (Function, Class, Interface, Method, CodeElement)
|
||||
const writer = codeWriterMap[node.label];
|
||||
if (writer) {
|
||||
const content = await extractContent(node, contentCache);
|
||||
await writer.addRow([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
node.properties.isExported ? 'true' : 'false',
|
||||
escapeCSVField(content),
|
||||
escapeCSVField((node.properties as any).description || ''),
|
||||
].join(','));
|
||||
} else {
|
||||
// Multi-language node types (Struct, Impl, Trait, Macro, etc.)
|
||||
const mlWriter = multiLangWriters.get(node.label);
|
||||
if (mlWriter) {
|
||||
const content = await extractContent(node, contentCache);
|
||||
await mlWriter.addRow([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
escapeCSVField(content),
|
||||
escapeCSVField((node.properties as any).description || ''),
|
||||
].join(','));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows.join('\n');
|
||||
};
|
||||
// Finish all node writers
|
||||
const allWriters = [fileWriter, folderWriter, functionWriter, classWriter, interfaceWriter, methodWriter, codeElemWriter, communityWriter, processWriter, ...multiLangWriters.values()];
|
||||
await Promise.all(allWriters.map(w => w.finish()));
|
||||
|
||||
/**
|
||||
* Generate CSV for the single CodeRelation table
|
||||
* Headers: from,to,type,confidence,reason
|
||||
*
|
||||
* confidence: 0-1 score for CALLS edges (how sure are we about the target?)
|
||||
* reason: 'import-resolved' | 'same-file' | 'fuzzy-global' (or empty for non-CALLS)
|
||||
*/
|
||||
const generateRelationCSV = (graph: KnowledgeGraph): string => {
|
||||
const headers = ['from', 'to', 'type', 'confidence', 'reason', 'step'];
|
||||
const rows: string[] = [headers.join(',')];
|
||||
|
||||
for (const rel of graph.relationships) {
|
||||
rows.push([
|
||||
// --- Stream relationship CSV ---
|
||||
const relCsvPath = path.join(csvDir, 'relations.csv');
|
||||
const relWriter = new BufferedCSVWriter(relCsvPath, 'from,to,type,confidence,reason,step');
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
await relWriter.addRow([
|
||||
escapeCSVField(rel.sourceId),
|
||||
escapeCSVField(rel.targetId),
|
||||
escapeCSVField(rel.type),
|
||||
|
|
@ -318,49 +345,26 @@ const generateRelationCSV = (graph: KnowledgeGraph): string => {
|
|||
escapeCSVNumber((rel as any).step, 0),
|
||||
].join(','));
|
||||
}
|
||||
|
||||
return rows.join('\n');
|
||||
};
|
||||
await relWriter.finish();
|
||||
|
||||
// ============================================================================
|
||||
// MAIN CSV GENERATION FUNCTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate all CSV data for hybrid schema bulk loading
|
||||
* Returns Maps of node table name -> CSV content, and single relation CSV
|
||||
*/
|
||||
export const generateAllCSVs = (
|
||||
graph: KnowledgeGraph,
|
||||
fileContents: Map<string, string>
|
||||
): CSVData => {
|
||||
const nodes = Array.from(graph.nodes);
|
||||
|
||||
// Generate node CSVs
|
||||
const nodeCSVs = new Map<NodeTableName, string>();
|
||||
nodeCSVs.set('File', generateFileCSV(nodes, fileContents));
|
||||
nodeCSVs.set('Folder', generateFolderCSV(nodes));
|
||||
nodeCSVs.set('Function', generateCodeElementCSV(nodes, 'Function', fileContents));
|
||||
nodeCSVs.set('Class', generateCodeElementCSV(nodes, 'Class', fileContents));
|
||||
nodeCSVs.set('Interface', generateCodeElementCSV(nodes, 'Interface', fileContents));
|
||||
nodeCSVs.set('Method', generateCodeElementCSV(nodes, 'Method', fileContents));
|
||||
nodeCSVs.set('CodeElement', generateCodeElementCSV(nodes, 'CodeElement', fileContents));
|
||||
nodeCSVs.set('Community', generateCommunityCSV(nodes));
|
||||
nodeCSVs.set('Process', generateProcessCSV(nodes));
|
||||
|
||||
// Multi-language node types (CODE_ELEMENT_BASE schema: id,name,filePath,startLine,endLine,content,description)
|
||||
const multiLangTypes = [
|
||||
'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl',
|
||||
'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation',
|
||||
'Constructor', 'Template', 'Module',
|
||||
] as const;
|
||||
for (const mlType of multiLangTypes) {
|
||||
nodeCSVs.set(mlType, generateMultiLangNodeCSV(nodes, mlType, fileContents));
|
||||
// Build result map — only include tables that have rows
|
||||
const nodeFiles = new Map<NodeTableName, { csvPath: string; rows: number }>();
|
||||
const tableMap: [NodeTableName, BufferedCSVWriter][] = [
|
||||
['File', fileWriter], ['Folder', folderWriter],
|
||||
['Function', functionWriter], ['Class', classWriter],
|
||||
['Interface', interfaceWriter], ['Method', methodWriter],
|
||||
['CodeElement', codeElemWriter],
|
||||
['Community', communityWriter], ['Process', processWriter],
|
||||
...Array.from(multiLangWriters.entries()).map(([name, w]) => [name as NodeTableName, w] as [NodeTableName, BufferedCSVWriter]),
|
||||
];
|
||||
for (const [name, writer] of tableMap) {
|
||||
if (writer.rows > 0) {
|
||||
nodeFiles.set(name, { csvPath: path.join(csvDir, `${name.toLowerCase()}.csv`), rows: writer.rows });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate single relation CSV
|
||||
const relCSV = generateRelationCSV(graph);
|
||||
|
||||
return { nodes: nodeCSVs, relCSV };
|
||||
};
|
||||
// Restore original process listener limit
|
||||
process.setMaxListeners(prevMax);
|
||||
|
||||
return { nodeFiles, relCsvPath, relRows: relWriter.rows };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import fs from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import { createInterface } from 'readline';
|
||||
import path from 'path';
|
||||
import kuzu from 'kuzu';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
|
|
@ -9,7 +11,7 @@ import {
|
|||
EMBEDDING_TABLE_NAME,
|
||||
NodeTableName,
|
||||
} from './schema.js';
|
||||
import { generateAllCSVs } from './csv-generator.js';
|
||||
import { streamAllCSVsToDisk } from './csv-generator.js';
|
||||
|
||||
let db: kuzu.Database | null = null;
|
||||
let conn: kuzu.Connection | null = null;
|
||||
|
|
@ -65,7 +67,7 @@ export type KuzuProgressCallback = (message: string) => void;
|
|||
|
||||
export const loadGraphToKuzu = async (
|
||||
graph: KnowledgeGraph,
|
||||
fileContents: Map<string, string>,
|
||||
repoPath: string,
|
||||
storagePath: string,
|
||||
onProgress?: KuzuProgressCallback
|
||||
) => {
|
||||
|
|
@ -75,23 +77,11 @@ export const loadGraphToKuzu = async (
|
|||
|
||||
const log = onProgress || (() => {});
|
||||
|
||||
const csvData = generateAllCSVs(graph, fileContents);
|
||||
const csvDir = path.join(storagePath, 'csv');
|
||||
await fs.mkdir(csvDir, { recursive: true });
|
||||
|
||||
log('Generating CSVs...');
|
||||
log('Streaming CSVs to disk...');
|
||||
const csvResult = await streamAllCSVsToDisk(graph, repoPath, csvDir);
|
||||
|
||||
const nodeFiles: Array<{ table: NodeTableName; path: string; rows: number }> = [];
|
||||
for (const [tableName, csv] of csvData.nodes.entries()) {
|
||||
const rowCount = csv.split('\n').length - 1;
|
||||
if (rowCount <= 0) continue;
|
||||
const filePath = path.join(csvDir, `${tableName.toLowerCase()}.csv`);
|
||||
await fs.writeFile(filePath, csv, 'utf-8');
|
||||
nodeFiles.push({ table: tableName, path: filePath, rows: rowCount });
|
||||
}
|
||||
|
||||
// Write relationship CSV to disk for bulk COPY
|
||||
const relCsvPath = path.join(csvDir, 'relations.csv');
|
||||
const validTables = new Set<string>(NODE_TABLES as readonly string[]);
|
||||
const getNodeLabel = (nodeId: string): string => {
|
||||
if (nodeId.startsWith('comm_')) return 'Community';
|
||||
|
|
@ -99,34 +89,16 @@ export const loadGraphToKuzu = async (
|
|||
return nodeId.split(':')[0];
|
||||
};
|
||||
|
||||
const relLines = csvData.relCSV.split('\n');
|
||||
const relHeader = relLines[0];
|
||||
const validRelLines = [relHeader];
|
||||
let skippedRels = 0;
|
||||
for (let i = 1; i < relLines.length; i++) {
|
||||
const line = relLines[i];
|
||||
if (!line.trim()) continue;
|
||||
const match = line.match(/"([^"]*)","([^"]*)"/);
|
||||
if (!match) { skippedRels++; continue; }
|
||||
const fromLabel = getNodeLabel(match[1]);
|
||||
const toLabel = getNodeLabel(match[2]);
|
||||
if (!validTables.has(fromLabel) || !validTables.has(toLabel)) {
|
||||
skippedRels++;
|
||||
continue;
|
||||
}
|
||||
validRelLines.push(line);
|
||||
}
|
||||
await fs.writeFile(relCsvPath, validRelLines.join('\n'), 'utf-8');
|
||||
|
||||
// Bulk COPY all node CSVs
|
||||
// Bulk COPY all node CSVs (sequential — KuzuDB allows only one write txn at a time)
|
||||
const nodeFiles = [...csvResult.nodeFiles.entries()];
|
||||
const totalSteps = nodeFiles.length + 1; // +1 for relationships
|
||||
let stepsDone = 0;
|
||||
|
||||
for (const { table, path: filePath, rows } of nodeFiles) {
|
||||
for (const [table, { csvPath, rows }] of nodeFiles) {
|
||||
stepsDone++;
|
||||
log(`Loading nodes ${stepsDone}/${totalSteps}: ${table} (${rows.toLocaleString()} rows)`);
|
||||
|
||||
const normalizedPath = normalizeCopyPath(filePath);
|
||||
const normalizedPath = normalizeCopyPath(csvPath);
|
||||
const copyQuery = getCopyQuery(table, normalizedPath);
|
||||
|
||||
try {
|
||||
|
|
@ -143,21 +115,39 @@ export const loadGraphToKuzu = async (
|
|||
}
|
||||
|
||||
// Bulk COPY relationships — split by FROM→TO label pair (KuzuDB requires it)
|
||||
const insertedRels = validRelLines.length - 1;
|
||||
const warnings: string[] = [];
|
||||
if (insertedRels > 0) {
|
||||
const relsByPair = new Map<string, string[]>();
|
||||
for (let i = 1; i < validRelLines.length; i++) {
|
||||
const line = validRelLines[i];
|
||||
// Stream-read the relation CSV line by line to avoid exceeding V8 max string length
|
||||
let relHeader = '';
|
||||
const relsByPair = new Map<string, string[]>();
|
||||
let skippedRels = 0;
|
||||
let totalValidRels = 0;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const rl = createInterface({ input: createReadStream(csvResult.relCsvPath, 'utf-8'), crlfDelay: Infinity });
|
||||
let isFirst = true;
|
||||
rl.on('line', (line) => {
|
||||
if (isFirst) { relHeader = line; isFirst = false; return; }
|
||||
if (!line.trim()) return;
|
||||
const match = line.match(/"([^"]*)","([^"]*)"/);
|
||||
if (!match) continue;
|
||||
if (!match) { skippedRels++; return; }
|
||||
const fromLabel = getNodeLabel(match[1]);
|
||||
const toLabel = getNodeLabel(match[2]);
|
||||
if (!validTables.has(fromLabel) || !validTables.has(toLabel)) {
|
||||
skippedRels++;
|
||||
return;
|
||||
}
|
||||
const pairKey = `${fromLabel}|${toLabel}`;
|
||||
let list = relsByPair.get(pairKey);
|
||||
if (!list) { list = []; relsByPair.set(pairKey, list); }
|
||||
list.push(line);
|
||||
}
|
||||
totalValidRels++;
|
||||
});
|
||||
rl.on('close', resolve);
|
||||
rl.on('error', reject);
|
||||
});
|
||||
|
||||
const insertedRels = totalValidRels;
|
||||
const warnings: string[] = [];
|
||||
if (insertedRels > 0) {
|
||||
|
||||
log(`Loading edges: ${insertedRels.toLocaleString()} across ${relsByPair.size} types`);
|
||||
|
||||
|
|
@ -200,9 +190,9 @@ export const loadGraphToKuzu = async (
|
|||
}
|
||||
|
||||
// Cleanup all CSVs
|
||||
try { await fs.unlink(relCsvPath); } catch {}
|
||||
for (const { path: filePath } of nodeFiles) {
|
||||
try { await fs.unlink(filePath); } catch {}
|
||||
try { await fs.unlink(csvResult.relCsvPath); } catch {}
|
||||
for (const [, { csvPath }] of csvResult.nodeFiles) {
|
||||
try { await fs.unlink(csvPath); } catch {}
|
||||
}
|
||||
try {
|
||||
const remaining = await fs.readdir(csvDir);
|
||||
|
|
@ -268,6 +258,9 @@ const fallbackRelationshipInserts = async (
|
|||
}
|
||||
};
|
||||
|
||||
/** Tables with isExported column (TypeScript/JS-native types) */
|
||||
const TABLES_WITH_EXPORTED = new Set<string>(['Function', 'Class', 'Interface', 'Method', 'CodeElement']);
|
||||
|
||||
const getCopyQuery = (table: NodeTableName, filePath: string): string => {
|
||||
const t = escapeTableName(table);
|
||||
if (table === 'File') {
|
||||
|
|
@ -282,12 +275,12 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => {
|
|||
if (table === 'Process') {
|
||||
return `COPY ${t}(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
// Multi-language code element tables (CODE_ELEMENT_BASE: no isExported, has description)
|
||||
if (BACKTICK_TABLES.has(table)) {
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
// TypeScript/JS code element tables have isExported; multi-language tables do not
|
||||
if (TABLES_WITH_EXPORTED.has(table)) {
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
// Core code element tables (Function, Class, Interface, Method, CodeElement)
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
// Multi-language tables (Struct, Impl, Trait, Macro, etc.)
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -316,16 +309,20 @@ export const insertNodeToKuzu = async (
|
|||
};
|
||||
|
||||
// Build INSERT query based on node type
|
||||
const t = escapeTableName(label);
|
||||
let query: string;
|
||||
|
||||
|
||||
if (label === 'File') {
|
||||
query = `CREATE (n:File {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, content: ${escapeValue(properties.content || '')}})`;
|
||||
} else if (label === 'Folder') {
|
||||
query = `CREATE (n:Folder {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}})`;
|
||||
} else if (TABLES_WITH_EXPORTED.has(label)) {
|
||||
const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` : '';
|
||||
query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || '')}${descPart}})`;
|
||||
} else {
|
||||
// Function, Class, Method, Interface, etc. - standard code element schema
|
||||
const descStr = properties.description ? `, description: ${escapeValue(properties.description)}` : '';
|
||||
query = `CREATE (n:${label} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${escapeValue(properties.content || '')}${descStr}})`;
|
||||
// Multi-language tables (Struct, Impl, Trait, Macro, etc.) — no isExported
|
||||
const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` : '';
|
||||
query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${escapeValue(properties.content || '')}${descPart}})`;
|
||||
}
|
||||
|
||||
// Use per-query connection if dbPath provided (avoids lock conflicts)
|
||||
|
|
@ -385,13 +382,17 @@ export const batchInsertNodesToKuzu = async (
|
|||
let query: string;
|
||||
|
||||
// Use MERGE instead of CREATE for upsert behavior (handles duplicates gracefully)
|
||||
const t = escapeTableName(label);
|
||||
if (label === 'File') {
|
||||
query = `MERGE (n:File {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.content = ${escapeValue(properties.content || '')}`;
|
||||
} else if (label === 'Folder') {
|
||||
query = `MERGE (n:Folder {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}`;
|
||||
} else if (TABLES_WITH_EXPORTED.has(label)) {
|
||||
const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` : '';
|
||||
query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
|
||||
} else {
|
||||
const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` : '';
|
||||
query = `MERGE (n:${label} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
|
||||
query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
|
||||
}
|
||||
|
||||
await tempConn.query(query);
|
||||
|
|
@ -457,7 +458,7 @@ export const getKuzuStats = async (): Promise<{ nodes: number; edges: number }>
|
|||
let totalNodes = 0;
|
||||
for (const tableName of NODE_TABLES) {
|
||||
try {
|
||||
const queryResult = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`);
|
||||
const queryResult = await conn.query(`MATCH (n:${escapeTableName(tableName)}) RETURN count(n) AS cnt`);
|
||||
const nodeResult = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
const nodeRows = await nodeResult.getAll();
|
||||
if (nodeRows.length > 0) {
|
||||
|
|
@ -531,6 +532,7 @@ export const closeKuzu = async (): Promise<void> => {
|
|||
} catch {}
|
||||
db = null;
|
||||
}
|
||||
ftsLoaded = false;
|
||||
};
|
||||
|
||||
export const isKuzuReady = (): boolean => conn !== null && db !== null;
|
||||
|
|
@ -569,17 +571,18 @@ export const deleteNodesForFile = async (filePath: string, dbPath?: string): Pro
|
|||
|
||||
try {
|
||||
// First count how many we'll delete
|
||||
const tn = escapeTableName(tableName);
|
||||
const countResult = await targetConn!.query(
|
||||
`MATCH (n:${tableName}) WHERE n.filePath = '${escapedPath}' RETURN count(n) AS cnt`
|
||||
`MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' RETURN count(n) AS cnt`
|
||||
);
|
||||
const result = Array.isArray(countResult) ? countResult[0] : countResult;
|
||||
const rows = await result.getAll();
|
||||
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
|
||||
|
||||
|
||||
if (count > 0) {
|
||||
// Delete nodes (and implicitly their relationships via DETACH)
|
||||
await targetConn!.query(
|
||||
`MATCH (n:${tableName}) WHERE n.filePath = '${escapedPath}' DETACH DELETE n`
|
||||
`MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' DETACH DELETE n`
|
||||
);
|
||||
deletedNodes += count;
|
||||
}
|
||||
|
|
@ -616,9 +619,12 @@ export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
|
|||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load the FTS extension (required before using FTS functions)
|
||||
* Load the FTS extension (required before using FTS functions).
|
||||
* Safe to call multiple times — tracks loaded state.
|
||||
*/
|
||||
let ftsLoaded = false;
|
||||
export const loadFTSExtension = async (): Promise<void> => {
|
||||
if (ftsLoaded) return;
|
||||
if (!conn) {
|
||||
throw new Error('KuzuDB not initialized. Call initKuzu first.');
|
||||
}
|
||||
|
|
@ -628,6 +634,7 @@ export const loadFTSExtension = async (): Promise<void> => {
|
|||
} catch {
|
||||
// Extension may already be loaded
|
||||
}
|
||||
ftsLoaded = true;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -646,16 +653,15 @@ export const createFTSIndex = async (
|
|||
if (!conn) {
|
||||
throw new Error('KuzuDB not initialized. Call initKuzu first.');
|
||||
}
|
||||
|
||||
|
||||
await loadFTSExtension();
|
||||
|
||||
|
||||
const propList = properties.map(p => `'${p}'`).join(', ');
|
||||
const query = `CALL CREATE_FTS_INDEX('${tableName}', '${indexName}', [${propList}], stemmer := '${stemmer}')`;
|
||||
|
||||
|
||||
try {
|
||||
await conn.query(query);
|
||||
} catch (e: any) {
|
||||
// Index may already exist
|
||||
if (!e.message?.includes('already exists')) {
|
||||
throw e;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,9 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM Function TO \`Impl\`,
|
||||
FROM Function TO Interface,
|
||||
FROM Function TO \`Constructor\`,
|
||||
FROM Function TO \`Const\`,
|
||||
FROM Function TO \`Typedef\`,
|
||||
FROM Function TO \`Union\`,
|
||||
FROM Class TO Method,
|
||||
FROM Class TO Function,
|
||||
FROM Class TO Class,
|
||||
|
|
@ -251,6 +254,11 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM Class TO \`Annotation\`,
|
||||
FROM Class TO \`Constructor\`,
|
||||
FROM Class TO \`Trait\`,
|
||||
FROM Class TO \`Macro\`,
|
||||
FROM Class TO \`Impl\`,
|
||||
FROM Class TO \`Union\`,
|
||||
FROM Class TO \`Namespace\`,
|
||||
FROM Class TO \`Typedef\`,
|
||||
FROM Method TO Function,
|
||||
FROM Method TO Method,
|
||||
FROM Method TO Class,
|
||||
|
|
@ -288,6 +296,9 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM Interface TO \`Constructor\`,
|
||||
FROM \`Struct\` TO Community,
|
||||
FROM \`Struct\` TO \`Trait\`,
|
||||
FROM \`Struct\` TO \`Struct\`,
|
||||
FROM \`Struct\` TO Class,
|
||||
FROM \`Struct\` TO \`Enum\`,
|
||||
FROM \`Struct\` TO Function,
|
||||
FROM \`Struct\` TO Method,
|
||||
FROM \`Enum\` TO Community,
|
||||
|
|
@ -299,10 +310,14 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM \`Typedef\` TO Community,
|
||||
FROM \`Union\` TO Community,
|
||||
FROM \`Namespace\` TO Community,
|
||||
FROM \`Namespace\` TO \`Struct\`,
|
||||
FROM \`Trait\` TO Community,
|
||||
FROM \`Impl\` TO Community,
|
||||
FROM \`Impl\` TO \`Trait\`,
|
||||
FROM \`Impl\` TO \`Struct\`,
|
||||
FROM \`Impl\` TO \`Impl\`,
|
||||
FROM \`TypeAlias\` TO Community,
|
||||
FROM \`TypeAlias\` TO \`Trait\`,
|
||||
FROM \`Const\` TO Community,
|
||||
FROM \`Static\` TO Community,
|
||||
FROM \`Property\` TO Community,
|
||||
|
|
|
|||
|
|
@ -43,10 +43,13 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
|
|||
|
||||
for (const device of devicesToTry) {
|
||||
try {
|
||||
// Silence stdout during model load — ONNX Runtime and transformers.js
|
||||
// may write progress/init messages to stdout which corrupts MCP stdio protocol.
|
||||
const origWrite = process.stdout.write;
|
||||
// Silence stdout and stderr during model load — ONNX Runtime and transformers.js
|
||||
// may write progress/init messages that corrupt MCP stdio protocol or produce
|
||||
// noisy warnings (e.g. node assignment to execution providers).
|
||||
const origStdout = process.stdout.write;
|
||||
const origStderr = process.stderr.write;
|
||||
process.stdout.write = (() => true) as any;
|
||||
process.stderr.write = (() => true) as any;
|
||||
try {
|
||||
embedderInstance = await (pipeline as any)(
|
||||
'feature-extraction',
|
||||
|
|
@ -57,7 +60,8 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
|
|||
}
|
||||
);
|
||||
} finally {
|
||||
process.stdout.write = origWrite;
|
||||
process.stdout.write = origStdout;
|
||||
process.stderr.write = origStderr;
|
||||
}
|
||||
console.error(`GitNexus: Embedding model loaded (${device})`);
|
||||
return embedderInstance!;
|
||||
|
|
|
|||
|
|
@ -279,8 +279,10 @@ export class LocalBackend {
|
|||
switch (method) {
|
||||
case 'query':
|
||||
return this.query(repo, params);
|
||||
case 'cypher':
|
||||
return this.cypher(repo, params);
|
||||
case 'cypher': {
|
||||
const raw = await this.cypher(repo, params);
|
||||
return this.formatCypherAsMarkdown(raw);
|
||||
}
|
||||
case 'context':
|
||||
return this.context(repo, params);
|
||||
case 'impact':
|
||||
|
|
@ -395,16 +397,18 @@ export class LocalBackend {
|
|||
`);
|
||||
} catch { /* symbol might not be in any process */ }
|
||||
|
||||
// Get cluster cohesion as internal ranking signal (never exposed)
|
||||
// Get cluster membership + cohesion (cohesion used as internal ranking signal)
|
||||
let cohesion = 0;
|
||||
let module: string | undefined;
|
||||
try {
|
||||
const cohesionRows = await executeQuery(repo.id, `
|
||||
MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
RETURN c.cohesion AS cohesion
|
||||
RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
|
||||
LIMIT 1
|
||||
`);
|
||||
if (cohesionRows.length > 0) {
|
||||
cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
|
||||
module = cohesionRows[0].module ?? cohesionRows[0][1];
|
||||
}
|
||||
} catch { /* no cluster info */ }
|
||||
|
||||
|
|
@ -429,6 +433,7 @@ export class LocalBackend {
|
|||
filePath: sym.filePath,
|
||||
startLine: sym.startLine,
|
||||
endLine: sym.endLine,
|
||||
...(module ? { module } : {}),
|
||||
...(includeContent && content ? { content } : {}),
|
||||
};
|
||||
|
||||
|
|
@ -577,6 +582,10 @@ export class LocalBackend {
|
|||
*/
|
||||
private async semanticSearch(repo: RepoHandle, query: string, limit: number): Promise<any[]> {
|
||||
try {
|
||||
// Check if embedding table exists before loading the model (avoids heavy model init when embeddings are off)
|
||||
const tableCheck = await executeQuery(repo.id, `MATCH (e:CodeEmbedding) RETURN COUNT(*) AS cnt LIMIT 1`);
|
||||
if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0) return [];
|
||||
|
||||
const queryVec = await embedQuery(query);
|
||||
const dims = getEmbeddingDims();
|
||||
const queryVecStr = `[${queryVec.join(',')}]`;
|
||||
|
|
@ -630,8 +639,8 @@ export class LocalBackend {
|
|||
}
|
||||
|
||||
return results;
|
||||
} catch (err: any) {
|
||||
console.error('GitNexus: Semantic search unavailable -', err.message);
|
||||
} catch {
|
||||
// Expected when embeddings are disabled — silently fall back to BM25-only
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -643,11 +652,11 @@ export class LocalBackend {
|
|||
|
||||
private async cypher(repo: RepoHandle, params: { query: string }): Promise<any> {
|
||||
await this.ensureInitialized(repo.id);
|
||||
|
||||
|
||||
if (!isKuzuReady(repo.id)) {
|
||||
return { error: 'KuzuDB not ready. Index may be corrupted.' };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const result = await executeQuery(repo.id, params.query);
|
||||
return result;
|
||||
|
|
@ -656,6 +665,36 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format raw Cypher result rows as a markdown table for LLM readability.
|
||||
* Falls back to raw result if rows aren't tabular objects.
|
||||
*/
|
||||
private formatCypherAsMarkdown(result: any): any {
|
||||
if (!Array.isArray(result) || result.length === 0) return result;
|
||||
|
||||
const firstRow = result[0];
|
||||
if (typeof firstRow !== 'object' || firstRow === null) return result;
|
||||
|
||||
const keys = Object.keys(firstRow);
|
||||
if (keys.length === 0) return result;
|
||||
|
||||
const header = '| ' + keys.join(' | ') + ' |';
|
||||
const separator = '| ' + keys.map(() => '---').join(' | ') + ' |';
|
||||
const dataRows = result.map((row: any) =>
|
||||
'| ' + keys.map(k => {
|
||||
const v = row[k];
|
||||
if (v === null || v === undefined) return '';
|
||||
if (typeof v === 'object') return JSON.stringify(v);
|
||||
return String(v);
|
||||
}).join(' | ') + ' |'
|
||||
);
|
||||
|
||||
return {
|
||||
markdown: [header, separator, ...dataRows].join('\n'),
|
||||
row_count: result.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate same-named clusters: group by heuristicLabel, sum symbols,
|
||||
* weighted-average cohesion, filter out tiny clusters (<5 symbols).
|
||||
|
|
@ -1318,7 +1357,69 @@ export class LocalBackend {
|
|||
if (!grouped[item.depth]) grouped[item.depth] = [];
|
||||
grouped[item.depth].push(item);
|
||||
}
|
||||
|
||||
|
||||
// ── Enrichment: affected processes, modules, risk ──────────────
|
||||
const directCount = (grouped[1] || []).length;
|
||||
let affectedProcesses: any[] = [];
|
||||
let affectedModules: any[] = [];
|
||||
|
||||
if (impacted.length > 0) {
|
||||
const allIds = impacted.map(i => `'${i.id.replace(/'/g, "''")}'`).join(', ');
|
||||
const d1Ids = (grouped[1] || []).map((i: any) => `'${i.id.replace(/'/g, "''")}'`).join(', ');
|
||||
|
||||
// Affected processes: which execution flows are broken and at which step
|
||||
const [processRows, moduleRows, directModuleRows] = await Promise.all([
|
||||
executeQuery(repo.id, `
|
||||
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
WHERE s.id IN [${allIds}]
|
||||
RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount
|
||||
ORDER BY hits DESC
|
||||
LIMIT 20
|
||||
`).catch(() => []),
|
||||
executeQuery(repo.id, `
|
||||
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
WHERE s.id IN [${allIds}]
|
||||
RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
|
||||
ORDER BY hits DESC
|
||||
LIMIT 20
|
||||
`).catch(() => []),
|
||||
d1Ids ? executeQuery(repo.id, `
|
||||
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
WHERE s.id IN [${d1Ids}]
|
||||
RETURN DISTINCT c.heuristicLabel AS name
|
||||
`).catch(() => []) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
affectedProcesses = processRows.map((r: any) => ({
|
||||
name: r.name || r[0],
|
||||
hits: r.hits || r[1],
|
||||
broken_at_step: r.minStep ?? r[2],
|
||||
step_count: r.stepCount ?? r[3],
|
||||
}));
|
||||
|
||||
const directModuleSet = new Set(directModuleRows.map((r: any) => r.name || r[0]));
|
||||
affectedModules = moduleRows.map((r: any) => {
|
||||
const name = r.name || r[0];
|
||||
return {
|
||||
name,
|
||||
hits: r.hits || r[1],
|
||||
impact: directModuleSet.has(name) ? 'direct' : 'indirect',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Risk scoring
|
||||
const processCount = affectedProcesses.length;
|
||||
const moduleCount = affectedModules.length;
|
||||
let risk = 'LOW';
|
||||
if (directCount >= 30 || processCount >= 5 || moduleCount >= 5 || impacted.length >= 200) {
|
||||
risk = 'CRITICAL';
|
||||
} else if (directCount >= 15 || processCount >= 3 || moduleCount >= 3 || impacted.length >= 100) {
|
||||
risk = 'HIGH';
|
||||
} else if (directCount >= 5 || impacted.length >= 30) {
|
||||
risk = 'MEDIUM';
|
||||
}
|
||||
|
||||
return {
|
||||
target: {
|
||||
id: symId,
|
||||
|
|
@ -1328,6 +1429,14 @@ export class LocalBackend {
|
|||
},
|
||||
direction,
|
||||
impactedCount: impacted.length,
|
||||
risk,
|
||||
summary: {
|
||||
direct: directCount,
|
||||
processes_affected: processCount,
|
||||
modules_affected: moduleCount,
|
||||
},
|
||||
affected_processes: affectedProcesses,
|
||||
affected_modules: affectedModules,
|
||||
byDepth: grouped,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ AFTER THIS: Use context() on a specific symbol for 360-degree view (callers, cal
|
|||
|
||||
Returns results grouped by process (execution flow):
|
||||
- processes: ranked execution flows with relevance priority
|
||||
- process_symbols: all symbols in those flows with file locations
|
||||
- process_symbols: all symbols in those flows with file locations and module (functional area)
|
||||
- definitions: standalone types/interfaces not in any process
|
||||
|
||||
Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank Fusion.`,
|
||||
|
|
@ -91,6 +91,8 @@ EXAMPLES:
|
|||
• Trace a process:
|
||||
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) WHERE p.heuristicLabel = "UserLogin" RETURN s.name, r.step ORDER BY r.step
|
||||
|
||||
OUTPUT: Returns { markdown, row_count } — results formatted as a Markdown table for easy reading.
|
||||
|
||||
TIPS:
|
||||
- All relationships use single CodeRelation table — filter with {type: 'CALLS'} etc.
|
||||
- Community = auto-detected functional area (Leiden algorithm)
|
||||
|
|
@ -172,10 +174,17 @@ Each edit is tagged with confidence:
|
|||
{
|
||||
name: 'impact',
|
||||
description: `Analyze the blast radius of changing a code symbol.
|
||||
Returns all symbols affected by modifying the target, grouped by depth with edge types and confidence.
|
||||
Returns affected symbols grouped by depth, plus risk assessment, affected execution flows, and affected modules.
|
||||
|
||||
WHEN TO USE: Before making code changes — especially refactoring, renaming, or modifying shared code. Shows what would break.
|
||||
AFTER THIS: Review d=1 items (WILL BREAK). READ gitnexus://repo/{name}/processes to check affected execution flows.
|
||||
AFTER THIS: Review d=1 items (WILL BREAK). Use context() on high-risk symbols.
|
||||
|
||||
Output includes:
|
||||
- risk: LOW / MEDIUM / HIGH / CRITICAL
|
||||
- summary: direct callers, processes affected, modules affected
|
||||
- affected_processes: which execution flows break and at which step
|
||||
- affected_modules: which functional areas are hit (direct vs indirect)
|
||||
- byDepth: all affected symbols grouped by traversal depth
|
||||
|
||||
Depth groups:
|
||||
- d=1: WILL BREAK (direct callers/importers)
|
||||
|
|
|
|||
|
|
@ -191,9 +191,9 @@ export const createServer = async (port: number) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const result = await backend.callTool('cypher', { repo: repoName, query: cypher });
|
||||
if (result && !Array.isArray(result) && result.error) {
|
||||
res.status(500).json({ error: result.error });
|
||||
const result = await backend.executeCypher(repoName, cypher);
|
||||
if (result && !Array.isArray(result) && (result as any).error) {
|
||||
res.status(500).json({ error: (result as any).error });
|
||||
return;
|
||||
}
|
||||
res.json({ result });
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ export interface PipelineProgress {
|
|||
// Original result type (used internally in pipeline)
|
||||
export interface PipelineResult {
|
||||
graph: KnowledgeGraph;
|
||||
fileContents: Map<string, string>;
|
||||
/** Absolute path to the repo root — used for lazy file reads during KuzuDB loading */
|
||||
repoPath: string;
|
||||
/** Total files scanned (for stats) */
|
||||
totalFileCount: number;
|
||||
communityResult?: CommunityDetectionResult;
|
||||
processResult?: ProcessDetectionResult;
|
||||
}
|
||||
|
|
@ -29,14 +32,16 @@ export interface PipelineResult {
|
|||
export interface SerializablePipelineResult {
|
||||
nodes: GraphNode[];
|
||||
relationships: GraphRelationship[];
|
||||
fileContents: Record<string, string>; // Object instead of Map
|
||||
repoPath: string;
|
||||
totalFileCount: number;
|
||||
}
|
||||
|
||||
// Helper to convert PipelineResult to serializable format
|
||||
export const serializePipelineResult = (result: PipelineResult): SerializablePipelineResult => ({
|
||||
nodes: result.graph.nodes,
|
||||
relationships: result.graph.relationships,
|
||||
fileContents: Object.fromEntries(result.fileContents),
|
||||
nodes: [...result.graph.iterNodes()],
|
||||
relationships: [...result.graph.iterRelationships()],
|
||||
repoPath: result.repoPath,
|
||||
totalFileCount: result.totalFileCount,
|
||||
});
|
||||
|
||||
// Helper to reconstruct from serializable format (used in main thread)
|
||||
|
|
@ -47,10 +52,11 @@ export const deserializePipelineResult = (
|
|||
const graph = createGraph();
|
||||
serialized.nodes.forEach(node => graph.addNode(node));
|
||||
serialized.relationships.forEach(rel => graph.addRelationship(rel));
|
||||
|
||||
|
||||
return {
|
||||
graph,
|
||||
fileContents: new Map(Object.entries(serialized.fileContents)),
|
||||
repoPath: serialized.repoPath,
|
||||
totalFileCount: serialized.totalFileCount,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "GitNexus",
|
||||
"name": "GitnexusV2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue