mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
commit
46b4b7e157
172 changed files with 13696 additions and 2225 deletions
19
.claude-plugin/marketplace.json
Normal file
19
.claude-plugin/marketplace.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "gitnexus-marketplace",
|
||||
"owner": {
|
||||
"name": "GitNexus",
|
||||
"email": "nico@gitnexus.dev"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Code intelligence powered by a knowledge graph — execution flows, blast radius, and semantic search",
|
||||
"homepage": "https://github.com/nicosxt/gitnexus"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
82
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
82
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal 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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
163
.claude/skills/gitnexus/gitnexus-pr-review/SKILL.md
Normal file
163
.claude/skills/gitnexus/gitnexus-pr-review/SKILL.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
49
.github/workflows/ci.yml
vendored
49
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
35
.github/workflows/publish.yml
vendored
35
.github/workflows/publish.yml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -17,6 +17,8 @@ dist/
|
|||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.claude/settings.local.json
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
|
@ -41,6 +43,7 @@ coverage/
|
|||
|
||||
.env*.local
|
||||
.gitnexus
|
||||
.claude/settings.local.json
|
||||
|
||||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -164,7 +165,7 @@ def run_swebench_evaluation(results_dir: Path, run_id: str, subset: str = "lite"
|
|||
try:
|
||||
eval_output = results_dir / run_id / "swebench_eval"
|
||||
cmd = [
|
||||
"python", "-m", "swebench.harness.run_evaluation",
|
||||
sys.executable, "-m", "swebench.harness.run_evaluation",
|
||||
"--dataset_name", dataset_mapping.get(subset, subset),
|
||||
"--predictions_path", str(preds_path),
|
||||
"--max_workers", "4",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
# Claude 3.5 Haiku — fast, cheap, good baseline
|
||||
# Claude Haiku 4.5 — fast, cheap, good baseline
|
||||
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
# To use Anthropic directly, change to: anthropic/claude-3-5-haiku-20241022
|
||||
model:
|
||||
model_name: "openrouter/anthropic/claude-3.5-haiku"
|
||||
model_name: "openrouter/anthropic/claude-haiku-4.5"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
|
|
|
|||
11
eval/configs/models/minimax-m2.1.yaml
Normal file
11
eval/configs/models/minimax-m2.1.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# MiniMax M2.5 — via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
# Uses text-based model class because MiniMax doesn't support tool_calls natively.
|
||||
# The action_regex tells mini-swe-agent to parse ```bash blocks from responses.
|
||||
model:
|
||||
model_class: litellm_textbased
|
||||
model_name: "openrouter/minimax/minimax-m2.5"
|
||||
action_regex: "```(?:bash|mswea_bash_command)\\s*\\n(.*?)\\n```"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
temperature: 0
|
||||
|
|
@ -30,6 +30,10 @@ gitnexus-eval-analyze = "analysis.analyze_results:app"
|
|||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["agents", "environments", "analysis", "bridge"]
|
||||
extra-files = ["run_eval.py"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ def process_instance(
|
|||
env_class_name = env_config.pop("environment_class", "docker")
|
||||
|
||||
if env_class_name == "eval.environments.gitnexus_docker.GitNexusDockerEnvironment":
|
||||
from eval.environments.gitnexus_docker import GitNexusDockerEnvironment
|
||||
from environments.gitnexus_docker import GitNexusDockerEnvironment
|
||||
env_config["image"] = get_swebench_docker_image(instance)
|
||||
env = GitNexusDockerEnvironment(**env_config)
|
||||
else:
|
||||
|
|
@ -189,7 +189,7 @@ def process_instance(
|
|||
agent_config = dict(config.get("agent", {}))
|
||||
agent_class_name = agent_config.pop("agent_class", "eval.agents.gitnexus_agent.GitNexusAgent")
|
||||
|
||||
from eval.agents.gitnexus_agent import GitNexusAgent
|
||||
from agents.gitnexus_agent import GitNexusAgent
|
||||
traj_path = instance_dir / f"{instance_id}.traj.json"
|
||||
agent_config["output_path"] = traj_path
|
||||
agent = GitNexusAgent(model, env, **agent_config)
|
||||
|
|
@ -199,11 +199,18 @@ def process_instance(
|
|||
info = agent.run(instance["problem_statement"])
|
||||
|
||||
result["exit_status"] = info.get("exit_status")
|
||||
result["submission"] = info.get("submission", "")
|
||||
result["cost"] = agent.cost
|
||||
result["n_calls"] = agent.n_calls
|
||||
result["gitnexus_metrics"] = agent.gitnexus_metrics.to_dict()
|
||||
|
||||
# Extract git diff patch from the container (SWE-bench needs the model_patch)
|
||||
try:
|
||||
patch_output = env.execute({"command": "cd /testbed && git diff"})
|
||||
result["submission"] = patch_output.get("output", "").strip()
|
||||
except Exception as patch_err:
|
||||
logger.warning(f"[{run_id}] Failed to extract patch: {patch_err}")
|
||||
result["submission"] = info.get("submission", "")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{run_id}] Error on {instance_id}: {e}")
|
||||
result["exit_status"] = type(e).__name__
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.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"]
|
||||
}
|
||||
|
|
|
|||
8
gitnexus-claude-plugin/.mcp.json
Normal file
8
gitnexus-claude-plugin/.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* GitNexus Claude Code Hook
|
||||
* GitNexus Claude Code Plugin Hook
|
||||
*
|
||||
* PreToolUse handler — intercepts Grep/Glob/Bash searches
|
||||
* and augments with graph context from the GitNexus index.
|
||||
*
|
||||
* NOTE: SessionStart hooks are broken on Windows (Claude Code bug).
|
||||
* NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
|
||||
* Session context is injected via CLAUDE.md / skills instead.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
|
|
@ -101,11 +101,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({
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
#!/bin/bash
|
||||
# GitNexus PreToolUse hook for Claude Code
|
||||
# Intercepts Grep/Glob/Bash searches and augments with graph context.
|
||||
# Receives JSON on stdin with { tool_name, tool_input, cwd, ... }
|
||||
# Returns JSON with additionalContext for graph-enriched results.
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
||||
CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
|
||||
|
||||
# Extract search pattern based on tool type
|
||||
PATTERN=""
|
||||
|
||||
case "$TOOL_NAME" in
|
||||
Grep)
|
||||
PATTERN=$(echo "$INPUT" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
|
||||
;;
|
||||
Glob)
|
||||
# Glob patterns are file paths, not search terms — extract meaningful part
|
||||
RAW=$(echo "$INPUT" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
|
||||
# Strip glob syntax to get the meaningful name (e.g., "**/*.ts" → skip, "auth*.ts" → "auth")
|
||||
PATTERN=$(echo "$RAW" | sed -n 's/.*[*\/]\([a-zA-Z][a-zA-Z0-9_-]*\).*/\1/p')
|
||||
;;
|
||||
Bash)
|
||||
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
||||
# Only augment grep/rg commands
|
||||
if echo "$CMD" | grep -qE '\brg\b|\bgrep\b'; then
|
||||
# Extract pattern from rg/grep
|
||||
if echo "$CMD" | grep -qE '\brg\b'; then
|
||||
PATTERN=$(echo "$CMD" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
elif echo "$CMD" | grep -qE '\bgrep\b'; then
|
||||
PATTERN=$(echo "$CMD" | sed -n "s/.*\bgrep\s\+\(-[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Not a search tool — skip
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Skip if pattern too short or empty
|
||||
if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if we're in a GitNexus-indexed repo
|
||||
dir="${CWD:-$PWD}"
|
||||
found=false
|
||||
for i in 1 2 3 4 5; do
|
||||
if [ -d "$dir/.gitnexus" ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
parent="$(dirname "$dir")"
|
||||
[ "$parent" = "$dir" ] && break
|
||||
dir="$parent"
|
||||
done
|
||||
|
||||
if [ "$found" = false ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run gitnexus augment — must be fast (<500ms target)
|
||||
RESULT=$(cd "$CWD" && npx -y gitnexus augment "$PATTERN" 2>/dev/null)
|
||||
|
||||
if [ -n "$RESULT" ]; then
|
||||
ESCAPED=$(echo "$RESULT" | jq -Rs .)
|
||||
jq -n --argjson ctx "$ESCAPED" '{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
additionalContext: $ctx
|
||||
}
|
||||
}'
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// GitNexus SessionStart hook for Claude Code
|
||||
// Fires on session startup. Stdout is injected into Claude's context.
|
||||
// Checks if the current directory has a GitNexus index.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let dir = process.cwd();
|
||||
let found = false;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (fs.existsSync(path.join(dir, '.gitnexus'))) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write(`## GitNexus Code Intelligence
|
||||
|
||||
This codebase is indexed by GitNexus, providing a knowledge graph with execution flows, relationships, and semantic search.
|
||||
|
||||
**Available MCP Tools:**
|
||||
- \`query\` — Process-grouped code intelligence (execution flows related to a concept)
|
||||
- \`context\` — 360-degree symbol view (categorized refs, process participation)
|
||||
- \`impact\` — Blast radius analysis (what breaks if you change a symbol)
|
||||
- \`detect_changes\` — Git-diff impact analysis (what do your changes affect)
|
||||
- \`rename\` — Multi-file coordinated rename with confidence tags
|
||||
- \`cypher\` — Raw graph queries
|
||||
- \`list_repos\` — Discover indexed repos
|
||||
|
||||
**Quick Start:** READ \`gitnexus://repo/{name}/context\` for codebase overview, then use \`query\` to find execution flows.
|
||||
|
||||
**Resources:** \`gitnexus://repo/{name}/context\` (overview), \`/processes\` (execution flows), \`/schema\` (for Cypher)
|
||||
`);
|
||||
process.exit(0);
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
#!/bin/bash
|
||||
# GitNexus SessionStart hook for Claude Code
|
||||
# Fires on session startup. Stdout is injected into Claude's context.
|
||||
# Checks if the current directory has a GitNexus index.
|
||||
|
||||
dir="$PWD"
|
||||
found=false
|
||||
for i in 1 2 3 4 5; do
|
||||
if [ -d "$dir/.gitnexus" ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
parent="$(dirname "$dir")"
|
||||
[ "$parent" = "$dir" ] && break
|
||||
dir="$parent"
|
||||
done
|
||||
|
||||
if [ "$found" = false ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Inject GitNexus context — this stdout goes directly into Claude's context
|
||||
cat << 'EOF'
|
||||
## GitNexus Code Intelligence
|
||||
|
||||
This codebase is indexed by GitNexus, providing a knowledge graph with execution flows, relationships, and semantic search.
|
||||
|
||||
**Available MCP Tools:**
|
||||
- `query` — Process-grouped code intelligence (execution flows related to a concept)
|
||||
- `context` — 360-degree symbol view (categorized refs, process participation)
|
||||
- `impact` — Blast radius analysis (what breaks if you change a symbol)
|
||||
- `detect_changes` — Git-diff impact analysis (what do your changes affect)
|
||||
- `rename` — Multi-file coordinated rename with confidence tags
|
||||
- `cypher` — Raw graph queries
|
||||
- `list_repos` — Discover indexed repos
|
||||
|
||||
**Quick Start:** READ `gitnexus://repo/{name}/context` for codebase overview, then use `query` to find execution flows.
|
||||
|
||||
**Resources:** `gitnexus://repo/{name}/context` (overview), `/processes` (execution flows), `/schema` (for Cypher)
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
82
gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md
Normal file
82
gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
name: gitnexus-cli
|
||||
description: "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
|
||||
8
gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json
Normal file
8
gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
64
gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md
Normal file
64
gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
name: gitnexus-guide
|
||||
description: "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
|
||||
```
|
||||
8
gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json
Normal file
8
gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"})
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
163
gitnexus-claude-plugin/skills/gitnexus-pr-review/SKILL.md
Normal file
163
gitnexus-claude-plugin/skills/gitnexus-pr-review/SKILL.md
Normal 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
|
||||
```
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
163
gitnexus-cursor-integration/skills/gitnexus-pr-review/SKILL.md
Normal file
163
gitnexus-cursor-integration/skills/gitnexus-pr-review/SKILL.md
Normal 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
|
||||
```
|
||||
BIN
gitnexus-web/public/wasm/swift/tree-sitter-swift.wasm
Executable file
BIN
gitnexus-web/public/wasm/swift/tree-sitter-swift.wasm
Executable file
Binary file not shown.
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 · {repo.stats?.files ?? '?'} files
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@ export enum SupportedLanguages {
|
|||
Rust = 'rust',
|
||||
PHP = 'php',
|
||||
// Ruby = 'ruby',
|
||||
// Swift = 'swift',
|
||||
Swift = 'swift',
|
||||
}
|
||||
|
|
@ -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') ||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
157
gitnexus-web/src/services/server-connection.ts
Normal file
157
gitnexus-web/src/services/server-connection.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 .)
|
||||
|
|
|
|||
1268
gitnexus/package-lock.json
generated
1268
gitnexus/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
74
gitnexus/scripts/patch-tree-sitter-swift.cjs
Normal file
74
gitnexus/scripts/patch-tree-sitter-swift.cjs
Normal 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');
|
||||
}
|
||||
82
gitnexus/skills/gitnexus-cli.md
Normal file
82
gitnexus/skills/gitnexus-cli.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
64
gitnexus/skills/gitnexus-guide.md
Normal file
64
gitnexus/skills/gitnexus-guide.md
Normal 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
|
||||
```
|
||||
|
|
@ -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"})
|
||||
|
||||
163
gitnexus/skills/gitnexus-pr-review.md
Normal file
163
gitnexus/skills/gitnexus-pr-review.md
Normal 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
|
||||
```
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
@ -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}
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (60–85%) ──────────────────────────────────────
|
||||
lastPhaseLabel = 'Loading into KuzuDB...'; phaseStart = Date.now();
|
||||
bar.update(60, { phase: lastPhaseLabel });
|
||||
updateBar(60, 'Loading into KuzuDB...');
|
||||
|
||||
await closeKuzu();
|
||||
const kuzuFiles = [kuzuPath, `${kuzuPath}.wal`, `${kuzuPath}.lock`];
|
||||
|
|
@ -148,17 +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 (85–90%) ─────────────────────────────────────────
|
||||
lastPhaseLabel = 'Creating search indexes...'; phaseStart = Date.now();
|
||||
bar.update(85, { phase: lastPhaseLabel });
|
||||
updateBar(85, 'Creating search indexes...');
|
||||
|
||||
const t0Fts = Date.now();
|
||||
try {
|
||||
|
|
@ -174,7 +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 (98–100%) ───────────────────────────────────
|
||||
bar.update(98, { phase: 'Saving metadata...' });
|
||||
updateBar(98, 'Saving metadata...');
|
||||
|
||||
const meta = {
|
||||
repoPath,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
stats: {
|
||||
files: pipelineResult.fileContents.size,
|
||||
files: pipelineResult.totalFileCount,
|
||||
nodes: stats.nodes,
|
||||
edges: stats.edges,
|
||||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export enum SupportedLanguages {
|
|||
Go = 'go',
|
||||
Rust = 'rust',
|
||||
PHP = 'php',
|
||||
Kotlin = 'kotlin',
|
||||
// Ruby = 'ruby',
|
||||
// Swift = 'swift',
|
||||
Swift = 'swift',
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -51,11 +51,17 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
|
|||
get nodes(){
|
||||
return Array.from(nodeMap.values())
|
||||
},
|
||||
|
||||
|
||||
get relationships(){
|
||||
return Array.from(relationshipMap.values())
|
||||
},
|
||||
|
||||
iterNodes: () => nodeMap.values(),
|
||||
iterRelationships: () => relationshipMap.values(),
|
||||
forEachNode(fn: (node: GraphNode) => void) { nodeMap.forEach(fn); },
|
||||
forEachRelationship(fn: (rel: GraphRelationship) => void) { relationshipMap.forEach(fn); },
|
||||
getNode: (id: string) => nodeMap.get(id),
|
||||
|
||||
// O(1) count getters - avoid creating arrays just for length
|
||||
get nodeCount() {
|
||||
return nodeMap.size;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,24 @@ export type NodeLabel =
|
|||
| 'Type'
|
||||
| 'CodeElement'
|
||||
| 'Community'
|
||||
| 'Process';
|
||||
| 'Process'
|
||||
// Multi-language node types
|
||||
| 'Struct'
|
||||
| 'Macro'
|
||||
| 'Typedef'
|
||||
| 'Union'
|
||||
| 'Namespace'
|
||||
| 'Trait'
|
||||
| 'Impl'
|
||||
| 'TypeAlias'
|
||||
| 'Const'
|
||||
| 'Static'
|
||||
| 'Property'
|
||||
| 'Record'
|
||||
| 'Delegate'
|
||||
| 'Annotation'
|
||||
| 'Constructor'
|
||||
| 'Template';
|
||||
|
||||
|
||||
export type NodeProperties = {
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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') ||
|
||||
|
|
|
|||
|
|
@ -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)! }));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()!;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -4,29 +4,33 @@ import os from 'node:os';
|
|||
export interface WorkerPool {
|
||||
/**
|
||||
* Dispatch items across workers. Items are split into chunks (one per worker),
|
||||
* each worker processes its chunk, and results are concatenated back in order.
|
||||
*
|
||||
* @param onProgress - Called with cumulative files processed across all workers
|
||||
* each worker processes its chunk via sub-batches to limit peak memory,
|
||||
* and results are concatenated back in order.
|
||||
*/
|
||||
dispatch<TInput, TResult>(items: TInput[], onProgress?: (filesProcessed: number) => void): Promise<TResult[]>;
|
||||
|
||||
/**
|
||||
* Terminate all workers. Must be called when done.
|
||||
*/
|
||||
/** Terminate all workers. Must be called when done. */
|
||||
terminate(): Promise<void>;
|
||||
|
||||
/** Number of workers in the pool */
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Max files to send to a worker in a single postMessage.
|
||||
* Keeps structured-clone memory bounded per sub-batch.
|
||||
*/
|
||||
const SUB_BATCH_SIZE = 1500;
|
||||
|
||||
/** Per sub-batch timeout. If a single sub-batch takes longer than this,
|
||||
* likely a pathological file (e.g. minified 50MB JS). Fail fast. */
|
||||
const SUB_BATCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Create a pool of worker threads.
|
||||
*
|
||||
* @param workerUrl - URL to the worker script (use `new URL('./parse-worker.js', import.meta.url)`)
|
||||
* @param poolSize - Number of workers (defaults to cpus - 1, minimum 1)
|
||||
*/
|
||||
export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool => {
|
||||
const size = poolSize ?? Math.max(1, os.cpus().length - 1);
|
||||
const size = poolSize ?? Math.min(8, Math.max(1, os.cpus().length - 1));
|
||||
const workers: Worker[] = [];
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
|
|
@ -36,35 +40,51 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
|||
const dispatch = <TInput, TResult>(items: TInput[], onProgress?: (filesProcessed: number) => void): Promise<TResult[]> => {
|
||||
if (items.length === 0) return Promise.resolve([]);
|
||||
|
||||
// Split items into one chunk per worker
|
||||
const chunkSize = Math.ceil(items.length / size);
|
||||
const chunks: TInput[][] = [];
|
||||
for (let i = 0; i < items.length; i += chunkSize) {
|
||||
chunks.push(items.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
// Track per-worker progress for cumulative reporting
|
||||
const workerProgress = new Array(chunks.length).fill(0);
|
||||
|
||||
// Send one chunk to each worker, collect results
|
||||
const promises = chunks.map((chunk, i) => {
|
||||
const worker = workers[i];
|
||||
return new Promise<TResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let subBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
if (subBatchTimer) clearTimeout(subBatchTimer);
|
||||
worker.removeListener('message', handler);
|
||||
worker.removeListener('error', errorHandler);
|
||||
worker.removeListener('exit', exitHandler);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Worker ${i} timed out after 5 minutes (chunk: ${chunk.length} items). Worker may have crashed or is processing too much data.`));
|
||||
const resetSubBatchTimer = () => {
|
||||
if (subBatchTimer) clearTimeout(subBatchTimer);
|
||||
subBatchTimer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Worker ${i} sub-batch timed out after ${SUB_BATCH_TIMEOUT_MS / 1000}s (chunk: ${chunk.length} items).`));
|
||||
}
|
||||
}, SUB_BATCH_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
let subBatchIdx = 0;
|
||||
|
||||
const sendNextSubBatch = () => {
|
||||
const start = subBatchIdx * SUB_BATCH_SIZE;
|
||||
if (start >= chunk.length) {
|
||||
worker.postMessage({ type: 'flush' });
|
||||
return;
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
const subBatch = chunk.slice(start, start + SUB_BATCH_SIZE);
|
||||
subBatchIdx++;
|
||||
resetSubBatchTimer();
|
||||
worker.postMessage({ type: 'sub-batch', files: subBatch });
|
||||
};
|
||||
|
||||
const handler = (msg: any) => {
|
||||
if (settled) return;
|
||||
|
|
@ -74,8 +94,9 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
|||
const total = workerProgress.reduce((a, b) => a + b, 0);
|
||||
onProgress(total);
|
||||
}
|
||||
} else if (msg && msg.type === 'sub-batch-done') {
|
||||
sendNextSubBatch();
|
||||
} else if (msg && msg.type === 'error') {
|
||||
// Error reported by worker via postMessage
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Worker ${i} error: ${msg.error}`));
|
||||
|
|
@ -84,7 +105,6 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
|||
cleanup();
|
||||
resolve(msg.data);
|
||||
} else {
|
||||
// Legacy: treat any non-typed message as result
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(msg);
|
||||
|
|
@ -92,25 +112,21 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
|
|||
};
|
||||
|
||||
const errorHandler = (err: any) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
if (!settled) { settled = true; cleanup(); reject(err); }
|
||||
};
|
||||
|
||||
const exitHandler = (code: number) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Worker ${i} exited unexpectedly with code ${code}. This usually indicates an out-of-memory crash or native addon failure.`));
|
||||
reject(new Error(`Worker ${i} exited with code ${code}. Likely OOM or native addon failure.`));
|
||||
}
|
||||
};
|
||||
|
||||
worker.on('message', handler);
|
||||
worker.once('error', errorHandler);
|
||||
worker.once('exit', exitHandler);
|
||||
worker.postMessage(chunk);
|
||||
sendNextSubBatch();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,315 +1,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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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) : [];
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue