Compare commits

..
Author SHA1 Message Date
abhigyanpatwariandClaude Opus 4.6 5308600c22 fix(swift): fix install path for tree-sitter-swift
Move tree-sitter-swift to optionalDependencies so npm doesn't abort
when its native build fails. The postinstall script now handles the
case where npm removes the package after a failed build by
re-installing with --ignore-scripts, then patching and rebuilding.

Also fixes JSON.parse crash on binding.gyp trailing commas and uses
createRequire for conditional imports in ESM context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:00:18 +05:30
157 changed files with 1271 additions and 16616 deletions
@@ -1,82 +0,0 @@
---
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,89 +1,85 @@
---
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
```
---
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,78 +1,75 @@
---
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
```
---
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
```
@@ -1,64 +0,0 @@
---
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,97 +1,94 @@
---
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
```
---
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
```
@@ -1,163 +0,0 @@
---
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,121 +1,113 @@
---
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
```
---
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
```
-28
View File
@@ -1,28 +0,0 @@
name: Setup GitNexus
description: Setup Node.js 20, install dependencies, and optionally build
inputs:
build:
description: Whether to run npm run build after install
required: false
default: 'false'
runs:
using: composite
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Install dependencies
run: npm ci
shell: bash
working-directory: gitnexus
- name: Build
if: ${{ inputs.build == 'true' }}
run: npm run build
shell: bash
working-directory: gitnexus
-45
View File
@@ -1,45 +0,0 @@
changelog:
exclude:
labels:
- chore
authors:
- dependabot
- dependabot[bot]
categories:
- title: "\U0001F6A8 Security"
labels:
- security
- title: "\U0001F4A5 Breaking Changes"
labels:
- breaking
- title: "\U0001F680 Features"
labels:
- enhancement
- title: "\U0001F41B Bug Fixes"
labels:
- bug
- title: "\U0001F3CE\uFE0F Performance"
labels:
- performance
- title: "\U0001F9EA Tests"
labels:
- test
- title: "\U0001F504 Refactoring"
labels:
- refactor
- title: "\U0001F477 CI/CD"
labels:
- ci
- title: "\U0001F4E6 Dependencies"
labels:
- dependencies
- title: "\U0001F4DD Other Changes"
labels:
- "*"
exclude:
labels:
- dependencies
- ci
- test
- refactor
- chore
-173
View File
@@ -1,173 +0,0 @@
name: Integration Tests
on:
workflow_call:
inputs:
collect-coverage:
description: 'Whether to run the coverage collection job (only needed for PR reports)'
required: false
default: true
type: boolean
jobs:
# ── Integration test matrix ─────────────────────────────────────────
# Each test-group runs on a SEPARATE runner per OS, giving full process
# isolation for the KuzuDB native C++ addon.
# 3 OS x 4 groups = 12 parallel jobs.
#
# Groups:
# kuzu-db — 7 files using withTestKuzuDB / kuzu-adapter (native addon)
# Each file runs as its own `vitest run` invocation for full
# process isolation. KuzuDB's native N-API addon registers
# persistent handles that prevent fork workers from exiting
# on Linux, and its C++ destructors segfault during
# process.exit(). Running each file in its own process lets
# the OS reclaim all resources cleanly.
# pipeline — 3 files: ingestion pipeline + csv, each creates own temp DB
# e2e — 2 files: child-process only (spawnSync), no in-process kuzu
# standalone — 4 files: pure logic, no kuzu, no child processes
test-matrix:
name: integration (${{ matrix.os }} / ${{ matrix.test-group }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
test-group: [kuzu-db, pipeline, e2e, standalone]
include:
- test-group: kuzu-db
# Marker — actual files are listed in the run step below
test-glob: ''
- test-group: pipeline
test-glob: >-
test/integration/pipeline.test.ts
test/integration/csv-pipeline.test.ts
test/integration/parsing.test.ts
- test-group: e2e
test-glob: >-
test/integration/cli-e2e.test.ts
test/integration/hooks-e2e.test.ts
- test-group: standalone
test-glob: >-
test/integration/filesystem-walker.test.ts
test/integration/enrichment.test.ts
test/integration/tree-sitter-languages.test.ts
test/integration/worker-pool.test.ts
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
# kuzu-db: run each file in its own vitest process for full isolation.
# KuzuDB's native addon hangs fork workers on Linux — process isolation
# is the only reliable fix boundary.
- name: Run integration tests — kuzu-db (process-isolated)
if: matrix.test-group == 'kuzu-db'
working-directory: gitnexus
shell: bash
run: |
set -e
files=(
test/integration/kuzu-core-adapter.test.ts
test/integration/kuzu-pool.test.ts
test/integration/local-backend.test.ts
test/integration/local-backend-calltool.test.ts
test/integration/search-core.test.ts
test/integration/search-pool.test.ts
test/integration/augmentation.test.ts
)
exit_code=0
for f in "${files[@]}"; do
echo "::group::$f"
if ! npx vitest run --reporter=verbose --pool=forks "$f"; then
exit_code=1
echo "::error::Test file failed: $f"
fi
echo "::endgroup::"
done
exit $exit_code
# Non-kuzu groups: run all files in a single vitest invocation
- name: Run integration tests — ${{ matrix.test-group }}
if: matrix.test-group != 'kuzu-db'
shell: bash
env:
TEST_GLOB: ${{ matrix.test-glob }}
run: npx vitest run --reporter=verbose $TEST_GLOB
working-directory: gitnexus
# ── Coverage collection (ubuntu only) ─────────────────────────────────
# Runs non-kuzu integration tests with coverage enabled so the PR report
# can merge integration + unit coverage for a combined view.
# kuzu-db tests are excluded because each file must run in its own vitest
# process (native addon isolation) which prevents single-run coverage merge.
coverage:
name: integration (ubuntu / coverage)
if: inputs.collect-coverage
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Run integration tests with coverage
working-directory: gitnexus
run: >-
npx vitest run
--reporter=default
--reporter=json
--outputFile=integration-results.json
--coverage
--coverage.reporter=json-summary
--coverage.reporter=json
--coverage.reporter=text
--coverage.thresholdAutoUpdate=false
--coverage.reportOnFailure=true
--coverage.thresholds.statements=0
--coverage.thresholds.branches=0
--coverage.thresholds.functions=0
--coverage.thresholds.lines=0
test/integration/pipeline.test.ts
test/integration/csv-pipeline.test.ts
test/integration/parsing.test.ts
test/integration/cli-e2e.test.ts
test/integration/hooks-e2e.test.ts
test/integration/filesystem-walker.test.ts
test/integration/enrichment.test.ts
test/integration/tree-sitter-languages.test.ts
test/integration/worker-pool.test.ts
- name: Upload integration coverage
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: integration-reports
path: |
gitnexus/coverage/coverage-summary.json
gitnexus/coverage/coverage-final.json
gitnexus/integration-results.json
retention-days: 5
# ── Unified status gate ──────────────────────────────────────────────
# Branch protection should require THIS job, not the matrix jobs directly.
# ci.yml's needs.integration.result aggregates through this gate.
status:
name: integration (all groups)
needs: test-matrix
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check all matrix jobs passed
shell: bash
env:
RESULT: ${{ needs.test-matrix.result }}
run: |
if [[ "$RESULT" != "success" ]]; then
echo "::error::Integration matrix failed or cancelled: $RESULT"
exit 1
fi
-14
View File
@@ -1,14 +0,0 @@
name: Quality Checks
on:
workflow_call:
jobs:
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-gitnexus
- run: npx tsc --noEmit
working-directory: gitnexus
-432
View File
@@ -1,432 +0,0 @@
name: CI Report
# Triggered after the CI workflow completes. Because workflow_run
# always runs code from the *default branch*, it receives a read/write
# GITHUB_TOKEN — even when the triggering PR comes from a fork.
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
actions: read # needed to list/download workflow run artifacts
contents: read # needed for sparse checkout of vitest.config.ts
pull-requests: write # needed to post sticky PR comment
jobs:
pr-report:
name: PR Report
# Only run for pull-request CI runs
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion != 'cancelled'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# ── Download artifacts from the CI run ────────────────────────
- name: Download artifacts
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const fs = require('fs');
const path = require('path');
const runId = context.payload.workflow_run.id;
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
});
async function downloadArtifact(name, dest) {
const match = allArtifacts.data.artifacts.find(a => a.name === name);
if (!match) {
core.warning(`Artifact "${name}" not found`);
return false;
}
const zip = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: match.id,
archive_format: 'zip',
});
fs.mkdirSync(dest, { recursive: true });
fs.writeFileSync(path.join(dest, `${name}.zip`), Buffer.from(zip.data));
return true;
}
const temp = process.env.RUNNER_TEMP;
await downloadArtifact('pr-meta', path.join(temp, 'dl'));
await downloadArtifact('test-reports', path.join(temp, 'dl'));
await downloadArtifact('integration-reports', path.join(temp, 'dl'));
- name: Extract artifacts
shell: bash
run: |
cd "$RUNNER_TEMP/dl"
# Extract each artifact into its own directory to avoid filename collisions
for z in *.zip; do
[ -f "$z" ] || continue
name="${z%.zip}"
mkdir -p "$RUNNER_TEMP/artifacts/$name"
unzip -o "$z" -d "$RUNNER_TEMP/artifacts/$name"
done
- name: Read PR metadata
id: meta
shell: bash
run: |
DIR="$RUNNER_TEMP/artifacts/pr-meta"
if [ ! -f "$DIR/pr_number" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::warning::pr_number artifact missing — skipping report"
exit 0
fi
# Validate PR number is a positive integer (artifact comes from
# untrusted fork code, so treat contents defensively).
PR_NUM=$(cat "$DIR/pr_number" | tr -d '[:space:]')
if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::error::Invalid PR number in artifact: '$PR_NUM'"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT"
# Validate job-result strings against known GitHub Actions values.
# Artifact contents come from the PR workflow (potentially untrusted
# fork code), so we whitelist to prevent newline injection into
# GITHUB_OUTPUT.
validate_result() {
local val
val=$(cat "$1" | tr -d '[:space:]')
case "$val" in
success|failure|cancelled|skipped) echo "$val" ;;
*) echo "unknown" ;;
esac
}
echo "quality=$(validate_result "$DIR/quality_result")" >> "$GITHUB_OUTPUT"
echo "unit=$(validate_result "$DIR/unit_result")" >> "$GITHUB_OUTPUT"
echo "integration=$(validate_result "$DIR/integration_result")" >> "$GITHUB_OUTPUT"
- name: Checkout (for vitest config)
if: steps.meta.outputs.skip != 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
sparse-checkout: gitnexus/vitest.config.ts
sparse-checkout-cone-mode: false
# ── Merge coverage from unit + integration ─────────────────────
- name: Setup Node.js
if: steps.meta.outputs.skip != 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
- name: Install coverage merge tools
if: steps.meta.outputs.skip != 'true'
run: npm install --no-save istanbul-lib-coverage istanbul-lib-report istanbul-reports
- name: Merge coverage reports
if: steps.meta.outputs.skip != 'true'
id: coverage
shell: bash
run: |
DIR="$RUNNER_TEMP/artifacts"
UNIT_COV=$(find "$DIR/test-reports" -name "coverage-final.json" -type f 2>/dev/null | head -1)
INTEG_COV=$(find "$DIR/integration-reports" -name "coverage-final.json" -type f 2>/dev/null | head -1)
MERGED_DIR="$RUNNER_TEMP/merged-coverage"
mkdir -p "$MERGED_DIR"
if [ -n "$UNIT_COV" ] && [ -n "$INTEG_COV" ]; then
echo "has_merged=true" >> "$GITHUB_OUTPUT"
# Merge using Node.js + istanbul-lib-coverage.
# Paths are passed via env vars to avoid shell interpolation
# inside the script string.
UNIT_COV_PATH="$UNIT_COV" \
INTEG_COV_PATH="$INTEG_COV" \
MERGED_OUT_DIR="$MERGED_DIR" \
node -e "
const libCoverage = require('istanbul-lib-coverage');
const libReport = require('istanbul-lib-report');
const reports = require('istanbul-reports');
const fs = require('fs');
const map = libCoverage.createCoverageMap({});
map.merge(JSON.parse(fs.readFileSync(process.env.UNIT_COV_PATH, 'utf8')));
map.merge(JSON.parse(fs.readFileSync(process.env.INTEG_COV_PATH, 'utf8')));
const context = libReport.createContext({
coverageMap: map,
dir: process.env.MERGED_OUT_DIR,
});
reports.create('json-summary').execute(context);
console.log('Merged coverage written to ' + process.env.MERGED_OUT_DIR + '/coverage-summary.json');
"
elif [ -n "$UNIT_COV" ]; then
echo "has_merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::Integration coverage not found — using unit coverage only"
else
echo "has_merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::No coverage data found"
fi
- name: Build report
if: steps.meta.outputs.skip != 'true'
id: report
shell: bash
env:
QUALITY: ${{ steps.meta.outputs.quality }}
UNIT: ${{ steps.meta.outputs.unit }}
INTEG: ${{ steps.meta.outputs.integration }}
HAS_MERGED: ${{ steps.coverage.outputs.has_merged }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
run: |
DIR="$RUNNER_TEMP/artifacts"
MERGED_DIR="$RUNNER_TEMP/merged-coverage"
# ── Helper: read coverage summary into prefixed vars ──
# Uses printf -v for safe variable assignment (no eval).
read_cov() {
local prefix=$1 file=$2
if [ -n "$file" ] && [ -f "$file" ]; then
local val
val=$(jq -r '.total.statements.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
printf -v "${prefix}_STMTS" '%s' "$val"
val=$(jq -r '.total.branches.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
printf -v "${prefix}_BRANCH" '%s' "$val"
val=$(jq -r '.total.functions.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
printf -v "${prefix}_FUNCS" '%s' "$val"
val=$(jq -r '.total.lines.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
printf -v "${prefix}_LINES" '%s' "$val"
val=$(jq -r '"\(.total.statements.covered)/\(.total.statements.total)"' "$file" 2>/dev/null) || val=""
printf -v "${prefix}_STMTS_COV" '%s' "$val"
val=$(jq -r '"\(.total.branches.covered)/\(.total.branches.total)"' "$file" 2>/dev/null) || val=""
printf -v "${prefix}_BRANCH_COV" '%s' "$val"
val=$(jq -r '"\(.total.functions.covered)/\(.total.functions.total)"' "$file" 2>/dev/null) || val=""
printf -v "${prefix}_FUNCS_COV" '%s' "$val"
val=$(jq -r '"\(.total.lines.covered)/\(.total.lines.total)"' "$file" 2>/dev/null) || val=""
printf -v "${prefix}_LINES_COV" '%s' "$val"
return 0
else
printf -v "${prefix}_STMTS" '%s' "N/A"
printf -v "${prefix}_BRANCH" '%s' "N/A"
printf -v "${prefix}_FUNCS" '%s' "N/A"
printf -v "${prefix}_LINES" '%s' "N/A"
printf -v "${prefix}_STMTS_COV" '%s' ""
printf -v "${prefix}_BRANCH_COV" '%s' ""
printf -v "${prefix}_FUNCS_COV" '%s' ""
printf -v "${prefix}_LINES_COV" '%s' ""
return 1
fi
}
# ── Read all three coverage reports ──
UNIT_SUMMARY=$(find "$DIR/test-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
INTEG_SUMMARY=$(find "$DIR/integration-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
MERGED_SUMMARY="$MERGED_DIR/coverage-summary.json"
read_cov "U" "$UNIT_SUMMARY"
HAS_UNIT=$?
read_cov "I" "$INTEG_SUMMARY"
HAS_INTEG=$?
read_cov "M" "$MERGED_SUMMARY"
# ── Locate test results (unit) ──
RESULTS_FILE=$(find "$DIR/test-reports" -name "test-results.json" -type f 2>/dev/null | head -1)
INTEG_RESULTS=$(find "$DIR/integration-reports" -name "integration-results.json" -type f 2>/dev/null | head -1)
if [ -n "$RESULTS_FILE" ]; then
U_TOTAL=$(jq -r '.numTotalTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
U_PASSED=$(jq -r '.numPassedTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
U_FAILED=$(jq -r '.numFailedTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
U_SKIPPED=$(jq -r '.numPendingTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
U_SUITES=$(jq -r '.numTotalTestSuites' "$RESULTS_FILE" 2>/dev/null || echo 0)
U_DURATION=$(jq -r '((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor' "$RESULTS_FILE" 2>/dev/null || echo 0)
else
U_TOTAL=0; U_PASSED=0; U_FAILED=0; U_SKIPPED=0; U_SUITES=0; U_DURATION=0
fi
if [ -n "$INTEG_RESULTS" ]; then
I_TOTAL=$(jq -r '.numTotalTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
I_PASSED=$(jq -r '.numPassedTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
I_FAILED=$(jq -r '.numFailedTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
I_SKIPPED=$(jq -r '.numPendingTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
I_SUITES=$(jq -r '.numTotalTestSuites' "$INTEG_RESULTS" 2>/dev/null || echo 0)
I_DURATION=$(jq -r '((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor' "$INTEG_RESULTS" 2>/dev/null || echo 0)
else
I_TOTAL=0; I_PASSED=0; I_FAILED=0; I_SKIPPED=0; I_SUITES=0; I_DURATION=0
fi
# ── Sum test results ──
TOTAL=$((U_TOTAL + I_TOTAL))
PASSED=$((U_PASSED + I_PASSED))
FAILED=$((U_FAILED + I_FAILED))
SKIPPED=$((U_SKIPPED + I_SKIPPED))
SUITES=$((U_SUITES + I_SUITES))
DURATION=$((U_DURATION + I_DURATION))
# ── Coverage thresholds (read from vitest.config.ts) ──
if [ -f gitnexus/vitest.config.ts ]; then
THRESH_STMTS=$(grep -oP 'statements:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
THRESH_BRANCH=$(grep -oP 'branches:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
THRESH_FUNCS=$(grep -oP 'functions:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
THRESH_LINES=$(grep -oP 'lines:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
else
THRESH_STMTS=0; THRESH_BRANCH=0; THRESH_FUNCS=0; THRESH_LINES=0
fi
# ── Status helpers ──
status_icon() {
case "$1" in
success) echo "✅" ;;
failure) echo "❌" ;;
cancelled) echo "⏭️" ;;
*) echo "❓" ;;
esac
}
cov_bar() {
local pct=$1 thresh=$2
if [ "$pct" = "N/A" ]; then echo "—"; return; fi
local filled
filled=$(awk "BEGIN { printf \"%d\", $pct / 5 }")
(( filled < 0 )) && filled=0
(( filled > 20 )) && filled=20
local empty=$((20 - filled))
local bar=""
for ((i=0; i<filled; i++)); do bar+="█"; done
for ((i=0; i<empty; i++)); do bar+="░"; done
if [ "$(awk "BEGIN { print ($pct >= $thresh) ? 1 : 0 }")" = "1" ]; then
echo "🟢 ${bar}"
else
echo "🔴 ${bar}"
fi
}
# ── Overall status ──
if [[ "$QUALITY" == "success" && "$UNIT" == "success" && "$INTEG" == "success" ]]; then
OVERALL="✅ **All checks passed**"
else
OVERALL="❌ **Some checks failed**"
fi
# ── Build markdown ──
{
echo "body<<GITNEXUS_CI_REPORT_EOF_7f3a"
echo "## CI Report"
echo ""
echo "${OVERALL}"
echo ""
echo "### Pipeline Status"
echo ""
echo "| Stage | Status | Details |"
echo "|-------|--------|---------|"
echo "| $(status_icon "$QUALITY") Typecheck | \`${QUALITY}\` | tsc --noEmit |"
echo "| $(status_icon "$UNIT") Unit Tests | \`${UNIT}\` | 3 platforms |"
echo "| $(status_icon "$INTEG") Integration | \`${INTEG}\` | 3 OS x 4 groups = 12 jobs |"
echo ""
if [ "$TOTAL" -gt 0 ] 2>/dev/null; then
echo "### Test Results"
echo ""
if [ "$FAILED" = "0" ]; then
echo "✅ **${PASSED}** passed"
else
echo "❌ **${FAILED}** failed / **${PASSED}** passed"
fi
if [ "$SKIPPED" != "0" ]; then
echo " · ${SKIPPED} skipped"
fi
echo " · ${SUITES} suites · ${TOTAL} total"
echo " · ⏱️ ${DURATION}s"
if [ "$I_TOTAL" -gt 0 ] 2>/dev/null; then
echo " · 📊 ${U_TOTAL} unit + ${I_TOTAL} integration"
fi
echo ""
fi
# ── Coverage table helper ──
cov_table() {
local label=$1 s=$2 b=$3 f=$4 l=$5 sc=$6 bc=$7 fc=$8 lc=$9
shift 9
local ts=$1 tb=$2 tf=$3 tl=$4
echo "#### ${label}"
echo ""
echo "| Metric | Coverage | Covered | Threshold | Status |"
echo "|--------|----------|---------|-----------|--------|"
echo "| Statements | **${s}%** | ${sc} | ${ts}% | $(cov_bar "$s" "$ts") |"
echo "| Branches | **${b}%** | ${bc} | ${tb}% | $(cov_bar "$b" "$tb") |"
echo "| Functions | **${f}%** | ${fc} | ${tf}% | $(cov_bar "$f" "$tf") |"
echo "| Lines | **${l}%** | ${lc} | ${tl}% | $(cov_bar "$l" "$tl") |"
echo ""
}
if [ "$M_STMTS" != "N/A" ]; then
echo "### Code Coverage"
echo ""
cov_table "Combined (Unit + Integration)" \
"$M_STMTS" "$M_BRANCH" "$M_FUNCS" "$M_LINES" \
"$M_STMTS_COV" "$M_BRANCH_COV" "$M_FUNCS_COV" "$M_LINES_COV" \
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
echo "<details>"
echo "<summary>Coverage breakdown by test suite</summary>"
echo ""
if [ "$U_STMTS" != "N/A" ]; then
cov_table "Unit Tests" \
"$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \
"$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
fi
if [ "$I_STMTS" != "N/A" ]; then
cov_table "Integration Tests" \
"$I_STMTS" "$I_BRANCH" "$I_FUNCS" "$I_LINES" \
"$I_STMTS_COV" "$I_BRANCH_COV" "$I_FUNCS_COV" "$I_LINES_COV" \
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
fi
echo "</details>"
echo ""
echo "<details>"
echo "<summary>Coverage thresholds are auto-ratcheted — they only go up</summary>"
echo ""
echo "Vitest \`thresholds.autoUpdate\` bumps the floor whenever local coverage exceeds it."
echo "CI enforces the current thresholds; developers commit the ratcheted values."
echo "</details>"
echo ""
elif [ "$U_STMTS" != "N/A" ]; then
echo "### Code Coverage (Unit only)"
echo ""
cov_table "Unit Tests" \
"$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \
"$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
echo "<details>"
echo "<summary>Coverage thresholds are auto-ratcheted — they only go up</summary>"
echo ""
echo "Vitest \`thresholds.autoUpdate\` bumps the floor whenever local coverage exceeds it."
echo "CI enforces the current thresholds; developers commit the ratcheted values."
echo "</details>"
echo ""
else
echo "### Code Coverage"
echo ""
echo "⚠️ Coverage data unavailable - check the [unit test job](${RUN_URL}) for details."
echo ""
fi
echo "---"
echo "<sub>📋 [View full run](${RUN_URL}) · Generated by CI</sub>"
echo "GITNEXUS_CI_REPORT_EOF_7f3a"
} >> "$GITHUB_OUTPUT"
- name: Comment on PR
if: steps.meta.outputs.skip != 'true'
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
with:
header: ci-report
number: ${{ steps.meta.outputs.pr_number }}
message: ${{ steps.report.outputs.body }}
-53
View File
@@ -1,53 +0,0 @@
name: Unit Tests
on:
workflow_call:
jobs:
unit-tests:
name: unit (ubuntu / coverage)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-gitnexus
- name: Run unit tests with coverage
run: >-
npx vitest run test/unit
--reporter=default
--reporter=json
--outputFile=test-results.json
--coverage
--coverage.reporter=json-summary
--coverage.reporter=json
--coverage.reporter=text
--coverage.thresholdAutoUpdate=false
--coverage.reportOnFailure=true
working-directory: gitnexus
- name: Upload test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: test-reports
path: |
gitnexus/coverage/coverage-summary.json
gitnexus/coverage/coverage-final.json
gitnexus/test-results.json
retention-days: 5
cross-platform:
name: unit (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
# Ubuntu already covered by the coverage job above
os: [windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-gitnexus
- run: npx vitest run test/unit
working-directory: gitnexus
+10 -89
View File
@@ -1,99 +1,20 @@
name: CI
on:
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
pull_request:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
workflow_call:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# ── Reusable workflow orchestration ─────────────────────────────────
# Each concern lives in its own workflow file for maintainability:
# ci-quality.yml — typecheck (tsc --noEmit)
# ci-unit-tests.yml — unit tests with coverage + cross-platform
# ci-integration.yml — integration test matrix (3 OS x 4 groups)
#
# Shared setup is DRY via .github/actions/setup-gitnexus composite action.
jobs:
quality:
uses: ./.github/workflows/ci-quality.yml
permissions:
contents: read
unit-tests:
uses: ./.github/workflows/ci-unit-tests.yml
permissions:
contents: read
integration:
uses: ./.github/workflows/ci-integration.yml
with:
collect-coverage: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
# ── Save PR metadata for the reporting workflow ─────────────────
# The ci-report.yml workflow (triggered by workflow_run) needs the
# PR number and job results to post a comment. We save them as an
# artifact because workflow_run context doesn't reliably carry PR
# info for fork PRs.
save-pr-meta:
name: Save PR Metadata
if: always() && github.event_name == 'pull_request'
needs: [quality, unit-tests, integration]
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Write metadata
shell: bash
env:
PR_NUMBER: ${{ github.event.number }}
QUALITY: ${{ needs.quality.result }}
UNIT: ${{ needs.unit-tests.result }}
INTEG: ${{ needs.integration.result }}
run: |
mkdir -p pr-meta
echo "$PR_NUMBER" > pr-meta/pr_number
echo "$QUALITY" > pr-meta/quality_result
echo "$UNIT" > pr-meta/unit_result
echo "$INTEG" > pr-meta/integration_result
- name: Upload PR metadata
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
name: pr-meta
path: pr-meta/
retention-days: 1
# ── Unified CI gate ──────────────────────────────────────────────
# Single required check for branch protection.
ci-status:
name: CI Gate
needs: [quality, unit-tests, integration]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check all jobs passed
shell: bash
env:
QUALITY: ${{ needs.quality.result }}
UNIT: ${{ needs.unit-tests.result }}
INTEG: ${{ needs.integration.result }}
run: |
echo "Quality: $QUALITY"
echo "Unit Tests: $UNIT"
echo "Integration: $INTEG"
if [[ "$QUALITY" != "success" ]] ||
[[ "$UNIT" != "success" ]] ||
[[ "$INTEG" != "success" ]]; then
echo "::error::One or more CI jobs failed"
exit 1
fi
node-version: 20
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npx tsc --noEmit
working-directory: gitnexus
+22 -75
View File
@@ -1,97 +1,44 @@
name: Claude Code Review
# Uses pull_request_target so the workflow runs as defined on the default branch,
# which allows access to secrets for posting review comments on fork PRs.
# SECURITY: The checkout below uses the PR head SHA to review the correct code.
# The claude-code-action sandboxes execution — it does NOT run arbitrary code
# from the checked-out source.
on:
# Trigger only when explicitly requested:
# - Add the "claude-review" label to a PR, OR
# - Comment "@claude" or "/review" on a PR
pull_request_target:
types: [labeled]
issue_comment:
types: [created]
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Run only when:
# 1. The "claude-review" label is added to a non-draft PR by a trusted contributor, OR
# 2. A trusted contributor comments "@claude" or "/review" on a PR
if: |
(
github.event_name == 'pull_request_target' &&
github.event.label.name == 'claude-review' &&
github.event.pull_request.draft == false &&
(github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR')
) ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(contains(github.event.comment.body, '@claude') ||
contains(github.event.comment.body, '/review')) &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')
)
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write # needed to push fork branch to origin
pull-requests: write
contents: read
pull-requests: read
issues: read
id-token: write
steps:
# For issue_comment triggers, resolve the PR number, head SHA, and branch name
- name: Resolve PR context
id: pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
- name: Checkout repository
uses: actions/checkout@v4
with:
script: |
let pr;
if (context.eventName === 'issue_comment') {
const resp = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.issue.number,
});
pr = resp.data;
} else {
pr = context.payload.pull_request;
}
core.setOutput('number', pr.number);
core.setOutput('sha', pr.head.sha);
core.setOutput('branch', pr.head.ref);
core.setOutput('is_fork', String(pr.head.repo.full_name !== pr.base.repo.full_name));
- name: Checkout PR head
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ steps.pr.outputs.sha }}
fetch-depth: 1
# claude-code-action fetches branches by name from origin, which fails
# for fork PRs. Work around by pushing the fork branch to origin so
# the action can find it. Cleaned up in the post step below.
- name: Push fork branch to origin
if: steps.pr.outputs.is_fork == 'true'
run: git push origin HEAD:refs/heads/${{ steps.pr.outputs.branch }}
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# Clean up the temporary branch we pushed for fork PRs
- name: Delete fork branch from origin
if: always() && steps.pr.outputs.is_fork == 'true'
run: git push origin --delete refs/heads/${{ steps.pr.outputs.branch }} || true
+13 -5
View File
@@ -18,25 +18,33 @@ jobs:
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: write
issues: write
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+6 -46
View File
@@ -5,25 +5,14 @@ on:
tags:
- 'v*'
# No workflow-level permissions — scoped per job below.
jobs:
ci:
uses: ./.github/workflows/ci.yml
publish:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
publish:
needs: ci
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
@@ -31,38 +20,9 @@ jobs:
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- name: Verify version consistency
shell: bash
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Tag does not follow semver: v$TAG_VERSION"
exit 1
fi
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"
- run: npx tsc --noEmit
working-directory: gitnexus
- 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
- run: npm publish
working-directory: gitnexus
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
generate_release_notes: true
-4
View File
@@ -56,7 +56,3 @@ repomix-output*
# Design docs (local only)
docs/plans/
gitnexus/test/fixtures/mini-repo/*.md
gitnexus/test/fixtures/mini-repo/.claude
gitnexus/test/fixtures/mini-repo/.gitignore
+43 -59
View File
@@ -1,78 +1,62 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
# GitNexus MCP
This project is indexed by GitNexus as **GitNexus** (1650 symbols, 4291 relationships, 125 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitnexusV2** (1348 symbols, 3469 relationships, 104 execution flows).
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
## Always Do
## Always Start Here
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
For any task involving code understanding, debugging, impact analysis, or refactoring, you must:
## When Debugging
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**
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
## When Refactoring
## Skills
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
## Never Do
## Tools Reference
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
| 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 |
## Tools Quick Reference
## Resources Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
Lightweight reads (~100-500 tokens) for navigation:
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
| Resource | Content |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
| `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 |
## Self-Check Before Finishing
## Graph Schema
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
## CLI
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
RETURN caller.name, caller.filePath
```
- Re-index: `npx gitnexus analyze`
- Check freshness: `npx gitnexus status`
- Generate docs: `npx gitnexus wiki`
<!-- gitnexus:end -->
<!-- gitnexus:end -->
-65
View File
@@ -1,65 +0,0 @@
# Changelog
All notable changes to GitNexus will be documented in this file.
## [1.3.11] - 2026-03-08
### Security
- Fix FTS Cypher injection by escaping backslashes in search queries (#209) — @magyargergo
### Added
- Auto-reindex hook that runs `gitnexus analyze` after commits and merges, with automatic embeddings preservation (#205) — @L1nusB
- 968 integration tests (up from ~840) covering unhappy paths across search, enrichment, CLI, pipeline, worker pool, and KuzuDB (#209) — @magyargergo
- Coverage auto-ratcheting so thresholds bump automatically on CI (#209) — @magyargergo
- Rich CI PR report with coverage bars, test counts, and threshold tracking (#209) — @magyargergo
- Modular CI workflow architecture with separate unit-test, integration-test, and orchestrator jobs (#209) — @magyargergo
### Fixed
- KuzuDB native addon crashes on Linux/macOS by running integration tests in isolated vitest processes with `--pool=forks` (#209) — @magyargergo
- Worker pool `MODULE_NOT_FOUND` crash when script path is invalid (#209) — @magyargergo
### Changed
- Added macOS to the cross-platform CI test matrix (#208) — @magyargergo
## [1.3.10] - 2026-03-07
### Security
- **MCP transport buffer cap**: Added 10 MB `MAX_BUFFER_SIZE` limit to prevent out-of-memory attacks via oversized `Content-Length` headers or unbounded newline-delimited input
- **Content-Length validation**: Reject `Content-Length` values exceeding the buffer cap before allocating memory
- **Stack overflow prevention**: Replaced recursive `readNewlineMessage` with iterative loop to prevent stack overflow from consecutive empty lines
- **Ambiguous prefix hardening**: Tightened `looksLikeContentLength` to require 14+ bytes before matching, preventing false framing detection on short input
- **Closed transport guard**: `send()` now rejects with a clear error when called after `close()`, with proper write-error propagation
### Added
- **Dual-framing MCP transport** (`CompatibleStdioServerTransport`): Auto-detects Content-Length (Codex/OpenCode) and newline-delimited JSON (Cursor/Claude Code) framing on the first message, responds in the same format (#207)
- **Lazy CLI module loading**: All CLI subcommands now use `createLazyAction()` to defer heavy imports (tree-sitter, ONNX, KuzuDB) until invocation, significantly improving `gitnexus mcp` startup time (#207)
- **Type-safe lazy actions**: `createLazyAction` uses constrained generics to validate export names against module types at compile time
- **Regression test suite**: 13 unit tests covering transport framing, security hardening, buffer limits, and lazy action loading
### Fixed
- **CALLS edge sourceId alignment**: `findEnclosingFunctionId` now generates IDs with `:startLine` suffix matching node creation format, fixing process detector finding 0 entry points (#194)
- **LRU cache zero maxSize crash**: Guard `createASTCache` against `maxSize=0` when repos have no parseable files (#144)
### Changed
- Transport constructor accepts `NodeJS.ReadableStream` / `NodeJS.WritableStream` (widened from concrete `ReadStream`/`WriteStream`)
- `processReadBuffer` simplified to break on first error instead of stale-buffer retry loop
## [1.3.9] - 2026-03-06
### Fixed
- Aligned CALLS edge sourceId with node ID format in parse worker (#194)
## [1.3.8] - 2026-03-05
### Fixed
- Force-exit after analyze to prevent KuzuDB native cleanup hang (#192)
+43 -59
View File
@@ -1,78 +1,62 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
# GitNexus MCP
This project is indexed by GitNexus as **GitNexus** (1650 symbols, 4291 relationships, 125 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitnexusV2** (1348 symbols, 3469 relationships, 104 execution flows).
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
## Always Do
## Always Start Here
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
For any task involving code understanding, debugging, impact analysis, or refactoring, you must:
## When Debugging
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**
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
## When Refactoring
## Skills
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
## Never Do
## Tools Reference
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
| 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 |
## Tools Quick Reference
## Resources Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
Lightweight reads (~100-500 tokens) for navigation:
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
| Resource | Content |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
| `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 |
## Self-Check Before Finishing
## Graph Schema
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
## CLI
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
RETURN caller.name, caller.filePath
```
- Re-index: `npx gitnexus analyze`
- Check freshness: `npx gitnexus status`
- Generate docs: `npx gitnexus wiki`
<!-- gitnexus:end -->
<!-- gitnexus:end -->
+7 -24
View File
@@ -1,30 +1,13 @@
# GitNexus
⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
<div align="center">
<a href="https://trendshift.io/repositories/19809" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19809" alt="abhigyanpatwari%2FGitNexus | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/19809" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/19809" alt="abhigyanpatwari%2FGitNexus | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
<h2>Join the official Discord to discuss ideas, issues etc!</h2>
<a href="https://discord.gg/AAsRVT6fGb">
<img src="https://img.shields.io/discord/1477255801545429032?color=5865F2&logo=discord&logoColor=white" alt="Discord"/>
</a>
<a href="https://www.npmjs.com/package/gitnexus">
<img src="https://img.shields.io/npm/v/gitnexus.svg" alt="npm version"/>
</a>
<a href="https://polyformproject.org/licenses/noncommercial/1.0.0/">
<img src="https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg" alt="License: PolyForm Noncommercial"/>
</a>
</div>
**Building nervous system for agent context.**
**Building git for agent context.**
Indexes any codebase into a knowledge graph — every dependency, call chain, cluster, and execution flow — then exposes it through smart tools so AI agents never miss code.
[![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus)
[![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/)
@@ -82,12 +65,12 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
| --------------------- | --- | ------ | -------------------- | -------------- |
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** |
| **Cursor** | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits.
> **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
@@ -320,7 +303,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
### Supported Languages
TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust, PHP, Swift
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Swift
---
@@ -1,11 +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.3.6",
"version": "1.3.3",
"author": {
"name": "GitNexus"
},
"homepage": "https://github.com/abhigyanpatwari/GitNexus",
"repository": "https://github.com/abhigyanpatwari/GitNexus",
"homepage": "https://github.com/nicosxt/gitnexus",
"repository": "https://github.com/nicosxt/gitnexus",
"keywords": ["code-intelligence", "knowledge-graph", "mcp", "static-analysis"]
}
+62 -146
View File
@@ -2,10 +2,8 @@
/**
* GitNexus Claude Code Plugin Hook
*
* PreToolUse — intercepts Grep/Glob/Bash searches and augments
* with graph context from the GitNexus index.
* PostToolUse — detects stale index after git mutations and notifies
* the agent to reindex.
* 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 #23576).
* Session context is injected via CLAUDE.md / skills instead.
@@ -28,19 +26,19 @@ function readInput() {
}
/**
* Find the .gitnexus directory by walking up from startDir.
* Returns the path to .gitnexus/ or null if not found.
* Check if a directory (or ancestor) has a .gitnexus index.
*/
function findGitNexusDir(startDir) {
function findGitNexusIndex(startDir) {
let dir = startDir || process.cwd();
for (let i = 0; i < 5; i++) {
const candidate = path.join(dir, '.gitnexus');
if (fs.existsSync(candidate)) return candidate;
if (fs.existsSync(path.join(dir, '.gitnexus'))) {
return true;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
return false;
}
/**
@@ -85,146 +83,64 @@ function extractPattern(toolName, toolInput) {
return null;
}
/**
* Spawn a gitnexus CLI command synchronously.
* Detects binary on PATH once, then runs exactly once.
*
* SECURITY: Never use shell: true with user-controlled arguments.
* On Windows, invoke gitnexus.cmd directly (no shell needed).
*/
function runGitNexusCli(args, cwd, timeout) {
const isWin = process.platform === 'win32';
// Detect whether 'gitnexus' is on PATH (cheap check, no execution)
let useDirectBinary = false;
try {
const which = spawnSync(
isWin ? 'where' : 'which', ['gitnexus'],
{ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }
);
useDirectBinary = which.status === 0;
} catch { /* not on PATH */ }
if (useDirectBinary) {
return spawnSync(
isWin ? 'gitnexus.cmd' : 'gitnexus', args,
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
}
// npx fallback needs shell on Windows since npx is a .cmd script
return spawnSync(
isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args],
{ encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
}
/**
* Emit a hook response with additional context for the agent.
*/
function sendHookResponse(hookEventName, message) {
console.log(JSON.stringify({
hookSpecificOutput: { hookEventName, additionalContext: message }
}));
}
/**
* PreToolUse handler — augment searches with graph context.
*/
function handlePreToolUse(input) {
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
if (!findGitNexusDir(cwd)) return;
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
let result = '';
try {
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = child.stderr || '';
}
} catch { /* graceful failure */ }
if (result && result.trim()) {
sendHookResponse('PreToolUse', result.trim());
}
}
/**
* PostToolUse handler — detect index staleness after git mutations.
*
* Instead of spawning a full `gitnexus analyze` synchronously (which blocks
* the agent for up to 120s and risks KuzuDB corruption on timeout), we do a
* lightweight staleness check: compare `git rev-parse HEAD` against the
* lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the
* agent so it can decide when to reindex.
*/
function handlePostToolUse(input) {
const toolName = input.tool_name || '';
if (toolName !== 'Bash') return;
const command = (input.tool_input || {}).command || '';
if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) return;
// Only proceed if the command succeeded
const toolOutput = input.tool_output || {};
if (toolOutput.exit_code !== undefined && toolOutput.exit_code !== 0) return;
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
const gitNexusDir = findGitNexusDir(cwd);
if (!gitNexusDir) return;
// Compare HEAD against last indexed commit — skip if unchanged
let currentHead = '';
try {
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'],
});
currentHead = (headResult.stdout || '').trim();
} catch { return; }
if (!currentHead) return;
let lastCommit = '';
let hadEmbeddings = false;
try {
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
lastCommit = meta.lastCommit || '';
hadEmbeddings = (meta.stats && meta.stats.embeddings > 0);
} catch { /* no meta — treat as stale */ }
// If HEAD matches last indexed commit, no reindex needed
if (currentHead && currentHead === lastCommit) return;
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
sendHookResponse('PostToolUse',
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
`Run \`${analyzeCmd}\` to update the knowledge graph.`
);
}
// Dispatch map for hook events
const handlers = {
PreToolUse: handlePreToolUse,
PostToolUse: handlePostToolUse,
};
function main() {
try {
const input = readInput();
const handler = handlers[input.hook_event_name || ''];
if (handler) handler(input);
} catch (err) {
if (process.env.GITNEXUS_DEBUG) {
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
const hookEvent = input.hook_event_name || '';
if (hookEvent !== 'PreToolUse') return;
const cwd = input.cwd || process.cwd();
if (!findGitNexusIndex(cwd)) return;
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
// augment CLI writes result to stderr (KuzuDB's native module captures
// stdout fd at OS level, making it unusable in subprocess contexts).
let result = '';
// Try direct gitnexus binary first (faster if globally installed)
try {
const child = spawnSync(
'gitnexus',
['augment', pattern],
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
if (child.status === 0 && child.stderr && child.stderr.trim()) {
result = child.stderr;
}
} catch { /* not on PATH */ }
// Fallback to npx if direct binary didn't produce output
if (!result || !result.trim()) {
try {
const child = spawnSync(
'npx',
['-y', 'gitnexus', 'augment', pattern],
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
if (child.status === 0 && child.stderr && child.stderr.trim()) {
result = child.stderr;
}
} catch { /* graceful failure */ }
}
if (result && result.trim()) {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: result.trim()
}
}));
}
} catch {
// Graceful failure
}
}
-13
View File
@@ -12,19 +12,6 @@
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js",
"timeout": 10,
"statusMessage": "Checking GitNexus index freshness..."
}
]
}
]
}
}
@@ -1,163 +0,0 @@
---
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,163 +0,0 @@
---
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
```
@@ -14,7 +14,7 @@ export const EmbeddingStatus = () => {
startEmbeddings,
graph,
viewMode,
serverBaseUrl,
isBackendMode,
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 || serverBaseUrl) return null;
if (viewMode !== 'exploring' || !graph || isBackendMode) return null;
const nodeCount = graph.nodes.length;
@@ -216,58 +216,6 @@ export const processCalls = async (
});
});
// Extract Laravel routes from route files via procedural AST walk
if (language === 'php' && (file.path.includes('/routes/') || file.path.startsWith('routes/')) && file.path.endsWith('.php')) {
const extractedRoutes = extractLaravelRoutes(tree, file.path);
for (const route of extractedRoutes) {
if (!route.controllerName || !route.methodName) continue;
const controllerDefs = symbolTable.lookupFuzzy(route.controllerName);
if (controllerDefs.length === 0) continue;
const routeImportedFiles = importMap.get(route.filePath);
let controllerDef = controllerDefs[0];
let conf = controllerDefs.length === 1 ? 0.7 : 0.5;
if (routeImportedFiles) {
for (const def of controllerDefs) {
if (routeImportedFiles.has(def.filePath)) {
controllerDef = def;
conf = 0.9;
break;
}
}
}
const methodId = symbolTable.lookupExact(controllerDef.filePath, route.methodName);
const routeSourceId = generateId('File', route.filePath);
if (!methodId) {
const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`);
const routeRelId = generateId('CALLS', `${routeSourceId}:route->${guessedId}`);
graph.addRelationship({
id: routeRelId,
sourceId: routeSourceId,
targetId: guessedId,
type: 'CALLS',
confidence: conf * 0.8,
reason: 'laravel-route',
});
continue;
}
const routeRelId = generateId('CALLS', `${routeSourceId}:route->${methodId}`);
graph.addRelationship({
id: routeRelId,
sourceId: routeSourceId,
targetId: methodId,
type: 'CALLS',
confidence: conf,
reason: 'laravel-route',
});
}
}
// Cleanup if re-parsed
if (wasReparsed) {
tree.delete();
@@ -275,387 +223,6 @@ export const processCalls = async (
}
};
// ============================================================================
// Laravel Route Extraction (procedural AST walk)
// ============================================================================
interface ExtractedRoute {
filePath: string;
httpMethod: string;
routePath: string | null;
controllerName: string | null;
methodName: string | null;
middleware: string[];
prefix: string | null;
lineNumber: number;
}
interface RouteGroupContext {
middleware: string[];
prefix: string | null;
controller: string | null;
}
const ROUTE_HTTP_METHODS = new Set([
'get', 'post', 'put', 'patch', 'delete', 'options', 'any', 'match',
]);
const ROUTE_RESOURCE_METHODS = new Set(['resource', 'apiResource']);
const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'];
const API_RESOURCE_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
function isRouteStaticCall(node: any): boolean {
if (node.type !== 'scoped_call_expression') return false;
const obj = node.childForFieldName?.('object') ?? node.children?.[0];
return obj?.text === 'Route';
}
function getCallMethodName(node: any): string | null {
const nameNode = node.childForFieldName?.('name') ??
node.children?.find((c: any) => c.type === 'name');
return nameNode?.text ?? null;
}
function getArguments(node: any): any {
return node.children?.find((c: any) => c.type === 'arguments') ?? null;
}
function findClosureBody(argsNode: any): any | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
if (child.type === 'argument') {
for (const inner of child.children ?? []) {
if (inner.type === 'anonymous_function' ||
inner.type === 'arrow_function') {
return inner.childForFieldName?.('body') ??
inner.children?.find((c: any) => c.type === 'compound_statement');
}
}
}
if (child.type === 'anonymous_function' ||
child.type === 'arrow_function') {
return child.childForFieldName?.('body') ??
child.children?.find((c: any) => c.type === 'compound_statement');
}
}
return null;
}
function findDescendant(node: any, type: string): any {
if (node.type === type) return node;
for (const child of (node.children ?? [])) {
const found = findDescendant(child, type);
if (found) return found;
}
return null;
}
function extractStringContent(node: any): string | null {
if (!node) return null;
const content = node.children?.find((c: any) => c.type === 'string_content');
if (content) return content.text;
if (node.type === 'string_content') return node.text;
return null;
}
function extractFirstStringArg(argsNode: any): string | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (!target) continue;
if (target.type === 'string' || target.type === 'encapsed_string') {
return extractStringContent(target);
}
}
return null;
}
function extractMiddlewareArg(argsNode: any): string[] {
if (!argsNode) return [];
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (!target) continue;
if (target.type === 'string' || target.type === 'encapsed_string') {
const val = extractStringContent(target);
return val ? [val] : [];
}
if (target.type === 'array_creation_expression') {
const items: string[] = [];
for (const el of target.children ?? []) {
if (el.type === 'array_element_initializer') {
const str = el.children?.find((c: any) => c.type === 'string' || c.type === 'encapsed_string');
const val = str ? extractStringContent(str) : null;
if (val) items.push(val);
}
}
return items;
}
}
return [];
}
function extractClassArg(argsNode: any): string | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (target?.type === 'class_constant_access_expression') {
return target.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
return null;
}
function extractControllerTarget(argsNode: any): { controller: string | null; method: string | null } {
if (!argsNode) return { controller: null, method: null };
const args: any[] = [];
for (const child of argsNode.children ?? []) {
if (child.type === 'argument') args.push(child.children?.[0]);
else if (child.type !== '(' && child.type !== ')' && child.type !== ',') args.push(child);
}
const handlerNode = args[1];
if (!handlerNode) return { controller: null, method: null };
if (handlerNode.type === 'array_creation_expression') {
let controller: string | null = null;
let method: string | null = null;
const elements: any[] = [];
for (const el of handlerNode.children ?? []) {
if (el.type === 'array_element_initializer') elements.push(el);
}
if (elements[0]) {
const classAccess = findDescendant(elements[0], 'class_constant_access_expression');
if (classAccess) {
controller = classAccess.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
if (elements[1]) {
const str = findDescendant(elements[1], 'string');
method = str ? extractStringContent(str) : null;
}
return { controller, method };
}
if (handlerNode.type === 'string' || handlerNode.type === 'encapsed_string') {
const text = extractStringContent(handlerNode);
if (text?.includes('@')) {
const [controller, method] = text.split('@');
return { controller, method };
}
}
if (handlerNode.type === 'class_constant_access_expression') {
const controller = handlerNode.children?.find((c: any) => c.type === 'name')?.text ?? null;
return { controller, method: '__invoke' };
}
return { controller: null, method: null };
}
interface ChainedRouteCall {
isRouteFacade: boolean;
terminalMethod: string;
attributes: { method: string; argsNode: any }[];
terminalArgs: any;
node: any;
}
function unwrapRouteChain(node: any): ChainedRouteCall | null {
if (node.type !== 'member_call_expression') return null;
const terminalMethod = getCallMethodName(node);
if (!terminalMethod) return null;
const terminalArgs = getArguments(node);
const attributes: { method: string; argsNode: any }[] = [];
let current = node.children?.[0];
while (current) {
if (current.type === 'member_call_expression') {
const method = getCallMethodName(current);
const args = getArguments(current);
if (method) attributes.unshift({ method, argsNode: args });
current = current.children?.[0];
} else if (current.type === 'scoped_call_expression') {
const obj = current.childForFieldName?.('object') ?? current.children?.[0];
if (obj?.text !== 'Route') return null;
const method = getCallMethodName(current);
const args = getArguments(current);
if (method) attributes.unshift({ method, argsNode: args });
return { isRouteFacade: true, terminalMethod, attributes, terminalArgs, node };
} else {
break;
}
}
return null;
}
function parseArrayGroupArgs(argsNode: any): RouteGroupContext {
const ctx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
if (!argsNode) return ctx;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (target?.type === 'array_creation_expression') {
for (const el of target.children ?? []) {
if (el.type !== 'array_element_initializer') continue;
const children = el.children ?? [];
const arrowIdx = children.findIndex((c: any) => c.type === '=>');
if (arrowIdx === -1) continue;
const key = extractStringContent(children[arrowIdx - 1]);
const val = children[arrowIdx + 1];
if (key === 'middleware') {
if (val?.type === 'string') {
const s = extractStringContent(val);
if (s) ctx.middleware.push(s);
} else if (val?.type === 'array_creation_expression') {
for (const item of val.children ?? []) {
if (item.type === 'array_element_initializer') {
const str = item.children?.find((c: any) => c.type === 'string');
const s = str ? extractStringContent(str) : null;
if (s) ctx.middleware.push(s);
}
}
}
} else if (key === 'prefix') {
ctx.prefix = extractStringContent(val) ?? null;
} else if (key === 'controller') {
if (val?.type === 'class_constant_access_expression') {
ctx.controller = val.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
}
}
}
return ctx;
}
function extractLaravelRoutes(tree: any, filePath: string): ExtractedRoute[] {
const routes: ExtractedRoute[] = [];
function resolveStack(stack: RouteGroupContext[]): { middleware: string[]; prefix: string | null; controller: string | null } {
const middleware: string[] = [];
let prefix: string | null = null;
let controller: string | null = null;
for (const ctx of stack) {
middleware.push(...ctx.middleware);
if (ctx.prefix) prefix = prefix ? `${prefix}/${ctx.prefix}`.replace(/\/+/g, '/') : ctx.prefix;
if (ctx.controller) controller = ctx.controller;
}
return { middleware, prefix, controller };
}
function emitRoute(
httpMethod: string,
argsNode: any,
lineNumber: number,
groupStack: RouteGroupContext[],
chainAttrs: { method: string; argsNode: any }[],
) {
const effective = resolveStack(groupStack);
for (const attr of chainAttrs) {
if (attr.method === 'middleware') effective.middleware.push(...extractMiddlewareArg(attr.argsNode));
if (attr.method === 'prefix') {
const p = extractFirstStringArg(attr.argsNode);
if (p) effective.prefix = effective.prefix ? `${effective.prefix}/${p}` : p;
}
if (attr.method === 'controller') {
const cls = extractClassArg(attr.argsNode);
if (cls) effective.controller = cls;
}
}
const routePath = extractFirstStringArg(argsNode);
if (ROUTE_RESOURCE_METHODS.has(httpMethod)) {
const target = extractControllerTarget(argsNode);
const actions = httpMethod === 'apiResource' ? API_RESOURCE_ACTIONS : RESOURCE_ACTIONS;
for (const action of actions) {
routes.push({
filePath, httpMethod, routePath,
controllerName: target.controller ?? effective.controller,
methodName: action,
middleware: [...effective.middleware],
prefix: effective.prefix,
lineNumber,
});
}
} else {
const target = extractControllerTarget(argsNode);
routes.push({
filePath, httpMethod, routePath,
controllerName: target.controller ?? effective.controller,
methodName: target.method,
middleware: [...effective.middleware],
prefix: effective.prefix,
lineNumber,
});
}
}
function walk(node: any, groupStack: RouteGroupContext[]) {
if (isRouteStaticCall(node)) {
const method = getCallMethodName(node);
if (method && (ROUTE_HTTP_METHODS.has(method) || ROUTE_RESOURCE_METHODS.has(method))) {
emitRoute(method, getArguments(node), node.startPosition.row, groupStack, []);
return;
}
if (method === 'group') {
const argsNode = getArguments(node);
const groupCtx = parseArrayGroupArgs(argsNode);
const body = findClosureBody(argsNode);
if (body) {
groupStack.push(groupCtx);
walkChildren(body, groupStack);
groupStack.pop();
}
return;
}
}
const chain = unwrapRouteChain(node);
if (chain) {
if (chain.terminalMethod === 'group') {
const groupCtx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
for (const attr of chain.attributes) {
if (attr.method === 'middleware') groupCtx.middleware.push(...extractMiddlewareArg(attr.argsNode));
if (attr.method === 'prefix') groupCtx.prefix = extractFirstStringArg(attr.argsNode);
if (attr.method === 'controller') groupCtx.controller = extractClassArg(attr.argsNode);
}
const body = findClosureBody(chain.terminalArgs);
if (body) {
groupStack.push(groupCtx);
walkChildren(body, groupStack);
groupStack.pop();
}
return;
}
if (ROUTE_HTTP_METHODS.has(chain.terminalMethod) || ROUTE_RESOURCE_METHODS.has(chain.terminalMethod)) {
emitRoute(chain.terminalMethod, chain.terminalArgs, node.startPosition.row, groupStack, chain.attributes);
return;
}
}
walkChildren(node, groupStack);
}
function walkChildren(node: any, groupStack: RouteGroupContext[]) {
for (const child of node.children ?? []) {
walk(child, groupStack);
}
}
walk(tree.rootNode, []);
return routes;
}
/**
* Resolution result with confidence scoring
*/
@@ -319,8 +319,7 @@ 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.
// FUTURE: AST-BASED PATTERNS (for Phase 3)
// ============================================================================
/**
@@ -69,9 +69,7 @@ export async function fetchRepoInfo(baseUrl: string, repoName?: string): Promise
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 };
return response.json();
}
export async function fetchGraph(
+51 -154
View File
@@ -2,10 +2,8 @@
/**
* GitNexus Claude Code Hook
*
* PreToolUse — intercepts Grep/Glob/Bash searches and augments
* with graph context from the GitNexus index.
* PostToolUse — detects stale index after git mutations and notifies
* the agent to reindex.
* 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).
* Session context is injected via CLAUDE.md / skills instead.
@@ -13,7 +11,7 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { execFileSync } = require('child_process');
/**
* Read JSON input from stdin synchronously.
@@ -28,19 +26,19 @@ function readInput() {
}
/**
* Find the .gitnexus directory by walking up from startDir.
* Returns the path to .gitnexus/ or null if not found.
* Check if a directory (or ancestor) has a .gitnexus index.
*/
function findGitNexusDir(startDir) {
function findGitNexusIndex(startDir) {
let dir = startDir || process.cwd();
for (let i = 0; i < 5; i++) {
const candidate = path.join(dir, '.gitnexus');
if (fs.existsSync(candidate)) return candidate;
if (fs.existsSync(path.join(dir, '.gitnexus'))) {
return true;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
return false;
}
/**
@@ -85,153 +83,52 @@ function extractPattern(toolName, toolInput) {
return null;
}
/**
* Resolve the gitnexus CLI path.
* 1. Relative path (works when script is inside npm package)
* 2. require.resolve (works when gitnexus is globally installed)
* 3. Fall back to npx (returns empty string)
*/
function resolveCliPath() {
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
if (!fs.existsSync(cliPath)) {
try {
cliPath = require.resolve('gitnexus/dist/cli/index.js');
} catch {
cliPath = '';
}
}
return cliPath;
}
/**
* Spawn a gitnexus CLI command synchronously.
* Returns the stderr output (KuzuDB captures stdout at OS level).
*/
function runGitNexusCli(cliPath, args, cwd, timeout) {
const isWin = process.platform === 'win32';
if (cliPath) {
return spawnSync(
process.execPath,
[cliPath, ...args],
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
}
// On Windows, invoke npx.cmd directly (no shell needed)
return spawnSync(
isWin ? 'npx.cmd' : 'npx',
['-y', 'gitnexus', ...args],
{ encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
}
/**
* PreToolUse handler — augment searches with graph context.
*/
function handlePreToolUse(input) {
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
if (!findGitNexusDir(cwd)) return;
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
const cliPath = resolveCliPath();
let result = '';
try {
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = child.stderr || '';
}
} catch { /* graceful failure */ }
if (result && result.trim()) {
sendHookResponse('PreToolUse', result.trim());
}
}
/**
* Emit a PostToolUse hook response with additional context for the agent.
*/
function sendHookResponse(hookEventName, message) {
console.log(JSON.stringify({
hookSpecificOutput: { hookEventName, additionalContext: message }
}));
}
/**
* PostToolUse handler — detect index staleness after git mutations.
*
* Instead of spawning a full `gitnexus analyze` synchronously (which blocks
* the agent for up to 120s and risks KuzuDB corruption on timeout), we do a
* lightweight staleness check: compare `git rev-parse HEAD` against the
* lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the
* agent so it can decide when to reindex.
*/
function handlePostToolUse(input) {
const toolName = input.tool_name || '';
if (toolName !== 'Bash') return;
const command = (input.tool_input || {}).command || '';
if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) return;
// Only proceed if the command succeeded
const toolOutput = input.tool_output || {};
if (toolOutput.exit_code !== undefined && toolOutput.exit_code !== 0) return;
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
const gitNexusDir = findGitNexusDir(cwd);
if (!gitNexusDir) return;
// Compare HEAD against last indexed commit — skip if unchanged
let currentHead = '';
try {
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'],
});
currentHead = (headResult.stdout || '').trim();
} catch { return; }
if (!currentHead) return;
let lastCommit = '';
let hadEmbeddings = false;
try {
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
lastCommit = meta.lastCommit || '';
hadEmbeddings = (meta.stats && meta.stats.embeddings > 0);
} catch { /* no meta — treat as stale */ }
// If HEAD matches last indexed commit, no reindex needed
if (currentHead && currentHead === lastCommit) return;
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
sendHookResponse('PostToolUse',
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
`Run \`${analyzeCmd}\` to update the knowledge graph.`
);
}
// Dispatch map for hook events
const handlers = {
PreToolUse: handlePreToolUse,
PostToolUse: handlePostToolUse,
};
function main() {
try {
const input = readInput();
const handler = handlers[input.hook_event_name || ''];
if (handler) handler(input);
} catch (err) {
if (process.env.GITNEXUS_DEBUG) {
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
const hookEvent = input.hook_event_name || '';
if (hookEvent !== 'PreToolUse') return;
const cwd = input.cwd || process.cwd();
if (!findGitNexusIndex(cwd)) return;
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
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');
// 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'] }
);
result = child.stderr || '';
} catch { /* graceful failure */ }
if (result && result.trim()) {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: result.trim()
}
}));
}
} catch (err) {
// Graceful failure — log to stderr for debugging
console.error('GitNexus hook error:', err.message);
}
}
+1 -2
View File
@@ -63,8 +63,7 @@ if [ "$found" = false ]; then
fi
# Run gitnexus augment — must be fast (<500ms target)
# 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)
RESULT=$(cd "$CWD" && npx -y gitnexus augment "$PATTERN" 2>/dev/null)
if [ -n "$RESULT" ]; then
ESCAPED=$(echo "$RESULT" | jq -Rs .)
+4 -1262
View File
File diff suppressed because it is too large Load Diff
+3 -11
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.3.11",
"version": "1.3.3",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@@ -39,11 +39,6 @@
"scripts": {
"build": "tsc",
"dev": "tsx watch src/cli/index.ts",
"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"
},
@@ -69,11 +64,11 @@
"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-typescript": "^0.21.0",
"typescript": "^5.4.5",
"uuid": "^13.0.0"
},
"optionalDependencies": {
@@ -85,10 +80,7 @@
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18",
"tsx": "^4.0.0",
"typescript": "^5.4.5",
"vitest": "^4.0.18"
"tsx": "^4.0.0"
},
"engines": {
"node": ">=18.0.0"
+48 -24
View File
@@ -17,10 +17,14 @@
* 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
* tree-sitter-swift is listed as an optionalDependency, so npm won't abort
* if its native build fails. However, npm may also remove the package
* entirely after a failed build. This script handles both cases:
*
* 1. If tree-sitter-swift exists but has no native binding:
* patch binding.gyp and rebuild
* 2. If tree-sitter-swift was removed by npm after build failure:
* re-install with --ignore-scripts, patch, and rebuild
*
* TODO: Remove this script when tree-sitter is upgraded to ^0.22.x,
* which allows using tree-sitter-swift@0.7.1+ directly.
@@ -29,46 +33,66 @@ const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
const nodeModules = path.join(__dirname, '..', 'node_modules');
const swiftDir = path.join(nodeModules, 'tree-sitter-swift');
const bindingPath = path.join(swiftDir, 'binding.gyp');
try {
if (!fs.existsSync(bindingPath)) {
process.exit(0);
}
function patchAndRebuild() {
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, '');
// Strip Python-style comments (#) and trailing commas before JSON parsing
// binding.gyp uses GYP format which allows both, but JSON.parse does not
const cleaned = content
.replace(/#[^\n]*/g, '')
.replace(/,\s*([}\]])/g, '$1');
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
// Check if native binding already exists
const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node');
if (!fs.existsSync(bindingNode)) {
needsRebuild = true;
if (fs.existsSync(bindingNode)) {
return; // Already built
}
if (needsRebuild) {
console.log('[tree-sitter-swift] Rebuilding native binding...');
execSync('npx node-gyp rebuild', {
cwd: swiftDir,
console.log('[tree-sitter-swift] Building native binding...');
execSync('npx node-gyp rebuild', {
cwd: swiftDir,
stdio: 'pipe',
timeout: 120000,
});
console.log('[tree-sitter-swift] Native binding built successfully');
}
try {
// Case 1: package exists (npm kept it despite failed build)
if (fs.existsSync(bindingPath)) {
patchAndRebuild();
process.exit(0);
}
// Case 2: package was removed by npm after build failure — re-install without scripts
if (!fs.existsSync(swiftDir)) {
console.log('[tree-sitter-swift] Package missing, re-installing with --ignore-scripts...');
execSync('npm install tree-sitter-swift@0.6.0 --ignore-scripts --no-save', {
cwd: path.join(__dirname, '..'),
stdio: 'pipe',
timeout: 120000,
timeout: 60000,
});
console.log('[tree-sitter-swift] Native binding built successfully');
}
if (fs.existsSync(bindingPath)) {
patchAndRebuild();
} else {
console.warn('[tree-sitter-swift] Could not install package. Swift support will be disabled.');
}
} 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');
console.warn('[tree-sitter-swift] Swift files will be skipped during analysis.');
}
+1 -1
View File
@@ -22,7 +22,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
| `--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. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
**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
-163
View File
@@ -1,163 +0,0 @@
---
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
```
+24 -96
View File
@@ -28,110 +28,38 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
/**
* Generate the full GitNexus context content.
*
* Design principles (learned from real agent behavior and industry research):
* - Inline critical workflows — skills are skipped 56% of the time (Vercel eval data)
* - Use RFC 2119 language (MUST, NEVER, ALWAYS) — models follow imperative rules
* - Three-tier boundaries (Always/When/Never) — proven to change model behavior
* - Keep under 120 lines — adherence degrades past 150 lines
* - Exact tool commands with parameters — vague directives get ignored
* - Self-review checklist — forces model to verify its own work
*
* Design principles (learned from real agent behavior):
* - AGENTS.md is the ROUTER — it tells the agent WHICH skill to read
* - Skills contain the actual workflows — AGENTS.md does NOT duplicate them
* - Bold **IMPORTANT** block + "Skills — Read First" heading — agents skip soft suggestions
* - One-line quick start (read context resource) gives agents an entry point
* - Tools/Resources sections are labeled "Reference" — agents treat them as lookup, not workflow
*/
function generateGitNexusContent(projectName: string, stats: RepoStats): string {
return `${GITNEXUS_START_MARKER}
# GitNexus — Code Intelligence
# GitNexus MCP
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows).
> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
## Always Start Here
## Always Do
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**
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`gitnexus_impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`.
> If step 1 warns the index is stale, run \`npx gitnexus analyze\` in the terminal first.
## When Debugging
## Skills
1. \`gitnexus_query({query: "<error or symptom>"})\` — find execution flows related to the issue
2. \`gitnexus_context({name: "<suspect function>"})\` — see all callers, callees, and process participation
3. \`READ gitnexus://repo/${projectName}/process/{processName}\` — trace the full execution flow step by step
4. For regressions: \`gitnexus_detect_changes({scope: "compare", base_ref: "main"})\` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with \`dry_run: false\`.
- **Extracting/Splitting**: MUST run \`gitnexus_context({name: "target"})\` to see all incoming/outgoing refs, then \`gitnexus_impact({target: "target", direction: "upstream"})\` to find all external callers before moving code.
- After any refactor: run \`gitnexus_detect_changes({scope: "all"})\` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running \`gitnexus_impact\` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use \`gitnexus_rename\` which understands the call graph.
- NEVER commit changes without running \`gitnexus_detect_changes()\` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| \`query\` | Find code by concept | \`gitnexus_query({query: "auth validation"})\` |
| \`context\` | 360-degree view of one symbol | \`gitnexus_context({name: "validateUser"})\` |
| \`impact\` | Blast radius before editing | \`gitnexus_impact({target: "X", direction: "upstream"})\` |
| \`detect_changes\` | Pre-commit scope check | \`gitnexus_detect_changes({scope: "staged"})\` |
| \`rename\` | Safe multi-file rename | \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` |
| \`cypher\` | Custom graph queries | \`gitnexus_cypher({query: "MATCH ..."})\` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| \`gitnexus://repo/${projectName}/context\` | Codebase overview, check index freshness |
| \`gitnexus://repo/${projectName}/clusters\` | All functional areas |
| \`gitnexus://repo/${projectName}/processes\` | All execution flows |
| \`gitnexus://repo/${projectName}/process/{name}\` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. \`gitnexus_impact\` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. \`gitnexus_detect_changes()\` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
\`\`\`bash
npx gitnexus analyze
\`\`\`
If the index previously included embeddings, preserve them by adding \`--embeddings\`:
\`\`\`bash
npx gitnexus analyze --embeddings
\`\`\`
To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`stats.embeddings\` field shows the count (0 means no embeddings). **Running analyze without \`--embeddings\` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`.
## CLI
- Re-index: \`npx gitnexus analyze\`
- Check freshness: \`npx gitnexus status\`
- Generate docs: \`npx gitnexus wiki\`
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` |
| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` |
| Rename / extract / split / refactor | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` |
| Tools, resources, schema reference | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` |
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |
${GITNEXUS_END_MARKER}`;
}
@@ -172,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 && endIdx > startIdx) {
if (startIdx !== -1 && endIdx !== -1) {
// Replace existing section
const before = existingContent.substring(0, startIdx);
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
+14 -17
View File
@@ -10,15 +10,13 @@ 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';
// 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).
import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
// 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}`;
@@ -258,7 +256,6 @@ export const analyzeCommand = async (
if (!embeddingSkipped) {
updateBar(90, 'Loading embedding model...');
const t0Emb = Date.now();
const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js');
await runEmbeddingPipeline(
executeQuery,
executeWithReusedStatement,
@@ -276,13 +273,6 @@ export const analyzeCommand = async (
// ── Phase 5: Finalize (98–100%) ───────────────────────────────────
updateBar(98, 'Saving metadata...');
// Count embeddings in the index (cached + newly generated)
let embeddingCount = 0;
try {
const embResult = await executeQuery(`MATCH (e:CodeEmbedding) RETURN count(e) AS cnt`);
embeddingCount = embResult?.[0]?.cnt ?? 0;
} catch { /* table may not exist if embeddings never ran */ }
const meta = {
repoPath,
lastCommit: currentCommit,
@@ -293,13 +283,14 @@ export const analyzeCommand = async (
edges: stats.edges,
communities: pipelineResult.communityResult?.stats.totalCommunities,
processes: pipelineResult.processResult?.stats.totalProcesses,
embeddings: embeddingCount,
},
};
await saveMeta(storagePath, meta);
await registerRepo(repoPath, meta);
await addToGitignore(repoPath);
const hookResult = await registerClaudeHook();
const projectName = path.basename(repoPath);
let aggregatedClusterCount = 0;
if (pipelineResult.communityResult?.communities) {
@@ -348,6 +339,10 @@ export const analyzeCommand = async (
console.log(` Context: ${aiContext.files.join(', ')}`);
}
if (hookResult.registered) {
console.log(` Hooks: ${hookResult.message}`);
}
// Show a quiet summary if some edge types needed fallback insertion
if (kuzuWarnings.length > 0) {
const totalFallback = kuzuWarnings.reduce((sum, w) => {
@@ -365,8 +360,10 @@ export const analyzeCommand = async (
console.log('');
// KuzuDB's native module holds open handles that prevent Node from exiting.
// ONNX Runtime also registers native atexit hooks that segfault on some
// platforms (#38, #40). Force-exit to ensure clean termination.
process.exit(0);
// ONNX Runtime registers native atexit hooks that segfault during process
// shutdown on macOS (#38) and some Linux configs (#40). Force-exit to
// bypass them when embeddings were loaded.
if (!embeddingSkipped) {
process.exit(0);
}
};
+111
View File
@@ -0,0 +1,111 @@
/**
* 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' };
}
+7 -17
View File
@@ -36,7 +36,7 @@ export interface EvalServerOptions {
// Convert structured JSON results into compact, LLM-friendly text.
// Design: minimize tokens, maximize actionability.
export function formatQueryResult(result: any): string {
function formatQueryResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const lines: string[] = [];
@@ -77,7 +77,7 @@ export function formatQueryResult(result: any): string {
return lines.join('\n').trim();
}
export function formatContextResult(result: any): string {
function formatContextResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
if (result.status === 'ambiguous') {
@@ -141,7 +141,7 @@ export function formatContextResult(result: any): string {
return lines.join('\n').trim();
}
export function formatImpactResult(result: any): string {
function formatImpactResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const target = result.target;
@@ -181,7 +181,7 @@ export function formatImpactResult(result: any): string {
return lines.join('\n').trim();
}
export function formatCypherResult(result: any): string {
function formatCypherResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
if (Array.isArray(result)) {
@@ -202,7 +202,7 @@ export function formatCypherResult(result: any): string {
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
export function formatDetectChangesResult(result: any): string {
function formatDetectChangesResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const summary = result.summary || {};
@@ -238,7 +238,7 @@ export function formatDetectChangesResult(result: any): string {
return lines.join('\n').trim();
}
export function formatListReposResult(result: any): string {
function formatListReposResult(result: any): string {
if (!Array.isArray(result) || result.length === 0) {
return 'No indexed repositories.';
}
@@ -420,20 +420,10 @@ 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[] = [];
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('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
+45 -22
View File
@@ -1,61 +1,84 @@
#!/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.
// Raise Node heap limit for large repos (e.g. Linux kernel).
// Must run before any heavy allocation. If already set by the user, respect it.
if (!process.env.NODE_OPTIONS?.includes('--max-old-space-size')) {
const execArgv = process.execArgv.join(' ');
if (!execArgv.includes('--max-old-space-size')) {
// Re-spawn with a larger heap (8 GB)
const { execFileSync } = await import('node:child_process');
try {
execFileSync(process.execPath, ['--max-old-space-size=8192', ...process.argv.slice(1)], {
stdio: 'inherit',
env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim() },
});
process.exit(0);
} catch (e: any) {
// If the child exited with an error code, propagate it
process.exit(e.status ?? 1);
}
}
}
import { Command } from 'commander';
import { createRequire } from 'node:module';
import { createLazyAction } from './lazy-action.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
import { analyzeCommand } from './analyze.js';
import { serveCommand } from './serve.js';
import { listCommand } from './list.js';
import { statusCommand } from './status.js';
import { mcpCommand } from './mcp.js';
import { cleanCommand } from './clean.js';
import { setupCommand } from './setup.js';
import { augmentCommand } from './augment.js';
import { wikiCommand } from './wiki.js';
import { queryCommand, contextCommand, impactCommand, cypherCommand } from './tool.js';
import { evalServerCommand } from './eval-server.js';
const program = new Command();
program
.name('gitnexus')
.description('GitNexus local CLI and MCP server')
.version(pkg.version);
.version('1.2.0');
program
.command('setup')
.description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode')
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
.action(setupCommand);
program
.command('analyze [path]')
.description('Index a repository (full analysis)')
.option('-f, --force', 'Force full re-index even if up to date')
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
.action(analyzeCommand);
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(createLazyAction(() => import('./serve.js'), 'serveCommand'));
.action(serveCommand);
program
.command('mcp')
.description('Start MCP server (stdio) — serves all indexed repos')
.action(createLazyAction(() => import('./mcp.js'), 'mcpCommand'));
.action(mcpCommand);
program
.command('list')
.description('List all indexed repositories')
.action(createLazyAction(() => import('./list.js'), 'listCommand'));
.action(listCommand);
program
.command('status')
.description('Show index status for current repo')
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
.action(statusCommand);
program
.command('clean')
.description('Delete GitNexus index for current repo')
.option('-f, --force', 'Skip confirmation prompt')
.option('--all', 'Clean all indexed repos')
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
.action(cleanCommand);
program
.command('wiki [path]')
@@ -66,12 +89,12 @@ program
.option('--api-key <key>', 'LLM API key (saved to ~/.gitnexus/config.json)')
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
.action(createLazyAction(() => import('./wiki.js'), 'wikiCommand'));
.action(wikiCommand);
program
.command('augment <pattern>')
.description('Augment a search pattern with knowledge graph context (used by hooks)')
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
.action(augmentCommand);
// ─── Direct Tool Commands (no MCP overhead) ────────────────────────
// These invoke LocalBackend directly for use in eval, scripts, and CI.
@@ -84,7 +107,7 @@ program
.option('-g, --goal <text>', 'What you want to find')
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
.option('--content', 'Include full symbol source code')
.action(createLazyAction(() => import('./tool.js'), 'queryCommand'));
.action(queryCommand);
program
.command('context [name]')
@@ -93,7 +116,7 @@ program
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
.option('-f, --file <path>', 'File path to disambiguate common names')
.option('--content', 'Include full symbol source code')
.action(createLazyAction(() => import('./tool.js'), 'contextCommand'));
.action(contextCommand);
program
.command('impact <target>')
@@ -102,13 +125,13 @@ program
.option('-r, --repo <name>', 'Target repository')
.option('--depth <n>', 'Max relationship depth (default: 3)')
.option('--include-tests', 'Include test files in results')
.action(createLazyAction(() => import('./tool.js'), 'impactCommand'));
.action(impactCommand);
program
.command('cypher <query>')
.description('Execute raw Cypher query against the knowledge graph')
.option('-r, --repo <name>', 'Target repository')
.action(createLazyAction(() => import('./tool.js'), 'cypherCommand'));
.action(cypherCommand);
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
@@ -117,6 +140,6 @@ program
.description('Start lightweight HTTP server for fast tool calls during evaluation')
.option('-p, --port <port>', 'Port number', '4848')
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
.action(evalServerCommand);
program.parse(process.argv);
-26
View File
@@ -1,26 +0,0 @@
/**
* Creates a lazy-loaded CLI action that defers module import until invocation.
* The generic constraints ensure the export name is a valid key of the module
* at compile time — catching typos when used with concrete module imports.
*/
function isCallable(value: unknown): value is (...args: unknown[]) => unknown {
return typeof value === 'function';
}
export function createLazyAction<
TModule extends Record<string, unknown>,
TKey extends string & keyof TModule,
>(
loader: () => Promise<TModule>,
exportName: TKey,
): (...args: unknown[]) => Promise<void> {
return async (...args: unknown[]): Promise<void> => {
const module = await loader();
const action = module[exportName];
if (!isCallable(action)) {
throw new Error(`Lazy action export not found: ${exportName}`);
}
await action(...args);
};
}
+26 -13
View File
@@ -8,33 +8,46 @@
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}`);
});
// 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();
await backend.init();
// Load all registered repos
const entries = await listRegisteredRepos({ validate: true });
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(', ')}`);
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);
}
// Start MCP server (serves all repos, discovers new ones lazily)
// Initialize multi-repo backend from registry
const backend = new LocalBackend();
const ok = await backend.init();
if (!ok) {
console.error('GitNexus: Failed to initialize backend from registry.');
process.exit(1);
}
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)
await startMCPServer(backend);
};
+18 -34
View File
@@ -163,23 +163,13 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const src = path.join(pluginHooksPath, 'gitnexus-hook.cjs');
const dest = path.join(destHooksDir, 'gitnexus-hook.cjs');
try {
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, '/');
const jsonCli = JSON.stringify(normalizedCli);
content = content.replace(
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
`let cliPath = ${jsonCli};`
);
const content = await fs.readFile(src, 'utf-8');
await fs.writeFile(dest, content, 'utf-8');
} catch {
// Script not found in source — skip
}
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`;
const hookCmd = `node "${path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/')}"`;
// Merge hook config into ~/.claude/settings.json
const existing = await readJsonFile(settingsPath) || {};
@@ -188,31 +178,25 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
// NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
// Session context is delivered via CLAUDE.md / skills instead.
// Helper: add a hook entry if one with 'gitnexus-hook' isn't already registered
interface HookEntry { hooks?: Array<{ command?: string }> }
function ensureHookEntry(
eventName: string,
matcher: string,
timeout: number,
statusMessage: string,
) {
if (!existing.hooks[eventName]) existing.hooks[eventName] = [];
const hasHook = existing.hooks[eventName].some(
(h: HookEntry) => h.hooks?.some(hh => hh.command?.includes('gitnexus-hook'))
);
if (!hasHook) {
existing.hooks[eventName].push({
matcher,
hooks: [{ type: 'command', command: hookCmd, timeout, statusMessage }],
});
}
// Add PreToolUse hook if not already present
if (!existing.hooks.PreToolUse) existing.hooks.PreToolUse = [];
const hasPreToolHook = existing.hooks.PreToolUse.some(
(h: any) => h.hooks?.some((hh: any) => hh.command?.includes('gitnexus'))
);
if (!hasPreToolHook) {
existing.hooks.PreToolUse.push({
matcher: 'Grep|Glob|Bash',
hooks: [{
type: 'command',
command: hookCmd,
timeout: 8000,
statusMessage: 'Enriching with GitNexus graph context...',
}],
});
}
ensureHookEntry('PreToolUse', 'Grep|Glob|Bash', 10, 'Enriching with GitNexus graph context...');
ensureHookEntry('PostToolUse', 'Bash', 10, 'Checking GitNexus index freshness...');
await writeJsonFile(settingsPath, existing);
result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)');
result.configured.push('Claude Code hooks (PreToolUse)');
} catch (err: any) {
result.errors.push(`Claude Code hooks: ${err.message}`);
}
+5 -6
View File
@@ -7,7 +7,7 @@
import path from 'path';
import readline from 'readline';
import { execSync, execFileSync } from 'child_process';
import { execSync } 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,11 +343,10 @@ function hasGhCLI(): boolean {
function publishGist(htmlPath: string): { url: string; rawUrl: string } | null {
try {
const output = execFileSync('gh', [
'gist', 'create', htmlPath,
'--desc', 'Repository Wiki — generated by GitNexus',
'--public',
], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
const output = execSync(
`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,7 +9,6 @@ export enum SupportedLanguages {
Go = 'go',
Rust = 'rust',
PHP = 'php',
Kotlin = 'kotlin',
// Ruby = 'ruby',
Swift = 'swift',
}
+1 -4
View File
@@ -42,9 +42,6 @@ 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,
@@ -116,4 +113,4 @@ export interface KnowledgeGraph {
addRelationship: (relationship: GraphRelationship) => void,
removeNode: (nodeId: string) => boolean,
removeNodesByFile: (filePath: string) => number,
}
}
+2 -3
View File
@@ -10,11 +10,10 @@ export interface ASTCache {
}
export const createASTCache = (maxSize: number = 50): ASTCache => {
const effectiveMax = Math.max(maxSize, 1);
// Initialize the cache with a 'dispose' handler
// This is the magic: When an item is evicted (dropped), this runs automatically.
const cache = new LRUCache<string, Parser.Tree>({
max: effectiveMax,
max: maxSize,
dispose: (tree) => {
try {
// NOTE: web-tree-sitter has tree.delete(); native tree-sitter trees are GC-managed.
@@ -42,7 +41,7 @@ export const createASTCache = (maxSize: number = 50): ASTCache => {
stats: () => ({
size: cache.size,
maxSize: effectiveMax
maxSize: maxSize
})
};
};
+4 -129
View File
@@ -7,7 +7,7 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
import type { ExtractedCall, ExtractedRoute } from './workers/parse-worker.js';
import type { ExtractedCall } from './workers/parse-worker.js';
/**
* Node types that represent function/method definitions across languages.
@@ -37,10 +37,6 @@ 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',
@@ -75,43 +71,10 @@ const findEnclosingFunction = (
current.type === 'async_function_declaration' ||
current.type === 'generator_function_declaration' ||
current.type === 'function_item') { // Rust function
// Try direct name field (JS/TS/Python/Rust)
// Named function: function foo() {}
const nameNode = current.childForFieldName?.('name') ||
current.children?.find((c: any) => c.type === 'identifier' || c.type === 'property_identifier');
if (nameNode) {
funcName = nameNode.text;
// C++ template functions: function_definition inside template_declaration
// are registered as 'Template' nodes (not 'Function'), so match that label.
if (current.type === 'function_definition' && current.parent?.type === 'template_declaration') {
label = 'Template';
}
} else {
// C/C++: name nested inside declarator -> function_declarator -> identifier
const declarator = current.childForFieldName?.('declarator');
if (declarator) {
const innerDecl = declarator.childForFieldName?.('declarator');
if (innerDecl?.type === 'identifier') {
funcName = innerDecl.text;
// Template function with declarator-style name
if (current.parent?.type === 'template_declaration') {
label = 'Template';
}
} else if (innerDecl?.type === 'qualified_identifier') {
const nameIdent = innerDecl.childForFieldName?.('name') ||
innerDecl.children?.find((c: any) => c.type === 'identifier');
funcName = nameIdent?.text;
label = 'Method'; // qualified_identifier => registered as Method
} else if (innerDecl?.type === 'field_identifier') {
// C++ inline method with body inside class: void myMethod() { ... }
funcName = innerDecl.text;
label = 'Method';
} else if (innerDecl?.type === 'operator_name') {
// C++ operator overload inside class body: operator[]
funcName = innerDecl.text;
label = 'Method';
}
}
}
funcName = nameNode?.text;
} else if (current.type === 'impl_item') {
// Rust method inside impl block: wrapper around function_item or const_item
// We need to look inside for the function_item
@@ -358,26 +321,9 @@ const BUILT_IN_NAMES = new Set([
'hasOwnProperty', 'toString', 'valueOf',
// Python built-ins
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
'append', 'extend', 'update',
// NOTE: 'open', 'read', 'write', 'close' removed — these are real C POSIX syscalls
'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',
@@ -490,74 +436,3 @@ export const processCallsFromExtracted = async (
onProgress?.(totalFiles, totalFiles);
};
/**
* Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods.
*/
export const processRoutesFromExtracted = async (
graph: KnowledgeGraph,
extractedRoutes: ExtractedRoute[],
symbolTable: SymbolTable,
importMap: ImportMap,
onProgress?: (current: number, total: number) => void
) => {
for (let i = 0; i < extractedRoutes.length; i++) {
const route = extractedRoutes[i];
if (i % 50 === 0) {
onProgress?.(i, extractedRoutes.length);
await yieldToEventLoop();
}
if (!route.controllerName || !route.methodName) continue;
// Resolve controller class in symbol table
const controllerDefs = symbolTable.lookupFuzzy(route.controllerName);
if (controllerDefs.length === 0) continue;
// Prefer import-resolved match
const importedFiles = importMap.get(route.filePath);
let controllerDef = controllerDefs[0];
let confidence = controllerDefs.length === 1 ? 0.7 : 0.5;
if (importedFiles) {
for (const def of controllerDefs) {
if (importedFiles.has(def.filePath)) {
controllerDef = def;
confidence = 0.9;
break;
}
}
}
// Find the method on the controller
const methodId = symbolTable.lookupExact(controllerDef.filePath, route.methodName);
const sourceId = generateId('File', route.filePath);
if (!methodId) {
// Construct method ID manually
const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`);
const relId = generateId('CALLS', `${sourceId}:route->${guessedId}`);
graph.addRelationship({
id: relId,
sourceId,
targetId: guessedId,
type: 'CALLS',
confidence: confidence * 0.8,
reason: 'laravel-route',
});
continue;
}
const relId = generateId('CALLS', `${sourceId}:route->${methodId}`);
graph.addRelationship({
id: relId,
sourceId,
targetId: methodId,
type: 'CALLS',
confidence,
reason: 'laravel-route',
});
}
onProgress?.(extractedRoutes.length, extractedRoutes.length);
};
@@ -86,60 +86,21 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^spawn/, // Async spawn
],
// C - explicit main() boost plus common C entry point conventions
// C - explicit main() boost (critical for C programs)
'c': [
/^main$/, // THE entry point
/^init_/, // init_server, init_client
/_init$/, // module_init, server_init
/^start_/, // start_server
/_start$/, // thread_start
/^run_/, // run_loop
/_run$/, // event_run
/^stop_/, // stop_server
/_stop$/, // service_stop
/^open_/, // open_connection
/_open$/, // file_open
/^close_/, // close_connection
/_close$/, // socket_close
/^create_/, // create_session
/_create$/, // object_create
/^destroy_/, // destroy_session
/_destroy$/, // object_destroy
/^handle_/, // handle_request
/_handler$/, // signal_handler
/_callback$/, // event_callback
/^cmd_/, // tmux: cmd_new_window, cmd_attach_session
/^server_/, // server_start, server_loop
/^client_/, // client_connect
/^session_/, // session_create
/^window_/, // window_resize (tmux)
/^key_/, // key_press
/^input_/, // input_parse
/^output_/, // output_write
/^notify_/, // notify_client
/^control_/, // control_start
/^init_/, // Initialization functions
/^start_/, // Start functions
/^run_/, // Run functions
],
// C++ - same as C plus OOP/template patterns
// C++ - same as C plus class patterns
'cpp': [
/^main$/, // THE entry point
/^init_/,
/_init$/,
/^Create[A-Z]/, // Factory patterns
/^create_/,
/^Run$/, // Run methods
/^run$/,
/^Start$/, // Start methods
/^start$/,
/^handle_/,
/_handler$/,
/_callback$/,
/^OnEvent/, // Event callbacks
/^on_/,
/::Run$/, // Class::Run
/::Start$/, // Class::Start
/::Init$/, // Class::Init
/::Execute$/, // Class::Execute
],
// Swift / iOS
@@ -1,10 +1,8 @@
/**
* Framework Detection
*
* Detects frameworks from:
* 1) file path patterns
* 2) AST definition text (decorators/annotations/attributes)
* and provides entry point multipliers for process scoring.
* Detects frameworks from file path patterns and provides entry point multipliers.
* This enables framework-aware entry point scoring.
*
* DESIGN: Returns null for unknown frameworks, which causes a 1.0 multiplier
* (no bonus, no penalty) - same behavior as before this feature.
@@ -129,49 +127,6 @@ 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
@@ -364,12 +319,12 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
}
// ============================================================================
// AST-BASED FRAMEWORK DETECTION
// FUTURE: AST-BASED PATTERNS (for Phase 3)
// ============================================================================
/**
* Patterns that indicate framework entry points within code definitions.
* These are matched against AST node text (class/method/function declaration text).
* Patterns that indicate entry points within code (for future AST-based detection)
* These would require parsing decorators/annotations in the code itself.
*/
export const FRAMEWORK_AST_PATTERNS = {
// JavaScript/TypeScript decorators
@@ -404,79 +359,3 @@ export const FRAMEWORK_AST_PATTERNS = {
'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;
}
+40 -92
View File
@@ -202,8 +202,6 @@ const EXTENSIONS = [
'.py', '/__init__.py',
// Java
'.java',
// Kotlin
'.kt', '.kts',
// C/C++
'.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh',
// C#
@@ -533,42 +531,26 @@ 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;
};
// ============================================================================
// JVM MULTI-FILE RESOLUTION (Java + Kotlin)
// JAVA MULTI-FILE RESOLUTION
// ============================================================================
/** Kotlin file extensions for JVM resolver reuse */
const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts'];
/**
* Resolve a JVM wildcard import (com.example.*) to all matching files.
* Works for both Java (.java) and Kotlin (.kt, .kts).
* Resolve a Java wildcard import (com.example.*) to all matching .java files.
* Returns an array of file paths.
*/
function resolveJvmWildcard(
function resolveJavaWildcard(
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) {
const candidates = extensions.flatMap(ext => index.getFilesInDir(packagePath, ext));
// Use directory index: get all .java files in this package directory
const candidates = index.getFilesInDir(packagePath, '.java');
// Filter to only direct children (no subdirectories)
const packageSuffix = '/' + packagePath + '/';
return candidates.filter(f => {
@@ -585,8 +567,7 @@ function resolveJvmWildcard(
const matches: string[] = [];
for (let i = 0; i < normalizedFileList.length; i++) {
const normalized = normalizedFileList[i];
if (normalized.includes(packageSuffix) &&
extensions.some(ext => normalized.endsWith(ext))) {
if (normalized.includes(packageSuffix) && normalized.endsWith('.java')) {
const afterPackage = normalized.substring(normalized.indexOf(packageSuffix) + packageSuffix.length);
if (!afterPackage.includes('/')) {
matches.push(allFileList[i]);
@@ -597,39 +578,36 @@ function resolveJvmWildcard(
}
/**
* 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"
* Try to resolve a Java static import by stripping the member name.
* "com.example.Constants.VALUE" -> resolve "com.example.Constants"
*/
function resolveJvmMemberImport(
function resolveJavaStaticImport(
importPath: string,
normalizedFileList: string[],
allFileList: string[],
extensions: readonly string[],
index?: SuffixIndex,
): string | null {
// 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
// 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
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';
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];
}
}
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];
}
}
}
@@ -839,42 +817,26 @@ export const processImports = async (
}
// Clean path (remove quotes and angle brackets for C/C++ includes)
const rawImportPath = language === SupportedLanguages.Kotlin
? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import'])
: sourceNode.text.replace(/['"<>]/g, '');
const rawImportPath = sourceNode.text.replace(/['"<>]/g, '');
totalImportsFound++;
// ---- JVM languages (Java + Kotlin): handle wildcards and member imports ----
if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) {
const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS;
// ---- Java: handle wildcards and static imports specially ----
if (language === SupportedLanguages.Java) {
if (rawImportPath.endsWith('.*')) {
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;
}
const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList, index);
for (const matchedFile of matchedFiles) {
addImportEdge(file.path, matchedFile);
}
return; // skip single-file resolution
}
// 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);
// Try static import resolution (strip member name)
const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList, index);
if (staticResolved) {
addImportEdge(file.path, staticResolved);
return;
}
// Fall through to normal resolution for regular imports
// Fall through to normal resolution for regular Java imports
}
// ---- Go: handle package-level imports ----
@@ -1038,34 +1000,20 @@ export const processImportsFromExtracted = async (
continue;
}
// JVM languages (Java + Kotlin): handle wildcards and member imports
if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) {
const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS;
// Java: handle wildcards and static imports
if (language === SupportedLanguages.Java) {
if (rawImportPath.endsWith('.*')) {
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;
}
const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList, index);
for (const matchedFile of matchedFiles) {
addImportEdge(filePath, matchedFile);
}
continue;
}
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);
const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList, index);
if (staticResolved) {
resolveCache.set(cacheKey, staticResolved);
addImportEdge(filePath, staticResolved);
continue;
}
}
+22 -136
View File
@@ -5,10 +5,9 @@ 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 { findSiblingChild, getLanguageFromFilename, yieldToEventLoop } from './utils.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
import { WorkerPool } from './workers/worker-pool.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage } from './workers/parse-worker.js';
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
@@ -16,41 +15,8 @@ export interface WorkerExtractedData {
imports: ExtractedImport[];
calls: ExtractedCall[];
heritage: ExtractedHeritage[];
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
// ============================================================================
@@ -64,7 +30,7 @@ const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | n
* @param language - The programming language
* @returns true if the symbol is exported/public
*/
export const isNodeExported = (node: any, name: string, language: string): boolean => {
const isNodeExported = (node: any, name: string, language: string): boolean => {
let current = node;
switch (language) {
@@ -133,64 +99,20 @@ export const isNodeExported = (node: any, name: string, language: string): boole
return first === first.toUpperCase() && first !== first.toLowerCase();
// Rust: Check for 'pub' visibility modifier
// In Rust AST, `visibility_modifier` is a SIBLING of the name node within the
// declaration node (function_item, struct_item, etc.), not a parent of it.
// Fix: walk up to the declaration node, then scan its direct children.
case 'rust': {
const RUST_DECL_TYPES = new Set([
'function_item', 'struct_item', 'enum_item', 'trait_item', 'impl_item',
'type_item', 'const_item', 'static_item', 'mod_item', 'use_declaration',
'associated_type', 'function_signature_item',
]);
case 'rust':
while (current) {
if (RUST_DECL_TYPES.has(current.type)) {
for (let i = 0; i < current.childCount; i++) {
const child = current.child(i);
if (child?.type === 'visibility_modifier' && child.text?.startsWith('pub')) return true;
}
return false;
if (current.type === 'visibility_modifier') {
if (current.text?.includes('pub')) return true;
}
current = current.parent;
}
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++: Functions without 'static' storage class have external linkage
// by default, making them globally accessible (equivalent to exported).
// Only functions explicitly marked 'static' are file-scoped (not exported).
// C/C++: No native export concept at language level
// Entry points will be detected via name patterns (main, etc.)
case 'c':
case 'cpp': {
// Walk up to the function_definition/declaration and check for 'static'
let cur = node;
while (cur) {
if (cur.type === 'function_definition' || cur.type === 'declaration') {
// Check text before the opening brace (or semicolon) for 'static'
const declText: string = (cur.text || '').split('{')[0].split(';')[0];
// 'static' as a storage class (not 'static_assert' etc.)
if (/\bstatic\b/.test(declText)) return false;
return true; // No 'static' = external linkage = exported
}
cur = cur.parent;
}
return true; // Top-level C/C++ functions default to external linkage
}
case 'cpp':
return false;
// Swift: Check for 'public' or 'open' access modifiers
case 'swift':
@@ -203,22 +125,6 @@ export const isNodeExported = (node: any, name: string, language: string): boole
}
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;
}
@@ -243,7 +149,7 @@ const processParsingWithWorkers = async (
if (lang) parseableFiles.push({ path: file.path, content: file.content });
}
if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [], routes: [] };
if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [] };
const total = files.length;
@@ -259,7 +165,6 @@ const processParsingWithWorkers = async (
const allImports: ExtractedImport[] = [];
const allCalls: ExtractedCall[] = [];
const allHeritage: ExtractedHeritage[] = [];
const allRoutes: ExtractedRoute[] = [];
for (const result of chunkResults) {
for (const node of result.nodes) {
graph.addNode({
@@ -280,12 +185,11 @@ const processParsingWithWorkers = async (
allImports.push(...result.imports);
allCalls.push(...result.calls);
allHeritage.push(...result.heritage);
allRoutes.push(...result.routes);
}
// Final progress
onFileProgress?.(total, total, 'done');
return { imports: allImports, calls: allCalls, heritage: allHeritage, routes: allRoutes };
return { imports: allImports, calls: allCalls, heritage: allHeritage };
};
// ============================================================================
@@ -316,18 +220,11 @@ const processParsingSequential = async (
// Skip very large files — they can crash tree-sitter or cause OOM
if (file.content.length > 512 * 1024) continue;
try {
await loadLanguage(language, file.path);
} catch {
continue; // parser unavailable — already warned in pipeline
}
await loadLanguage(language, file.path);
let tree;
try {
// bufferSize must be >= file size. Use 2× file size, minimum 512KB, maximum 32MB.
const fileSizeBytes = Buffer.byteLength(file.content, 'utf8');
const bufSize = Math.min(Math.max(fileSizeBytes * 2, 512 * 1024), 32 * 1024 * 1024);
tree = parser.parse(file.content, undefined, { bufferSize: bufSize });
tree = parser.parse(file.content, undefined, { bufferSize: 1024 * 256 });
} catch (parseError) {
console.warn(`Skipping unparseable file: ${file.path}`);
continue;
@@ -367,9 +264,9 @@ const processParsingSequential = async (
}
const nameNode = captureMap['name'];
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && !captureMap['definition.constructor']) return;
const nodeName = nameNode ? nameNode.text : 'init';
if (!nameNode) return;
const nodeName = nameNode.text;
let nodeLabel = 'CodeElement';
@@ -396,14 +293,7 @@ const processParsingSequential = async (
else if (captureMap['definition.constructor']) nodeLabel = 'Constructor';
else if (captureMap['definition.template']) nodeLabel = 'Template';
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 nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
const node: GraphNode = {
id: nodeId,
@@ -411,15 +301,11 @@ const processParsingSequential = async (
properties: {
name: nodeName,
filePath: file.path,
startLine: definitionNodeForRange ? definitionNodeForRange.startPosition.row : startLine,
endLine: definitionNodeForRange ? definitionNodeForRange.endPosition.row : startLine,
startLine: nameNode.startPosition.row,
endLine: nameNode.endPosition.row,
language: language,
isExported: isNodeExported(nameNode || definitionNodeForRange, nodeName, language),
...(frameworkHint ? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
astFrameworkReason: frameworkHint.reason,
} : {}),
},
isExported: isNodeExported(nameNode, nodeName, language),
}
};
graph.addNode(node);
+4 -46
View File
@@ -2,7 +2,7 @@ import { createKnowledgeGraph } from '../graph/graph.js';
import { processStructure } from './structure-processor.js';
import { processParsing } from './parsing-processor.js';
import { processImports, processImportsFromExtracted, createImportMap, buildImportResolutionContext } from './import-processor.js';
import { processCalls, processCallsFromExtracted, processRoutesFromExtracted } from './call-processor.js';
import { processCalls, processCallsFromExtracted } from './call-processor.js';
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
import { processCommunities } from './community-processor.js';
import { processProcesses } from './process-processor.js';
@@ -11,11 +11,7 @@ import { createASTCache } from './ast-cache.js';
import { PipelineProgress, PipelineResult } from '../../types/pipeline.js';
import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
import { getLanguageFromFilename } from './utils.js';
import { isLanguageAvailable } from '../tree-sitter/parser-loader.js';
import { createWorkerPool, WorkerPool } from './workers/worker-pool.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const isDev = process.env.NODE_ENV === 'development';
@@ -92,34 +88,9 @@ export const runPipelineFromRepo = async (
// 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 => {
const lang = getLanguageFromFilename(f.path);
return lang && isLanguageAvailable(lang);
});
// Warn about files skipped due to unavailable parsers
const skippedByLang = new Map<string, number>();
for (const f of scannedFiles) {
const lang = getLanguageFromFilename(f.path);
if (lang && !isLanguageAvailable(lang)) {
skippedByLang.set(lang, (skippedByLang.get(lang) || 0) + 1);
}
}
for (const [lang, count] of skippedByLang) {
console.warn(`Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild tree-sitter-${lang}`);
}
const parseableScanned = scannedFiles.filter(f => getLanguageFromFilename(f.path));
const totalParseable = parseableScanned.length;
if (totalParseable === 0) {
onProgress({
phase: 'parsing',
percent: 82,
message: 'No parseable files found — skipping parsing phase',
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: graph.nodeCount },
});
}
// Build byte-budget chunks
const chunks: string[][] = [];
let currentChunk: string[] = [];
@@ -152,19 +123,10 @@ export const runPipelineFromRepo = async (
// Create worker pool once, reuse across chunks
let workerPool: WorkerPool | undefined;
try {
let workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
// When running under vitest, import.meta.url points to src/ where no .js exists.
// Fall back to the compiled dist/ worker so the pool can spawn real worker threads.
const thisDir = fileURLToPath(new URL('.', import.meta.url));
if (!fs.existsSync(fileURLToPath(workerUrl))) {
const distWorker = path.resolve(thisDir, '..', '..', '..', 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
if (fs.existsSync(distWorker)) {
workerUrl = pathToFileURL(distWorker) as URL;
}
}
const workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
workerPool = createWorkerPool(workerUrl);
} catch (err) {
if (isDev) console.warn('Worker pool creation failed, using sequential fallback:', (err as Error).message);
// Worker pool creation failed — sequential fallback
}
let filesParsedSoFar = 0;
@@ -222,10 +184,6 @@ export const runPipelineFromRepo = async (
if (chunkWorkerData.heritage.length > 0) {
await processHeritageFromExtracted(graph, chunkWorkerData.heritage, symbolTable);
}
// Routes — resolve immediately (Laravel route→controller CALLS edges)
if (chunkWorkerData.routes && chunkWorkerData.routes.length > 0) {
await processRoutesFromExtracted(graph, chunkWorkerData.routes, symbolTable, importMap);
}
} else {
await processImports(graph, chunkFiles, astCache, importMap, undefined, repoPath, allPaths);
sequentialChunkPaths.push(chunkPaths);
@@ -285,7 +285,7 @@ const findEntryPoints = (
if (callees.length === 0) continue;
// Calculate entry point score using new scoring system
const { score: baseScore, reasons } = calculateEntryPointScore(
const { score, reasons } = calculateEntryPointScore(
node.properties.name,
node.properties.language || 'javascript',
node.properties.isExported ?? false,
@@ -294,13 +294,6 @@ const findEntryPoints = (
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 });
}
@@ -344,7 +337,8 @@ 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()!;
@@ -228,30 +228,9 @@ export const CPP_QUERIES = `
(namespace_definition name: (namespace_identifier) @name) @definition.namespace
(enum_specifier name: (type_identifier) @name) @definition.enum
; Typedefs and unions (common in C-style headers and mixed C/C++ code)
(type_definition declarator: (type_identifier) @name) @definition.typedef
(union_specifier name: (type_identifier) @name) @definition.union
; Macros
(preproc_function_def name: (identifier) @name) @definition.macro
(preproc_def name: (identifier) @name) @definition.macro
; Functions & Methods
(function_definition declarator: (function_declarator declarator: (identifier) @name)) @definition.function
(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (identifier) @name))) @definition.method
; Function declarations / prototypes (common in headers)
(declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.function
; Inline class method declarations (inside class body, no body: void Foo();)
(field_declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.method
; Inline class method definitions (inside class body, with body: void Foo() { ... })
; The function_definition is a direct child of field_declaration_list, not wrapped in field_declaration.
; Name uses field_identifier (regular methods) or identifier (constructors) or operator_name (operators).
(field_declaration_list
(function_definition
declarator: (function_declarator
declarator: [(field_identifier) (identifier) (operator_name)] @name))) @definition.method
; Templates
(template_declaration (class_specifier name: (type_identifier) @name)) @definition.template
@@ -303,9 +282,9 @@ export const CSHARP_QUERIES = `
; Heritage
(class_declaration name: (identifier) @heritage.class
(base_list (identifier) @heritage.extends)) @heritage
(base_list (simple_base_type (identifier) @heritage.extends))) @heritage
(class_declaration name: (identifier) @heritage.class
(base_list (generic_name (identifier) @heritage.extends))) @heritage
(base_list (simple_base_type (generic_name (identifier) @heritage.extends)))) @heritage
`;
// Rust queries - works with tree-sitter-rust
@@ -417,86 +396,6 @@ 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
@@ -561,7 +460,6 @@ 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,
};
+4 -25
View File
@@ -6,23 +6,6 @@ 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
*/
@@ -37,21 +20,17 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
if (filename.endsWith('.py')) return SupportedLanguages.Python;
// Java
if (filename.endsWith('.java')) return SupportedLanguages.Java;
// C source files
if (filename.endsWith('.c')) return SupportedLanguages.C;
// C++ (all common extensions, including .h)
// .h is parsed as C++ because tree-sitter-cpp is a strict superset of C, so pure-C
// headers parse correctly, and C++ headers (classes, templates) are handled properly.
// C (source and headers)
if (filename.endsWith('.c') || filename.endsWith('.h')) return SupportedLanguages.C;
// C++ (all common extensions)
if (filename.endsWith('.cpp') || filename.endsWith('.cc') || filename.endsWith('.cxx') ||
filename.endsWith('.h') || filename.endsWith('.hpp') || filename.endsWith('.hxx') || filename.endsWith('.hh')) return SupportedLanguages.CPlusPlus;
filename.endsWith('.hpp') || filename.endsWith('.hxx') || filename.endsWith('.hh')) return SupportedLanguages.CPlusPlus;
// C#
if (filename.endsWith('.cs')) return SupportedLanguages.CSharp;
// Go
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') ||
@@ -9,18 +9,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';
import { LANGUAGE_QUERIES } from '../tree-sitter-queries.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 { LANGUAGE_QUERIES } from '../tree-sitter-queries.js';
import { getLanguageFromFilename } from '../utils.js';
import { generateId } from '../../../lib/utils.js';
// ============================================================================
@@ -37,8 +35,6 @@ interface ParsedNode {
endLine: number;
language: string;
isExported: boolean;
astFrameworkMultiplier?: number;
astFrameworkReason?: string;
description?: string;
};
}
@@ -80,17 +76,6 @@ export interface ExtractedHeritage {
kind: string;
}
export interface ExtractedRoute {
filePath: string;
httpMethod: string;
routePath: string | null;
controllerName: string | null;
methodName: string | null;
middleware: string[];
prefix: string | null;
lineNumber: number;
}
export interface ParseWorkerResult {
nodes: ParsedNode[];
relationships: ParsedRelationship[];
@@ -98,7 +83,6 @@ export interface ParseWorkerResult {
imports: ExtractedImport[];
calls: ExtractedCall[];
heritage: ExtractedHeritage[];
routes: ExtractedRoute[];
fileCount: number;
}
@@ -124,7 +108,6 @@ 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 } : {}),
};
@@ -185,92 +168,42 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
}
return false;
case 'csharp': {
// In C# AST, `modifier` nodes are SIBLINGS of the name node inside the
// declaration (e.g. method_declaration, class_declaration). Walking up
// from the name node reaches the declaration, then we check its children.
const CSHARP_DECL_TYPES = new Set([
'method_declaration', 'local_function_statement', 'constructor_declaration',
'class_declaration', 'interface_declaration', 'struct_declaration',
'enum_declaration', 'record_declaration', 'delegate_declaration',
'property_declaration', 'field_declaration', 'event_declaration',
'namespace_declaration',
]);
case 'csharp':
while (current) {
if (CSHARP_DECL_TYPES.has(current.type)) {
// Check siblings: any child of the declaration that is a modifier with text 'public'
for (let i = 0; i < current.childCount; i++) {
const child = current.child(i);
if (child?.type === 'modifier' && child.text === 'public') return true;
}
return false;
if (current.type === 'modifier' || current.type === 'modifiers') {
if (current.text?.includes('public')) return true;
}
current = current.parent;
}
return false;
}
case 'go':
if (name.length === 0) return false;
const first = name[0];
return first === first.toUpperCase() && first !== first.toLowerCase();
case 'rust': {
// In Rust AST, `visibility_modifier` is a SIBLING of the name node (identifier/type_identifier)
// within the declaration node (function_item, struct_item, impl_item, etc.).
// Walking up parents from the name node will never hit `visibility_modifier` directly.
// Fix: walk up to the declaration node, then check its children for visibility_modifier.
const RUST_DECL_TYPES = new Set([
'function_item', 'struct_item', 'enum_item', 'trait_item', 'impl_item',
'type_item', 'const_item', 'static_item', 'mod_item', 'use_declaration',
'associated_type', 'function_signature_item',
]);
case 'rust':
while (current) {
if (RUST_DECL_TYPES.has(current.type)) {
for (let i = 0; i < current.childCount; i++) {
const child = current.child(i);
if (child?.type === 'visibility_modifier' && child.text?.startsWith('pub')) return true;
}
return false;
if (current.type === 'visibility_modifier') {
if (current.text?.includes('pub')) return true;
}
current = current.parent;
}
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':
case 'c':
case 'cpp':
return false;
case 'swift':
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;
}
if (current.type === 'modifiers' || current.type === 'visibility_modifier') {
const text = current.text || '';
if (text.includes('public') || text.includes('open')) return true;
}
current = current.parent;
}
// No visibility modifier = public (Kotlin default)
return true;
// C/C++: Functions without 'static' storage class have external linkage
// by default, making them globally accessible (equivalent to exported).
// Only functions explicitly marked 'static' are file-scoped (not exported).
case 'c':
case 'cpp': {
let cur = node;
while (cur) {
if (cur.type === 'function_definition' || cur.type === 'declaration') {
const declText: string = (cur.text || '').split('{')[0].split(';')[0];
if (/\bstatic\b/.test(declText)) return false;
return true;
}
cur = cur.parent;
}
return true;
}
return false;
case 'php':
// Top-level classes/interfaces/traits are always accessible
@@ -290,16 +223,6 @@ 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;
}
@@ -315,12 +238,8 @@ const FUNCTION_NODE_TYPES = new Set([
'function_definition', 'async_function_declaration', 'async_arrow_function',
'method_declaration', 'constructor_declaration',
'local_function_statement', 'function_item', 'impl_item',
// Kotlin
'lambda_literal',
// PHP
'anonymous_function',
// Swift initializers/deinitializers
'init_declaration', 'deinit_declaration',
'anonymous_function_creation_expression', // PHP anonymous functions
'init_declaration', 'deinit_declaration', // Swift initializers/deinitializers
]);
/** Walk up AST to find enclosing function, return its generateId or null for top-level */
@@ -334,52 +253,14 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
if (current.type === 'init_declaration' || current.type === 'deinit_declaration') {
const funcName = current.type === 'init_declaration' ? 'init' : 'deinit';
const label = 'Constructor';
const startLine = current.startPosition?.row ?? 0;
return generateId(label, `${filePath}:${funcName}:${startLine}`);
return generateId(label, `${filePath}:${funcName}`);
}
if (['function_declaration', 'function_definition', 'async_function_declaration',
'generator_function_declaration', 'function_item'].includes(current.type)) {
// Try direct name field (JS/TS/Python/Rust)
const nameNode = current.childForFieldName?.('name') ||
current.children?.find((c: any) => c.type === 'identifier' || c.type === 'property_identifier');
if (nameNode) {
funcName = nameNode.text;
// C++ template functions: function_definition inside template_declaration
// are registered as 'Template' nodes (not 'Function'), so match that label.
if (current.type === 'function_definition' && current.parent?.type === 'template_declaration') {
label = 'Template';
}
} else {
// C/C++: name is nested in declarator -> function_declarator -> identifier/qualified_identifier
const declarator = current.childForFieldName?.('declarator');
if (declarator) {
const innerDecl = declarator.childForFieldName?.('declarator');
if (innerDecl?.type === 'identifier') {
funcName = innerDecl.text;
// Template function with qualified-style declarator (rare, but check)
if (current.parent?.type === 'template_declaration') {
label = 'Template';
}
} else if (innerDecl?.type === 'qualified_identifier') {
// C++ qualified name: Foo::bar — captured as 'Method' node
const nameIdent = innerDecl.childForFieldName?.('name') ||
innerDecl.children?.find((c: any) => c.type === 'identifier');
funcName = nameIdent?.text;
label = 'Method'; // qualified_identifier => registered as Method
} else if (innerDecl?.type === 'field_identifier') {
// C++ inline method with body inside class: void myMethod() { ... }
// The function_definition is a direct child of field_declaration_list.
// Name node is field_identifier, registered as 'Method'.
funcName = innerDecl.text;
label = 'Method';
} else if (innerDecl?.type === 'operator_name') {
// C++ operator overload inside class body: operator[]
funcName = innerDecl.text;
label = 'Method';
}
}
}
funcName = nameNode?.text;
} else if (current.type === 'impl_item') {
const funcItem = current.children?.find((c: any) => c.type === 'function_item');
if (funcItem) {
@@ -408,8 +289,7 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
}
if (funcName) {
const startLine = current.startPosition?.row ?? 0;
return generateId(label, `${filePath}:${funcName}:${startLine}`);
return generateId(label, `${filePath}:${funcName}`);
}
}
current = current.parent;
@@ -439,26 +319,9 @@ const BUILT_INS = new Set([
'hasOwnProperty', 'toString', 'valueOf',
// Python
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
'append', 'extend', 'update',
// NOTE: 'open', 'read', 'write', 'close' removed — these are real C POSIX syscalls
'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',
@@ -564,51 +427,6 @@ 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
// ============================================================================
@@ -621,7 +439,6 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
imports: [],
calls: [],
heritage: [],
routes: [],
fileCount: 0,
};
@@ -672,22 +489,14 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
// Process regular files for this language
if (regularFiles.length > 0) {
try {
setLanguage(language, regularFiles[0].path);
processFileGroup(regularFiles, language, queryString, result, onFileProcessed);
} catch {
// parser unavailable — skip this language group
}
setLanguage(language, regularFiles[0].path);
processFileGroup(regularFiles, language, queryString, result, onFileProcessed);
}
// Process tsx files separately (different grammar)
if (tsxFiles.length > 0) {
try {
setLanguage(language, tsxFiles[0].path);
processFileGroup(tsxFiles, language, queryString, result, onFileProcessed);
} catch {
// parser unavailable — skip this language group
}
setLanguage(language, tsxFiles[0].path);
processFileGroup(tsxFiles, language, queryString, result, onFileProcessed);
}
}
@@ -797,378 +606,6 @@ function extractEloquentRelationDescription(methodNode: any): string | null {
return null;
}
// ============================================================================
// Laravel Route Extraction (procedural AST walk)
// ============================================================================
interface RouteGroupContext {
middleware: string[];
prefix: string | null;
controller: string | null;
}
const ROUTE_HTTP_METHODS = new Set([
'get', 'post', 'put', 'patch', 'delete', 'options', 'any', 'match',
]);
const ROUTE_RESOURCE_METHODS = new Set(['resource', 'apiResource']);
const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'];
const API_RESOURCE_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
/** Check if node is a scoped_call_expression with object 'Route' */
function isRouteStaticCall(node: any): boolean {
if (node.type !== 'scoped_call_expression') return false;
const obj = node.childForFieldName?.('object') ?? node.children?.[0];
return obj?.text === 'Route';
}
/** Get the method name from a scoped_call_expression or member_call_expression */
function getCallMethodName(node: any): string | null {
const nameNode = node.childForFieldName?.('name') ??
node.children?.find((c: any) => c.type === 'name');
return nameNode?.text ?? null;
}
/** Get the arguments node from a call expression */
function getArguments(node: any): any {
return node.children?.find((c: any) => c.type === 'arguments') ?? null;
}
/** Find the closure body inside arguments */
function findClosureBody(argsNode: any): any | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
if (child.type === 'argument') {
for (const inner of child.children ?? []) {
if (inner.type === 'anonymous_function' ||
inner.type === 'arrow_function') {
return inner.childForFieldName?.('body') ??
inner.children?.find((c: any) => c.type === 'compound_statement');
}
}
}
if (child.type === 'anonymous_function' ||
child.type === 'arrow_function') {
return child.childForFieldName?.('body') ??
child.children?.find((c: any) => c.type === 'compound_statement');
}
}
return null;
}
/** Extract first string argument from arguments node */
function extractFirstStringArg(argsNode: any): string | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (!target) continue;
if (target.type === 'string' || target.type === 'encapsed_string') {
return extractStringContent(target);
}
}
return null;
}
/** Extract middleware from arguments — handles string or array */
function extractMiddlewareArg(argsNode: any): string[] {
if (!argsNode) return [];
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (!target) continue;
if (target.type === 'string' || target.type === 'encapsed_string') {
const val = extractStringContent(target);
return val ? [val] : [];
}
if (target.type === 'array_creation_expression') {
const items: string[] = [];
for (const el of target.children ?? []) {
if (el.type === 'array_element_initializer') {
const str = el.children?.find((c: any) => c.type === 'string' || c.type === 'encapsed_string');
const val = str ? extractStringContent(str) : null;
if (val) items.push(val);
}
}
return items;
}
}
return [];
}
/** Extract Controller::class from arguments */
function extractClassArg(argsNode: any): string | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (target?.type === 'class_constant_access_expression') {
return target.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
return null;
}
/** Extract controller class name from arguments: [Controller::class, 'method'] or 'Controller@method' */
function extractControllerTarget(argsNode: any): { controller: string | null; method: string | null } {
if (!argsNode) return { controller: null, method: null };
const args: any[] = [];
for (const child of argsNode.children ?? []) {
if (child.type === 'argument') args.push(child.children?.[0]);
else if (child.type !== '(' && child.type !== ')' && child.type !== ',') args.push(child);
}
// Second arg is the handler
const handlerNode = args[1];
if (!handlerNode) return { controller: null, method: null };
// Array syntax: [UserController::class, 'index']
if (handlerNode.type === 'array_creation_expression') {
let controller: string | null = null;
let method: string | null = null;
const elements: any[] = [];
for (const el of handlerNode.children ?? []) {
if (el.type === 'array_element_initializer') elements.push(el);
}
if (elements[0]) {
const classAccess = findDescendant(elements[0], 'class_constant_access_expression');
if (classAccess) {
controller = classAccess.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
if (elements[1]) {
const str = findDescendant(elements[1], 'string');
method = str ? extractStringContent(str) : null;
}
return { controller, method };
}
// String syntax: 'UserController@index'
if (handlerNode.type === 'string' || handlerNode.type === 'encapsed_string') {
const text = extractStringContent(handlerNode);
if (text?.includes('@')) {
const [controller, method] = text.split('@');
return { controller, method };
}
}
// Class reference: UserController::class (invokable controller)
if (handlerNode.type === 'class_constant_access_expression') {
const controller = handlerNode.children?.find((c: any) => c.type === 'name')?.text ?? null;
return { controller, method: '__invoke' };
}
return { controller: null, method: null };
}
interface ChainedRouteCall {
isRouteFacade: boolean;
terminalMethod: string;
attributes: { method: string; argsNode: any }[];
terminalArgs: any;
node: any;
}
/**
* Unwrap a chained call like Route::middleware('auth')->prefix('api')->group(fn)
*/
function unwrapRouteChain(node: any): ChainedRouteCall | null {
if (node.type !== 'member_call_expression') return null;
const terminalMethod = getCallMethodName(node);
if (!terminalMethod) return null;
const terminalArgs = getArguments(node);
const attributes: { method: string; argsNode: any }[] = [];
let current = node.children?.[0];
while (current) {
if (current.type === 'member_call_expression') {
const method = getCallMethodName(current);
const args = getArguments(current);
if (method) attributes.unshift({ method, argsNode: args });
current = current.children?.[0];
} else if (current.type === 'scoped_call_expression') {
const obj = current.childForFieldName?.('object') ?? current.children?.[0];
if (obj?.text !== 'Route') return null;
const method = getCallMethodName(current);
const args = getArguments(current);
if (method) attributes.unshift({ method, argsNode: args });
return { isRouteFacade: true, terminalMethod, attributes, terminalArgs, node };
} else {
break;
}
}
return null;
}
/** Parse Route::group(['middleware' => ..., 'prefix' => ...], fn) array syntax */
function parseArrayGroupArgs(argsNode: any): RouteGroupContext {
const ctx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
if (!argsNode) return ctx;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (target?.type === 'array_creation_expression') {
for (const el of target.children ?? []) {
if (el.type !== 'array_element_initializer') continue;
const children = el.children ?? [];
const arrowIdx = children.findIndex((c: any) => c.type === '=>');
if (arrowIdx === -1) continue;
const key = extractStringContent(children[arrowIdx - 1]);
const val = children[arrowIdx + 1];
if (key === 'middleware') {
if (val?.type === 'string') {
const s = extractStringContent(val);
if (s) ctx.middleware.push(s);
} else if (val?.type === 'array_creation_expression') {
for (const item of val.children ?? []) {
if (item.type === 'array_element_initializer') {
const str = item.children?.find((c: any) => c.type === 'string');
const s = str ? extractStringContent(str) : null;
if (s) ctx.middleware.push(s);
}
}
}
} else if (key === 'prefix') {
ctx.prefix = extractStringContent(val) ?? null;
} else if (key === 'controller') {
if (val?.type === 'class_constant_access_expression') {
ctx.controller = val.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
}
}
}
return ctx;
}
function extractLaravelRoutes(tree: any, filePath: string): ExtractedRoute[] {
const routes: ExtractedRoute[] = [];
function resolveStack(stack: RouteGroupContext[]): { middleware: string[]; prefix: string | null; controller: string | null } {
const middleware: string[] = [];
let prefix: string | null = null;
let controller: string | null = null;
for (const ctx of stack) {
middleware.push(...ctx.middleware);
if (ctx.prefix) prefix = prefix ? `${prefix}/${ctx.prefix}`.replace(/\/+/g, '/') : ctx.prefix;
if (ctx.controller) controller = ctx.controller;
}
return { middleware, prefix, controller };
}
function emitRoute(
httpMethod: string,
argsNode: any,
lineNumber: number,
groupStack: RouteGroupContext[],
chainAttrs: { method: string; argsNode: any }[],
) {
const effective = resolveStack(groupStack);
for (const attr of chainAttrs) {
if (attr.method === 'middleware') effective.middleware.push(...extractMiddlewareArg(attr.argsNode));
if (attr.method === 'prefix') {
const p = extractFirstStringArg(attr.argsNode);
if (p) effective.prefix = effective.prefix ? `${effective.prefix}/${p}` : p;
}
if (attr.method === 'controller') {
const cls = extractClassArg(attr.argsNode);
if (cls) effective.controller = cls;
}
}
const routePath = extractFirstStringArg(argsNode);
if (ROUTE_RESOURCE_METHODS.has(httpMethod)) {
const target = extractControllerTarget(argsNode);
const actions = httpMethod === 'apiResource' ? API_RESOURCE_ACTIONS : RESOURCE_ACTIONS;
for (const action of actions) {
routes.push({
filePath, httpMethod, routePath,
controllerName: target.controller ?? effective.controller,
methodName: action,
middleware: [...effective.middleware],
prefix: effective.prefix,
lineNumber,
});
}
} else {
const target = extractControllerTarget(argsNode);
routes.push({
filePath, httpMethod, routePath,
controllerName: target.controller ?? effective.controller,
methodName: target.method,
middleware: [...effective.middleware],
prefix: effective.prefix,
lineNumber,
});
}
}
function walk(node: any, groupStack: RouteGroupContext[]) {
// Case 1: Simple Route::get(...), Route::post(...), etc.
if (isRouteStaticCall(node)) {
const method = getCallMethodName(node);
if (method && (ROUTE_HTTP_METHODS.has(method) || ROUTE_RESOURCE_METHODS.has(method))) {
emitRoute(method, getArguments(node), node.startPosition.row, groupStack, []);
return;
}
if (method === 'group') {
const argsNode = getArguments(node);
const groupCtx = parseArrayGroupArgs(argsNode);
const body = findClosureBody(argsNode);
if (body) {
groupStack.push(groupCtx);
walkChildren(body, groupStack);
groupStack.pop();
}
return;
}
}
// Case 2: Fluent chain — Route::middleware(...)->group(...) or Route::middleware(...)->get(...)
const chain = unwrapRouteChain(node);
if (chain) {
if (chain.terminalMethod === 'group') {
const groupCtx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
for (const attr of chain.attributes) {
if (attr.method === 'middleware') groupCtx.middleware.push(...extractMiddlewareArg(attr.argsNode));
if (attr.method === 'prefix') groupCtx.prefix = extractFirstStringArg(attr.argsNode);
if (attr.method === 'controller') groupCtx.controller = extractClassArg(attr.argsNode);
}
const body = findClosureBody(chain.terminalArgs);
if (body) {
groupStack.push(groupCtx);
walkChildren(body, groupStack);
groupStack.pop();
}
return;
}
if (ROUTE_HTTP_METHODS.has(chain.terminalMethod) || ROUTE_RESOURCE_METHODS.has(chain.terminalMethod)) {
emitRoute(chain.terminalMethod, chain.terminalArgs, node.startPosition.row, groupStack, chain.attributes);
return;
}
}
// Default: recurse into children
walkChildren(node, groupStack);
}
function walkChildren(node: any, groupStack: RouteGroupContext[]) {
for (const child of node.children ?? []) {
walk(child, groupStack);
}
}
walk(tree.rootNode, []);
return routes;
}
const processFileGroup = (
files: ParseWorkerInput[],
language: SupportedLanguages,
@@ -1190,10 +627,7 @@ const processFileGroup = (
let tree;
try {
// bufferSize must be >= file size. Use 2× file size, minimum 512KB, maximum 32MB.
const fileSizeBytes = Buffer.byteLength(file.content, 'utf8');
const bufSize = Math.min(Math.max(fileSizeBytes * 2, 512 * 1024), 32 * 1024 * 1024);
tree = parser.parse(file.content, undefined, { bufferSize: bufSize });
tree = parser.parse(file.content, undefined, { bufferSize: 1024 * 256 });
} catch {
continue;
}
@@ -1216,9 +650,7 @@ const processFileGroup = (
// Extract import paths before skipping
if (captureMap['import'] && captureMap['import.source']) {
const rawImportPath = language === SupportedLanguages.Kotlin
? appendKotlinWildcard(captureMap['import.source'].text.replace(/['"<>]/g, ''), captureMap['import'])
: captureMap['import.source'].text.replace(/['"<>]/g, '');
const rawImportPath = captureMap['import.source'].text.replace(/['"<>]/g, '');
result.imports.push({
filePath: file.path,
rawImportPath,
@@ -1277,12 +709,8 @@ const processFileGroup = (
if (!nodeLabel) continue;
const nameNode = captureMap['name'];
// 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}`);
const nodeName = nameNode.text;
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
let description: string | undefined;
if (language === SupportedLanguages.PHP) {
@@ -1293,24 +721,16 @@ 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: definitionNode ? definitionNode.startPosition.row : startLine,
endLine: definitionNode ? definitionNode.endPosition.row : startLine,
startLine: nameNode.startPosition.row,
endLine: nameNode.endPosition.row,
language: language,
isExported: isNodeExported(nameNode || definitionNode, nodeName, language),
...(frameworkHint ? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
astFrameworkReason: frameworkHint.reason,
} : {}),
isExported: isNodeExported(nameNode, nodeName, language),
...(description !== undefined ? { description } : {}),
},
});
@@ -1333,12 +753,6 @@ const processFileGroup = (
reason: '',
});
}
// Extract Laravel routes from route files via procedural AST walk
if (language === SupportedLanguages.PHP && (file.path.includes('/routes/') || file.path.startsWith('routes/')) && file.path.endsWith('.php')) {
const extractedRoutes = extractLaravelRoutes(tree, file.path);
result.routes.push(...extractedRoutes);
}
}
};
@@ -1349,7 +763,7 @@ const processFileGroup = (
/** Accumulated result across sub-batches */
let accumulated: ParseWorkerResult = {
nodes: [], relationships: [], symbols: [],
imports: [], calls: [], heritage: [], routes: [], fileCount: 0,
imports: [], calls: [], heritage: [], fileCount: 0,
};
let cumulativeProcessed = 0;
@@ -1360,7 +774,6 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
target.imports.push(...src.imports);
target.calls.push(...src.calls);
target.heritage.push(...src.heritage);
target.routes.push(...src.routes);
target.fileCount += src.fileCount;
};
@@ -1382,7 +795,7 @@ parentPort!.on('message', (msg: any) => {
if (msg && msg.type === 'flush') {
parentPort!.postMessage({ type: 'result', data: accumulated });
// Reset for potential reuse
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], routes: [], fileCount: 0 };
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], fileCount: 0 };
cumulativeProcessed = 0;
return;
}
@@ -1,7 +1,5 @@
import { Worker } from 'node:worker_threads';
import os from 'node:os';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
export interface WorkerPool {
/**
@@ -32,13 +30,6 @@ const SUB_BATCH_TIMEOUT_MS = 30_000;
* Create a pool of worker threads.
*/
export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool => {
// Validate worker script exists before spawning to prevent uncaught
// MODULE_NOT_FOUND crashes in worker threads (e.g. when running from src/ via vitest)
const workerPath = fileURLToPath(workerUrl);
if (!fs.existsSync(workerPath)) {
throw new Error(`Worker script not found: ${workerPath}`);
}
const size = poolSize ?? Math.min(8, Math.max(1, os.cpus().length - 1));
const workers: Worker[] = [];
+8 -24
View File
@@ -25,7 +25,7 @@ const FLUSH_EVERY = 500;
// CSV ESCAPE UTILITIES
// ============================================================================
export const sanitizeUTF8 = (str: string): string => {
const sanitizeUTF8 = (str: string): string => {
return str
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
@@ -34,14 +34,14 @@ export const sanitizeUTF8 = (str: string): string => {
.replace(/[\uFFFE\uFFFF]/g, '');
};
export const escapeCSVField = (value: string | number | undefined | null): string => {
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, '""')}"`;
};
export const escapeCSVNumber = (value: number | undefined | null, defaultValue: number = -1): string => {
const escapeCSVNumber = (value: number | undefined | null, defaultValue: number = -1): string => {
if (value === undefined || value === null) return String(defaultValue);
return String(value);
};
@@ -50,7 +50,7 @@ export const escapeCSVNumber = (value: number | undefined | null, defaultValue:
// CONTENT EXTRACTION (lazy — reads from disk on demand)
// ============================================================================
export const isBinaryContent = (content: string): boolean => {
const isBinaryContent = (content: string): boolean => {
if (!content || content.length === 0) return false;
const sample = content.slice(0, 1000);
let nonPrintable = 0;
@@ -80,15 +80,7 @@ class FileContentCache {
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;
}
if (cached !== undefined) return cached;
try {
const fullPath = path.join(this.repoPath, relativePath);
const content = await fs.readFile(fullPath, 'utf-8');
@@ -171,17 +163,9 @@ class BufferedCSVWriter {
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();
});
}
if (ok) resolve();
else this.ws.once('drain', resolve);
});
}
@@ -280,7 +264,7 @@ export const streamAllCSVsToDisk = async (
break;
case 'Community': {
const keywords = (node.properties as any).keywords || [];
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/,/g, '\\,')}'`).join(',')}]`;
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`;
await communityWriter.addRow([
escapeCSVField(node.id),
escapeCSVField(node.properties.name || ''),
+9 -11
View File
@@ -591,7 +591,6 @@ export const closeKuzu = async (): Promise<void> => {
export const isKuzuReady = (): boolean => conn !== null && db !== null;
/**
* Delete all nodes (and their relationships) for a specific file from KuzuDB
* @param filePath - The file path to delete nodes for
@@ -675,25 +674,24 @@ export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
/**
* Load the FTS extension (required before using FTS functions).
* Safe to call multiple times — tracks loaded state via module-level ftsLoaded.
* Safe to call multiple times — tracks loaded state.
*/
let ftsLoaded = false;
export const loadFTSExtension = async (): Promise<void> => {
if (ftsLoaded) return;
if (!conn) {
throw new Error('KuzuDB not initialized. Call initKuzu first.');
}
if (ftsLoaded) return;
try {
await conn.query('INSTALL fts');
await conn.query('LOAD EXTENSION fts');
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);
}
} catch {
// Extension may already be loaded
ftsLoaded = true;
}
ftsLoaded = true;
};
/**
@@ -747,8 +745,8 @@ export const queryFTS = async (
throw new Error('KuzuDB not initialized. Call initKuzu first.');
}
// Escape backslashes and single quotes to prevent Cypher injection
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
// Escape single quotes in query
const escapedQuery = query.replace(/'/g, "''");
const cypher = `
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := ${conjunctive})
+1 -2
View File
@@ -24,8 +24,7 @@ async function queryFTSViaExecutor(
query: string,
limit: number,
): Promise<Array<{ filePath: string; score: number }>> {
// Escape single quotes and backslashes to prevent Cypher injection
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
const escapedQuery = query.replace(/'/g, "''");
const cypher = `
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := false)
RETURN node, score
@@ -8,7 +8,6 @@ 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';
@@ -31,14 +30,10 @@ 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 isLanguageAvailable = (language: SupportedLanguages): boolean =>
language in languageMap;
export const loadParser = async (): Promise<Parser> => {
if (parser) return parser;
parser = new Parser();
+3 -3
View File
@@ -12,7 +12,7 @@
import fs from 'fs/promises';
import path from 'path';
import { execSync, execFileSync } from 'child_process';
import { execSync } from 'child_process';
import {
initWikiDb,
@@ -712,8 +712,8 @@ export class WikiGenerator {
private getChangedFiles(fromCommit: string, toCommit: string): string[] {
try {
const output = execFileSync(
'git', ['diff', `${fromCommit}..${toCommit}`, '--name-only'],
const output = execSync(
`git diff ${fromCommit}..${toCommit} --name-only`,
{ cwd: this.repoPath },
).toString().trim();
return output ? output.split('\n').filter(Boolean) : [];
@@ -1,240 +0,0 @@
import process from 'node:process';
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
import { JSONRPCMessageSchema, type JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
export type StdioFraming = 'content-length' | 'newline';
function deserializeMessage(raw: string): JSONRPCMessage {
return JSONRPCMessageSchema.parse(JSON.parse(raw));
}
function serializeNewlineMessage(message: JSONRPCMessage): string {
return `${JSON.stringify(message)}\n`;
}
function serializeContentLengthMessage(message: JSONRPCMessage): string {
const body = JSON.stringify(message);
return `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
}
function findHeaderEnd(buffer: Buffer): { index: number; separatorLength: number } | null {
const crlfEnd = buffer.indexOf('\r\n\r\n');
if (crlfEnd !== -1) {
return { index: crlfEnd, separatorLength: 4 };
}
const lfEnd = buffer.indexOf('\n\n');
if (lfEnd !== -1) {
return { index: lfEnd, separatorLength: 2 };
}
return null;
}
function looksLikeContentLength(buffer: Buffer): boolean {
if (buffer.length < 14) {
return false;
}
const probe = buffer.toString('utf8', 0, Math.min(buffer.length, 32));
return /^content-length\s*:/i.test(probe);
}
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB — generous for JSON-RPC
export class CompatibleStdioServerTransport implements Transport {
private _readBuffer: Buffer | undefined;
private _started = false;
private _framing: StdioFraming | null = null;
onmessage?: (message: JSONRPCMessage) => void;
onerror?: (error: Error) => void;
onclose?: () => void;
constructor(
private readonly _stdin: NodeJS.ReadableStream = process.stdin,
private readonly _stdout: NodeJS.WritableStream = process.stdout,
) {}
private readonly _ondata = (chunk: Buffer) => {
this._readBuffer = this._readBuffer ? Buffer.concat([this._readBuffer, chunk]) : chunk;
if (this._readBuffer.length > MAX_BUFFER_SIZE) {
this.onerror?.(new Error(`Read buffer exceeded maximum size (${MAX_BUFFER_SIZE} bytes)`));
this.discardBufferedInput();
return;
}
this.processReadBuffer();
};
private readonly _onerror = (error: Error) => {
this.onerror?.(error);
};
async start() {
if (this._started) {
throw new Error('CompatibleStdioServerTransport already started!');
}
this._started = true;
this._stdin.on('data', this._ondata);
this._stdin.on('error', this._onerror);
}
private detectFraming(): StdioFraming | null {
if (!this._readBuffer || this._readBuffer.length === 0) {
return null;
}
const firstByte = this._readBuffer[0];
if (firstByte === 0x7b || firstByte === 0x5b) {
return 'newline';
}
if (looksLikeContentLength(this._readBuffer)) {
return 'content-length';
}
return null;
}
private discardBufferedInput() {
this._readBuffer = undefined;
this._framing = null;
}
private readContentLengthMessage(): JSONRPCMessage | null {
if (!this._readBuffer) {
return null;
}
const header = findHeaderEnd(this._readBuffer);
if (header === null) {
return null;
}
const headerText = this._readBuffer
.toString('utf8', 0, header.index)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
const match = headerText.match(/(?:^|\n)content-length\s*:\s*(\d+)/i);
if (!match) {
this.discardBufferedInput();
throw new Error('Missing Content-Length header from MCP client');
}
const contentLength = Number.parseInt(match[1], 10);
if (!Number.isFinite(contentLength) || contentLength < 0) {
this.discardBufferedInput();
throw new Error('Invalid Content-Length header from MCP client');
}
if (contentLength > MAX_BUFFER_SIZE) {
this.discardBufferedInput();
throw new Error(`Content-Length ${contentLength} exceeds maximum allowed size (${MAX_BUFFER_SIZE} bytes)`);
}
const bodyStart = header.index + header.separatorLength;
const bodyEnd = bodyStart + contentLength;
if (this._readBuffer.length < bodyEnd) {
return null;
}
const body = this._readBuffer.toString('utf8', bodyStart, bodyEnd);
this._readBuffer = this._readBuffer.subarray(bodyEnd);
return deserializeMessage(body);
}
private readNewlineMessage(): JSONRPCMessage | null {
if (!this._readBuffer) {
return null;
}
while (true) {
const newlineIndex = this._readBuffer.indexOf('\n');
if (newlineIndex === -1) {
return null;
}
const line = this._readBuffer.toString('utf8', 0, newlineIndex).replace(/\r$/, '');
this._readBuffer = this._readBuffer.subarray(newlineIndex + 1);
if (line.trim().length === 0) {
continue;
}
return deserializeMessage(line);
}
}
private readMessage(): JSONRPCMessage | null {
if (!this._readBuffer || this._readBuffer.length === 0) {
return null;
}
if (this._framing === null) {
this._framing = this.detectFraming();
if (this._framing === null) {
return null;
}
}
return this._framing === 'content-length'
? this.readContentLengthMessage()
: this.readNewlineMessage();
}
private processReadBuffer() {
while (true) {
try {
const message = this.readMessage();
if (message === null) {
break;
}
this.onmessage?.(message);
} catch (error) {
this.onerror?.(error as Error);
break;
}
}
}
async close() {
this._stdin.off('data', this._ondata);
this._stdin.off('error', this._onerror);
const remainingDataListeners = this._stdin.listenerCount('data');
if (remainingDataListeners === 0) {
this._stdin.pause();
}
this._started = false;
this._readBuffer = undefined;
this.onclose?.();
}
send(message: JSONRPCMessage, _options?: TransportSendOptions) {
return new Promise<void>((resolve, reject) => {
if (!this._started) {
reject(new Error('Transport is closed'));
return;
}
const payload = this._framing === 'newline'
? serializeNewlineMessage(message)
: serializeContentLengthMessage(message);
const onError = (error: Error) => {
this._stdout.removeListener('error', onError);
reject(error);
};
this._stdout.on('error', onError);
if (this._stdout.write(payload)) {
this._stdout.removeListener('error', onError);
resolve();
} else {
this._stdout.once('drain', () => {
this._stdout.removeListener('error', onError);
resolve();
});
}
});
}
}
+21 -90
View File
@@ -42,10 +42,6 @@ const INITIAL_CONNS_PER_REPO = 2;
let idleTimer: ReturnType<typeof setInterval> | null = null;
/** Saved real stdout.write — used to silence KuzuDB native output without race conditions */
const realStdoutWrite = process.stdout.write.bind(process.stdout);
let stdoutSilenceCount = 0;
/**
* Start the idle cleanup timer (runs every 60s)
*/
@@ -54,7 +50,7 @@ function ensureIdleTimer(): void {
idleTimer = setInterval(() => {
const now = Date.now();
for (const [repoId, entry] of pool) {
if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) {
if (now - entry.lastUsed > IDLE_TIMEOUT_MS) {
closeOne(repoId);
}
}
@@ -73,7 +69,7 @@ function evictLRU(): void {
let oldestId: string | null = null;
let oldestTime = Infinity;
for (const [id, entry] of pool) {
if (entry.checkedOut === 0 && entry.lastUsed < oldestTime) {
if (entry.lastUsed < oldestTime) {
oldestTime = entry.lastUsed;
oldestId = id;
}
@@ -84,14 +80,15 @@ function evictLRU(): void {
}
/**
* Remove a repo from the pool without calling native close methods.
*
* KuzuDB's native .closeSync() triggers N-API destructor hooks that
* segfault on Linux/macOS. Pool databases are opened read-only, so
* there is no WAL to flush — just deleting the pool entry and letting
* the GC (or process exit) reclaim native resources is safe.
* Close all connections for a repo and remove it from the pool
*/
function closeOne(repoId: string): void {
const entry = pool.get(repoId);
if (!entry) return;
for (const conn of entry.available) {
try { conn.close(); } catch {}
}
try { entry.db.close(); } catch {}
pool.delete(repoId);
}
@@ -99,33 +96,16 @@ function closeOne(repoId: string): void {
* Create a new Connection from a repo's Database.
* Silences stdout to prevent native module output from corrupting MCP stdio.
*/
function silenceStdout(): void {
if (stdoutSilenceCount++ === 0) {
process.stdout.write = (() => true) as any;
}
}
function restoreStdout(): void {
if (--stdoutSilenceCount <= 0) {
stdoutSilenceCount = 0;
process.stdout.write = realStdoutWrite;
}
}
function createConnection(db: kuzu.Database): kuzu.Connection {
silenceStdout();
const origWrite = process.stdout.write;
process.stdout.write = (() => true) as any;
try {
return new kuzu.Connection(db);
} finally {
restoreStdout();
process.stdout.write = origWrite;
}
}
/** Query timeout in milliseconds */
const QUERY_TIMEOUT_MS = 30_000;
/** Waiter queue timeout in milliseconds */
const WAITER_TIMEOUT_MS = 15_000;
const LOCK_RETRY_ATTEMPTS = 3;
const LOCK_RETRY_DELAY_MS = 2000;
@@ -154,7 +134,8 @@ export const initKuzu = async (repoId: string, dbPath: string): Promise<void> =>
// avoids lock conflicts when `gitnexus analyze` is writing.
let lastError: Error | null = null;
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
silenceStdout();
const origWrite = process.stdout.write;
process.stdout.write = (() => true) as any;
try {
const db = new kuzu.Database(
dbPath,
@@ -162,7 +143,7 @@ export const initKuzu = async (repoId: string, dbPath: string): Promise<void> =>
false, // enableCompression (default)
true, // readOnly
);
restoreStdout();
process.stdout.write = origWrite;
// Pre-create a small pool of connections
const available: kuzu.Connection[] = [];
@@ -174,7 +155,7 @@ export const initKuzu = async (repoId: string, dbPath: string): Promise<void> =>
ensureIdleTimer();
return;
} catch (err: any) {
restoreStdout();
process.stdout.write = origWrite;
lastError = err instanceof Error ? err : new Error(String(err));
const isLockError = lastError.message.includes('Could not set lock')
|| lastError.message.includes('lock');
@@ -208,18 +189,10 @@ function checkout(entry: PoolEntry): Promise<kuzu.Connection> {
return Promise.resolve(createConnection(entry.db));
}
// At capacity — queue the caller with a timeout.
return new Promise<kuzu.Connection>((resolve, reject) => {
const waiter = (conn: kuzu.Connection) => {
clearTimeout(timer);
resolve(conn);
};
const timer = setTimeout(() => {
const idx = entry.waiters.indexOf(waiter);
if (idx !== -1) entry.waiters.splice(idx, 1);
reject(new Error(`Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`));
}, WAITER_TIMEOUT_MS);
entry.waiters.push(waiter);
// At capacity — queue the caller. checkin() will resolve this when
// a connection is returned, handing it directly to the next waiter.
return new Promise<kuzu.Connection>(resolve => {
entry.waiters.push(resolve);
});
}
@@ -243,15 +216,6 @@ function checkin(entry: PoolEntry, conn: kuzu.Connection): void {
* Execute a query on a specific repo's connection pool.
* Automatically checks out a connection, runs the query, and returns it.
*/
/** Race a promise against a timeout */
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
export const executeQuery = async (repoId: string, cypher: string): Promise<any[]> => {
const entry = pool.get(repoId);
if (!entry) {
@@ -262,39 +226,7 @@ export const executeQuery = async (repoId: string, cypher: string): Promise<any[
const conn = await checkout(entry);
try {
const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
} finally {
checkin(entry, conn);
}
};
/**
* Execute a parameterized query on a specific repo's connection pool.
* Uses prepare/execute pattern to prevent Cypher injection.
*/
export const executeParameterized = async (
repoId: string,
cypher: string,
params: Record<string, any>,
): Promise<any[]> => {
const entry = pool.get(repoId);
if (!entry) {
throw new Error(`KuzuDB not initialized for repo "${repoId}". Call initKuzu first.`);
}
entry.lastUsed = Date.now();
const conn = await checkout(entry);
try {
const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare');
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Prepare failed: ${errMsg}`);
}
const queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute');
const queryResult = await conn.query(cypher);
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
@@ -324,7 +256,6 @@ export const closeKuzu = async (repoId?: string): Promise<void> => {
}
};
/**
* Check if a specific repo's pool is active
*/
+140 -176
View File
@@ -8,9 +8,8 @@
import fs from 'fs/promises';
import path from 'path';
import { initKuzu, executeQuery, executeParameterized, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
import { embedQuery, getEmbeddingDims, disposeEmbedder } from '../core/embedder.js';
// git utilities available if needed
// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
import {
@@ -24,7 +23,7 @@ import {
* Quick test-file detection for filtering impact results.
* Matches common test file patterns across all supported languages.
*/
export function isTestFilePath(filePath: string): boolean {
function isTestFilePath(filePath: string): boolean {
const p = filePath.toLowerCase().replace(/\\/g, '/');
return (
p.includes('.test.') || p.includes('.spec.') ||
@@ -37,30 +36,13 @@ export function isTestFilePath(filePath: string): boolean {
}
/** Valid KuzuDB node labels for safe Cypher query construction */
export const VALID_NODE_LABELS = new Set([
const VALID_NODE_LABELS = new Set([
'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement',
'Community', 'Process', 'Struct', 'Enum', 'Macro', 'Typedef', 'Union',
'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property',
'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module',
]);
/** Valid relation types for impact analysis filtering */
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']);
/** Regex to detect write operations in user-supplied Cypher queries */
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
/** Check if a Cypher query contains write operations */
export function isWriteQuery(query: string): boolean {
return CYPHER_WRITE_RE.test(query);
}
/** Structured error logging for query failures — replaces empty catch blocks */
function logQueryError(context: string, err: unknown): void {
const msg = err instanceof Error ? err.message : String(err);
console.error(`GitNexus [${context}]: ${msg}`);
}
export interface CodebaseContext {
projectName: string;
stats: {
@@ -404,44 +386,46 @@ export class LocalBackend {
continue;
}
const escaped = sym.nodeId.replace(/'/g, "''");
// Find processes this symbol participates in
let processRows: any[] = [];
try {
processRows = await executeParameterized(repo.id, `
MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
processRows = await executeQuery(repo.id, `
MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
`, { nodeId: sym.nodeId });
} catch (e) { logQueryError('query:process-lookup', e); }
`);
} catch { /* symbol might not be in any process */ }
// Get cluster membership + cohesion (cohesion used as internal ranking signal)
let cohesion = 0;
let module: string | undefined;
try {
const cohesionRows = await executeParameterized(repo.id, `
MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
const cohesionRows = await executeQuery(repo.id, `
MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
LIMIT 1
`, { nodeId: sym.nodeId });
`);
if (cohesionRows.length > 0) {
cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
module = cohesionRows[0].module ?? cohesionRows[0][1];
}
} catch (e) { logQueryError('query:cluster-info', e); }
} catch { /* no cluster info */ }
// Optionally fetch content
let content: string | undefined;
if (includeContent) {
try {
const contentRows = await executeParameterized(repo.id, `
MATCH (n {id: $nodeId})
const contentRows = await executeQuery(repo.id, `
MATCH (n {id: '${escaped}'})
RETURN n.content AS content
`, { nodeId: sym.nodeId });
`);
if (contentRows.length > 0) {
content = contentRows[0].content ?? contentRows[0][0];
}
} catch (e) { logQueryError('query:content-fetch', e); }
} catch { /* skip */ }
}
const symbolEntry = {
id: sym.nodeId,
name: sym.name,
@@ -550,12 +534,13 @@ export class LocalBackend {
for (const bm25Result of bm25Results) {
const fullPath = bm25Result.filePath;
try {
const symbols = await executeParameterized(repo.id, `
MATCH (n)
WHERE n.filePath = $filePath
const symbolQuery = `
MATCH (n)
WHERE n.filePath = '${fullPath.replace(/'/g, "''")}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
LIMIT 3
`, { filePath: fullPath });
`;
const symbols = await executeQuery(repo.id, symbolQuery);
if (symbols.length > 0) {
for (const sym of symbols) {
@@ -601,7 +586,6 @@ export class LocalBackend {
const tableCheck = await executeQuery(repo.id, `MATCH (e:CodeEmbedding) RETURN COUNT(*) AS cnt LIMIT 1`);
if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0) return [];
const { embedQuery, getEmbeddingDims } = await import('../core/embedder.js');
const queryVec = await embedQuery(query);
const dims = getEmbeddingDims();
const queryVecStr = `[${queryVec.join(',')}]`;
@@ -633,11 +617,12 @@ export class LocalBackend {
if (!VALID_NODE_LABELS.has(label)) continue;
try {
const escapedId = nodeId.replace(/'/g, "''");
const nodeQuery = label === 'File'
? `MATCH (n:File {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath`
: `MATCH (n:\`${label}\` {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
const nodeRows = await executeParameterized(repo.id, nodeQuery, { nodeId });
? `MATCH (n:File {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath`
: `MATCH (n:\`${label}\` {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
const nodeRows = await executeQuery(repo.id, nodeQuery);
if (nodeRows.length > 0) {
const nodeRow = nodeRows[0];
results.push({
@@ -672,11 +657,6 @@ export class LocalBackend {
return { error: 'KuzuDB not ready. Index may be corrupted.' };
}
// Block write operations (defense-in-depth — DB is already read-only)
if (CYPHER_WRITE_RE.test(params.query)) {
return { error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.' };
}
try {
const result = await executeQuery(repo.id, params.query);
return result;
@@ -835,32 +815,31 @@ export class LocalBackend {
let symbols: any[];
if (uid) {
symbols = await executeParameterized(repo.id, `
MATCH (n {id: $uid})
const escaped = uid.replace(/'/g, "''");
symbols = await executeQuery(repo.id, `
MATCH (n {id: '${escaped}'})
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
LIMIT 1
`, { uid });
`);
} else {
const escaped = name!.replace(/'/g, "''");
const isQualified = name!.includes('/') || name!.includes(':');
let whereClause: string;
let queryParams: Record<string, any>;
if (file_path) {
whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
queryParams = { symName: name!, filePath: file_path };
const fpEscaped = file_path.replace(/'/g, "''");
whereClause = `WHERE n.name = '${escaped}' AND n.filePath CONTAINS '${fpEscaped}'`;
} else if (isQualified) {
whereClause = `WHERE n.id = $symName OR n.name = $symName`;
queryParams = { symName: name! };
whereClause = `WHERE n.id = '${escaped}' OR n.name = '${escaped}'`;
} else {
whereClause = `WHERE n.name = $symName`;
queryParams = { symName: name! };
whereClause = `WHERE n.name = '${escaped}'`;
}
symbols = await executeParameterized(repo.id, `
symbols = await executeQuery(repo.id, `
MATCH (n) ${whereClause}
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
LIMIT 10
`, queryParams);
`);
}
if (symbols.length === 0) {
@@ -884,32 +863,32 @@ export class LocalBackend {
// Step 3: Build full context
const sym = symbols[0];
const symId = sym.id || sym[0];
const symId = (sym.id || sym[0]).replace(/'/g, "''");
// Categorized incoming refs
const incomingRows = await executeParameterized(repo.id, `
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
const incomingRows = await executeQuery(repo.id, `
MATCH (caller)-[r:CodeRelation]->(n {id: '${symId}'})
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
LIMIT 30
`, { symId });
`);
// Categorized outgoing refs
const outgoingRows = await executeParameterized(repo.id, `
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
const outgoingRows = await executeQuery(repo.id, `
MATCH (n {id: '${symId}'})-[r:CodeRelation]->(target)
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
LIMIT 30
`, { symId });
`);
// Process participation
let processRows: any[] = [];
try {
processRows = await executeParameterized(repo.id, `
MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
processRows = await executeQuery(repo.id, `
MATCH (n {id: '${symId}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN p.id AS pid, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
`, { symId });
} catch (e) { logQueryError('context:process-participation', e); }
`);
} catch { /* no process info */ }
// Helper to categorize refs
const categorize = (rows: any[]) => {
@@ -963,31 +942,33 @@ export class LocalBackend {
}
if (type === 'cluster') {
const clusters = await executeParameterized(repo.id, `
const escaped = name.replace(/'/g, "''");
const clusterQuery = `
MATCH (c:Community)
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
`, { clusterName: name });
`;
const clusters = await executeQuery(repo.id, clusterQuery);
if (clusters.length === 0) return { error: `Cluster '${name}' not found` };
const rawClusters = clusters.map((c: any) => ({
id: c.id || c[0], label: c.label || c[1], heuristicLabel: c.heuristicLabel || c[2],
cohesion: c.cohesion || c[3], symbolCount: c.symbolCount || c[4],
}));
let totalSymbols = 0, weightedCohesion = 0;
for (const c of rawClusters) {
const s = c.symbolCount || 0;
totalSymbols += s;
weightedCohesion += (c.cohesion || 0) * s;
}
const members = await executeParameterized(repo.id, `
const members = await executeQuery(repo.id, `
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 30
`, { clusterName: name });
`);
return {
cluster: {
@@ -1005,21 +986,21 @@ export class LocalBackend {
}
if (type === 'process') {
const processes = await executeParameterized(repo.id, `
const processes = await executeQuery(repo.id, `
MATCH (p:Process)
WHERE p.label = $processName OR p.heuristicLabel = $processName
WHERE p.label = '${name.replace(/'/g, "''")}' OR p.heuristicLabel = '${name.replace(/'/g, "''")}'
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
LIMIT 1
`, { processName: name });
`);
if (processes.length === 0) return { error: `Process '${name}' not found` };
const proc = processes[0];
const procId = proc.id || proc[0];
const steps = await executeParameterized(repo.id, `
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
const steps = await executeQuery(repo.id, `
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
ORDER BY r.step
`, { procId });
`);
return {
process: {
@@ -1046,30 +1027,30 @@ export class LocalBackend {
await this.ensureInitialized(repo.id);
const scope = params.scope || 'unstaged';
const { execFileSync } = await import('child_process');
// Build git diff args based on scope (using execFileSync to avoid shell injection)
let diffArgs: string[];
const { execSync } = await import('child_process');
// Build git diff command based on scope
let diffCmd: string;
switch (scope) {
case 'staged':
diffArgs = ['diff', '--staged', '--name-only'];
diffCmd = 'git diff --staged --name-only';
break;
case 'all':
diffArgs = ['diff', 'HEAD', '--name-only'];
diffCmd = 'git diff HEAD --name-only';
break;
case 'compare':
if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' };
diffArgs = ['diff', params.base_ref, '--name-only'];
diffCmd = `git diff ${params.base_ref} --name-only`;
break;
case 'unstaged':
default:
diffArgs = ['diff', '--name-only'];
diffCmd = 'git diff --name-only';
break;
}
let changedFiles: string[];
try {
const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
const output = execSync(diffCmd, { cwd: repo.repoPath, encoding: 'utf-8' });
changedFiles = output.trim().split('\n').filter(f => f.length > 0);
} catch (err: any) {
return { error: `Git diff failed: ${err.message}` };
@@ -1086,13 +1067,13 @@ export class LocalBackend {
// Map changed files to indexed symbols
const changedSymbols: any[] = [];
for (const file of changedFiles) {
const normalizedFile = file.replace(/\\/g, '/');
const escaped = file.replace(/\\/g, '/').replace(/'/g, "''");
try {
const symbols = await executeParameterized(repo.id, `
MATCH (n) WHERE n.filePath CONTAINS $filePath
const symbols = await executeQuery(repo.id, `
MATCH (n) WHERE n.filePath CONTAINS '${escaped}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 20
`, { filePath: normalizedFile });
`);
for (const sym of symbols) {
changedSymbols.push({
id: sym.id || sym[0],
@@ -1102,17 +1083,18 @@ export class LocalBackend {
change_type: 'Modified',
});
}
} catch (e) { logQueryError('detect-changes:file-symbols', e); }
} catch { /* skip */ }
}
// Find affected processes
const affectedProcesses = new Map<string, any>();
for (const sym of changedSymbols) {
const escaped = (sym.id as string).replace(/'/g, "''");
try {
const procs = await executeParameterized(repo.id, `
MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
const procs = await executeQuery(repo.id, `
MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
`, { nodeId: sym.id });
`);
for (const proc of procs) {
const pid = proc.pid || proc[0];
if (!affectedProcesses.has(pid)) {
@@ -1129,9 +1111,9 @@ export class LocalBackend {
step: proc.step || proc[4],
});
}
} catch (e) { logQueryError('detect-changes:process-lookup', e); }
} catch { /* skip */ }
}
const processCount = affectedProcesses.size;
const risk = processCount === 0 ? 'low' : processCount <= 5 ? 'medium' : processCount <= 15 ? 'high' : 'critical';
@@ -1163,19 +1145,10 @@ export class LocalBackend {
const { new_name, file_path } = params;
const dry_run = params.dry_run ?? true;
if (!params.symbol_name && !params.symbol_uid) {
return { error: 'Either symbol_name or symbol_uid is required.' };
}
/** Guard: ensure a file path resolves within the repo root (prevents path traversal) */
const assertSafePath = (filePath: string): string => {
const full = path.resolve(repo.repoPath, filePath);
if (!full.startsWith(repo.repoPath + path.sep) && full !== repo.repoPath) {
throw new Error(`Path traversal blocked: ${filePath}`);
}
return full;
};
// Step 1: Find the target symbol (reuse context's lookup)
const lookupResult = await this.context(repo, {
@@ -1211,16 +1184,15 @@ export class LocalBackend {
// The definition itself
if (sym.filePath && sym.startLine) {
try {
const content = await fs.readFile(assertSafePath(sym.filePath), 'utf-8');
const content = await fs.readFile(path.join(repo.repoPath, sym.filePath), 'utf-8');
const lines = content.split('\n');
const lineIdx = sym.startLine - 1;
if (lineIdx >= 0 && lineIdx < lines.length && lines[lineIdx].includes(oldName)) {
const defRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(defRegex, new_name).trim(), 'graph');
addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(oldName, new_name).trim(), 'graph');
}
} catch (e) { logQueryError('rename:read-definition', e); }
} catch { /* skip */ }
}
// All incoming refs from graph (callers, importers, etc.)
const allIncoming = [
...(lookupResult.incoming.calls || []),
@@ -1234,7 +1206,7 @@ export class LocalBackend {
for (const ref of allIncoming) {
if (!ref.filePath) continue;
try {
const content = await fs.readFile(assertSafePath(ref.filePath), 'utf-8');
const content = await fs.readFile(path.join(repo.repoPath, ref.filePath), 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(oldName)) {
@@ -1243,24 +1215,18 @@ export class LocalBackend {
break; // one edit per file from graph refs
}
}
} catch (e) { logQueryError('rename:read-ref', e); }
} catch { /* skip */ }
}
// Step 3: Text search for refs the graph might have missed
let astSearchEdits = 0;
const graphFiles = new Set([sym.filePath, ...allIncoming.map(r => r.filePath)].filter(Boolean));
// Simple text search across the repo for the old name (in files not already covered by graph)
try {
const { execFileSync } = await import('child_process');
const rgArgs = [
'-l',
'--type-add', 'code:*.{ts,tsx,js,jsx,py,go,rs,java,c,h,cpp,cc,cxx,hpp,hxx,hh,cs,php,swift}',
'-t', 'code',
`\\b${oldName}\\b`,
'.',
];
const output = execFileSync('rg', rgArgs, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
const { execSync } = await import('child_process');
const rgCmd = `rg -l --type-add "code:*.{ts,tsx,js,jsx,py,go,rs,java}" -t code "\\b${oldName}\\b" .`;
const output = execSync(rgCmd, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
const files = output.trim().split('\n').filter(f => f.length > 0);
for (const file of files) {
@@ -1268,20 +1234,19 @@ export class LocalBackend {
if (graphFiles.has(normalizedFile)) continue; // already covered by graph
try {
const content = await fs.readFile(assertSafePath(normalizedFile), 'utf-8');
const content = await fs.readFile(path.join(repo.repoPath, normalizedFile), 'utf-8');
const lines = content.split('\n');
const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
for (let i = 0; i < lines.length; i++) {
regex.lastIndex = 0;
if (regex.test(lines[i])) {
regex.lastIndex = 0;
addEdit(normalizedFile, i + 1, lines[i].trim(), lines[i].replace(regex, new_name).trim(), 'text_search');
astSearchEdits++;
regex.lastIndex = 0; // reset regex
}
}
} catch (e) { logQueryError('rename:text-search-read', e); }
} catch { /* skip */ }
}
} catch (e) { logQueryError('rename:ripgrep', e); }
} catch { /* rg not available or no additional matches */ }
// Step 4: Apply or preview
const allChanges = Array.from(changes.values());
@@ -1291,12 +1256,12 @@ export class LocalBackend {
// Apply edits to files
for (const change of allChanges) {
try {
const fullPath = assertSafePath(change.file_path);
const fullPath = path.join(repo.repoPath, change.file_path);
let content = await fs.readFile(fullPath, 'utf-8');
const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
content = content.replace(regex, new_name);
await fs.writeFile(fullPath, content, 'utf-8');
} catch (e) { logQueryError('rename:apply-edit', e); }
} catch { /* skip failed files */ }
}
}
@@ -1325,22 +1290,22 @@ export class LocalBackend {
const { target, direction } = params;
const maxDepth = params.maxDepth || 3;
const rawRelTypes = params.relationTypes && params.relationTypes.length > 0
? params.relationTypes.filter(t => VALID_RELATION_TYPES.has(t))
const relationTypes = params.relationTypes && params.relationTypes.length > 0
? params.relationTypes
: ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
const relationTypes = rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
const includeTests = params.includeTests ?? false;
const minConfidence = params.minConfidence ?? 0;
const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', ');
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
const targets = await executeParameterized(repo.id, `
const targetQuery = `
MATCH (n)
WHERE n.name = $targetName
WHERE n.name = '${target.replace(/'/g, "''")}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 1
`, { targetName: target });
`;
const targets = await executeQuery(repo.id, targetQuery);
if (targets.length === 0) return { error: `Target '${target}' not found` };
const sym = targets[0];
@@ -1382,7 +1347,7 @@ export class LocalBackend {
});
}
}
} catch (e) { logQueryError('impact:depth-traversal', e); }
} catch { /* query failed for this depth level */ }
frontier = nextFrontier;
}
@@ -1544,11 +1509,13 @@ export class LocalBackend {
const repo = await this.resolveRepo(repoName);
await this.ensureInitialized(repo.id);
const clusters = await executeParameterized(repo.id, `
const escaped = name.replace(/'/g, "''");
const clusterQuery = `
MATCH (c:Community)
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
`, { clusterName: name });
`;
const clusters = await executeQuery(repo.id, clusterQuery);
if (clusters.length === 0) return { error: `Cluster '${name}' not found` };
const rawClusters = clusters.map((c: any) => ({
@@ -1563,12 +1530,12 @@ export class LocalBackend {
weightedCohesion += (c.cohesion || 0) * s;
}
const members = await executeParameterized(repo.id, `
const members = await executeQuery(repo.id, `
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 30
`, { clusterName: name });
`);
return {
cluster: {
@@ -1593,21 +1560,22 @@ export class LocalBackend {
const repo = await this.resolveRepo(repoName);
await this.ensureInitialized(repo.id);
const processes = await executeParameterized(repo.id, `
const escaped = name.replace(/'/g, "''");
const processes = await executeQuery(repo.id, `
MATCH (p:Process)
WHERE p.label = $processName OR p.heuristicLabel = $processName
WHERE p.label = '${escaped}' OR p.heuristicLabel = '${escaped}'
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
LIMIT 1
`, { processName: name });
`);
if (processes.length === 0) return { error: `Process '${name}' not found` };
const proc = processes[0];
const procId = proc.id || proc[0];
const steps = await executeParameterized(repo.id, `
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
const steps = await executeQuery(repo.id, `
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
ORDER BY r.step
`, { procId });
`);
return {
process: {
@@ -1622,11 +1590,7 @@ export class LocalBackend {
async disconnect(): Promise<void> {
await closeKuzu(); // close all connections
// Note: we intentionally do NOT call disposeEmbedder() here.
// ONNX Runtime's native cleanup segfaults on macOS and some Linux configs,
// and importing the embedder module on Node v24+ crashes if onnxruntime
// was never loaded during the session. Since process.exit(0) follows
// immediately after disconnect(), the OS reclaims everything. See #38, #89.
await disposeEmbedder();
this.repos.clear();
this.contextCache.clear();
this.initializedRepos.clear();
+13 -22
View File
@@ -11,9 +11,8 @@
* Resources: repos, repo/{name}/context, repo/{name}/clusters, ...
*/
import { createRequire } from 'module';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CompatibleStdioServerTransport } from './compatible-stdio-transport.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
@@ -81,12 +80,10 @@ function getNextStepHint(toolName: string, args: Record<string, any> | undefined
* Transport-agnostic — caller connects the desired transport.
*/
export function createMCPServer(backend: LocalBackend): Server {
const require = createRequire(import.meta.url);
const pkgVersion: string = require('../../package.json').version;
const server = new Server(
{
name: 'gitnexus',
version: pkgVersion,
version: '1.1.9',
},
{
capabilities: {
@@ -277,25 +274,19 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
const server = createMCPServer(backend);
// Connect to stdio transport
const transport = new CompatibleStdioServerTransport();
const transport = new StdioServerTransport();
await server.connect(transport);
// Graceful shutdown helper
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
try { await backend.disconnect(); } catch {}
try { await server.close(); } catch {}
process.exit(0);
};
// Handle graceful shutdown
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('SIGINT', async () => {
await backend.disconnect();
await server.close();
process.exit(0);
});
// Handle stdio errors — stdin close means the parent process is gone
process.stdin.on('end', shutdown);
process.stdin.on('error', () => shutdown());
process.stdout.on('error', () => shutdown());
process.on('SIGTERM', async () => {
await backend.disconnect();
await server.close();
process.exit(0);
});
}
+3 -3
View File
@@ -5,7 +5,7 @@
* Returns a hint for the LLM to call analyze if stale.
*/
import { execFileSync } from 'child_process';
import { execSync } from 'child_process';
import path from 'path';
export interface StalenessInfo {
@@ -20,8 +20,8 @@ export interface StalenessInfo {
export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo {
try {
// Get count of commits between lastCommit and HEAD
const result = execFileSync(
'git', ['rev-list', '--count', `${lastCommit}..HEAD`],
const result = execSync(
`git rev-list --count ${lastCommit}..HEAD`,
{ cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
+2 -4
View File
@@ -18,8 +18,8 @@ import { NODE_TABLES } from '../core/kuzu/schema.js';
import { GraphNode, GraphRelationship } from '../core/graph/types.js';
import { searchFTSFromKuzu } from '../core/search/bm25-index.js';
import { hybridSearch } from '../core/search/hybrid-search.js';
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
// at server startup — crashes on unsupported Node ABI versions (#89)
import { semanticSearch } from '../core/embeddings/embedding-pipeline.js';
import { isEmbedderReady } from '../core/embeddings/embedder.js';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { mountMCPEndpoints } from './mcp-http.js';
@@ -230,9 +230,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
: 10;
const results = await withKuzuDb(kuzuPath, async () => {
const { isEmbedderReady } = await import('../core/embeddings/embedder.js');
if (isEmbedderReady()) {
const { semanticSearch } = await import('../core/embeddings/embedding-pipeline.js');
return hybridSearch(query, limit, executeQuery, semanticSearch);
}
// FTS-only fallback when embeddings aren't loaded
+1 -4
View File
@@ -1,5 +1,4 @@
import { execSync } from 'child_process';
import path from 'path';
// Git utilities for repository detection, commit tracking, and diff analysis
@@ -25,11 +24,9 @@ export const getCurrentCommit = (repoPath: string): string => {
*/
export const getGitRoot = (fromPath: string): string | null => {
try {
const raw = execSync('git rev-parse --show-toplevel', { cwd: fromPath })
return execSync('git rev-parse --show-toplevel', { cwd: fromPath })
.toString()
.trim();
// On Windows, git returns /d/Projects/Foo — path.resolve normalizes to D:\Projects\Foo
return path.resolve(raw);
} catch {
return null;
}
+4 -13
View File
@@ -201,13 +201,9 @@ export const registerRepo = async (repoPath: string, meta: RepoMeta): Promise<vo
const { storagePath } = getStoragePaths(resolved);
const entries = await readRegistry();
const existing = entries.findIndex((e) => {
const a = path.resolve(e.path);
const b = resolved;
return process.platform === 'win32'
? a.toLowerCase() === b.toLowerCase()
: a === b;
});
const existing = entries.findIndex(
(e) => path.resolve(e.path) === resolved
);
const entry: RegistryEntry = {
name,
@@ -300,10 +296,5 @@ export const loadCLIConfig = async (): Promise<CLIConfig> => {
export const saveCLIConfig = async (config: CLIConfig): Promise<void> => {
const dir = getGlobalDir();
await fs.mkdir(dir, { recursive: true });
const configPath = getGlobalConfigPath();
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
// Restrict file permissions on Unix (config may contain API keys)
if (process.platform !== 'win32') {
try { await fs.chmod(configPath, 0o600); } catch { /* best-effort */ }
}
await fs.writeFile(getGlobalConfigPath(), JSON.stringify(config, null, 2), 'utf-8');
};
-34
View File
@@ -1,34 +0,0 @@
import type { FTSIndexDef } from '../helpers/test-indexed-db.js';
export const LOCAL_BACKEND_SEED_DATA = [
// Files
`CREATE (f:File {id: 'file:auth.ts', name: 'auth.ts', filePath: 'src/auth.ts', content: 'auth module'})`,
`CREATE (f:File {id: 'file:utils.ts', name: 'utils.ts', filePath: 'src/utils.ts', content: 'utils module'})`,
// Functions
`CREATE (fn:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 15, isExported: true, content: 'function login() {}', description: 'User login'})`,
`CREATE (fn:Function {id: 'func:validate', name: 'validate', filePath: 'src/auth.ts', startLine: 17, endLine: 25, isExported: true, content: 'function validate() {}', description: 'Validate input'})`,
`CREATE (fn:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash() {}', description: 'Hash utility'})`,
// Class
`CREATE (c:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService {}', description: 'Authentication service'})`,
// Community
`CREATE (c:Community {id: 'comm:auth', label: 'Auth', heuristicLabel: 'Authentication', keywords: ['auth', 'login'], description: 'Auth module', enrichedBy: 'heuristic', cohesion: 0.8, symbolCount: 3})`,
// Process
`CREATE (p:Process {id: 'proc:login-flow', label: 'LoginFlow', heuristicLabel: 'User Login', processType: 'intra_community', stepCount: 2, communities: ['auth'], entryPointId: 'func:login', terminalId: 'func:validate'})`,
// Relationships
`MATCH (a:Function), (b:Function) WHERE a.id = 'func:login' AND b.id = 'func:validate'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
`MATCH (a:Function), (b:Function) WHERE a.id = 'func:login' AND b.id = 'func:hash'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.9, reason: 'import-resolved', step: 0}]->(b)`,
`MATCH (a:Function), (c:Community) WHERE a.id = 'func:login' AND c.id = 'comm:auth'
CREATE (a)-[:CodeRelation {type: 'MEMBER_OF', confidence: 1.0, reason: '', step: 0}]->(c)`,
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:login' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 1}]->(p)`,
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 2}]->(p)`,
];
export const LOCAL_BACKEND_FTS_INDEXES: FTSIndexDef[] = [
{ table: 'Function', indexName: 'function_fts', columns: ['name', 'content', 'description'] },
{ table: 'Class', indexName: 'class_fts', columns: ['name', 'content', 'description'] },
{ table: 'File', indexName: 'file_fts', columns: ['name', 'content'] },
];
-19
View File
@@ -1,19 +0,0 @@
import type { ValidationResult } from './validator';
export interface DbRecord {
id: string;
value: string;
timestamp: number;
}
export async function saveToDb(input: ValidationResult): Promise<DbRecord> {
return {
id: Math.random().toString(36),
value: input.value,
timestamp: Date.now(),
};
}
export async function findById(id: string): Promise<DbRecord | null> {
return null;
}
-15
View File
@@ -1,15 +0,0 @@
import type { DbRecord } from './db';
export function formatResponse(record: DbRecord): string {
return JSON.stringify({
success: true,
data: record,
});
}
export function formatError(message: string): string {
return JSON.stringify({
success: false,
error: message,
});
}
-15
View File
@@ -1,15 +0,0 @@
import { validateInput } from './validator';
import { saveToDb } from './db';
import { formatResponse } from './formatter';
export class RequestHandler {
async handleRequest(input: string): Promise<string> {
const validated = validateInput(input);
const saved = await saveToDb(validated);
return formatResponse(saved);
}
}
export function createHandler(): RequestHandler {
return new RequestHandler();
}
-5
View File
@@ -1,5 +0,0 @@
export { RequestHandler, createHandler } from './handler';
export { validateInput, sanitize } from './validator';
export { formatResponse, formatError } from './formatter';
export { processRequest, errorMiddleware } from './middleware';
export { createLogEntry, formatLogEntry, logMessage } from './logger';
-18
View File
@@ -1,18 +0,0 @@
export interface LogEntry {
level: string;
message: string;
timestamp: number;
}
export function createLogEntry(level: string, message: string): LogEntry {
return { level, message, timestamp: Date.now() };
}
export function formatLogEntry(entry: LogEntry): string {
return `[${entry.level}] ${entry.message}`;
}
export function logMessage(level: string, message: string): string {
const entry = createLogEntry(level, message);
return formatLogEntry(entry);
}
-11
View File
@@ -1,11 +0,0 @@
import { sanitize } from './validator';
import { logMessage } from './logger';
export function processRequest(input: string): string {
const clean = sanitize(input);
return logMessage('info', `Processing: ${clean}`);
}
export function errorMiddleware(error: string): string {
return logMessage('error', error);
}
-15
View File
@@ -1,15 +0,0 @@
export interface ValidationResult {
valid: boolean;
value: string;
}
export function validateInput(input: string): ValidationResult {
if (!input || input.trim().length === 0) {
return { valid: false, value: '' };
}
return { valid: true, value: input.trim() };
}
export function sanitize(input: string): string {
return input.replace(/[<>]/g, '');
}
-13
View File
@@ -1,13 +0,0 @@
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
static int internal_helper(void) {
return 0;
}
void print_message(const char* msg) {
printf("%s\n", msg);
}
-19
View File
@@ -1,19 +0,0 @@
#include <string>
class UserManager {
public:
void addUser(const std::string& name) {
users_.push_back(name);
}
int getCount() const {
return static_cast<int>(users_.size());
}
private:
std::vector<std::string> users_;
};
int helperFunction(int x) {
return x * 2;
}
-22
View File
@@ -1,22 +0,0 @@
using System;
namespace SampleApp
{
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
private int Multiply(int a, int b)
{
return a * b;
}
}
internal class Helper
{
public void DoWork() { }
}
}
-21
View File
@@ -1,21 +0,0 @@
package main
import "fmt"
// ExportedFunction is a public function
func ExportedFunction(name string) string {
return fmt.Sprintf("Hello, %s", name)
}
// unexportedFunction is a private function
func unexportedFunction() int {
return 42
}
type UserService struct {
Name string
}
func (s *UserService) GetName() string {
return s.Name
}
-15
View File
@@ -1,15 +0,0 @@
public class UserService {
private String name;
public UserService(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
private void reset() {
this.name = "";
}
}
-32
View File
@@ -1,32 +0,0 @@
const path = require('path');
class EventEmitter {
constructor() {
this.listeners = {};
}
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
emit(event, ...args) {
const handlers = this.listeners[event] || [];
handlers.forEach(handler => handler(...args));
}
}
function createLogger(prefix) {
return {
log: (msg) => console.log(`[${prefix}] ${msg}`),
error: (msg) => console.error(`[${prefix}] ${msg}`),
};
}
const formatDate = (date) => {
return date.toISOString().split('T')[0];
};
module.exports = { EventEmitter, createLogger, formatDate };
-21
View File
@@ -1,21 +0,0 @@
<?php
function topLevelFunction(string $name): string {
return "Hello, " . $name;
}
class UserRepository {
private array $users = [];
public function addUser(string $name): void {
$this->users[] = $name;
}
private function validateName(string $name): bool {
return strlen($name) > 0;
}
public function getUsers(): array {
return $this->users;
}
}
-14
View File
@@ -1,14 +0,0 @@
def public_function(x: int, y: int) -> int:
"""A public function."""
return x + y
def _private_helper(data: str) -> str:
"""A private helper function."""
return data.strip()
class Calculator:
def add(self, a: int, b: int) -> int:
return a + b
def _reset(self) -> None:
pass
-17
View File
@@ -1,17 +0,0 @@
pub fn public_function(x: i32) -> i32 {
x + 1
}
fn private_function() -> &'static str {
"private"
}
pub struct Config {
pub name: String,
}
impl Config {
pub fn new(name: &str) -> Self {
Config { name: name.to_string() }
}
}
-19
View File
@@ -1,19 +0,0 @@
class UserManager {
var users: [String] = []
init() {
users = []
}
func addUser(_ name: String) {
users.append(name)
}
public func getCount() -> Int {
return users.count
}
}
func helperFunction() -> String {
return "swift helper"
}
-27
View File
@@ -1,27 +0,0 @@
export interface UserConfig {
name: string;
email: string;
active: boolean;
}
export function validateUser(config: UserConfig): boolean {
return config.name.length > 0 && config.email.includes('@');
}
export class UserService {
private users: UserConfig[] = [];
addUser(user: UserConfig): void {
if (validateUser(user)) {
this.users.push(user);
}
}
getUser(name: string): UserConfig | undefined {
return this.users.find(u => u.name === name);
}
}
function internalHelper(): string {
return 'helper';
}
-41
View File
@@ -1,41 +0,0 @@
import React, { useState } from 'react';
interface ButtonProps {
label: string;
onClick: () => void;
}
export class Counter extends React.Component<{}, { count: number }> {
state = { count: 0 };
increment() {
this.setState({ count: this.state.count + 1 });
}
render() {
return <button onClick={() => this.increment()}>{this.state.count}</button>;
}
}
export const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
return <button onClick={onClick}>{label}</button>;
};
export function useCounter(initial: number = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return { count, increment, decrement };
}
const App = () => {
const { count, increment } = useCounter();
return (
<div>
<h1>Count: {count}</h1>
<Button label="+" onClick={increment} />
</div>
);
};
export default App;
-31
View File
@@ -1,31 +0,0 @@
import type { FTSIndexDef } from '../helpers/test-indexed-db.js';
export const SEARCH_SEED_DATA = [
// File nodes — content is the searchable field
`CREATE (n:File {id: 'file:auth.ts', name: 'auth.ts', filePath: 'src/auth.ts', content: 'authentication module for user login and session management'})`,
`CREATE (n:File {id: 'file:router.ts', name: 'router.ts', filePath: 'src/router.ts', content: 'HTTP request routing and middleware pipeline'})`,
`CREATE (n:File {id: 'file:utils.ts', name: 'utils.ts', filePath: 'src/utils.ts', content: 'general utility functions for string manipulation'})`,
// Function nodes
`CREATE (n:Function {id: 'func:validateUser', name: 'validateUser', filePath: 'src/auth.ts', startLine: 10, endLine: 30, isExported: true, content: 'validates user credentials and authentication tokens', description: 'user auth validator'})`,
`CREATE (n:Function {id: 'func:hashPassword', name: 'hashPassword', filePath: 'src/auth.ts', startLine: 35, endLine: 50, isExported: true, content: 'hashes user password with bcrypt for secure authentication', description: 'password hashing'})`,
`CREATE (n:Function {id: 'func:handleRoute', name: 'handleRoute', filePath: 'src/router.ts', startLine: 1, endLine: 20, isExported: true, content: 'handles HTTP request routing to controllers', description: 'route handler'})`,
`CREATE (n:Function {id: 'func:formatString', name: 'formatString', filePath: 'src/utils.ts', startLine: 1, endLine: 10, isExported: true, content: 'formats a string with template placeholders', description: 'string formatter'})`,
// Class nodes
`CREATE (n:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 55, endLine: 120, isExported: true, content: 'authentication service handling user login logout and token refresh', description: 'auth service class'})`,
// Method nodes
`CREATE (n:Method {id: 'method:AuthService.login', name: 'login', filePath: 'src/auth.ts', startLine: 60, endLine: 80, isExported: false, content: 'authenticates user with username and password returning JWT token', description: 'login method'})`,
// Interface nodes
`CREATE (n:Interface {id: 'iface:UserCredentials', name: 'UserCredentials', filePath: 'src/auth.ts', startLine: 1, endLine: 8, isExported: true, content: 'interface for user authentication credentials username password', description: 'credentials interface'})`,
];
export const SEARCH_FTS_INDEXES: FTSIndexDef[] = [
{ table: 'File', indexName: 'file_fts', columns: ['name', 'content'] },
{ table: 'Function', indexName: 'function_fts', columns: ['name', 'content', 'description'] },
{ table: 'Class', indexName: 'class_fts', columns: ['name', 'content', 'description'] },
{ table: 'Method', indexName: 'method_fts', columns: ['name', 'content', 'description'] },
{ table: 'Interface', indexName: 'interface_fts', columns: ['name', 'content', 'description'] },
];
-60
View File
@@ -1,60 +0,0 @@
/**
* Vitest globalSetup — runs once in the MAIN process before any forks.
*
* Creates a single shared KuzuDB with full schema so that forked test
* files only need to clear + reseed data instead of recreating the
* entire schema each time (~29 DDL queries per file eliminated).
*
* The dbPath is shared with test files via vitest's provide/inject API.
*/
import path from 'path';
import kuzu from 'kuzu';
import type { GlobalSetupContext } from 'vitest/node';
import { createTempDir } from './helpers/test-db.js';
import {
NODE_SCHEMA_QUERIES,
REL_SCHEMA_QUERIES,
EMBEDDING_SCHEMA,
} from '../src/core/kuzu/schema.js';
export default async function setup({ provide }: GlobalSetupContext) {
const tmpHandle = await createTempDir('gitnexus-shared-');
const dbPath = path.join(tmpHandle.dbPath, 'kuzu');
// Create DB with full schema
const db = new kuzu.Database(dbPath);
const conn = new kuzu.Connection(db);
for (const q of NODE_SCHEMA_QUERIES) {
await conn.query(q);
}
for (const q of REL_SCHEMA_QUERIES) {
await conn.query(q);
}
await conn.query(EMBEDDING_SCHEMA);
// Pre-install FTS extension so forks don't need to download it
try {
await conn.query('INSTALL fts');
await conn.query('LOAD EXTENSION fts');
} catch {
// FTS may already be installed system-wide — not fatal
}
// Close native handles explicitly on Windows (file locks require it).
// On Linux/macOS, skip close — the N-API destructor hooks can segfault
// or deadlock. The teardown function removes the temp directory, and
// process exit reclaims all native resources.
if (process.platform === 'win32') {
conn.close();
db.close();
}
// Share the dbPath with all test files via inject('kuzuDbPath')
provide('kuzuDbPath', dbPath);
// Teardown: remove temp directory after all tests complete
return async () => {
await tmpHandle.cleanup();
};
}
-32
View File
@@ -1,32 +0,0 @@
/**
* Test helper: Temporary KuzuDB factory
*
* Creates a temp directory, initializes KuzuDB with schema, and
* optionally loads minimal test data. Returns a cleanup function.
*/
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
export interface TestDBHandle {
dbPath: string;
cleanup: () => Promise<void>;
}
/**
* Create a temporary directory for KuzuDB tests.
* Returns the path and a cleanup function.
*/
export async function createTempDir(prefix: string = 'gitnexus-test-'): Promise<TestDBHandle> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
return {
dbPath: tmpDir,
cleanup: async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
},
};
}
-90
View File
@@ -1,90 +0,0 @@
/**
* Test helper: In-memory knowledge graph builder
*
* Provides a convenient API for constructing test graphs
* without touching the filesystem or KuzuDB.
*/
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { KnowledgeGraph, GraphNode, NodeLabel, RelationshipType } from '../../src/core/graph/types.js';
export interface TestNodeInput {
id: string;
label: NodeLabel;
name: string;
filePath: string;
startLine?: number;
endLine?: number;
isExported?: boolean;
extra?: Record<string, any>;
}
export interface TestRelInput {
sourceId: string;
targetId: string;
type: RelationshipType;
confidence?: number;
reason?: string;
step?: number;
}
/**
* Build a test graph from simple input arrays.
*/
export function buildTestGraph(
nodes: TestNodeInput[],
relationships: TestRelInput[] = [],
): KnowledgeGraph {
const graph = createKnowledgeGraph();
for (const n of nodes) {
graph.addNode({
id: n.id,
label: n.label,
properties: {
name: n.name,
filePath: n.filePath,
startLine: n.startLine,
endLine: n.endLine,
isExported: n.isExported,
...n.extra,
},
});
}
for (const r of relationships) {
graph.addRelationship({
id: `${r.sourceId}-${r.type}-${r.targetId}`,
sourceId: r.sourceId,
targetId: r.targetId,
type: r.type,
confidence: r.confidence ?? 1.0,
reason: r.reason ?? '',
step: r.step,
});
}
return graph;
}
/**
* Create a minimal graph with a few files, functions, and relationships.
* Useful as a baseline for integration tests.
*/
export function createMinimalTestGraph(): KnowledgeGraph {
return buildTestGraph(
[
{ id: 'File:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
{ id: 'File:src/utils.ts', label: 'File', name: 'utils.ts', filePath: 'src/utils.ts' },
{ id: 'Function:src/index.ts:main:1', label: 'Function', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true },
{ id: 'Function:src/utils.ts:helper:1', label: 'Function', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true },
{ id: 'Class:src/index.ts:App:12', label: 'Class', name: 'App', filePath: 'src/index.ts', startLine: 12, endLine: 30, isExported: true },
{ id: 'Folder:src', label: 'Folder', name: 'src', filePath: 'src' },
],
[
{ sourceId: 'Function:src/index.ts:main:1', targetId: 'Function:src/utils.ts:helper:1', type: 'CALLS' },
{ sourceId: 'Function:src/index.ts:main:1', targetId: 'Class:src/index.ts:App:12', type: 'CALLS' },
{ sourceId: 'File:src/index.ts', targetId: 'Function:src/index.ts:main:1', type: 'CONTAINS' },
{ sourceId: 'File:src/utils.ts', targetId: 'Function:src/utils.ts:helper:1', type: 'CONTAINS' },
],
);
}
-168
View File
@@ -1,168 +0,0 @@
/**
* Test helper: Indexed KuzuDB lifecycle manager
*
* Uses a shared KuzuDB created by globalSetup (test/global-setup.ts).
* Each test file clears all data, reseeds, and initializes adapters —
* avoiding per-file schema creation overhead.
*
* Cleanup is intentionally a no-op: CI runs each KuzuDB test file in its
* own vitest process, so the OS reclaims all native resources on exit.
*
* Each test file gets a unique repoId to prevent MCP pool map collisions.
* Seed data is NOT included — each test provides its own via options.seed.
*/
/// <reference path="../vitest.d.ts" />
import path from 'path';
import { describe, beforeAll, afterAll, inject } from 'vitest';
import type { TestDBHandle } from './test-db.js';
import {
NODE_TABLES,
EMBEDDING_TABLE_NAME,
} from '../../src/core/kuzu/schema.js';
export interface IndexedDBHandle {
/** Path to the KuzuDB database file */
dbPath: string;
/** Unique repoId for MCP pool adapter — prevents cross-file collisions */
repoId: string;
/** Temp directory handle for filesystem cleanup */
tmpHandle: TestDBHandle;
/** Cleanup: detaches adapters (null-out, no native .close()) */
cleanup: () => Promise<void>;
}
let repoCounter = 0;
/** FTS index definition for withTestKuzuDB */
export interface FTSIndexDef {
table: string;
indexName: string;
columns: string[];
}
/**
* Options for withTestKuzuDB lifecycle.
*
* Lifecycle: initKuzu → loadFTS → dropFTS → clearData → seed
* → createFTS → [closeCoreKuzu + poolInitKuzu] → afterSetup
*/
export interface WithTestKuzuDBOptions {
/** Cypher CREATE queries to insert seed data (runs before core adapter opens). */
seed?: string[];
/** FTS indexes to create after seeding. */
ftsIndexes?: FTSIndexDef[];
/** Close core adapter and open pool adapter (read-only) after FTS setup. */
poolAdapter?: boolean;
/** Run after all lifecycle phases complete (mocks, dynamic imports, etc). */
afterSetup?: (handle: IndexedDBHandle) => Promise<void>;
/** Timeout for beforeAll in ms (default: 30000). */
timeout?: number;
}
/**
* Manages the full KuzuDB test lifecycle using the shared global DB:
* data clearing, reseeding, FTS indexes, adapter init/teardown.
*
* All data operations go through the core adapter's writable connection —
* no raw kuzu.Database() connections are opened. This avoids file-lock
* conflicts with orphaned native objects from previous test files.
*
* Each call is wrapped in its own `describe` block to isolate lifecycle
* hooks — safe to call multiple times in the same file.
*/
export function withTestKuzuDB(
prefix: string,
fn: (handle: IndexedDBHandle) => void,
options?: WithTestKuzuDBOptions,
): void {
const ref: { handle: IndexedDBHandle | undefined } = { handle: undefined };
const timeout = options?.timeout ?? 30000;
const setup = async () => {
// Get shared DB path from globalSetup (created once with full schema)
const dbPath = inject<'kuzuDbPath'>('kuzuDbPath');
const repoId = `test-${prefix}-${Date.now()}-${repoCounter++}`;
const adapter = await import('../../src/core/kuzu/kuzu-adapter.js');
// 1. Init core adapter (writable) — reuses existing connection if
// already open for this dbPath (no new native objects created).
await adapter.initKuzu(dbPath);
// 2. Load FTS extension (idempotent — skips if already loaded)
await adapter.loadFTSExtension();
// 3. Drop stale FTS indexes from previous test file
if (options?.ftsIndexes?.length) {
for (const idx of options.ftsIndexes) {
try { await adapter.dropFTSIndex(idx.table, idx.indexName); } catch { /* may not exist */ }
}
}
// 4. Clear all data via adapter (DETACH DELETE cascades to relationships)
for (const table of NODE_TABLES) {
await adapter.executeQuery(`MATCH (n:\`${table}\`) DETACH DELETE n`);
}
await adapter.executeQuery(`MATCH (n:${EMBEDDING_TABLE_NAME}) DELETE n`);
// 5. Seed new data via adapter
if (options?.seed?.length) {
for (const q of options.seed) {
await adapter.executeQuery(q);
}
}
// 6. Create FTS indexes on fresh data
if (options?.ftsIndexes?.length) {
for (const idx of options.ftsIndexes) {
await adapter.createFTSIndex(idx.table, idx.indexName, idx.columns);
}
}
// 7. Close core adapter (Windows only), then open pool adapter (read-only).
// On Windows, KuzuDB enforces file locks — writable + read-only
// can't coexist on the same path, so we must close the core first.
// On Linux/macOS, .close() deadlocks or segfaults via N-API
// destructor hooks, but concurrent Database instances on the same
// path are allowed, so we skip the close entirely.
if (options?.poolAdapter) {
if (process.platform === 'win32') {
await adapter.closeKuzu();
}
const { initKuzu: poolInitKuzu } = await import('../../src/mcp/core/kuzu-adapter.js');
await poolInitKuzu(repoId, dbPath);
}
// Cleanup: intentionally a no-op. We do NOT call detachKuzu() here
// because .closeSync() segfaults on Linux (KuzuDB N-API destructor bug).
// CI runs each KuzuDB test file in its own vitest process, so the OS
// reclaims all native resources on process exit — no explicit cleanup needed.
const cleanup = async () => {};
// tmpHandle.dbPath → parent temp dir (not the kuzu file) so tests
// that create sibling directories (e.g. 'storage') still work.
const tmpDir = path.dirname(dbPath);
const tmpHandle: TestDBHandle = { dbPath: tmpDir, cleanup: async () => {} };
ref.handle = { dbPath, repoId, tmpHandle, cleanup };
// 8. User's final setup (mocks, dynamic imports, etc.)
if (options?.afterSetup) {
await options.afterSetup(ref.handle);
}
};
const lazyHandle = new Proxy({} as IndexedDBHandle, {
get(_target, prop) {
if (!ref.handle) throw new Error('withTestKuzuDB: handle not initialized — beforeAll has not run yet');
return (ref.handle as any)[prop];
},
});
// Wrap in describe to scope beforeAll/afterAll — prevents lifecycle
// collisions when multiple withTestKuzuDB calls share the same file.
describe(`withTestKuzuDB(${prefix})`, () => {
beforeAll(setup, timeout);
afterAll(async () => { if (ref.handle) await ref.handle.cleanup(); });
fn(lazyHandle);
});
}
@@ -1,129 +0,0 @@
/**
* Integration Tests: Augmentation Engine
*
* augment() against a real indexed KuzuDB
* - Matching pattern returns non-empty string with callers/callees
* - Non-matching pattern returns empty string
* - Pattern shorter than 3 chars returns empty string
*/
import { describe, it, expect, vi } from 'vitest';
import { withTestKuzuDB } from '../helpers/test-indexed-db.js';
// ─── Seed data & FTS indexes for augmentation ────────
const AUGMENT_SEED_DATA = [
// File nodes
`CREATE (n:File {id: 'file:auth.ts', name: 'auth.ts', filePath: 'src/auth.ts', content: 'authentication module for user login'})`,
`CREATE (n:File {id: 'file:utils.ts', name: 'utils.ts', filePath: 'src/utils.ts', content: 'utility functions for hashing'})`,
// Function nodes
`CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 15, isExported: true, content: 'function login authenticates user credentials', description: 'user login'})`,
`CREATE (n:Function {id: 'func:validate', name: 'validate', filePath: 'src/auth.ts', startLine: 17, endLine: 25, isExported: true, content: 'function validate checks user input', description: 'input validation'})`,
`CREATE (n:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash computes bcrypt hash', description: 'password hashing'})`,
// Class / Method / Interface nodes
`CREATE (n:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService handles authentication', description: 'auth service'})`,
`CREATE (n:Method {id: 'method:AuthService.login', name: 'loginMethod', filePath: 'src/auth.ts', startLine: 35, endLine: 50, isExported: false, content: 'method login in AuthService', description: 'login method'})`,
`CREATE (n:Interface {id: 'iface:Creds', name: 'Credentials', filePath: 'src/auth.ts', startLine: 1, endLine: 5, isExported: true, content: 'interface Credentials for login authentication', description: 'credentials type'})`,
// Community & Process nodes
`CREATE (n:Community {id: 'comm:auth', label: 'Auth', heuristicLabel: 'Authentication', keywords: ['auth'], description: 'Auth cluster', enrichedBy: 'heuristic', cohesion: 0.8, symbolCount: 3})`,
`CREATE (n:Process {id: 'proc:login-flow', label: 'LoginFlow', heuristicLabel: 'User Login', processType: 'intra_community', stepCount: 2, communities: ['auth'], entryPointId: 'func:login', terminalId: 'func:validate'})`,
// Relationships
`MATCH (a:Function), (b:Function) WHERE a.id = 'func:login' AND b.id = 'func:validate'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
`MATCH (a:Function), (b:Function) WHERE a.id = 'func:login' AND b.id = 'func:hash'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.9, reason: 'import-resolved', step: 0}]->(b)`,
`MATCH (a:Function), (c:Community) WHERE a.id = 'func:login' AND c.id = 'comm:auth'
CREATE (a)-[:CodeRelation {type: 'MEMBER_OF', confidence: 1.0, reason: '', step: 0}]->(c)`,
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:login' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 1}]->(p)`,
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 2}]->(p)`,
];
const AUGMENT_FTS_INDEXES = [
{ table: 'File', indexName: 'file_fts', columns: ['name', 'content'] },
{ table: 'Function', indexName: 'function_fts', columns: ['name', 'content', 'description'] },
{ table: 'Class', indexName: 'class_fts', columns: ['name', 'content', 'description'] },
{ table: 'Method', indexName: 'method_fts', columns: ['name', 'content', 'description'] },
{ table: 'Interface', indexName: 'interface_fts', columns: ['name', 'content', 'description'] },
];
// Mock repo-manager so augment() finds our test DB
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn(),
}));
let augment: (pattern: string, cwd?: string) => Promise<string>;
withTestKuzuDB('augment', (handle) => {
describe('augment()', () => {
it('returns non-empty string with relationship info for a matching pattern', async () => {
const result = await augment('login', handle.dbPath);
expect(result.length).toBeGreaterThan(0);
expect(result).toContain('[GitNexus]');
expect(result).toContain('login');
});
it('returns empty string for a non-matching pattern', async () => {
const result = await augment('nonexistent_xyz', handle.dbPath);
expect(result).toBe('');
});
it('returns empty string for patterns shorter than 3 characters', async () => {
const result = await augment('ab', handle.dbPath);
expect(result).toBe('');
});
it('returns empty string for empty pattern', async () => {
const result = await augment('', handle.dbPath);
expect(result).toBe('');
});
// ─── Unhappy paths ────────────────────────────────────────────────
it('returns empty string for whitespace-only pattern', async () => {
const result = await augment(' ', handle.dbPath);
expect(result).toBe('');
});
it('handles special regex characters in pattern without throwing', async () => {
const result = await augment('func()', handle.dbPath);
expect(typeof result).toBe('string');
});
it('handles very long pattern without throwing', async () => {
const result = await augment('a'.repeat(500), handle.dbPath);
expect(typeof result).toBe('string');
});
it('handles unicode pattern without throwing', async () => {
const result = await augment('日本語テスト', handle.dbPath);
expect(typeof result).toBe('string');
});
});
}, {
seed: AUGMENT_SEED_DATA,
ftsIndexes: AUGMENT_FTS_INDEXES,
poolAdapter: true,
afterSetup: async (handle) => {
// Configure mock to return our test DB so augment() can find it
const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js');
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: handle.dbPath,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
// Dynamically import augment after mocks are in place
const engine = await import('../../src/core/augmentation/engine.js');
augment = engine.augment;
},
});

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