Merge origin/main — add Kotlin/Swift support, resolve conflicts

- Resolved FUNCTION_NODE_TYPES: keep 'anonymous_function' for PHP (php_only grammar),
  add Kotlin 'lambda_literal' and Swift 'init_declaration'/'deinit_declaration'
- Resolved pipeline.ts: adopt chunked pipeline structure, integrate
  processRoutesFromExtracted into per-chunk worker data processing
- Resolved framework-detection.ts: use upstream AST-BASED FRAMEWORK DETECTION heading
- Fixed accumulated/mergeResult in parse-worker to include routes field
This commit is contained in:
Güneş Bizim 2026-03-01 22:33:23 +03:00
commit 46b4b7e157
172 changed files with 13696 additions and 2225 deletions

View 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.3.3",
"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."
}
]
}

View file

@ -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"
]
}

View file

@ -0,0 +1,82 @@
---
name: gitnexus-cli
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
---
# 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

View file

@ -1,85 +1,89 @@
---
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: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
---
# 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
```

View file

@ -1,75 +1,78 @@
---
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: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
---
# 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
```

View file

@ -0,0 +1,64 @@
---
name: gitnexus-guide
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
---
# 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
```

View file

@ -1,94 +1,97 @@
---
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: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
---
# 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
```

View file

@ -0,0 +1,163 @@
---
name: gitnexus-pr-review
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
---
# PR Review with GitNexus
## When to Use
- "Review this PR"
- "What does PR #42 change?"
- "Is this safe to merge?"
- "What's the blast radius of this PR?"
- "Are there missing tests for this PR?"
- Reviewing someone else's code changes before merge
## Workflow
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
## Checklist
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
```
## Review Dimensions
| Dimension | How GitNexus Helps |
| --- | --- |
| **Correctness** | `context` shows callers — are they all compatible with the change? |
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
## Risk Assessment
| Signal | Risk |
| --- | --- |
| Changes touch <3 symbols, 0-1 processes | LOW |
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
| Changes touch >10 symbols or many processes | HIGH |
| Changes touch auth, payments, or data integrity code | CRITICAL |
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
```
## Example: "Review PR #42"
```
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
- MEDIUM risk — 3 changed symbols affect 2 execution flows
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
- BUG: createPayment depends on PaymentInput type which changed
- OK: formatAmount change is backwards-compatible
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
```
## Review Output Format
Structure your review as:
```markdown
## PR Review: <title>
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
### Changes Summary
- <N> symbols changed across <M> files
- <P> execution flows affected
### Findings
1. **[severity]** Description of finding
- Evidence from GitNexus tools
- Affected callers/flows
### Missing Coverage
- Callers not updated in PR: ...
- Untested flows: ...
### Recommendation
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
```

View file

@ -1,113 +1,121 @@
---
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: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
---
# 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
```

View file

@ -1,8 +1,11 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
jobs:
typecheck:
@ -18,3 +21,49 @@ jobs:
working-directory: gitnexus
- run: npx tsc --noEmit
working-directory: gitnexus
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npx vitest run test/unit --coverage --coverage.thresholdAutoUpdate=false
working-directory: gitnexus
integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npx vitest run test/integration
working-directory: gitnexus
cross-platform:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npx vitest run test/unit
working-directory: gitnexus

View file

@ -6,10 +6,15 @@ on:
- 'v*'
jobs:
ci:
uses: ./.github/workflows/ci.yml
publish:
needs: ci
runs-on: ubuntu-latest
permissions:
contents: read
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -20,9 +25,33 @@ jobs:
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npx tsc --noEmit
- name: Verify version consistency
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag version (v$TAG_VERSION) does not match package.json version ($PKG_VERSION)"
exit 1
fi
echo "Version verified: $PKG_VERSION"
working-directory: gitnexus
- run: npm publish
- name: Build
run: npm run build
working-directory: gitnexus
- name: Dry-run publish
run: npm publish --dry-run
working-directory: gitnexus
- name: Publish to npm
run: npm publish --provenance --access public
working-directory: gitnexus
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true

3
.gitignore vendored
View file

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

View file

@ -3,7 +3,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 **GitNexus** (1348 symbols, 3492 relationships, 104 execution flows).
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.

View file

@ -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 **GitNexus** (1348 symbols, 3492 relationships, 104 execution flows).
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.

View file

@ -297,7 +297,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
### Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP
---

View file

@ -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",

View file

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

View 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

View file

@ -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"

View file

@ -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__

View file

@ -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.3.6",
"author": {
"name": "GitNexus"
},
"homepage": "https://github.com/nicosxt/gitnexus",
"repository": "https://github.com/nicosxt/gitnexus"
"homepage": "https://github.com/abhigyanpatwari/GitNexus",
"repository": "https://github.com/abhigyanpatwari/GitNexus",
"keywords": ["code-intelligence", "knowledge-graph", "mcp", "static-analysis"]
}

View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}

View file

@ -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,37 @@ 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 = '';
const isWin = process.platform === 'win32';
// 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'], shell: isWin }
);
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'], shell: isWin }
);
if (child.status === 0 && child.stderr && child.stderr.trim()) {
result = child.stderr;
}
} catch { /* graceful failure */ }
}
if (result && result.trim()) {
console.log(JSON.stringify({

View file

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

View file

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

View file

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

View file

@ -0,0 +1,82 @@
---
name: gitnexus-cli
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
---
# 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

View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}

View file

@ -1,11 +1,12 @@
---
name: gitnexus-debugging
description: Trace bugs through call chains using knowledge graph
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
---
# 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

View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}

View file

@ -1,11 +1,12 @@
---
name: gitnexus-exploring
description: Navigate unfamiliar code using GitNexus knowledge graph
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
---
# 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

View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}

View file

@ -0,0 +1,64 @@
---
name: gitnexus-guide
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
---
# 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
```

View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}

View file

@ -1,11 +1,12 @@
---
name: gitnexus-impact-analysis
description: Analyze blast radius before making code changes
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
---
# 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"})

View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}

View file

@ -0,0 +1,163 @@
---
name: gitnexus-pr-review
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
---
# PR Review with GitNexus
## When to Use
- "Review this PR"
- "What does PR #42 change?"
- "Is this safe to merge?"
- "What's the blast radius of this PR?"
- "Are there missing tests for this PR?"
- Reviewing someone else's code changes before merge
## Workflow
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
## Checklist
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
```
## Review Dimensions
| Dimension | How GitNexus Helps |
| --- | --- |
| **Correctness** | `context` shows callers — are they all compatible with the change? |
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
## Risk Assessment
| Signal | Risk |
| --- | --- |
| Changes touch <3 symbols, 0-1 processes | LOW |
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
| Changes touch >10 symbols or many processes | HIGH |
| Changes touch auth, payments, or data integrity code | CRITICAL |
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
```
## Example: "Review PR #42"
```
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
- MEDIUM risk — 3 changed symbols affect 2 execution flows
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
- BUG: createPayment depends on PaymentInput type which changed
- OK: formatAmount change is backwards-compatible
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
```
## Review Output Format
Structure your review as:
```markdown
## PR Review: <title>
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
### Changes Summary
- <N> symbols changed across <M> files
- <P> execution flows affected
### Findings
1. **[severity]** Description of finding
- Evidence from GitNexus tools
- Affected callers/flows
### Missing Coverage
- Callers not updated in PR: ...
- Untested flows: ...
### Recommendation
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
```

View file

@ -1,11 +1,12 @@
---
name: gitnexus-refactoring
description: Plan safe refactors using blast radius and dependency mapping
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
---
# 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`

View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}

View file

@ -0,0 +1,163 @@
---
name: gitnexus-pr-review
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
---
# PR Review with GitNexus
## When to Use
- "Review this PR"
- "What does PR #42 change?"
- "Is this safe to merge?"
- "What's the blast radius of this PR?"
- "Are there missing tests for this PR?"
- Reviewing someone else's code changes before merge
## Workflow
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
## Checklist
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
```
## Review Dimensions
| Dimension | How GitNexus Helps |
| --- | --- |
| **Correctness** | `context` shows callers — are they all compatible with the change? |
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
## Risk Assessment
| Signal | Risk |
| --- | --- |
| Changes touch <3 symbols, 0-1 processes | LOW |
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
| Changes touch >10 symbols or many processes | HIGH |
| Changes touch auth, payments, or data integrity code | CRITICAL |
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
```
## Example: "Review PR #42"
```
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
- MEDIUM risk — 3 changed symbols affect 2 execution flows
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
- BUG: createPayment depends on PaymentInput type which changed
- OK: formatAmount change is backwards-compatible
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
```
## Review Output Format
Structure your review as:
```markdown
## PR Review: <title>
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
### Changes Summary
- <N> symbols changed across <M> files
- <P> execution flows affected
### Findings
1. **[severity]** Description of finding
- Evidence from GitNexus tools
- Affected callers/flows
### Missing Coverage
- Callers not updated in PR: ...
- Untested flows: ...
### Recommendation
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
```

Binary file not shown.

View file

@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { AppStateProvider, useAppState } from './hooks/useAppState';
import { DropZone } from './components/DropZone';
import { LoadingOverlay } from './components/LoadingOverlay';
@ -11,9 +11,8 @@ import { FileTreePanel } from './components/FileTreePanel';
import { CodeReferencesPanel } from './components/CodeReferencesPanel';
import { FileEntry } from './services/zip';
import { getActiveProviderConfig } from './core/llm/settings-service';
import { useBackend } from './hooks/useBackend';
import { fetchGraph } from './services/backend';
import { createKnowledgeGraph } from './core/graph/graph';
import { connectToServer, fetchRepos, normalizeServerUrl, type ConnectToServerResult } from './services/server-connection';
const AppContent = () => {
const {
@ -36,12 +35,13 @@ const AppContent = () => {
codeReferences,
selectedNode,
isCodePanelOpen,
setBackendMode,
setBackendRepo,
serverBaseUrl,
setServerBaseUrl,
availableRepos,
setAvailableRepos,
switchRepo,
} = useAppState();
const backend = useBackend();
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
const handleFileSelect = useCallback(async (file: File) => {
@ -132,63 +132,105 @@ const AppContent = () => {
}
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]);
const handleFocusNode = useCallback((nodeId: string) => {
graphCanvasRef.current?.focusNode(nodeId);
}, []);
const handleServerConnect = useCallback((result: ConnectToServerResult) => {
// Extract project name from repoPath
const repoPath = result.repoInfo.repoPath;
const projectName = repoPath.split('/').pop() || 'server-project';
setProjectName(projectName);
const handleSelectBackendRepo = useCallback(async (repoName: string) => {
// Build KnowledgeGraph from server data (bypasses WASM pipeline entirely)
const graph = createKnowledgeGraph();
for (const node of result.nodes) {
graph.addNode(node);
}
for (const rel of result.relationships) {
graph.addRelationship(rel);
}
setGraph(graph);
// Set file contents from extracted File node content
const fileMap = new Map<string, string>();
for (const [path, content] of Object.entries(result.fileContents)) {
fileMap.set(path, content);
}
setFileContents(fileMap);
// Transition directly to exploring view
setViewMode('exploring');
// Initialize agent if LLM is configured
if (getActiveProviderConfig()) {
initializeAgent(projectName);
}
// Auto-start embeddings
startEmbeddings().catch((err) => {
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
startEmbeddings('wasm').catch(console.warn);
} else {
console.warn('Embeddings auto-start failed:', err);
}
});
}, [setViewMode, setGraph, setFileContents, setProjectName, initializeAgent, startEmbeddings]);
// Auto-connect when ?server query param is present (bookmarkable shortcut)
const autoConnectRan = useRef(false);
useEffect(() => {
if (autoConnectRan.current) return;
const params = new URLSearchParams(window.location.search);
if (!params.has('server')) return;
autoConnectRan.current = true;
// Clean the URL so a refresh won't re-trigger
const cleanUrl = window.location.pathname + window.location.hash;
window.history.replaceState(null, '', cleanUrl);
setProgress({ phase: 'extracting', percent: 0, message: 'Connecting to server...', detail: 'Validating server' });
setViewMode('loading');
setProjectName(repoName);
setProgress({ phase: 'extracting', percent: 50, message: 'Loading from server...', detail: 'Fetching graph data' });
try {
const graphData = await fetchGraph(repoName);
const serverUrl = params.get('server') || window.location.origin;
// Build KnowledgeGraph from server data
const graph = createKnowledgeGraph();
for (const node of graphData.nodes) {
graph.addNode(node as any);
const baseUrl = normalizeServerUrl(serverUrl);
connectToServer(serverUrl, (phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({ phase: 'extracting', percent: 5, message: 'Connecting to server...', detail: 'Validating server' });
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` });
} else if (phase === 'extracting') {
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
}
for (const rel of graphData.relationships) {
graph.addRelationship(rel as any);
}
setGraph(graph);
}).then(async (result) => {
handleServerConnect(result);
// Extract file contents from File nodes (content is in node properties)
const contents = new Map<string, string>();
for (const node of graphData.nodes) {
const n = node as any;
if (n.label === 'File' && n.properties?.content && n.properties?.filePath) {
contents.set(n.properties.filePath, n.properties.content);
}
// Store server URL and fetch available repos for the repo switcher
setServerBaseUrl(baseUrl);
try {
const repos = await fetchRepos(baseUrl);
setAvailableRepos(repos);
} catch (e) {
console.warn('Failed to fetch repo list:', e);
}
setFileContents(contents);
// Enter backend mode
setBackendMode(true);
setBackendRepo(repoName);
backend.selectRepo(repoName);
setProgress(null);
setViewMode('exploring');
// Initialize agent if LLM configured
if (getActiveProviderConfig()) {
initializeAgent(repoName);
}
} catch (error) {
console.error('Backend load error:', error);
}).catch((err) => {
console.error('Auto-connect failed:', err);
setProgress({
phase: 'error',
percent: 0,
message: 'Error loading from server',
detail: error instanceof Error ? error.message : 'Unknown error',
message: 'Failed to connect to server',
detail: err instanceof Error ? err.message : 'Unknown error',
});
setTimeout(() => {
setViewMode('onboarding');
setProgress(null);
}, 3000);
}
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, setBackendMode, setBackendRepo, backend, initializeAgent]);
});
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
const handleFocusNode = useCallback((nodeId: string) => {
graphCanvasRef.current?.focusNode(nodeId);
}, []);
// Handle settings saved - refresh and reinitialize agent
// NOTE: Must be defined BEFORE any conditional returns (React hooks rule)
@ -203,10 +245,19 @@ const AppContent = () => {
<DropZone
onFileSelect={handleFileSelect}
onGitClone={handleGitClone}
backendRepos={backend.repos}
isBackendConnected={backend.isConnected}
backendUrl={backend.backendUrl}
onSelectBackendRepo={handleSelectBackendRepo}
onServerConnect={async (result, serverUrl) => {
handleServerConnect(result);
if (serverUrl) {
const baseUrl = normalizeServerUrl(serverUrl);
setServerBaseUrl(baseUrl);
try {
const repos = await fetchRepos(baseUrl);
setAvailableRepos(repos);
} catch (e) {
console.warn('Failed to fetch repo list:', e);
}
}
}}
/>
);
}
@ -218,7 +269,7 @@ const AppContent = () => {
// Exploring view
return (
<div className="flex flex-col h-screen bg-void overflow-hidden">
<Header onFocusNode={handleFocusNode} />
<Header onFocusNode={handleFocusNode} availableRepos={availableRepos} onSwitchRepo={switchRepo} />
<main className="flex-1 flex min-h-0">
{/* Left Panel - File Tree */}
@ -247,9 +298,6 @@ const AppContent = () => {
isOpen={isSettingsPanelOpen}
onClose={() => setSettingsPanelOpen(false)}
onSettingsSaved={handleSettingsSaved}
backendUrl={backend.backendUrl}
isBackendConnected={backend.isConnected}
onBackendUrlChange={backend.setBackendUrl}
/>
</div>

View file

@ -1,22 +1,24 @@
import { useState, useCallback, useEffect, useRef, DragEvent } from 'react';
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Server } from 'lucide-react';
import { useState, useCallback, useRef, DragEvent } from 'react';
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Globe, X } from 'lucide-react';
import { cloneRepository, parseGitHubUrl } from '../services/git-clone';
import { connectToServer, type ConnectToServerResult } from '../services/server-connection';
import { FileEntry } from '../services/zip';
import { BackendRepo } from '../services/backend';
import { BackendRepoSelector } from './BackendRepoSelector';
interface DropZoneProps {
onFileSelect: (file: File) => void;
onGitClone?: (files: FileEntry[]) => void;
backendRepos?: BackendRepo[];
isBackendConnected?: boolean;
backendUrl?: string;
onSelectBackendRepo?: (repoName: string) => void;
onServerConnect?: (result: ConnectToServerResult, serverUrl?: string) => void;
}
export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConnected, backendUrl, onSelectBackendRepo }: DropZoneProps) => {
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export const DropZone = ({ onFileSelect, onGitClone, onServerConnect }: DropZoneProps) => {
const [isDragging, setIsDragging] = useState(false);
const [activeTab, setActiveTab] = useState<'zip' | 'github' | 'local'>('zip');
const [activeTab, setActiveTab] = useState<'zip' | 'github' | 'server'>('zip');
const [githubUrl, setGithubUrl] = useState('');
const [githubToken, setGithubToken] = useState('');
const [showToken, setShowToken] = useState(false);
@ -24,13 +26,17 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
const [cloneProgress, setCloneProgress] = useState({ phase: '', percent: 0 });
const [error, setError] = useState<string | null>(null);
const hasAutoSwitched = useRef(false);
useEffect(() => {
if (!hasAutoSwitched.current && isBackendConnected && backendRepos && backendRepos.length > 0) {
setActiveTab('local');
hasAutoSwitched.current = true;
}
}, [isBackendConnected, backendRepos]);
// Server tab state
const [serverUrl, setServerUrl] = useState(() =>
localStorage.getItem('gitnexus-server-url') || ''
);
const [isConnecting, setIsConnecting] = useState(false);
const [serverProgress, setServerProgress] = useState<{
phase: string;
downloaded: number;
total: number | null;
}>({ phase: '', downloaded: 0, total: null });
const abortControllerRef = useRef<AbortController | null>(null);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
@ -92,10 +98,9 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
const files = await cloneRepository(
githubUrl,
(phase, percent) => setCloneProgress({ phase, percent }),
githubToken || undefined // Pass token if provided
githubToken || undefined
);
// Clear token from memory after successful clone
setGithubToken('');
if (onGitClone) {
@ -104,12 +109,11 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
} catch (err) {
console.error('Clone failed:', err);
const message = err instanceof Error ? err.message : 'Failed to clone repository';
// Provide helpful error for auth failures
if (message.includes('401') || message.includes('403') || message.includes('Authentication')) {
if (!githubToken) {
setError('🔒 This looks like a private repo. Add a GitHub PAT (Personal Access Token) to access it.');
setError('This looks like a private repo. Add a GitHub PAT (Personal Access Token) to access it.');
} else {
setError('🔑 Authentication failed. Check your token permissions (needs repo access).');
setError('Authentication failed. Check your token permissions (needs repo access).');
}
} else if (message.includes('404') || message.includes('not found')) {
setError('Repository not found. Check the URL or it might be private (needs PAT).');
@ -121,6 +125,62 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
}
};
const handleServerConnect = async () => {
const urlToUse = serverUrl.trim() || window.location.origin;
if (!urlToUse) {
setError('Please enter a server URL');
return;
}
// Persist URL to localStorage
localStorage.setItem('gitnexus-server-url', serverUrl);
setError(null);
setIsConnecting(true);
setServerProgress({ phase: 'validating', downloaded: 0, total: null });
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const result = await connectToServer(
urlToUse,
(phase, downloaded, total) => {
setServerProgress({ phase, downloaded, total });
},
abortController.signal
);
if (onServerConnect) {
onServerConnect(result, urlToUse);
}
} catch (err) {
if ((err as Error).name === 'AbortError') {
// User cancelled
return;
}
console.error('Server connect failed:', err);
const message = err instanceof Error ? err.message : 'Failed to connect to server';
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
setError('Cannot reach server. Check the URL and ensure the server is running.');
} else {
setError(message);
}
} finally {
setIsConnecting(false);
abortControllerRef.current = null;
}
};
const handleCancelConnect = () => {
abortControllerRef.current?.abort();
setIsConnecting(false);
};
const serverProgressPercent = serverProgress.total
? Math.round((serverProgress.downloaded / serverProgress.total) * 100)
: null;
return (
<div className="flex items-center justify-center min-h-screen p-8 bg-void">
{/* Background gradient effects */}
@ -161,25 +221,18 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
GitHub URL
</button>
<button
onClick={() => { setActiveTab('local'); setError(null); }}
onClick={() => { setActiveTab('server'); setError(null); }}
className={`
flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg
text-sm font-medium transition-all duration-200
${activeTab === 'local'
${activeTab === 'server'
? 'bg-accent text-white shadow-md'
: isBackendConnected
? 'text-text-secondary hover:text-text-primary hover:bg-elevated'
: 'text-text-muted cursor-not-allowed opacity-50'
: 'text-text-secondary hover:text-text-primary hover:bg-elevated'
}
`}
disabled={!isBackendConnected}
title={!isBackendConnected ? 'Start gitnexus serve to connect' : undefined}
>
<Server className="w-4 h-4" />
Local Server
{isBackendConnected && (
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" />
)}
<Globe className="w-4 h-4" />
Server
</button>
</div>
@ -195,7 +248,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
<>
<div
className={`
relative p-16
relative p-16
bg-surface border-2 border-dashed rounded-3xl
transition-all duration-300 cursor-pointer
${isDragging
@ -282,7 +335,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
data-1p-ignore="true"
data-form-type="other"
className="
w-full px-4 py-3
w-full px-4 py-3
bg-elevated border border-border-default rounded-xl
text-text-primary placeholder-text-muted
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
@ -308,7 +361,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
data-1p-ignore="true"
data-form-type="other"
className="
w-full pl-10 pr-10 py-3
w-full pl-10 pr-10 py-3
bg-elevated border border-border-default rounded-xl
text-text-primary placeholder-text-muted
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
@ -329,9 +382,9 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
onClick={handleGitClone}
disabled={isCloning || !githubUrl.trim()}
className="
w-full flex items-center justify-center gap-2
px-4 py-3
bg-accent hover:bg-accent/90
w-full flex items-center justify-center gap-2
px-4 py-3
bg-accent hover:bg-accent/90
text-white font-medium rounded-xl
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
@ -371,7 +424,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
{/* Security note */}
{githubToken && (
<p className="mt-3 text-xs text-text-muted text-center">
🔒 Token stays in your browser only, never sent to any server
Token stays in your browser only, never sent to any server
</p>
)}
@ -387,14 +440,131 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
</div>
)}
{/* Local Server Tab */}
{activeTab === 'local' && isBackendConnected && backendRepos && onSelectBackendRepo && (
<BackendRepoSelector
repos={backendRepos}
onSelectRepo={onSelectBackendRepo}
backendUrl={backendUrl ?? 'http://localhost:4747'}
isConnected={isBackendConnected}
/>
{/* Server Tab */}
{activeTab === 'server' && (
<div className="p-8 bg-surface border border-border-default rounded-3xl">
{/* Icon */}
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-accent to-emerald-600 rounded-2xl shadow-lg">
<Globe className="w-10 h-10 text-white" />
</div>
{/* Text */}
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
Connect to Server
</h2>
<p className="text-sm text-text-secondary text-center mb-6">
Load a pre-built knowledge graph from a running GitNexus server
</p>
{/* Inputs */}
<div className="space-y-3" data-form-type="other">
<input
type="url"
name="server-url-input"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isConnecting && handleServerConnect()}
placeholder={window.location.origin}
disabled={isConnecting}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
className="
w-full px-4 py-3
bg-elevated border border-border-default rounded-xl
text-text-primary placeholder-text-muted
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
"
/>
<div className="flex gap-2">
<button
onClick={handleServerConnect}
disabled={isConnecting}
className="
flex-1 flex items-center justify-center gap-2
px-4 py-3
bg-accent hover:bg-accent/90
text-white font-medium rounded-xl
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
"
>
{isConnecting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{serverProgress.phase === 'validating'
? 'Validating...'
: serverProgress.phase === 'downloading'
? serverProgressPercent !== null
? `Downloading... ${serverProgressPercent}%`
: `Downloading... ${formatBytes(serverProgress.downloaded)}`
: serverProgress.phase === 'extracting'
? 'Processing...'
: 'Connecting...'
}
</>
) : (
<>
Connect
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
{isConnecting && (
<button
onClick={handleCancelConnect}
className="
flex items-center justify-center
px-4 py-3
bg-red-500/20 hover:bg-red-500/30
text-red-400 font-medium rounded-xl
transition-all duration-200
"
>
<X className="w-5 h-5" />
</button>
)}
</div>
</div>
{/* Progress bar */}
{isConnecting && serverProgress.phase === 'downloading' && (
<div className="mt-4">
<div className="h-2 bg-elevated rounded-full overflow-hidden">
<div
className={`h-full bg-accent transition-all duration-300 ease-out ${
serverProgressPercent === null ? 'animate-pulse' : ''
}`}
style={{
width: serverProgressPercent !== null
? `${serverProgressPercent}%`
: '100%',
}}
/>
</div>
{serverProgress.total && (
<p className="mt-1 text-xs text-text-muted text-center">
{formatBytes(serverProgress.downloaded)} / {formatBytes(serverProgress.total)}
</p>
)}
</div>
)}
{/* Hints */}
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
Pre-indexed
</span>
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
No WASM needed
</span>
</div>
</div>
)}
</div>
</div>

View file

@ -14,7 +14,7 @@ export const EmbeddingStatus = () => {
startEmbeddings,
graph,
viewMode,
isBackendMode,
serverBaseUrl,
testArrayParams,
} = useAppState();
@ -22,7 +22,7 @@ export const EmbeddingStatus = () => {
const [showFallbackDialog, setShowFallbackDialog] = useState(false);
// Only show when exploring a loaded graph; hide in backend mode (no WASM DB)
if (viewMode !== 'exploring' || !graph || isBackendMode) return null;
if (viewMode !== 'exploring' || !graph || serverBaseUrl) return null;
const nodeCount = graph.nodes.length;

View file

@ -1,5 +1,6 @@
import { Search, Settings, HelpCircle, Sparkles, Github, Star } from 'lucide-react';
import { Search, Settings, HelpCircle, Sparkles, Github, Star, ChevronDown } from 'lucide-react';
import { useAppState } from '../hooks/useAppState';
import type { RepoSummary } from '../services/server-connection';
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { GraphNode } from '../core/graph/types';
import { EmbeddingStatus } from './EmbeddingStatus';
@ -19,9 +20,11 @@ const NODE_TYPE_COLORS: Record<string, string> = {
interface HeaderProps {
onFocusNode?: (nodeId: string) => void;
availableRepos?: RepoSummary[];
onSwitchRepo?: (repoName: string) => void;
}
export const Header = ({ onFocusNode }: HeaderProps) => {
export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: HeaderProps) => {
const {
projectName,
graph,
@ -29,8 +32,9 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
isRightPanelOpen,
rightPanelTab,
setSettingsPanelOpen,
isBackendMode,
} = useAppState();
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
const repoDropdownRef = useRef<HTMLDivElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
@ -50,12 +54,15 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
.slice(0, 10); // Limit to 10 results
}, [graph, searchQuery]);
// Handle clicking outside to close dropdown
// Handle clicking outside to close dropdowns
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setIsSearchOpen(false);
}
if (repoDropdownRef.current && !repoDropdownRef.current.contains(e.target as Node)) {
setIsRepoDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
@ -117,18 +124,50 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
<span className="font-semibold text-[15px] tracking-tight">GitNexus</span>
</div>
{/* Project badge */}
{/* Project badge / Repo selector dropdown */}
{projectName && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary">
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
<span className="truncate max-w-[200px]">{projectName}</span>
</div>
)}
<div className="relative" ref={repoDropdownRef}>
<button
onClick={() => availableRepos.length >= 2 && setIsRepoDropdownOpen(prev => !prev)}
className={`flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary transition-colors ${availableRepos.length >= 2 ? 'hover:bg-hover cursor-pointer' : ''}`}
>
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
<span className="truncate max-w-[200px]">{projectName}</span>
{availableRepos.length >= 2 && (
<ChevronDown className={`w-3.5 h-3.5 text-text-muted transition-transform ${isRepoDropdownOpen ? 'rotate-180' : ''}`} />
)}
</button>
{isBackendMode && (
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-green-500/10 border border-green-500/30 rounded-lg text-xs text-green-400">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
Local
{/* Repo dropdown */}
{isRepoDropdownOpen && availableRepos.length >= 2 && (
<div className="absolute top-full left-0 mt-1 w-72 bg-surface border border-border-subtle rounded-lg shadow-xl overflow-hidden z-50">
{availableRepos.map((repo) => {
const isCurrent = repo.name === projectName;
return (
<button
key={repo.name}
onClick={() => {
if (!isCurrent && onSwitchRepo) {
onSwitchRepo(repo.name);
}
setIsRepoDropdownOpen(false);
}}
className={`w-full px-4 py-3 flex items-center gap-3 text-left transition-colors ${isCurrent ? 'bg-accent/10 border-l-2 border-accent' : 'hover:bg-hover border-l-2 border-transparent'}`}
>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${isCurrent ? 'bg-node-function animate-pulse' : 'bg-text-muted'}`} />
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isCurrent ? 'text-accent' : 'text-text-primary'}`}>
{repo.name}
</div>
<div className="text-xs text-text-muted mt-0.5">
{repo.stats?.nodes ?? '?'} nodes &middot; {repo.stats?.files ?? '?'} files
</div>
</div>
</button>
);
})}
</div>
)}
</div>
)}
</div>

View file

@ -1,10 +1,11 @@
import React from 'react';
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { MermaidDiagram } from './MermaidDiagram';
import { ToolCallCard } from './ToolCallCard';
import { Copy, Check } from 'lucide-react';
// Custom syntax theme
const customTheme = {
@ -28,13 +29,26 @@ interface MarkdownRendererProps {
content: string;
onLinkClick?: (href: string) => void;
toolCalls?: any[]; // Keep flexible for now
showCopyButton?: boolean;
}
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
onLinkClick,
toolCalls
toolCalls,
showCopyButton = false
}) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// Helper to format text for display (convert [[links]] to markdown links)
const formatMarkdownForDisplay = (md: string) => {
@ -166,6 +180,20 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
{formattedContent}
</ReactMarkdown>
{/* Copy Button */}
{showCopyButton && (
<div className="mt-2 flex justify-end">
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-2 py-1 text-xs text-text-muted hover:text-text-primary hover:bg-surface border border-transparent hover:border-border-subtle rounded transition-all"
title="Copy to clipboard"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
)}
{/* Tool Call Cards appended at the bottom if provided */}
{toolCalls && toolCalls.length > 0 && (
<div className="mt-3 space-y-2">

View file

@ -345,7 +345,7 @@ export const RightPanel = () => {
{/* Render steps in order (reasoning, tool calls, content interleaved) */}
{message.steps && message.steps.length > 0 ? (
<div className="space-y-4">
{message.steps.map((step) => (
{message.steps.map((step, index) => (
<div key={step.id}>
{step.type === 'reasoning' && step.content && (
<div className="text-text-secondary text-sm italic border-l-2 border-text-muted/30 pl-3 mb-3">
@ -364,6 +364,7 @@ export const RightPanel = () => {
<MarkdownRenderer
content={step.content}
onLinkClick={handleLinkClick}
showCopyButton={index === message.steps!.length - 1}
/>
)}
</div>
@ -375,6 +376,7 @@ export const RightPanel = () => {
content={message.content}
onLinkClick={handleLinkClick}
toolCalls={message.toolCalls}
showCopyButton={true}
/>
)}
</div>

View file

@ -10,5 +10,5 @@ export enum SupportedLanguages {
Rust = 'rust',
PHP = 'php',
// Ruby = 'ruby',
// Swift = 'swift',
Swift = 'swift',
}

View file

@ -103,6 +103,26 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^Start$/, // Start methods
],
// Swift / iOS
'swift': [
/^viewDidLoad$/, // UIKit lifecycle
/^viewWillAppear$/, // UIKit lifecycle
/^viewDidAppear$/, // UIKit lifecycle
/^viewWillDisappear$/, // UIKit lifecycle
/^viewDidDisappear$/, // UIKit lifecycle
/^application\(/, // AppDelegate methods
/^scene\(/, // SceneDelegate methods
/^body$/, // SwiftUI View.body
/Coordinator$/, // Coordinator pattern
/^sceneDidBecomeActive$/, // SceneDelegate lifecycle
/^sceneWillResignActive$/, // SceneDelegate lifecycle
/^didFinishLaunchingWithOptions$/, // AppDelegate
/ViewController$/, // ViewController classes
/^configure[A-Z]/, // Configuration methods
/^setup[A-Z]/, // Setup methods
/^makeBody$/, // SwiftUI ViewModifier
],
// PHP / Laravel
'php': [
/Controller$/, // UserController (class name convention)
@ -271,6 +291,10 @@ export function isTestFile(filePath: string): boolean {
p.includes('/src/test/') ||
// Rust test patterns (inline tests are different, but test files)
p.includes('/tests/') ||
// Swift/iOS test patterns
p.endsWith('tests.swift') ||
p.endsWith('test.swift') ||
p.includes('uitests/') ||
// C# test patterns
p.includes('.tests/') ||
p.includes('tests.cs') ||

View file

@ -257,21 +257,63 @@ 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' };
// ========== SWIFT / iOS ==========
// iOS App entry points (highest priority)
if (p.endsWith('/appdelegate.swift') || p.endsWith('/scenedelegate.swift') || p.endsWith('/app.swift')) {
return { framework: 'ios', entryPointMultiplier: 3.0, reason: 'ios-app-entry' };
}
// SwiftUI App entry (@main)
if (p.endsWith('app.swift') && p.includes('/sources/')) {
return { framework: 'swiftui', entryPointMultiplier: 3.0, reason: 'swiftui-app' };
}
// UIKit ViewControllers (high priority - screen entry points)
if ((p.includes('/viewcontrollers/') || p.includes('/controllers/') || p.includes('/screens/')) && p.endsWith('.swift')) {
return { framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-viewcontroller' };
}
// ViewController by filename convention
if (p.endsWith('viewcontroller.swift') || p.endsWith('vc.swift')) {
return { framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-viewcontroller-file' };
}
// Coordinator pattern (navigation entry points)
if (p.includes('/coordinators/') && p.endsWith('.swift')) {
return { framework: 'ios-coordinator', entryPointMultiplier: 2.5, reason: 'ios-coordinator' };
}
// Coordinator by filename
if (p.endsWith('coordinator.swift')) {
return { framework: 'ios-coordinator', entryPointMultiplier: 2.5, reason: 'ios-coordinator-file' };
}
// SwiftUI Views (moderate - reusable components)
if ((p.includes('/views/') || p.includes('/scenes/')) && p.endsWith('.swift')) {
return { framework: 'swiftui', entryPointMultiplier: 1.8, reason: 'swiftui-view' };
}
// Service layer
if (p.includes('/services/') && p.endsWith('.swift')) {
return { framework: 'ios-service', entryPointMultiplier: 1.8, reason: 'ios-service' };
}
// Router / navigation
if (p.includes('/router/') && p.endsWith('.swift')) {
return { framework: 'ios-router', entryPointMultiplier: 2.0, reason: 'ios-router' };
}
// ========== GENERIC PATTERNS ==========
// Any language: index files in API folders
if (p.includes('/api/') && (
p.endsWith('/index.ts') || p.endsWith('/index.js') ||
p.endsWith('/index.ts') || p.endsWith('/index.js') ||
p.endsWith('/__init__.py')
)) {
return { framework: 'api', entryPointMultiplier: 1.8, reason: 'api-index' };
}
// No framework detected - return null for graceful fallback (1.0 multiplier)
return null;
}
@ -303,7 +345,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('],
@ -312,4 +354,9 @@ export const FRAMEWORK_AST_PATTERNS = {
'actix': ['#[get', '#[post', '#[put', '#[delete'],
'axum': ['Router::new'],
'rocket': ['#[get', '#[post'],
// Swift/iOS
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController'],
'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject'],
'combine': ['sink', 'assign', 'Publisher', 'Subscriber'],
};

View file

@ -396,6 +396,59 @@ export const PHP_QUERIES = `
[(name) (qualified_name)] @heritage.trait))) @heritage
`;
// Swift queries - works with tree-sitter-swift
export const SWIFT_QUERIES = `
; Classes
(class_declaration "class" name: (type_identifier) @name) @definition.class
; Structs
(class_declaration "struct" name: (type_identifier) @name) @definition.struct
; Enums
(class_declaration "enum" name: (type_identifier) @name) @definition.enum
; Extensions (mapped to class no dedicated label in schema)
(class_declaration "extension" name: (user_type (type_identifier) @name)) @definition.class
; Actors
(class_declaration "actor" name: (type_identifier) @name) @definition.class
; Protocols (mapped to interface)
(protocol_declaration name: (type_identifier) @name) @definition.interface
; Type aliases
(typealias_declaration name: (type_identifier) @name) @definition.type
; Functions (top-level and methods)
(function_declaration name: (simple_identifier) @name) @definition.function
; Protocol method declarations
(protocol_function_declaration name: (simple_identifier) @name) @definition.method
; Initializers
(init_declaration) @definition.constructor
; Properties (stored and computed)
(property_declaration (pattern (simple_identifier) @name)) @definition.property
; Imports
(import_declaration (identifier (simple_identifier) @import.source)) @import
; Calls - direct function calls
(call_expression (simple_identifier) @call.name) @call
; Calls - member/navigation calls (obj.method())
(call_expression (navigation_expression (navigation_suffix (simple_identifier) @call.name))) @call
; Heritage - class/struct/enum inheritance and protocol conformance
(class_declaration name: (type_identifier) @heritage.class
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
; Heritage - protocol inheritance
(protocol_declaration name: (type_identifier) @heritage.class
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
`;
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES,
[SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES,
@ -407,5 +460,6 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
[SupportedLanguages.Rust]: RUST_QUERIES,
[SupportedLanguages.PHP]: PHP_QUERIES,
[SupportedLanguages.Swift]: SWIFT_QUERIES,
};

View file

@ -31,6 +31,8 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
filename.endsWith('.php5') || filename.endsWith('.php8')) {
return SupportedLanguages.PHP;
}
// Swift
if (filename.endsWith('.swift')) return SupportedLanguages.Swift;
return null;
};

View file

@ -40,6 +40,7 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string =>
[SupportedLanguages.Go]: '/wasm/go/tree-sitter-go.wasm',
[SupportedLanguages.Rust]: '/wasm/rust/tree-sitter-rust.wasm',
[SupportedLanguages.PHP]: '/wasm/php/tree-sitter-php.wasm',
[SupportedLanguages.Swift]: '/wasm/swift/tree-sitter-swift.wasm',
};
return languageFileMap[language];

View file

@ -11,7 +11,8 @@ import type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCa
import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service';
import type { AgentMessage } from '../core/llm/agent';
import { DEFAULT_VISIBLE_EDGES, type EdgeType } from '../lib/constants';
import { runCypherQuery, getBackendUrl } from '../services/backend';
import type { RepoSummary, ConnectToServerResult } from '../services/server-connection';
import { fetchRepos, connectToServer } from '../services/server-connection';
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
export type RightPanelTab = 'code' | 'chat';
@ -112,6 +113,13 @@ interface AppState {
projectName: string;
setProjectName: (name: string) => void;
// Multi-repo switching
serverBaseUrl: string | null;
setServerBaseUrl: (url: string | null) => void;
availableRepos: RepoSummary[];
setAvailableRepos: (repos: RepoSummary[]) => void;
switchRepo: (repoName: string) => Promise<void>;
// Worker API (shared across app)
runPipeline: (file: File, onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
@ -161,12 +169,6 @@ interface AppState {
clearAICodeReferences: () => void;
clearCodeReferences: () => void;
codeReferenceFocus: CodeReferenceFocus | null;
// Backend mode
isBackendMode: boolean;
backendRepo: string | null;
setBackendMode: (mode: boolean) => void;
setBackendRepo: (repo: string | null) => void;
}
const AppStateContext = createContext<AppState | null>(null);
@ -277,6 +279,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
// Project info
const [projectName, setProjectName] = useState<string>('');
// Multi-repo switching
const [serverBaseUrl, setServerBaseUrl] = useState<string | null>(null);
const [availableRepos, setAvailableRepos] = useState<RepoSummary[]>([]);
// Embedding state
const [embeddingStatus, setEmbeddingStatus] = useState<EmbeddingStatus>('idle');
const [embeddingProgress, setEmbeddingProgress] = useState<EmbeddingProgress | null>(null);
@ -298,11 +304,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
const [isCodePanelOpen, setCodePanelOpen] = useState(false);
const [codeReferenceFocus, setCodeReferenceFocus] = useState<CodeReferenceFocus | null>(null);
// Backend mode
const [isBackendMode, setIsBackendMode] = useState(false);
const [backendRepo, setBackendRepo] = useState<string | null>(null);
const normalizePath = useCallback((p: string) => {
const normalizePath = useCallback((p: string) => {
return p.replace(/\\/g, '/').replace(/^\.?\//, '');
}, []);
@ -465,16 +467,12 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
}, []);
const runQuery = useCallback(async (cypher: string): Promise<any[]> => {
if (isBackendMode && backendRepo) {
return runCypherQuery(backendRepo, cypher);
}
const api = apiRef.current;
if (!api) throw new Error('Worker not initialized');
return api.runQuery(cypher);
}, [isBackendMode, backendRepo]);
}, []);
const isDatabaseReady = useCallback(async (): Promise<boolean> => {
if (isBackendMode) return true; // backend handles DB
const api = apiRef.current;
if (!api) return false;
try {
@ -482,13 +480,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
} catch {
return false;
}
}, [isBackendMode]);
}, []);
// Embedding methods
const startEmbeddings = useCallback(async (forceDevice?: 'webgpu' | 'wasm'): Promise<void> => {
// Embeddings require the WASM worker DB — skip in backend mode
if (isBackendMode) return;
const api = apiRef.current;
if (!api) throw new Error('Worker not initialized');
@ -530,7 +525,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
}
throw error;
}
}, [isBackendMode]);
}, []);
const semanticSearch = useCallback(async (
query: string,
@ -571,39 +566,25 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
}, []);
const initializeAgent = useCallback(async (overrideProjectName?: string): Promise<void> => {
const config = getActiveProviderConfig();
if (!config) {
setAgentError('Please configure an LLM provider in settings');
return;
}
const api = apiRef.current;
if (!api) {
setAgentError('Worker not initialized');
return;
}
const config = getActiveProviderConfig();
if (!config) {
setAgentError('Please configure an LLM provider in settings');
return;
}
setIsAgentInitializing(true);
setAgentError(null);
try {
// Use override if provided (for fresh loads), fallback to state (for re-init)
const effectiveProjectName = overrideProjectName || projectName || 'project';
let result: { success: boolean; error?: string };
if (isBackendMode && backendRepo) {
// Backend mode: pass HTTP config + file contents to worker
const entries = Array.from(fileContents.entries());
result = await api.initializeBackendAgent(
config,
getBackendUrl(),
backendRepo,
entries,
effectiveProjectName,
);
} else {
result = await api.initializeAgent(config, effectiveProjectName);
}
const result = await api.initializeAgent(config, effectiveProjectName);
if (result.success) {
setIsAgentReady(true);
setAgentError(null);
@ -621,7 +602,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
} finally {
setIsAgentInitializing(false);
}
}, [projectName, isBackendMode, backendRepo, fileContents]);
}, [projectName]);
const sendChatMessage = useCallback(async (message: string): Promise<void> => {
const api = apiRef.current;
@ -991,6 +972,73 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setAgentError(null);
}, []);
// Switch to a different repo on the connected server
const switchRepo = useCallback(async (repoName: string) => {
if (!serverBaseUrl) return;
setProgress({ phase: 'extracting', percent: 0, message: 'Switching repository...', detail: `Loading ${repoName}` });
setViewMode('loading');
// Clear stale graph state from previous repo (highlights, selections, blast radius)
// Without this, sigma reducers dim ALL nodes/edges because old node IDs don't match
setHighlightedNodeIds(new Set());
clearAIToolHighlights();
clearBlastRadius();
setSelectedNode(null);
setQueryResult(null);
setCodeReferences([]);
setCodePanelOpen(false);
setCodeReferenceFocus(null);
try {
const result: ConnectToServerResult = await connectToServer(serverBaseUrl, (phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({ phase: 'extracting', percent: 5, message: 'Switching repository...', detail: 'Validating' });
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` });
} else if (phase === 'extracting') {
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
}
}, undefined, repoName);
// Reuse the same handleServerConnect logic inline
const repoPath = result.repoInfo.repoPath;
const pName = result.repoInfo.name || repoPath.split('/').pop() || 'server-project';
setProjectName(pName);
const graph = createKnowledgeGraph();
for (const node of result.nodes) graph.addNode(node);
for (const rel of result.relationships) graph.addRelationship(rel);
setGraph(graph);
const fileMap = new Map<string, string>();
for (const [p, c] of Object.entries(result.fileContents)) fileMap.set(p, c);
setFileContents(fileMap);
setViewMode('exploring');
if (getActiveProviderConfig()) initializeAgent(pName);
startEmbeddings().catch((err) => {
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
startEmbeddings('wasm').catch(console.warn);
} else {
console.warn('Embeddings auto-start failed:', err);
}
});
} catch (err) {
console.error('Repo switch failed:', err);
setProgress({
phase: 'error', percent: 0,
message: 'Failed to switch repository',
detail: err instanceof Error ? err.message : 'Unknown error',
});
setTimeout(() => { setViewMode('exploring'); setProgress(null); }, 3000);
}
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
const removeCodeReference = useCallback((id: string) => {
setCodeReferences(prev => {
const ref = prev.find(r => r.id === id);
@ -1084,6 +1132,12 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setProgress,
projectName,
setProjectName,
// Multi-repo switching
serverBaseUrl,
setServerBaseUrl,
availableRepos,
setAvailableRepos,
switchRepo,
runPipeline,
runPipelineFromFiles,
runQuery,
@ -1124,11 +1178,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
clearAICodeReferences,
clearCodeReferences,
codeReferenceFocus,
// Backend mode
isBackendMode,
backendRepo,
setBackendMode: setIsBackendMode,
setBackendRepo,
};
return (

View file

@ -0,0 +1,157 @@
import { GraphNode, GraphRelationship } from '../core/graph/types';
export interface RepoSummary {
name: string;
path: string;
indexedAt: string;
lastCommit: string;
stats: {
files: number;
nodes: number;
edges: number;
communities: number;
processes: number;
};
}
export interface ServerRepoInfo {
name: string;
repoPath: string;
indexedAt: string;
stats: {
files: number;
nodes: number;
edges: number;
communities: number;
processes: number;
};
}
export interface ConnectToServerResult {
nodes: GraphNode[];
relationships: GraphRelationship[];
fileContents: Record<string, string>;
repoInfo: ServerRepoInfo;
}
export function normalizeServerUrl(input: string): string {
let url = input.trim();
// Strip trailing slashes
url = url.replace(/\/+$/, '');
// Add protocol if missing
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (url.startsWith('localhost') || url.startsWith('127.0.0.1')) {
url = `http://${url}`;
} else {
url = `https://${url}`;
}
}
// Add /api if not already present
if (!url.endsWith('/api')) {
url = `${url}/api`;
}
return url;
}
export async function fetchRepos(baseUrl: string): Promise<RepoSummary[]> {
const response = await fetch(`${baseUrl}/repos`);
if (!response.ok) throw new Error(`Server returned ${response.status}`);
return response.json();
}
export async function fetchRepoInfo(baseUrl: string, repoName?: string): Promise<ServerRepoInfo> {
const url = repoName ? `${baseUrl}/repo?repo=${encodeURIComponent(repoName)}` : `${baseUrl}/repo`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// npm gitnexus@1.3.3 returns "path"; git HEAD returns "repoPath"
return { ...data, repoPath: data.repoPath ?? data.path };
}
export async function fetchGraph(
baseUrl: string,
onProgress?: (downloaded: number, total: number | null) => void,
signal?: AbortSignal,
repoName?: string
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> {
const url = repoName ? `${baseUrl}/graph?repo=${encodeURIComponent(repoName)}` : `${baseUrl}/graph`;
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
}
const contentLength = response.headers.get('Content-Length');
const total = contentLength ? parseInt(contentLength, 10) : null;
if (!response.body) {
const data = await response.json();
return data;
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let downloaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
downloaded += value.length;
onProgress?.(downloaded, total);
}
const combined = new Uint8Array(downloaded);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
const text = new TextDecoder().decode(combined);
return JSON.parse(text);
}
export function extractFileContents(nodes: GraphNode[]): Record<string, string> {
const contents: Record<string, string> = {};
for (const node of nodes) {
if (node.label === 'File' && (node.properties as any).content) {
contents[node.properties.filePath] = (node.properties as any).content;
}
}
return contents;
}
export async function connectToServer(
url: string,
onProgress?: (phase: string, downloaded: number, total: number | null) => void,
signal?: AbortSignal,
repoName?: string
): Promise<ConnectToServerResult> {
const baseUrl = normalizeServerUrl(url);
// Phase 1: Validate server
onProgress?.('validating', 0, null);
const repoInfo = await fetchRepoInfo(baseUrl, repoName);
// Phase 2: Download graph
onProgress?.('downloading', 0, null);
const { nodes, relationships } = await fetchGraph(
baseUrl,
(downloaded, total) => onProgress?.('downloading', downloaded, total),
signal,
repoName
);
// Phase 3: Extract file contents
onProgress?.('extracting', 0, null);
const fileContents = extractFileContents(nodes);
return { nodes, relationships, fileContents, repoInfo };
}

View file

@ -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`:
@ -150,7 +156,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
## Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Swift
## Agent Skills

View file

@ -101,20 +101,40 @@ function main() {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
// Resolve CLI path relative to this hook script (same package)
// hooks/claude/gitnexus-hook.cjs → dist/cli/index.js
const cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
// Resolve CLI path — try multiple strategies:
// 1. Relative path (works when script is inside npm package)
// 2. require.resolve (works when gitnexus is globally installed)
// 3. Fall back to npx (works when neither is available)
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
if (!fs.existsSync(cliPath)) {
try {
cliPath = require.resolve('gitnexus/dist/cli/index.js');
} catch {
cliPath = ''; // will use npx fallback
}
}
// augment CLI writes result to stderr (KuzuDB's native module captures
// stdout fd at OS level, making it unusable in subprocess contexts).
const { spawnSync } = require('child_process');
let result = '';
try {
const child = spawnSync(
process.execPath,
[cliPath, 'augment', pattern],
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
let child;
if (cliPath) {
child = spawnSync(
process.execPath,
[cliPath, 'augment', pattern],
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
} else {
// npx fallback
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
child = spawnSync(
cmd,
['-y', 'gitnexus', 'augment', pattern],
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
}
result = child.stderr || '';
} catch { /* graceful failure */ }

View file

@ -63,7 +63,8 @@ if [ "$found" = false ]; then
fi
# Run gitnexus augment — must be fast (<500ms target)
RESULT=$(cd "$CWD" && npx -y gitnexus augment "$PATTERN" 2>/dev/null)
# augment writes to stderr (KuzuDB captures stdout at OS level), so capture stderr and discard stdout
RESULT=$(cd "$CWD" && npx -y gitnexus augment "$PATTERN" 2>&1 1>/dev/null)
if [ -n "$RESULT" ]; then
ESCAPED=$(echo "$RESULT" | jq -Rs .)

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.2.8",
"version": "1.3.6",
"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",
@ -32,13 +32,20 @@
"files": [
"dist",
"hooks",
"scripts",
"skills",
"vendor"
],
"scripts": {
"build": "tsc",
"dev": "tsx watch src/cli/index.ts",
"prepare": "npm run build"
"test": "vitest run test/unit",
"test:integration": "vitest run test/integration",
"test:all": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "npm run build",
"postinstall": "node scripts/patch-tree-sitter-swift.cjs"
},
"dependencies": {
"@huggingface/transformers": "^3.0.0",
@ -62,20 +69,26 @@
"tree-sitter-go": "^0.21.0",
"tree-sitter-java": "^0.21.0",
"tree-sitter-javascript": "^0.21.0",
"tree-sitter-kotlin": "^0.3.8",
"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"
},
"optionalDependencies": {
"tree-sitter-swift": "^0.6.0"
},
"devDependencies": {
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.0.0"
"@vitest/coverage-v8": "^4.0.18",
"tsx": "^4.0.0",
"typescript": "^5.4.5",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=18.0.0"

View file

@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure
*
* Background:
* tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that
* invokes `tree-sitter generate` to regenerate parser.c from grammar.js.
* This is intended for grammar developers, but the published npm package
* already ships pre-generated parser files (parser.c, scanner.c), so the
* actions are unnecessary for consumers. Since consumers don't have
* tree-sitter-cli installed, the actions always fail during `npm install`.
*
* Why we can't just upgrade:
* tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds),
* but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter
* to ^0.21.0 and all other grammar packages depend on that version.
* Upgrading tree-sitter would be a separate breaking change.
*
* How this workaround works:
* 1. tree-sitter-swift's own postinstall fails (npm warns but continues)
* 2. This script runs as gitnexus's postinstall
* 3. It removes the "actions" array from binding.gyp
* 4. It rebuilds the native binding with the cleaned binding.gyp
*
* TODO: Remove this script when tree-sitter is upgraded to ^0.22.x,
* which allows using tree-sitter-swift@0.7.1+ directly.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
const bindingPath = path.join(swiftDir, 'binding.gyp');
try {
if (!fs.existsSync(bindingPath)) {
process.exit(0);
}
const content = fs.readFileSync(bindingPath, 'utf8');
let needsRebuild = false;
if (content.includes('"actions"')) {
// Strip Python-style comments (#) before JSON parsing
const cleaned = content.replace(/#[^\n]*/g, '');
const gyp = JSON.parse(cleaned);
if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) {
delete gyp.targets[0].actions;
fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n');
console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)');
needsRebuild = true;
}
}
// Check if native binding exists
const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node');
if (!fs.existsSync(bindingNode)) {
needsRebuild = true;
}
if (needsRebuild) {
console.log('[tree-sitter-swift] Rebuilding native binding...');
execSync('npx node-gyp rebuild', {
cwd: swiftDir,
stdio: 'pipe',
timeout: 120000,
});
console.log('[tree-sitter-swift] Native binding built successfully');
}
} catch (err) {
console.warn('[tree-sitter-swift] Could not build native binding:', err.message);
console.warn('[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild');
}

View file

@ -0,0 +1,82 @@
---
name: gitnexus-cli
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
---
# 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

View file

@ -1,11 +1,12 @@
---
name: gitnexus-debugging
description: Trace bugs through call chains using knowledge graph
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
---
# 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

View file

@ -1,11 +1,12 @@
---
name: gitnexus-exploring
description: Navigate unfamiliar code using GitNexus knowledge graph
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
---
# 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

View file

@ -0,0 +1,64 @@
---
name: gitnexus-guide
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
---
# 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
```

View file

@ -1,11 +1,12 @@
---
name: gitnexus-impact-analysis
description: Analyze blast radius before making code changes
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
---
# 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"})

View file

@ -0,0 +1,163 @@
---
name: gitnexus-pr-review
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
---
# PR Review with GitNexus
## When to Use
- "Review this PR"
- "What does PR #42 change?"
- "Is this safe to merge?"
- "What's the blast radius of this PR?"
- "Are there missing tests for this PR?"
- Reviewing someone else's code changes before merge
## Workflow
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
## Checklist
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
```
## Review Dimensions
| Dimension | How GitNexus Helps |
| --- | --- |
| **Correctness** | `context` shows callers — are they all compatible with the change? |
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
## Risk Assessment
| Signal | Risk |
| --- | --- |
| Changes touch <3 symbols, 0-1 processes | LOW |
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
| Changes touch >10 symbols or many processes | HIGH |
| Changes touch auth, payments, or data integrity code | CRITICAL |
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
```
## Example: "Review PR #42"
```
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
- MEDIUM risk — 3 changed symbols affect 2 execution flows
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
- BUG: createPayment depends on PaymentInput type which changed
- OK: formatAmount change is backwards-compatible
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
```
## Review Output Format
Structure your review as:
```markdown
## PR Review: <title>
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
### Changes Summary
- <N> symbols changed across <M> files
- <P> execution flows affected
### Findings
1. **[severity]** Description of finding
- Evidence from GitNexus tools
- Affected callers/flows
### Missing Coverage
- Callers not updated in PR: ...
- Untested flows: ...
### Recommendation
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
```

View file

@ -1,11 +1,12 @@
---
name: gitnexus-refactoring
description: Plan safe refactors using blast radius and dependency mapping
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
---
# 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`

View file

@ -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}`;
}
@ -137,7 +100,7 @@ async function upsertGitNexusSection(
const startIdx = existingContent.indexOf(GITNEXUS_START_MARKER);
const endIdx = existingContent.indexOf(GITNEXUS_END_MARKER);
if (startIdx !== -1 && endIdx !== -1) {
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
// Replace existing section
const before = existingContent.substring(0, startIdx);
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
@ -163,20 +126,28 @@ async function installSkills(repoPath: string): Promise<string[]> {
// Skill definitions bundled with the package
const skills = [
{
name: 'exploring',
description: 'Navigate unfamiliar code using GitNexus knowledge graph',
name: 'gitnexus-exploring',
description: 'Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: "How does X work?", "What calls this function?", "Show me the auth flow"',
},
{
name: 'debugging',
description: 'Trace bugs through call chains using knowledge graph',
name: 'gitnexus-debugging',
description: 'Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: "Why is X failing?", "Where does this error come from?", "Trace this bug"',
},
{
name: 'impact-analysis',
description: 'Analyze blast radius before making code changes',
name: 'gitnexus-impact-analysis',
description: 'Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: "Is it safe to change X?", "What depends on this?", "What will break?"',
},
{
name: 'refactoring',
description: 'Plan safe refactors using blast radius and dependency mapping',
name: 'gitnexus-refactoring',
description: 'Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: "Rename this function", "Extract this into a module", "Refactor this class", "Move this to a separate file"',
},
{
name: 'gitnexus-guide',
description: 'Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: "What GitNexus tools are available?", "How do I use GitNexus?"',
},
{
name: 'gitnexus-cli',
description: 'Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: "Index this repo", "Reanalyze the codebase", "Generate a wiki"',
},
];
@ -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}
---

View file

@ -5,16 +5,42 @@
*/
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';
import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
// Embedding imports are lazy (dynamic import) so onnxruntime-node is never
// loaded when embeddings are not requested. This avoids crashes on Node
// versions whose ABI is not yet supported by the native binary (#89).
// disposeEmbedder intentionally not called — ONNX Runtime segfaults on cleanup (see #38)
import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath } from '../storage/repo-manager.js';
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
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;
@ -44,6 +70,8 @@ export const analyzeCommand = async (
inputPath?: string,
options?: AnalyzeOptions
) => {
if (ensureHeap()) return;
console.log('\n GitNexus Analyzer\n');
let repoPath: string;
@ -88,19 +116,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 +172,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 +187,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 (6085%) ──────────────────────────────────────
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 +202,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 (8590%) ─────────────────────────────────────────
lastPhaseLabel = 'Creating search indexes...'; phaseStart = Date.now();
bar.update(85, { phase: lastPhaseLabel });
updateBar(85, 'Creating search indexes...');
const t0Fts = Date.now();
try {
@ -174,7 +227,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,17 +256,16 @@ 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();
const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js');
await runEmbeddingPipeline(
executeQuery,
executeWithReusedStatement,
(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 +274,14 @@ export const analyzeCommand = async (
}
// ── Phase 5: Finalize (98100%) ───────────────────────────────────
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,
@ -240,8 +292,6 @@ export const analyzeCommand = async (
await registerRepo(repoPath, meta);
await addToGitignore(repoPath);
const hookResult = await registerClaudeHook();
const projectName = path.basename(repoPath);
let aggregatedClusterCount = 0;
if (pipelineResult.communityResult?.communities) {
@ -254,7 +304,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 +320,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;
@ -288,16 +340,13 @@ export const analyzeCommand = async (
console.log(` Context: ${aiContext.files.join(', ')}`);
}
if (hookResult.registered) {
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 {

View file

@ -1,111 +0,0 @@
/**
* Claude Code Hook Registration
*
* Registers the GitNexus PreToolUse hook in ~/.claude/hooks.json
* so that grep/glob/bash calls are automatically augmented with
* knowledge graph context.
*
* Idempotent safe to call multiple times.
*/
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Get the absolute path to the gitnexus-hook.js file.
* Works for both local dev and npm-installed packages.
*/
function getHookScriptPath(): string {
// From dist/cli/claude-hooks.js → hooks/claude/gitnexus-hook.js
const packageRoot = path.resolve(__dirname, '..', '..');
return path.join(packageRoot, 'hooks', 'claude', 'gitnexus-hook.cjs');
}
/**
* Register (or verify) the GitNexus hook in Claude Code's global hooks.json.
*
* - Creates ~/.claude/ and hooks.json if they don't exist
* - Preserves existing hooks from other tools
* - Skips if GitNexus hook is already registered
*
* Returns a status message for the CLI output.
*/
export async function registerClaudeHook(): Promise<{ registered: boolean; message: string }> {
const claudeDir = path.join(os.homedir(), '.claude');
const hooksFile = path.join(claudeDir, 'hooks.json');
const hookScript = getHookScriptPath();
// Check if the hook script exists
try {
await fs.access(hookScript);
} catch {
return { registered: false, message: 'Hook script not found (package may be incomplete)' };
}
// Build the hook command — use node + absolute path for reliability
const hookCommand = `node "${hookScript}"`;
// Check if ~/.claude/ exists (user has Claude Code installed)
try {
await fs.access(claudeDir);
} catch {
// No Claude Code installation — skip silently
return { registered: false, message: 'Claude Code not detected (~/.claude/ not found)' };
}
// Read existing hooks.json or start fresh
let hooksConfig: any = {};
try {
const existing = await fs.readFile(hooksFile, 'utf-8');
hooksConfig = JSON.parse(existing);
} catch {
// File doesn't exist or is invalid — we'll create it
}
// Ensure the hooks structure exists
if (!hooksConfig.hooks) {
hooksConfig.hooks = {};
}
if (!Array.isArray(hooksConfig.hooks.PreToolUse)) {
hooksConfig.hooks.PreToolUse = [];
}
// Check if GitNexus hook is already registered
const existingEntry = hooksConfig.hooks.PreToolUse.find((entry: any) => {
if (!entry.hooks || !Array.isArray(entry.hooks)) return false;
return entry.hooks.some((h: any) =>
h.command && (
h.command.includes('gitnexus-hook') ||
h.command.includes('gitnexus augment')
)
);
});
if (existingEntry) {
return { registered: true, message: 'Claude Code hook already registered' };
}
// Add the GitNexus hook entry
hooksConfig.hooks.PreToolUse.push({
matcher: {
tool_name: "Grep|Glob|Bash"
},
hooks: [
{
type: "command",
command: hookCommand,
timeout: 8000
}
]
});
// Write back
await fs.writeFile(hooksFile, JSON.stringify(hooksConfig, null, 2) + '\n', 'utf-8');
return { registered: true, message: 'Claude Code hook registered' };
}

View file

@ -36,7 +36,7 @@ export interface EvalServerOptions {
// Convert structured JSON results into compact, LLM-friendly text.
// Design: minimize tokens, maximize actionability.
function formatQueryResult(result: any): string {
export function formatQueryResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const lines: string[] = [];
@ -77,7 +77,7 @@ function formatQueryResult(result: any): string {
return lines.join('\n').trim();
}
function formatContextResult(result: any): string {
export function formatContextResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
if (result.status === 'ambiguous') {
@ -141,7 +141,7 @@ function formatContextResult(result: any): string {
return lines.join('\n').trim();
}
function formatImpactResult(result: any): string {
export function formatImpactResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const target = result.target;
@ -181,7 +181,7 @@ function formatImpactResult(result: any): string {
return lines.join('\n').trim();
}
function formatCypherResult(result: any): string {
export function formatCypherResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
if (Array.isArray(result)) {
@ -202,7 +202,7 @@ function formatCypherResult(result: any): string {
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
function formatDetectChangesResult(result: any): string {
export function formatDetectChangesResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const summary = result.summary || {};
@ -238,7 +238,7 @@ function formatDetectChangesResult(result: any): string {
return lines.join('\n').trim();
}
function formatListReposResult(result: any): string {
export function formatListReposResult(result: any): string {
if (!Array.isArray(result) || result.length === 0) {
return 'No indexed repositories.';
}
@ -420,10 +420,20 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
process.on('SIGTERM', shutdown);
}
export const MAX_BODY_SIZE = 1024 * 1024; // 1MB
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
let totalSize = 0;
req.on('data', (chunk: Buffer) => {
totalSize += chunk.length;
if (totalSize > MAX_BODY_SIZE) {
req.destroy(new Error('Request body too large (max 1MB)'));
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});

View file

@ -1,4 +1,8 @@
#!/usr/bin/env node
// Heap re-spawn removed — only analyze.ts needs the 8GB heap (via its own ensureHeap()).
// Removing it from here improves MCP server startup time significantly.
import { Command } from 'commander';
import { analyzeCommand } from './analyze.js';
import { serveCommand } from './serve.js';
@ -11,12 +15,15 @@ import { augmentCommand } from './augment.js';
import { wikiCommand } from './wiki.js';
import { queryCommand, contextCommand, impactCommand, cypherCommand } from './tool.js';
import { evalServerCommand } from './eval-server.js';
import { createRequire } from 'node:module';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
const program = new Command();
program
.name('gitnexus')
.description('GitNexus local CLI and MCP server')
.version('1.2.0');
.version(pkg.version);
program
.command('setup')
@ -34,6 +41,7 @@ program
.command('serve')
.description('Start local HTTP server for web UI connection')
.option('-p, --port <port>', 'Port number', '4747')
.option('--host <host>', 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)')
.action(serveCommand);
program

View file

@ -8,46 +8,33 @@
import { startMCPServer } from '../mcp/server.js';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { listRegisteredRepos } from '../storage/repo-manager.js';
export const mcpCommand = async () => {
// Prevent unhandled errors from crashing the MCP server process.
// KuzuDB lock conflicts and transient errors should degrade gracefully.
process.on('uncaughtException', (err) => {
console.error(`GitNexus MCP: uncaught exception — ${err.message}`);
// Process is in an undefined state after uncaughtException — exit after flushing
setTimeout(() => process.exit(1), 100);
});
process.on('unhandledRejection', (reason) => {
const msg = reason instanceof Error ? reason.message : String(reason);
console.error(`GitNexus MCP: unhandled rejection — ${msg}`);
});
// Load all registered repos
const entries = await listRegisteredRepos({ validate: true });
if (entries.length === 0) {
console.error('');
console.error(' GitNexus: No indexed repositories found.');
console.error('');
console.error(' To get started:');
console.error(' 1. cd into a git repository');
console.error(' 2. Run: gitnexus analyze');
console.error(' 3. Restart your editor');
console.error('');
process.exit(1);
}
// Initialize multi-repo backend from registry
// Initialize multi-repo backend from registry.
// The server starts even with 0 repos — tools call refreshRepos() lazily,
// so repos indexed after the server starts are discovered automatically.
const backend = new LocalBackend();
const ok = await backend.init();
await backend.init();
if (!ok) {
console.error('GitNexus: Failed to initialize backend from registry.');
process.exit(1);
const repos = await backend.listRepos();
if (repos.length === 0) {
console.error('GitNexus: No indexed repos yet. Run `gitnexus analyze` in a git repo — the server will pick it up automatically.');
} else {
console.error(`GitNexus: MCP server starting with ${repos.length} repo(s): ${repos.map(r => r.name).join(', ')}`);
}
const repoNames = (await backend.listRepos()).map(r => r.name);
console.error(`GitNexus: MCP server starting with ${repoNames.length} repo(s): ${repoNames.join(', ')}`);
// Start MCP server (serves all repos)
// Start MCP server (serves all repos, discovers new ones lazily)
await startMCPServer(backend);
};

View file

@ -1,7 +1,7 @@
import { createServer } from '../server/api.js';
export const serveCommand = async (options?: { port?: string }) => {
export const serveCommand = async (options?: { port?: string; host?: string }) => {
const port = Number(options?.port ?? 4747);
await createServer(port);
const host = options?.host ?? '127.0.0.1';
await createServer(port, host);
};

View file

@ -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'],
@ -156,7 +163,15 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const src = path.join(pluginHooksPath, 'gitnexus-hook.cjs');
const dest = path.join(destHooksDir, 'gitnexus-hook.cjs');
try {
const content = await fs.readFile(src, 'utf-8');
let content = await fs.readFile(src, 'utf-8');
// Inject resolved CLI path so the copied hook can find the CLI
// even when it's no longer inside the npm package tree
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
content = content.replace(
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
`let cliPath = '${normalizedCli}';`
);
await fs.writeFile(dest, content, 'utf-8');
} catch {
// Script not found in source — skip
@ -217,7 +232,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 +248,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)

View file

@ -7,7 +7,7 @@
import path from 'path';
import readline from 'readline';
import { execSync } from 'child_process';
import { execSync, execFileSync } from 'child_process';
import cliProgress from 'cli-progress';
import { getGitRoot, isGitRepo } from '../storage/git.js';
import { getStoragePaths, loadMeta, loadCLIConfig, saveCLIConfig } from '../storage/repo-manager.js';
@ -343,10 +343,11 @@ function hasGhCLI(): boolean {
function publishGist(htmlPath: string): { url: string; rawUrl: string } | null {
try {
const output = execSync(
`gh gist create "${htmlPath}" --desc "Repository Wiki — generated by GitNexus" --public`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
).trim();
const output = execFileSync('gh', [
'gist', 'create', htmlPath,
'--desc', 'Repository Wiki — generated by GitNexus',
'--public',
], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
// gh gist create prints the gist URL as the last line
const lines = output.split('\n');

View file

@ -9,6 +9,7 @@ export enum SupportedLanguages {
Go = 'go',
Rust = 'rust',
PHP = 'php',
Kotlin = 'kotlin',
// Ruby = 'ruby',
// Swift = 'swift',
Swift = 'swift',
}

View file

@ -15,8 +15,44 @@ if (!process.env.ORT_LOG_LEVEL) {
}
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
import { existsSync } from 'fs';
import { execFileSync } from 'child_process';
import { join } from 'path';
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
/**
* Check whether CUDA libraries are actually available on this system.
* ONNX Runtime's native layer crashes (uncatchable) if we attempt CUDA
* without the required shared libraries, so we probe first.
*
* Checks the dynamic linker cache (ldconfig) which covers all architectures
* and install paths, then falls back to CUDA_PATH / LD_LIBRARY_PATH env vars.
*/
function isCudaAvailable(): boolean {
// Primary: query the dynamic linker cache — covers all architectures,
// distro layouts, and custom install paths registered with ldconfig
try {
const out = execFileSync('ldconfig', ['-p'], { timeout: 3000, encoding: 'utf-8' });
if (out.includes('libcublasLt.so.12')) return true;
} catch {
// ldconfig not available (e.g. non-standard container)
}
// Fallback: check CUDA_PATH and LD_LIBRARY_PATH for environments where
// ldconfig doesn't know about the CUDA install (conda, manual /opt/cuda, etc.)
for (const envVar of ['CUDA_PATH', 'LD_LIBRARY_PATH']) {
const val = process.env[envVar];
if (!val) continue;
for (const dir of val.split(':').filter(Boolean)) {
if (existsSync(join(dir, 'lib64', 'libcublasLt.so.12')) ||
existsSync(join(dir, 'lib', 'libcublasLt.so.12')) ||
existsSync(join(dir, 'libcublasLt.so.12'))) return true;
}
}
return false;
}
// Module-level state for singleton pattern
let embedderInstance: FeatureExtractionPipeline | null = null;
let isInitializing = false;
@ -62,8 +98,10 @@ export const initEmbedder = async (
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
// On Windows, use DirectML for GPU acceleration (via DirectX12)
// CUDA is only available on Linux x64 with onnxruntime-node
// Probe for CUDA first — ONNX Runtime crashes (uncatchable native error)
// if we attempt CUDA without the required shared libraries
const isWindows = process.platform === 'win32';
const gpuDevice = isWindows ? 'dml' : 'cuda';
const gpuDevice = isWindows ? 'dml' : (isCudaAvailable() ? 'cuda' : 'cpu');
let requestedDevice = forceDevice || (finalConfig.device === 'auto' ? gpuDevice : finalConfig.device);
initPromise = (async () => {

View file

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

View file

@ -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 = {
@ -25,6 +42,9 @@ export type NodeProperties = {
endLine?: number,
language?: string,
isExported?: boolean,
// Optional AST-derived framework hint (e.g. @Controller, @GetMapping)
astFrameworkMultiplier?: number,
astFrameworkReason?: string,
// Community-specific properties
heuristicLabel?: string,
cohesion?: number,
@ -77,12 +97,23 @@ 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,
addRelationship: (relationship: GraphRelationship) => void,
removeNode: (nodeId: string) => boolean,
removeNodesByFile: (filePath: string) => number,
}
}

View file

@ -37,6 +37,13 @@ const FUNCTION_NODE_TYPES = new Set([
// Rust
'function_item',
'impl_item', // Methods inside impl blocks
// Kotlin (function_declaration already included above via JS/TS)
'anonymous_function',
'lambda_literal',
// PHP — no additional node types needed
// Swift
'init_declaration',
'deinit_declaration',
]);
/**
@ -57,7 +64,13 @@ const findEnclosingFunction = (
let label = 'Function';
// Different node types have different name locations
if (current.type === 'function_declaration' ||
// Swift init/deinit — handle before generic cases (more specific)
if (current.type === 'init_declaration' || current.type === 'deinit_declaration') {
const funcName = current.type === 'init_declaration' ? 'init' : 'deinit';
return generateId('Constructor', `${filePath}:${funcName}`);
}
if (current.type === 'function_declaration' ||
current.type === 'function_definition' ||
current.type === 'async_function_declaration' ||
current.type === 'generator_function_declaration' ||
@ -286,39 +299,106 @@ 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',
// Kotlin stdlib (IMPORTANT: keep in sync with parse-worker.ts BUILT_IN_NAMES)
'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error',
'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf',
'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless',
'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet',
'repeat', 'synchronized',
// Kotlin coroutine builders & scope functions
'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope',
'supervisorScope', 'delay',
// Kotlin Flow operators
'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch',
'buffer', 'conflate', 'distinctUntilChanged',
'flatMapLatest', 'flatMapMerge', 'combine',
'stateIn', 'shareIn', 'launchIn',
// Kotlin infix stdlib functions
'to', 'until', 'downTo', 'step',
// 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',
// Swift/iOS built-ins and standard library
'print', 'debugPrint', 'dump', 'fatalError', 'precondition', 'preconditionFailure',
'assert', 'assertionFailure', 'NSLog',
'abs', 'min', 'max', 'zip', 'stride', 'sequence', 'repeatElement',
'swap', 'withUnsafePointer', 'withUnsafeMutablePointer', 'withUnsafeBytes',
'autoreleasepool', 'unsafeBitCast', 'unsafeDowncast', 'numericCast',
'type', 'MemoryLayout',
// Swift collection/string methods (common noise)
'map', 'flatMap', 'compactMap', 'filter', 'reduce', 'forEach', 'contains',
'first', 'last', 'prefix', 'suffix', 'dropFirst', 'dropLast',
'sorted', 'reversed', 'enumerated', 'joined', 'split',
'append', 'insert', 'remove', 'removeAll', 'removeFirst', 'removeLast',
'isEmpty', 'count', 'index', 'startIndex', 'endIndex',
// UIKit/Foundation common methods (noise in call graph)
'addSubview', 'removeFromSuperview', 'layoutSubviews', 'setNeedsLayout',
'layoutIfNeeded', 'setNeedsDisplay', 'invalidateIntrinsicContentSize',
'addTarget', 'removeTarget', 'addGestureRecognizer',
'addConstraint', 'addConstraints', 'removeConstraint', 'removeConstraints',
'NSLocalizedString', 'Bundle',
'reloadData', 'reloadSections', 'reloadRows', 'performBatchUpdates',
'register', 'dequeueReusableCell', 'dequeueReusableSupplementaryView',
'beginUpdates', 'endUpdates', 'insertRows', 'deleteRows', 'insertSections', 'deleteSections',
'present', 'dismiss', 'pushViewController', 'popViewController', 'popToRootViewController',
'performSegue', 'prepare',
// GCD / async
'DispatchQueue', 'async', 'sync', 'asyncAfter',
'Task', 'withCheckedContinuation', 'withCheckedThrowingContinuation',
// Combine
'sink', 'store', 'assign', 'receive', 'subscribe',
// Notification / KVO
'addObserver', 'removeObserver', 'post', 'NotificationCenter',
]);
return builtIns.has(name);
};
const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name);
/**
* Fast path: resolve pre-extracted call sites from workers.

View file

@ -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[] = [];

View file

@ -103,6 +103,26 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^Start$/, // Start methods
],
// Swift / iOS
'swift': [
/^viewDidLoad$/, // UIKit lifecycle
/^viewWillAppear$/, // UIKit lifecycle
/^viewDidAppear$/, // UIKit lifecycle
/^viewWillDisappear$/, // UIKit lifecycle
/^viewDidDisappear$/, // UIKit lifecycle
/^application\(/, // AppDelegate methods
/^scene\(/, // SceneDelegate methods
/^body$/, // SwiftUI View.body
/Coordinator$/, // Coordinator pattern
/^sceneDidBecomeActive$/, // SceneDelegate lifecycle
/^sceneWillResignActive$/, // SceneDelegate lifecycle
/^didFinishLaunchingWithOptions$/, // AppDelegate
/ViewController$/, // ViewController classes
/^configure[A-Z]/, // Configuration methods
/^setup[A-Z]/, // Setup methods
/^makeBody$/, // SwiftUI ViewModifier
],
// PHP / Laravel
'php': [
/Controller$/, // UserController (class name convention)
@ -271,6 +291,10 @@ export function isTestFile(filePath: string): boolean {
p.includes('/src/test/') ||
// Rust test patterns (inline tests are different, but test files)
p.includes('/tests/') ||
// Swift/iOS test patterns
p.endsWith('tests.swift') ||
p.endsWith('test.swift') ||
p.includes('uitests/') ||
// C# test patterns
p.includes('.tests/') ||
p.includes('tests.cs') ||

View file

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

View file

@ -1,8 +1,10 @@
/**
* Framework Detection
*
* Detects frameworks from file path patterns and provides entry point multipliers.
* This enables framework-aware entry point scoring.
* Detects frameworks from:
* 1) file path patterns
* 2) AST definition text (decorators/annotations/attributes)
* and provides entry point multipliers for process scoring.
*
* DESIGN: Returns null for unknown frameworks, which causes a 1.0 multiplier
* (no bonus, no penalty) - same behavior as before this feature.
@ -127,6 +129,49 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'java-service', entryPointMultiplier: 1.8, reason: 'java-service' };
}
// ========== KOTLIN FRAMEWORKS ==========
// Spring Boot Kotlin controllers
if ((p.includes('/controller/') || p.includes('/controllers/')) && p.endsWith('.kt')) {
return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller' };
}
// Spring Boot - files ending in Controller.kt
if (p.endsWith('controller.kt')) {
return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller-file' };
}
// Ktor routes
if (p.includes('/routes/') && p.endsWith('.kt')) {
return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routes' };
}
// Ktor plugins folder or Routing.kt files
if (p.includes('/plugins/') && p.endsWith('.kt')) {
return { framework: 'ktor', entryPointMultiplier: 2.0, reason: 'ktor-plugin' };
}
if (p.endsWith('routing.kt') || p.endsWith('routes.kt')) {
return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routing-file' };
}
// Android Activities, Fragments
if ((p.includes('/activity/') || p.includes('/ui/')) && p.endsWith('.kt')) {
return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-ui' };
}
if (p.endsWith('activity.kt') || p.endsWith('fragment.kt')) {
return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-component' };
}
// Kotlin main entry point
if (p.endsWith('/main.kt')) {
return { framework: 'kotlin', entryPointMultiplier: 3.0, reason: 'kotlin-main' };
}
// Kotlin Application entry point (common naming)
if (p.endsWith('/application.kt')) {
return { framework: 'kotlin', entryPointMultiplier: 2.5, reason: 'kotlin-application' };
}
// ========== C# / .NET FRAMEWORKS ==========
// ASP.NET Controllers
@ -257,13 +302,55 @@ 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' };
// ========== SWIFT / iOS ==========
// iOS App entry points (highest priority)
if (p.endsWith('/appdelegate.swift') || p.endsWith('/scenedelegate.swift') || p.endsWith('/app.swift')) {
return { framework: 'ios', entryPointMultiplier: 3.0, reason: 'ios-app-entry' };
}
// SwiftUI App entry (@main)
if (p.endsWith('app.swift') && p.includes('/sources/')) {
return { framework: 'swiftui', entryPointMultiplier: 3.0, reason: 'swiftui-app' };
}
// UIKit ViewControllers (high priority - screen entry points)
if ((p.includes('/viewcontrollers/') || p.includes('/controllers/') || p.includes('/screens/')) && p.endsWith('.swift')) {
return { framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-viewcontroller' };
}
// ViewController by filename convention
if (p.endsWith('viewcontroller.swift') || p.endsWith('vc.swift')) {
return { framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-viewcontroller-file' };
}
// Coordinator pattern (navigation entry points)
if (p.includes('/coordinators/') && p.endsWith('.swift')) {
return { framework: 'ios-coordinator', entryPointMultiplier: 2.5, reason: 'ios-coordinator' };
}
// Coordinator by filename
if (p.endsWith('coordinator.swift')) {
return { framework: 'ios-coordinator', entryPointMultiplier: 2.5, reason: 'ios-coordinator-file' };
}
// SwiftUI Views (moderate - reusable components)
if ((p.includes('/views/') || p.includes('/scenes/')) && p.endsWith('.swift')) {
return { framework: 'swiftui', entryPointMultiplier: 1.8, reason: 'swiftui-view' };
}
// Service layer
if (p.includes('/services/') && p.endsWith('.swift')) {
return { framework: 'ios-service', entryPointMultiplier: 1.8, reason: 'ios-service' };
}
// Router / navigation
if (p.includes('/router/') && p.endsWith('.swift')) {
return { framework: 'ios-router', entryPointMultiplier: 2.0, reason: 'ios-router' };
}
// ========== GENERIC PATTERNS ==========
// Any language: index files in API folders
if (p.includes('/api/') && (
p.endsWith('/index.ts') || p.endsWith('/index.js') ||
@ -277,13 +364,12 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
}
// ============================================================================
// PARTIALLY IMPLEMENTED: Route::* detection via procedural AST walk in parse-worker/call-processor
// Remaining: NestJS, Express, FastAPI, Flask, Spring, etc.
// AST-BASED FRAMEWORK DETECTION
// ============================================================================
/**
* Patterns that indicate entry points within code (for future AST-based detection)
* These would require parsing decorators/annotations in the code itself.
* Patterns that indicate framework entry points within code definitions.
* These are matched against AST node text (class/method/function declaration text).
*/
export const FRAMEWORK_AST_PATTERNS = {
// JavaScript/TypeScript decorators
@ -303,7 +389,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('],
@ -312,4 +398,85 @@ export const FRAMEWORK_AST_PATTERNS = {
'actix': ['#[get', '#[post', '#[put', '#[delete'],
'axum': ['Router::new'],
'rocket': ['#[get', '#[post'],
// Swift/iOS
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController'],
'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject'],
'combine': ['sink', 'assign', 'Publisher', 'Subscriber'],
};
interface AstFrameworkPatternConfig {
framework: string;
entryPointMultiplier: number;
reason: string;
patterns: string[];
}
const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConfig[]> = {
javascript: [
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
],
typescript: [
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
],
python: [
{ framework: 'fastapi', entryPointMultiplier: 3.0, reason: 'fastapi-decorator', patterns: FRAMEWORK_AST_PATTERNS.fastapi },
{ framework: 'flask', entryPointMultiplier: 2.8, reason: 'flask-decorator', patterns: FRAMEWORK_AST_PATTERNS.flask },
],
java: [
{ framework: 'spring', entryPointMultiplier: 3.2, reason: 'spring-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
],
kotlin: [
{ framework: 'spring-kotlin', entryPointMultiplier: 3.2, reason: 'spring-kotlin-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
{ framework: 'ktor', entryPointMultiplier: 2.8, reason: 'ktor-routing', patterns: ['routing', 'embeddedServer', 'Application.module'] },
{ framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-annotation', patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('] },
],
csharp: [
{ framework: 'aspnet', entryPointMultiplier: 3.2, reason: 'aspnet-attribute', patterns: FRAMEWORK_AST_PATTERNS.aspnet },
],
php: [
{ framework: 'laravel', entryPointMultiplier: 3.0, reason: 'php-route-attribute', patterns: FRAMEWORK_AST_PATTERNS.laravel },
],
};
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
const AST_PATTERNS_LOWERED: Record<string, Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>> =
Object.fromEntries(
Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [
lang,
cfgs.map(cfg => ({ ...cfg, patterns: cfg.patterns.map(p => p.toLowerCase()) })),
])
);
/**
* Detect framework entry points from AST definition text (decorators/annotations/attributes).
* Returns null if no known pattern is found.
* Note: callers should slice definitionText to ~300 chars since annotations appear at the start.
*/
export function detectFrameworkFromAST(
language: string,
definitionText: string
): FrameworkHint | null {
if (!language || !definitionText) return null;
const configs = AST_PATTERNS_LOWERED[language.toLowerCase()];
if (!configs || configs.length === 0) return null;
const normalized = definitionText.toLowerCase();
for (const cfg of configs) {
for (const pattern of cfg.patterns) {
if (normalized.includes(pattern)) {
return {
framework: cfg.framework,
entryPointMultiplier: cfg.entryPointMultiplier,
reason: cfg.reason,
};
}
}
}
return null;
}

View file

@ -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
// ============================================================================
@ -132,6 +153,42 @@ async function loadComposerConfig(repoRoot: string): Promise<ComposerConfig | nu
}
}
/** Swift Package Manager module config */
interface SwiftPackageConfig {
/** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
targets: Map<string, string>;
}
async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
// Swift imports are module-name based (e.g., `import SiuperModel`)
// SPM convention: Sources/<TargetName>/ or Package/Sources/<TargetName>/
// We scan for these directories to build a target map
const targets = new Map<string, string>();
const sourceDirs = ['Sources', 'Package/Sources', 'src'];
for (const sourceDir of sourceDirs) {
try {
const fullPath = path.join(repoRoot, sourceDir);
const entries = await fs.readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
targets.set(entry.name, sourceDir + '/' + entry.name);
}
}
} catch {
// Directory doesn't exist
}
}
if (targets.size > 0) {
if (isDev) {
console.log(`📦 Loaded ${targets.size} Swift package targets`);
}
return { targets };
}
return null;
}
// ============================================================================
// IMPORT PATH RESOLUTION
// ============================================================================
@ -145,6 +202,8 @@ const EXTENSIONS = [
'.py', '/__init__.py',
// Java
'.java',
// Kotlin
'.kt', '.kts',
// C/C++
'.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh',
// C#
@ -155,6 +214,8 @@ const EXTENSIONS = [
'.rs', '/mod.rs',
// PHP
'.php', '.phtml',
// Swift
'.swift',
];
/**
@ -309,6 +370,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;
};
@ -463,26 +533,42 @@ function tryRustModulePath(modulePath: string, allFiles: Set<string>): string |
return null;
}
/**
* Append .* to a Kotlin import path if the AST has a wildcard_import sibling node.
* Pure function returns a new string without mutating the input.
*/
const appendKotlinWildcard = (importPath: string, importNode: any): string => {
for (let i = 0; i < importNode.childCount; i++) {
if (importNode.child(i)?.type === 'wildcard_import') {
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;
}
}
return importPath;
};
// ============================================================================
// JAVA MULTI-FILE RESOLUTION
// JVM MULTI-FILE RESOLUTION (Java + Kotlin)
// ============================================================================
/** Kotlin file extensions for JVM resolver reuse */
const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts'];
/**
* Resolve a Java wildcard import (com.example.*) to all matching .java files.
* Returns an array of file paths.
* Resolve a JVM wildcard import (com.example.*) to all matching files.
* Works for both Java (.java) and Kotlin (.kt, .kts).
*/
function resolveJavaWildcard(
function resolveJvmWildcard(
importPath: string,
normalizedFileList: string[],
allFileList: string[],
extensions: readonly string[],
index?: SuffixIndex,
): string[] {
// "com.example.util.*" -> "com/example/util"
const packagePath = importPath.slice(0, -2).replace(/\./g, '/');
if (index) {
// Use directory index: get all .java files in this package directory
const candidates = index.getFilesInDir(packagePath, '.java');
const candidates = extensions.flatMap(ext => index.getFilesInDir(packagePath, ext));
// Filter to only direct children (no subdirectories)
const packageSuffix = '/' + packagePath + '/';
return candidates.filter(f => {
@ -499,7 +585,8 @@ function resolveJavaWildcard(
const matches: string[] = [];
for (let i = 0; i < normalizedFileList.length; i++) {
const normalized = normalizedFileList[i];
if (normalized.includes(packageSuffix) && normalized.endsWith('.java')) {
if (normalized.includes(packageSuffix) &&
extensions.some(ext => normalized.endsWith(ext))) {
const afterPackage = normalized.substring(normalized.indexOf(packageSuffix) + packageSuffix.length);
if (!afterPackage.includes('/')) {
matches.push(allFileList[i]);
@ -510,36 +597,39 @@ function resolveJavaWildcard(
}
/**
* Try to resolve a Java static import by stripping the member name.
* "com.example.Constants.VALUE" -> resolve "com.example.Constants"
* Try to resolve a JVM member/static import by stripping the member name.
* Java: "com.example.Constants.VALUE" -> resolve "com.example.Constants"
* Kotlin: "com.example.Constants.VALUE" -> resolve "com.example.Constants"
*/
function resolveJavaStaticImport(
function resolveJvmMemberImport(
importPath: string,
normalizedFileList: string[],
allFileList: string[],
extensions: readonly string[],
index?: SuffixIndex,
): string | null {
// Static imports look like: com.example.Constants.VALUE or com.example.Constants.*
// The last segment is a member name (field/method) if it starts with lowercase or is ALL_CAPS
// Member imports: com.example.Constants.VALUE or com.example.Constants.*
// The last segment is a member name if it starts with lowercase, is ALL_CAPS, or is a wildcard
const segments = importPath.split('.');
if (segments.length < 3) return null;
const lastSeg = segments[segments.length - 1];
// If last segment is a wildcard or ALL_CAPS constant or starts with lowercase, strip it
if (lastSeg === '*' || /^[a-z]/.test(lastSeg) || /^[A-Z_]+$/.test(lastSeg)) {
const classPath = segments.slice(0, -1).join('/');
const classSuffix = classPath + '.java';
if (index) {
return index.get(classSuffix) || index.getInsensitive(classSuffix) || null;
}
// Fallback: linear scan
const fullSuffix = '/' + classSuffix;
for (let i = 0; i < normalizedFileList.length; i++) {
if (normalizedFileList[i].endsWith(fullSuffix) ||
normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) {
return allFileList[i];
for (const ext of extensions) {
const classSuffix = classPath + ext;
if (index) {
const result = index.get(classSuffix) || index.getInsensitive(classSuffix);
if (result) return result;
} else {
const fullSuffix = '/' + classSuffix;
for (let i = 0; i < normalizedFileList.length; i++) {
if (normalizedFileList[i].endsWith(fullSuffix) ||
normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) {
return allFileList[i];
}
}
}
}
}
@ -637,12 +727,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
@ -657,6 +748,7 @@ export const processImports = async (
const tsconfigPaths = await loadTsconfigPaths(effectiveRoot);
const goModule = await loadGoModulePath(effectiveRoot);
const composerConfig = await loadComposerConfig(effectiveRoot);
const swiftPackageConfig = await loadSwiftPackageConfig(effectiveRoot);
// Helper: add an IMPORTS edge + update import map
const addImportEdge = (filePath: string, resolvedPath: string) => {
@ -747,26 +839,42 @@ export const processImports = async (
}
// Clean path (remove quotes and angle brackets for C/C++ includes)
const rawImportPath = sourceNode.text.replace(/['"<>]/g, '');
const rawImportPath = language === SupportedLanguages.Kotlin
? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import'])
: sourceNode.text.replace(/['"<>]/g, '');
totalImportsFound++;
// ---- Java: handle wildcards and static imports specially ----
if (language === SupportedLanguages.Java) {
// ---- JVM languages (Java + Kotlin): handle wildcards and member imports ----
if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) {
const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS;
if (rawImportPath.endsWith('.*')) {
const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList, index);
const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index);
// Kotlin can import Java files in mixed codebases — try .java as fallback
if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) {
const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
for (const matchedFile of javaMatches) {
addImportEdge(file.path, matchedFile);
}
if (javaMatches.length > 0) return;
}
for (const matchedFile of matchedFiles) {
addImportEdge(file.path, matchedFile);
}
return; // skip single-file resolution
}
// Try static import resolution (strip member name)
const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList, index);
if (staticResolved) {
addImportEdge(file.path, staticResolved);
// Try member/static import resolution (strip member name)
let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
// Kotlin can import Java files in mixed codebases — try .java as fallback
if (!memberResolved && language === SupportedLanguages.Kotlin) {
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
}
if (memberResolved) {
addImportEdge(file.path, memberResolved);
return;
}
// Fall through to normal resolution for regular Java imports
// Fall through to normal resolution for regular imports
}
// ---- Go: handle package-level imports ----
@ -790,6 +898,25 @@ export const processImports = async (
return;
}
// ---- Swift: handle module imports ----
if (language === SupportedLanguages.Swift && swiftPackageConfig) {
// Swift imports are module names: `import SiuperModel`
// Resolve to the module's source directory → all .swift files in it
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
if (targetDir) {
// Find all .swift files in this target directory
const dirPrefix = targetDir + '/';
for (const filePath2 of allFileList) {
if (filePath2.startsWith(dirPrefix) && filePath2.endsWith('.swift')) {
addImportEdge(file.path, filePath2);
}
}
return;
}
// External framework (Foundation, UIKit, etc.) — skip
return;
}
// ---- Standard single-file resolution ----
const resolvedPath = resolveImportPath(
file.path,
@ -823,18 +950,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;
@ -843,6 +967,7 @@ export const processImportsFromExtracted = async (
const tsconfigPaths = await loadTsconfigPaths(effectiveRoot);
const goModule = await loadGoModulePath(effectiveRoot);
const composerConfig = await loadComposerConfig(effectiveRoot);
const swiftPackageConfig = await loadSwiftPackageConfig(effectiveRoot);
const addImportEdge = (filePath: string, resolvedPath: string) => {
const sourceId = generateId('File', filePath);
@ -913,20 +1038,34 @@ export const processImportsFromExtracted = async (
continue;
}
// Java: handle wildcards and static imports
if (language === SupportedLanguages.Java) {
// JVM languages (Java + Kotlin): handle wildcards and member imports
if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) {
const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS;
if (rawImportPath.endsWith('.*')) {
const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList, index);
const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index);
// Kotlin can import Java files in mixed codebases — try .java as fallback
if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) {
const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
for (const matchedFile of javaMatches) {
addImportEdge(filePath, matchedFile);
}
if (javaMatches.length > 0) continue;
}
for (const matchedFile of matchedFiles) {
addImportEdge(filePath, matchedFile);
}
continue;
}
const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList, index);
if (staticResolved) {
resolveCache.set(cacheKey, staticResolved);
addImportEdge(filePath, staticResolved);
let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
// Kotlin can import Java files in mixed codebases — try .java as fallback
if (!memberResolved && language === SupportedLanguages.Kotlin) {
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
}
if (memberResolved) {
resolveCache.set(cacheKey, memberResolved);
addImportEdge(filePath, memberResolved);
continue;
}
}
@ -952,6 +1091,20 @@ export const processImportsFromExtracted = async (
continue;
}
// Swift: handle module imports
if (language === SupportedLanguages.Swift && swiftPackageConfig) {
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
if (targetDir) {
const dirPrefix = targetDir + '/';
for (const fp of allFileList) {
if (fp.startsWith(dirPrefix) && fp.endsWith('.swift')) {
addImportEdge(filePath, fp);
}
}
}
continue;
}
// Standard resolution (has its own internal cache)
const resolvedPath = resolveImportPath(
filePath,

View file

@ -5,7 +5,8 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { SymbolTable } from './symbol-table.js';
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
import { findSiblingChild, getLanguageFromFilename, yieldToEventLoop } from './utils.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { WorkerPool } from './workers/worker-pool.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js';
@ -18,6 +19,38 @@ export interface WorkerExtractedData {
routes: ExtractedRoute[];
}
const DEFINITION_CAPTURE_KEYS = [
'definition.function',
'definition.class',
'definition.interface',
'definition.method',
'definition.struct',
'definition.enum',
'definition.namespace',
'definition.module',
'definition.trait',
'definition.impl',
'definition.type',
'definition.const',
'definition.static',
'definition.typedef',
'definition.macro',
'definition.union',
'definition.property',
'definition.record',
'definition.delegate',
'definition.annotation',
'definition.constructor',
'definition.template',
] as const;
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
for (const key of DEFINITION_CAPTURE_KEYS) {
if (captureMap[key]) return captureMap[key];
}
return null;
};
// ============================================================================
// EXPORT DETECTION - Language-specific visibility detection
// ============================================================================
@ -31,7 +64,7 @@ export interface WorkerExtractedData {
* @param language - The programming language
* @returns true if the symbol is exported/public
*/
const isNodeExported = (node: any, name: string, language: string): boolean => {
export const isNodeExported = (node: any, name: string, language: string): boolean => {
let current = node;
switch (language) {
@ -109,12 +142,56 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
}
return false;
// Kotlin: Default visibility is public (unlike Java)
// visibility_modifier is inside modifiers, a sibling of the name node within the declaration
case 'kotlin':
while (current) {
if (current.parent) {
const visMod = findSiblingChild(current.parent, 'modifiers', 'visibility_modifier');
if (visMod) {
const text = visMod.text;
if (text === 'private' || text === 'internal' || text === 'protected') return false;
if (text === 'public') return true;
}
}
current = current.parent;
}
// No visibility modifier = public (Kotlin default)
return true;
// C/C++: No native export concept at language level
// Entry points will be detected via name patterns (main, etc.)
case 'c':
case 'cpp':
return false;
// Swift: Check for 'public' or 'open' access modifiers
case 'swift':
while (current) {
if (current.type === 'modifiers' || current.type === 'visibility_modifier') {
const text = current.text || '';
if (text.includes('public') || text.includes('open')) return true;
}
current = current.parent;
}
return false;
// PHP: Check for visibility modifier or top-level scope
case 'php':
while (current) {
if (current.type === 'class_declaration' ||
current.type === 'interface_declaration' ||
current.type === 'trait_declaration' ||
current.type === 'enum_declaration') {
return true;
}
if (current.type === 'visibility_modifier') {
return current.text === 'public';
}
current = current.parent;
}
return true; // Top-level functions are globally accessible
default:
return false;
}
@ -130,28 +207,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: [], routes: [] };
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
@ -259,9 +333,9 @@ const processParsingSequential = async (
}
const nameNode = captureMap['name'];
if (!nameNode) return;
const nodeName = nameNode.text;
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && !captureMap['definition.constructor']) return;
const nodeName = nameNode ? nameNode.text : 'init';
let nodeLabel = 'CodeElement';
@ -288,7 +362,14 @@ const processParsingSequential = async (
else if (captureMap['definition.constructor']) nodeLabel = 'Constructor';
else if (captureMap['definition.template']) nodeLabel = 'Template';
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
const startLine = definitionNodeForRange ? definitionNodeForRange.startPosition.row : (nameNode ? nameNode.startPosition.row : 0);
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}:${startLine}`);
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
: null;
const node: GraphNode = {
id: nodeId,
@ -296,11 +377,15 @@ const processParsingSequential = async (
properties: {
name: nodeName,
filePath: file.path,
startLine: nameNode.startPosition.row,
endLine: nameNode.endPosition.row,
startLine: definitionNodeForRange ? definitionNodeForRange.startPosition.row : startLine,
endLine: definitionNodeForRange ? definitionNodeForRange.endPosition.row : startLine,
language: language,
isExported: isNodeExported(nameNode, nodeName, language),
}
isExported: isNodeExported(nameNode || definitionNodeForRange, nodeName, language),
...(frameworkHint ? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
astFrameworkReason: frameworkHint.reason,
} : {}),
},
};
graph.addNode(node);

View file

@ -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, processRoutesFromExtracted } 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,191 +57,194 @@ 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);
}
// Routes — resolve immediately (Laravel route→controller CALLS edges)
if (chunkWorkerData.routes && chunkWorkerData.routes.length > 0) {
await processRoutesFromExtracted(graph, chunkWorkerData.routes, symbolTable, importMap);
}
} else {
await processImports(graph, chunkFiles, astCache, importMap, undefined, repoPath, allPaths);
sequentialChunkPaths.push(chunkPaths);
}
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 },
});
});
}
// Route detection (Laravel) — after calls, before heritage
if (workerData?.routes && workerData.routes.length > 0) {
onProgress({
phase: 'calls',
percent: 91,
message: 'Resolving Laravel routes...',
stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount },
});
await processRoutesFromExtracted(graph, workerData.routes, symbolTable, importMap);
}
onProgress({
phase: 'heritage',
percent: 92,
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 },
});
});
@ -265,27 +277,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 }
@ -329,18 +342,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;
}
};

View file

@ -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,22 +270,22 @@ 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(
const { score: baseScore, reasons } = calculateEntryPointScore(
node.properties.name,
node.properties.language || 'javascript',
node.properties.isExported ?? false,
@ -293,11 +293,18 @@ const findEntryPoints = (
callees.length,
filePath // Pass filePath for framework detection
);
let score = baseScore;
const astFrameworkMultiplier = node.properties.astFrameworkMultiplier ?? 1.0;
if (astFrameworkMultiplier > 1.0) {
score *= astFrameworkMultiplier;
reasons.push(`framework-ast:${node.properties.astFrameworkReason || 'decorator'}`);
}
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 +313,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})`);
@ -337,8 +344,7 @@ const traceFromEntryPoint = (
// BFS with path tracking
// Each queue item: [currentNodeId, pathSoFar]
const queue: [string, string[]][] = [[entryId, [entryId]]];
const visited = new Set<string>();
while (queue.length > 0 && traces.length < config.maxBranching * 3) {
const [currentId, path] = queue.shift()!;

View file

@ -396,6 +396,139 @@ export const PHP_QUERIES = `
[(name) (qualified_name)] @heritage.trait))) @heritage
`;
// Kotlin queries - works with tree-sitter-kotlin (fwcd/tree-sitter-kotlin)
// Based on official tags.scm; functions use simple_identifier, classes use type_identifier
export const KOTLIN_QUERIES = `
; Interfaces
; tree-sitter-kotlin (fwcd) has no interface_declaration node type.
; Interfaces are class_declaration nodes with an anonymous "interface" keyword child.
(class_declaration
"interface"
(type_identifier) @name) @definition.interface
; Classes (regular, data, sealed, enum)
; All have the anonymous "class" keyword child. enum class has both
; "enum" and "class" children the "class" child still matches.
(class_declaration
"class"
(type_identifier) @name) @definition.class
; Object declarations (Kotlin singletons)
(object_declaration
(type_identifier) @name) @definition.class
; Companion objects (named only)
(companion_object
(type_identifier) @name) @definition.class
; Functions (top-level, member, extension)
(function_declaration
(simple_identifier) @name) @definition.function
; Properties
(property_declaration
(variable_declaration
(simple_identifier) @name)) @definition.property
; Enum entries
(enum_entry
(simple_identifier) @name) @definition.enum
; Type aliases
(type_alias
(type_identifier) @name) @definition.type
; Imports
(import_header
(identifier) @import.source) @import
; Function calls (direct)
(call_expression
(simple_identifier) @call.name) @call
; Method calls (via navigation: obj.method())
(call_expression
(navigation_expression
(navigation_suffix
(simple_identifier) @call.name))) @call
; Constructor invocations
(constructor_invocation
(user_type
(type_identifier) @call.name)) @call
; Infix function calls (e.g., a to b, x until y)
(infix_expression
(simple_identifier) @call.name) @call
; Heritage: extends / implements via delegation_specifier
; Interface implementation (bare user_type): class Foo : Bar
(class_declaration
(type_identifier) @heritage.class
(delegation_specifier
(user_type (type_identifier) @heritage.extends))) @heritage
; Class extension (constructor_invocation): class Foo : Bar()
(class_declaration
(type_identifier) @heritage.class
(delegation_specifier
(constructor_invocation
(user_type (type_identifier) @heritage.extends)))) @heritage
`;
// Swift queries - works with tree-sitter-swift
export const SWIFT_QUERIES = `
; Classes
(class_declaration "class" name: (type_identifier) @name) @definition.class
; Structs
(class_declaration "struct" name: (type_identifier) @name) @definition.struct
; Enums
(class_declaration "enum" name: (type_identifier) @name) @definition.enum
; Extensions (mapped to class no dedicated label in schema)
(class_declaration "extension" name: (user_type (type_identifier) @name)) @definition.class
; Actors
(class_declaration "actor" name: (type_identifier) @name) @definition.class
; Protocols (mapped to interface)
(protocol_declaration name: (type_identifier) @name) @definition.interface
; Type aliases
(typealias_declaration name: (type_identifier) @name) @definition.type
; Functions (top-level and methods)
(function_declaration name: (simple_identifier) @name) @definition.function
; Protocol method declarations
(protocol_function_declaration name: (simple_identifier) @name) @definition.method
; Initializers
(init_declaration) @definition.constructor
; Properties (stored and computed)
(property_declaration (pattern (simple_identifier) @name)) @definition.property
; Imports
(import_declaration (identifier (simple_identifier) @import.source)) @import
; Calls - direct function calls
(call_expression (simple_identifier) @call.name) @call
; Calls - member/navigation calls (obj.method())
(call_expression (navigation_expression (navigation_suffix (simple_identifier) @call.name))) @call
; Heritage - class/struct/enum inheritance and protocol conformance
(class_declaration name: (type_identifier) @heritage.class
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
; Heritage - protocol inheritance
(protocol_declaration name: (type_identifier) @heritage.class
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
`;
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES,
[SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES,
@ -407,5 +540,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
[SupportedLanguages.Rust]: RUST_QUERIES,
[SupportedLanguages.PHP]: PHP_QUERIES,
[SupportedLanguages.Kotlin]: KOTLIN_QUERIES,
[SupportedLanguages.Swift]: SWIFT_QUERIES,
};

View file

@ -6,6 +6,23 @@ import { SupportedLanguages } from '../../config/supported-languages.js';
*/
export const yieldToEventLoop = (): Promise<void> => new Promise(resolve => setImmediate(resolve));
/**
* Find a child of `childType` within a sibling node of `siblingType`.
* Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling.
*/
export const findSiblingChild = (parent: any, siblingType: string, childType: string): any | null => {
for (let i = 0; i < parent.childCount; i++) {
const sibling = parent.child(i);
if (sibling?.type === siblingType) {
for (let j = 0; j < sibling.childCount; j++) {
const child = sibling.child(j);
if (child?.type === childType) return child;
}
}
}
return null;
};
/**
* Map file extension to SupportedLanguage enum
*/
@ -31,12 +48,15 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
if (filename.endsWith('.go')) return SupportedLanguages.Go;
// Rust
if (filename.endsWith('.rs')) return SupportedLanguages.Rust;
// Kotlin
if (filename.endsWith('.kt') || filename.endsWith('.kts')) return SupportedLanguages.Kotlin;
// PHP (all common extensions)
if (filename.endsWith('.php') || filename.endsWith('.phtml') ||
filename.endsWith('.php3') || filename.endsWith('.php4') ||
filename.endsWith('.php5') || filename.endsWith('.php8')) {
return SupportedLanguages.PHP;
}
if (filename.endsWith('.swift')) return SupportedLanguages.Swift;
return null;
};

View file

@ -9,10 +9,18 @@ import CPP from 'tree-sitter-cpp';
import CSharp from 'tree-sitter-c-sharp';
import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import Kotlin from 'tree-sitter-kotlin';
import PHP from 'tree-sitter-php';
import { createRequire } from 'node:module';
import { SupportedLanguages } from '../../../config/supported-languages.js';
import { LANGUAGE_QUERIES } from '../tree-sitter-queries.js';
import { getLanguageFromFilename } from '../utils.js';
// tree-sitter-swift is an optionalDependency — may not be installed
const _require = createRequire(import.meta.url);
let Swift: any = null;
try { Swift = _require('tree-sitter-swift'); } catch {}
import { findSiblingChild, getLanguageFromFilename } from '../utils.js';
import { detectFrameworkFromAST } from '../framework-detection.js';
import { generateId } from '../../../lib/utils.js';
// ============================================================================
@ -29,6 +37,9 @@ interface ParsedNode {
endLine: number;
language: string;
isExported: boolean;
astFrameworkMultiplier?: number;
astFrameworkReason?: string;
description?: string;
};
}
@ -113,7 +124,9 @@ const languageMap: Record<string, any> = {
[SupportedLanguages.CSharp]: CSharp,
[SupportedLanguages.Go]: Go,
[SupportedLanguages.Rust]: Rust,
[SupportedLanguages.Kotlin]: Kotlin,
[SupportedLanguages.PHP]: PHP.php_only,
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
};
const setLanguage = (language: SupportedLanguages, filePath: string): void => {
@ -195,6 +208,23 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
}
return false;
// Kotlin: Default visibility is public (unlike Java)
// visibility_modifier is inside modifiers, a sibling of the name node within the declaration
case 'kotlin':
while (current) {
if (current.parent) {
const visMod = findSiblingChild(current.parent, 'modifiers', 'visibility_modifier');
if (visMod) {
const text = visMod.text;
if (text === 'private' || text === 'internal' || text === 'protected') return false;
if (text === 'public') return true;
}
}
current = current.parent;
}
// No visibility modifier = public (Kotlin default)
return true;
case 'c':
case 'cpp':
return false;
@ -217,6 +247,16 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
// Top-level functions (no parent class) are globally accessible
return true;
case 'swift':
while (current) {
if (current.type === 'modifiers' || current.type === 'visibility_modifier') {
const text = current.text || '';
if (text.includes('public') || text.includes('open')) return true;
}
current = current.parent;
}
return false;
default:
return false;
}
@ -232,7 +272,12 @@ const FUNCTION_NODE_TYPES = new Set([
'function_definition', 'async_function_declaration', 'async_arrow_function',
'method_declaration', 'constructor_declaration',
'local_function_statement', 'function_item', 'impl_item',
'anonymous_function', // PHP anonymous functions
// Kotlin
'lambda_literal',
// PHP
'anonymous_function',
// Swift initializers/deinitializers
'init_declaration', 'deinit_declaration',
]);
/** Walk up AST to find enclosing function, return its generateId or null for top-level */
@ -243,6 +288,12 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
let funcName: string | null = null;
let label = 'Function';
if (current.type === 'init_declaration' || current.type === 'deinit_declaration') {
const funcName = current.type === 'init_declaration' ? 'init' : 'deinit';
const label = 'Constructor';
return generateId(label, `${filePath}:${funcName}`);
}
if (['function_declaration', 'function_definition', 'async_function_declaration',
'generator_function_declaration', 'function_item'].includes(current.type)) {
const nameNode = current.childForFieldName?.('name') ||
@ -285,6 +336,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',
@ -303,10 +355,48 @@ 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',
// Kotlin stdlib (IMPORTANT: keep in sync with call-processor.ts BUILT_IN_NAMES)
'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error',
'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf',
'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless',
'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet',
'repeat', 'synchronized',
// Kotlin coroutine builders & scope functions
'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope',
'supervisorScope', 'delay',
// Kotlin Flow operators
'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch',
'buffer', 'conflate', 'distinctUntilChanged',
'flatMapLatest', 'flatMapMerge', 'combine',
'stateIn', 'shareIn', 'launchIn',
// Kotlin infix stdlib functions
'to', 'until', 'downTo', 'step',
// 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',
@ -324,6 +414,37 @@ const BUILT_INS = new Set([
'preg_match', 'preg_match_all', 'preg_replace', 'preg_split',
'header', 'session_start', 'session_destroy', 'ob_start', 'ob_end_clean', 'ob_get_clean',
'dd', 'dump',
// Swift/iOS built-ins and standard library
'print', 'debugPrint', 'dump', 'fatalError', 'precondition', 'preconditionFailure',
'assert', 'assertionFailure', 'NSLog',
'abs', 'min', 'max', 'zip', 'stride', 'sequence', 'repeatElement',
'swap', 'withUnsafePointer', 'withUnsafeMutablePointer', 'withUnsafeBytes',
'autoreleasepool', 'unsafeBitCast', 'unsafeDowncast', 'numericCast',
'type', 'MemoryLayout',
// Swift collection/string methods (common noise)
'map', 'flatMap', 'compactMap', 'filter', 'reduce', 'forEach', 'contains',
'first', 'last', 'prefix', 'suffix', 'dropFirst', 'dropLast',
'sorted', 'reversed', 'enumerated', 'joined', 'split',
'append', 'insert', 'remove', 'removeAll', 'removeFirst', 'removeLast',
'isEmpty', 'count', 'index', 'startIndex', 'endIndex',
// UIKit/Foundation common methods (noise in call graph)
'addSubview', 'removeFromSuperview', 'layoutSubviews', 'setNeedsLayout',
'layoutIfNeeded', 'setNeedsDisplay', 'invalidateIntrinsicContentSize',
'addTarget', 'removeTarget', 'addGestureRecognizer',
'addConstraint', 'addConstraints', 'removeConstraint', 'removeConstraints',
'NSLocalizedString', 'Bundle',
'reloadData', 'reloadSections', 'reloadRows', 'performBatchUpdates',
'register', 'dequeueReusableCell', 'dequeueReusableSupplementaryView',
'beginUpdates', 'endUpdates', 'insertRows', 'deleteRows', 'insertSections', 'deleteSections',
'present', 'dismiss', 'pushViewController', 'popViewController', 'popToRootViewController',
'performSegue', 'prepare',
// GCD / async
'DispatchQueue', 'async', 'sync', 'asyncAfter',
'Task', 'withCheckedContinuation', 'withCheckedThrowingContinuation',
// Combine
'sink', 'store', 'assign', 'receive', 'subscribe',
// Notification / KVO
'addObserver', 'removeObserver', 'post', 'NotificationCenter',
]);
// ============================================================================
@ -360,6 +481,51 @@ const getLabelFromCaptures = (captureMap: Record<string, any>): string | null =>
return 'CodeElement';
};
const DEFINITION_CAPTURE_KEYS = [
'definition.function',
'definition.class',
'definition.interface',
'definition.method',
'definition.struct',
'definition.enum',
'definition.namespace',
'definition.module',
'definition.trait',
'definition.impl',
'definition.type',
'definition.const',
'definition.static',
'definition.typedef',
'definition.macro',
'definition.union',
'definition.property',
'definition.record',
'definition.delegate',
'definition.annotation',
'definition.constructor',
'definition.template',
] as const;
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
for (const key of DEFINITION_CAPTURE_KEYS) {
if (captureMap[key]) return captureMap[key];
}
return null;
};
/**
* Append .* to a Kotlin import path if the AST has a wildcard_import sibling node.
* Pure function returns a new string without mutating the input.
*/
const appendKotlinWildcard = (importPath: string, importNode: any): string => {
for (let i = 0; i < importNode.childCount; i++) {
if (importNode.child(i)?.type === 'wildcard_import') {
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;
}
}
return importPath;
};
// ============================================================================
// Process a batch of files
// ============================================================================
@ -956,7 +1122,9 @@ const processFileGroup = (
// Extract import paths before skipping
if (captureMap['import'] && captureMap['import.source']) {
const rawImportPath = captureMap['import.source'].text.replace(/['"<>]/g, '');
const rawImportPath = language === SupportedLanguages.Kotlin
? appendKotlinWildcard(captureMap['import.source'].text.replace(/['"<>]/g, ''), captureMap['import'])
: captureMap['import.source'].text.replace(/['"<>]/g, '');
result.imports.push({
filePath: file.path,
rawImportPath,
@ -1015,8 +1183,12 @@ const processFileGroup = (
if (!nodeLabel) continue;
const nameNode = captureMap['name'];
const nodeName = nameNode.text;
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && nodeLabel !== 'Constructor') continue;
const nodeName = nameNode ? nameNode.text : 'init';
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const startLine = definitionNode ? definitionNode.startPosition.row : (nameNode ? nameNode.startPosition.row : 0);
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}:${startLine}`);
let description: string | undefined;
if (language === SupportedLanguages.PHP) {
@ -1027,16 +1199,24 @@ const processFileGroup = (
}
}
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
: null;
result.nodes.push({
id: nodeId,
label: nodeLabel,
properties: {
name: nodeName,
filePath: file.path,
startLine: nameNode.startPosition.row,
endLine: nameNode.endPosition.row,
startLine: definitionNode ? definitionNode.startPosition.row : startLine,
endLine: definitionNode ? definitionNode.endPosition.row : startLine,
language: language,
isExported: isNodeExported(nameNode, nodeName, language),
isExported: isNodeExported(nameNode || definitionNode, nodeName, language),
...(frameworkHint ? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
astFrameworkReason: frameworkHint.reason,
} : {}),
...(description !== undefined ? { description } : {}),
},
});
@ -1069,15 +1249,58 @@ 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: [], routes: [], 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.routes.push(...src.routes);
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: [], routes: [], 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 });

View file

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

View file

@ -1,315 +1,358 @@
/**
* 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 => {
export 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 '""';
}
export const escapeCSVField = (value: string | number | undefined | null): string => {
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);
}
export const escapeCSVNumber = (value: number | undefined | null, defaultValue: number = -1): string => {
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 => {
export 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) {
// Move to end of accessOrder (LRU promotion)
const idx = this.accessOrder.indexOf(relativePath);
if (idx !== -1) {
this.accessOrder.splice(idx, 1);
this.accessOrder.push(relativePath);
}
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) => {
this.ws.once('error', reject);
const ok = this.ws.write(chunk);
if (ok) {
this.ws.removeListener('error', reject);
resolve();
} else {
this.ws.once('drain', () => {
this.ws.removeListener('error', reject);
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, '\\\\').replace(/'/g, "''").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 +361,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 };
};

View file

@ -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,15 +11,67 @@ 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;
let currentDbPath: string | null = null;
let ftsLoaded = false;
// Global session lock for operations that touch module-level kuzu globals.
// This guarantees no DB switch can happen while an operation is running.
let sessionLock: Promise<void> = Promise.resolve();
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
const previous = sessionLock;
let release: (() => void) | null = null;
sessionLock = new Promise<void>(resolve => {
release = resolve;
});
await previous;
try {
return await operation();
} finally {
release?.();
}
};
const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/');
export const initKuzu = async (dbPath: string) => {
if (conn) return { db, conn };
return runWithSessionLock(() => ensureKuzuInitialized(dbPath));
};
/**
* Execute multiple queries against one repo DB atomically.
* While the callback runs, no other request can switch the active DB.
*/
export const withKuzuDb = async <T>(dbPath: string, operation: () => Promise<T>): Promise<T> => {
return runWithSessionLock(async () => {
await ensureKuzuInitialized(dbPath);
return operation();
});
};
const ensureKuzuInitialized = async (dbPath: string) => {
if (conn && currentDbPath === dbPath) {
return { db, conn };
}
await doInitKuzu(dbPath);
return { db, conn };
};
const doInitKuzu = async (dbPath: string) => {
// Different database requested — close the old one first
if (conn || db) {
try { if (conn) await conn.close(); } catch {}
try { if (db) await db.close(); } catch {}
conn = null;
db = null;
currentDbPath = null;
ftsLoaded = false;
}
// kuzu v0.11 stores the database as a single file (not a directory).
// If the path already exists, it must be a valid kuzu database file.
@ -58,6 +112,7 @@ export const initKuzu = async (dbPath: string) => {
}
}
currentDbPath = dbPath;
return { db, conn };
};
@ -65,7 +120,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 +130,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 +142,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 +168,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 +243,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 +311,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 +328,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 +362,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 +435,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 +511,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 +585,8 @@ export const closeKuzu = async (): Promise<void> => {
} catch {}
db = null;
}
currentDbPath = null;
ftsLoaded = false;
};
export const isKuzuReady = (): boolean => conn !== null && db !== null;
@ -569,17 +625,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,17 +673,25 @@ 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 via module-level ftsLoaded.
*/
export const loadFTSExtension = async (): Promise<void> => {
if (ftsLoaded) return;
if (!conn) {
throw new Error('KuzuDB not initialized. Call initKuzu first.');
}
try {
await conn.query('INSTALL fts');
await conn.query('LOAD EXTENSION fts');
} catch {
// Extension may already be loaded
ftsLoaded = true;
} catch (err: any) {
const msg = err?.message || '';
if (msg.includes('already loaded') || msg.includes('already installed') || msg.includes('already exists')) {
ftsLoaded = true;
} else {
console.error('GitNexus: FTS extension load failed:', msg);
}
}
};
@ -646,16 +711,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;
}

View file

@ -239,6 +239,10 @@ 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 Function TO \`Property\`,
FROM Class TO Method,
FROM Class TO Function,
FROM Class TO Class,
@ -251,6 +255,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,9 +297,16 @@ 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 \`Struct\` TO Interface,
FROM \`Enum\` TO \`Enum\`,
FROM \`Enum\` TO Community,
FROM \`Enum\` TO Class,
FROM \`Enum\` TO Interface,
FROM \`Macro\` TO Community,
FROM \`Macro\` TO Function,
FROM \`Macro\` TO Method,
@ -299,10 +315,15 @@ 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 \`TypeAlias\` TO Class,
FROM \`Const\` TO Community,
FROM \`Static\` TO Community,
FROM \`Property\` TO Community,
@ -324,6 +345,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Constructor\` TO \`Impl\`,
FROM \`Constructor\` TO \`Namespace\`,
FROM \`Constructor\` TO \`Module\`,
FROM \`Constructor\` TO \`Property\`,
FROM \`Constructor\` TO \`Typedef\`,
FROM \`Template\` TO Community,
FROM \`Module\` TO Community,
FROM Function TO Process,

View file

@ -8,9 +8,16 @@ import CPP from 'tree-sitter-cpp';
import CSharp from 'tree-sitter-c-sharp';
import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import Kotlin from 'tree-sitter-kotlin';
import PHP from 'tree-sitter-php';
import { createRequire } from 'node:module';
import { SupportedLanguages } from '../../config/supported-languages.js';
// tree-sitter-swift is an optionalDependency — may not be installed
const _require = createRequire(import.meta.url);
let Swift: any = null;
try { Swift = _require('tree-sitter-swift'); } catch {}
let parser: Parser | null = null;
const languageMap: Record<string, any> = {
@ -24,7 +31,9 @@ const languageMap: Record<string, any> = {
[SupportedLanguages.CSharp]: CSharp,
[SupportedLanguages.Go]: Go,
[SupportedLanguages.Rust]: Rust,
[SupportedLanguages.Kotlin]: Kotlin,
[SupportedLanguages.PHP]: PHP.php_only,
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
};
export const loadParser = async (): Promise<Parser> => {

View file

@ -12,7 +12,7 @@
import fs from 'fs/promises';
import path from 'path';
import { execSync } from 'child_process';
import { execSync, execFileSync } from 'child_process';
import {
initWikiDb,
@ -712,8 +712,8 @@ export class WikiGenerator {
private getChangedFiles(fromCommit: string, toCommit: string): string[] {
try {
const output = execSync(
`git diff ${fromCommit}..${toCommit} --name-only`,
const output = execFileSync(
'git', ['diff', `${fromCommit}..${toCommit}`, '--name-only'],
{ cwd: this.repoPath },
).toString().trim();
return output ? output.split('\n').filter(Boolean) : [];

View file

@ -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!;

Some files were not shown because too many files have changed in this diff Show more