mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge remote-tracking branch 'origin/main' into pr-179-merge
This commit is contained in:
commit
79cdcb04a6
72 changed files with 6079 additions and 1340 deletions
|
|
@ -1,89 +1,89 @@
|
|||
---
|
||||
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,78 @@
|
|||
---
|
||||
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,97 +1,97 @@
|
|||
---
|
||||
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,121 +1,121 @@
|
|||
---
|
||||
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
.github/actions/setup-gitnexus/action.yml
vendored
Normal file
28
.github/actions/setup-gitnexus/action.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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
.github/release.yml
vendored
Normal file
45
.github/release.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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
.github/workflows/ci-integration.yml
vendored
Normal file
173
.github/workflows/ci-integration.yml
vendored
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
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
.github/workflows/ci-quality.yml
vendored
Normal file
14
.github/workflows/ci-quality.yml
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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
.github/workflows/ci-report.yml
vendored
Normal file
432
.github/workflows/ci-report.yml
vendored
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
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
.github/workflows/ci-unit-tests.yml
vendored
Normal file
53
.github/workflows/ci-unit-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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
|
||||
136
.github/workflows/ci.yml
vendored
136
.github/workflows/ci.yml
vendored
|
|
@ -3,67 +3,97 @@ 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:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
quality:
|
||||
uses: ./.github/workflows/ci-quality.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
- run: npx vitest run test/unit --coverage --coverage.thresholdAutoUpdate=false
|
||||
working-directory: gitnexus
|
||||
uses: ./.github/workflows/ci-unit-tests.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
integration-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
- run: npx vitest run test/integration
|
||||
working-directory: gitnexus
|
||||
integration:
|
||||
uses: ./.github/workflows/ci-integration.yml
|
||||
with:
|
||||
collect-coverage: ${{ github.event_name == 'pull_request' }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
cross-platform:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
# ── 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]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- 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
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
- run: npx vitest run test/unit
|
||||
working-directory: gitnexus
|
||||
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
|
||||
|
|
|
|||
97
.github/workflows/claude-code-review.yml
vendored
97
.github/workflows/claude-code-review.yml
vendored
|
|
@ -1,44 +1,97 @@
|
|||
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:
|
||||
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"
|
||||
# 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]
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# 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'
|
||||
|
||||
# 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')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
contents: write # needed to push fork branch to origin
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
# 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
|
||||
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@v1
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # 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/${{ 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
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
|
||||
|
||||
# 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
|
||||
|
|
|
|||
18
.github/workflows/claude.yml
vendored
18
.github/workflows/claude.yml
vendored
|
|
@ -18,33 +18,25 @@ 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: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # 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:*)'
|
||||
|
||||
|
|
|
|||
17
.github/workflows/publish.yml
vendored
17
.github/workflows/publish.yml
vendored
|
|
@ -5,19 +5,25 @@ on:
|
|||
tags:
|
||||
- 'v*'
|
||||
|
||||
# No workflow-level permissions — scoped per job below.
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
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@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
|
@ -27,8 +33,13 @@ jobs:
|
|||
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)"
|
||||
|
|
@ -52,6 +63,6 @@ jobs:
|
|||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -56,3 +56,7 @@ 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
|
||||
85
AGENTS.md
85
AGENTS.md
|
|
@ -1,25 +1,78 @@
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus MCP
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1444 symbols, 3700 relationships, 111 execution flows).
|
||||
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.
|
||||
|
||||
## Always Start Here
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
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**
|
||||
## Always Do
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
- **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"})`.
|
||||
|
||||
## Skills
|
||||
## When Debugging
|
||||
|
||||
| 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` |
|
||||
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
|
||||
|
||||
## 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/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 |
|
||||
|
||||
## 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
|
||||
|
||||
## CLI
|
||||
|
||||
- Re-index: `npx gitnexus analyze`
|
||||
- Check freshness: `npx gitnexus status`
|
||||
- Generate docs: `npx gitnexus wiki`
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
|
|
|||
65
CHANGELOG.md
Normal file
65
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# 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)
|
||||
85
CLAUDE.md
85
CLAUDE.md
|
|
@ -1,25 +1,78 @@
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus MCP
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1444 symbols, 3700 relationships, 111 execution flows).
|
||||
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.
|
||||
|
||||
## Always Start Here
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
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**
|
||||
## Always Do
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
- **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"})`.
|
||||
|
||||
## Skills
|
||||
## When Debugging
|
||||
|
||||
| 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` |
|
||||
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
|
||||
|
||||
## 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/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 |
|
||||
|
||||
## 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
|
||||
|
||||
## CLI
|
||||
|
||||
- Re-index: `npx gitnexus analyze`
|
||||
- Check freshness: `npx gitnexus status`
|
||||
- Generate docs: `npx gitnexus wiki`
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
|
|
|||
|
|
@ -82,12 +82,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) | **Full** |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **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 automatically enrich grep/glob/bash calls with knowledge graph context.
|
||||
> **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.
|
||||
|
||||
### Community Integrations
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
/**
|
||||
* GitNexus Claude Code Plugin Hook
|
||||
*
|
||||
* PreToolUse handler — intercepts Grep/Glob/Bash searches
|
||||
* and augments with graph context from the GitNexus index.
|
||||
* 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.
|
||||
*
|
||||
* NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
|
||||
* Session context is injected via CLAUDE.md / skills instead.
|
||||
|
|
@ -26,19 +28,19 @@ function readInput() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if a directory (or ancestor) has a .gitnexus index.
|
||||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function findGitNexusIndex(startDir) {
|
||||
function findGitNexusDir(startDir) {
|
||||
let dir = startDir || process.cwd();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (fs.existsSync(path.join(dir, '.gitnexus'))) {
|
||||
return true;
|
||||
}
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -83,66 +85,146 @@ 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 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 = '';
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
// Try direct gitnexus binary first (faster if globally installed)
|
||||
try {
|
||||
const child = spawnSync(
|
||||
'gitnexus',
|
||||
['augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: isWin }
|
||||
);
|
||||
if (child.status === 0 && child.stderr && child.stderr.trim()) {
|
||||
result = child.stderr;
|
||||
}
|
||||
} catch { /* not on PATH */ }
|
||||
|
||||
// Fallback to npx if direct binary didn't produce output
|
||||
if (!result || !result.trim()) {
|
||||
try {
|
||||
const child = spawnSync(
|
||||
'npx',
|
||||
['-y', 'gitnexus', 'augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: isWin }
|
||||
);
|
||||
if (child.status === 0 && child.stderr && child.stderr.trim()) {
|
||||
result = child.stderr;
|
||||
}
|
||||
} catch { /* graceful failure */ }
|
||||
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));
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: result.trim()
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Graceful failure
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,19 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Checking GitNexus index freshness..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
/**
|
||||
* GitNexus Claude Code Hook
|
||||
*
|
||||
* PreToolUse handler — intercepts Grep/Glob/Bash searches
|
||||
* and augments with graph context from the GitNexus index.
|
||||
* 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.
|
||||
*
|
||||
* NOTE: SessionStart hooks are broken on Windows (Claude Code bug).
|
||||
* Session context is injected via CLAUDE.md / skills instead.
|
||||
|
|
@ -11,7 +13,7 @@
|
|||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
|
|
@ -26,19 +28,19 @@ function readInput() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if a directory (or ancestor) has a .gitnexus index.
|
||||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function findGitNexusIndex(startDir) {
|
||||
function findGitNexusDir(startDir) {
|
||||
let dir = startDir || process.cwd();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (fs.existsSync(path.join(dir, '.gitnexus'))) {
|
||||
return true;
|
||||
}
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -83,72 +85,153 @@ 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 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 — try multiple strategies:
|
||||
// 1. Relative path (works when script is inside npm package)
|
||||
// 2. require.resolve (works when gitnexus is globally installed)
|
||||
// 3. Fall back to npx (works when neither is available)
|
||||
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
|
||||
if (!fs.existsSync(cliPath)) {
|
||||
try {
|
||||
cliPath = require.resolve('gitnexus/dist/cli/index.js');
|
||||
} catch {
|
||||
cliPath = ''; // will use npx fallback
|
||||
}
|
||||
}
|
||||
|
||||
// augment CLI writes result to stderr (KuzuDB's native module captures
|
||||
// stdout fd at OS level, making it unusable in subprocess contexts).
|
||||
const { spawnSync } = require('child_process');
|
||||
let result = '';
|
||||
try {
|
||||
let child;
|
||||
if (cliPath) {
|
||||
child = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, 'augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
} else {
|
||||
// npx fallback
|
||||
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
child = spawnSync(
|
||||
cmd,
|
||||
['-y', 'gitnexus', 'augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
}
|
||||
result = child.stderr || '';
|
||||
} catch { /* graceful failure */ }
|
||||
|
||||
if (result && result.trim()) {
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: result.trim()
|
||||
}
|
||||
}));
|
||||
}
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
// Graceful failure — log to stderr for debugging
|
||||
console.error('GitNexus hook error:', err.message);
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
4
gitnexus/package-lock.json
generated
4
gitnexus/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.3.6",
|
||||
"version": "1.3.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.3.6",
|
||||
"version": "1.3.11",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.3.6",
|
||||
"version": "1.3.11",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
**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.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
|
|
|
|||
|
|
@ -28,38 +28,110 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
|
|||
|
||||
/**
|
||||
* Generate the full GitNexus context content.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
function generateGitNexusContent(projectName: string, stats: RepoStats): string {
|
||||
return `${GITNEXUS_START_MARKER}
|
||||
# GitNexus MCP
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows).
|
||||
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.
|
||||
|
||||
## Always Start Here
|
||||
> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
|
||||
|
||||
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**
|
||||
## Always Do
|
||||
|
||||
> If step 1 warns the index is stale, run \`npx gitnexus analyze\` in the terminal first.
|
||||
- **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"})\`.
|
||||
|
||||
## Skills
|
||||
## When Debugging
|
||||
|
||||
| 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\` |
|
||||
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\`
|
||||
|
||||
${GITNEXUS_END_MARKER}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,6 +276,13 @@ 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,
|
||||
|
|
@ -286,6 +293,7 @@ export const analyzeCommand = async (
|
|||
edges: stats.edges,
|
||||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||||
embeddings: embeddingCount,
|
||||
},
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
|
|
@ -357,10 +365,8 @@ export const analyzeCommand = async (
|
|||
|
||||
console.log('');
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,18 +4,9 @@
|
|||
// Removing it from here improves MCP server startup time significantly.
|
||||
|
||||
import { Command } from 'commander';
|
||||
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';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createLazyAction } from './lazy-action.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require('../../package.json');
|
||||
const program = new Command();
|
||||
|
|
@ -28,43 +19,43 @@ program
|
|||
program
|
||||
.command('setup')
|
||||
.description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode')
|
||||
.action(setupCommand);
|
||||
.action(createLazyAction(() => import('./setup.js'), '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(analyzeCommand);
|
||||
.action(createLazyAction(() => import('./analyze.js'), '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(serveCommand);
|
||||
.action(createLazyAction(() => import('./serve.js'), 'serveCommand'));
|
||||
|
||||
program
|
||||
.command('mcp')
|
||||
.description('Start MCP server (stdio) — serves all indexed repos')
|
||||
.action(mcpCommand);
|
||||
.action(createLazyAction(() => import('./mcp.js'), 'mcpCommand'));
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('List all indexed repositories')
|
||||
.action(listCommand);
|
||||
.action(createLazyAction(() => import('./list.js'), 'listCommand'));
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show index status for current repo')
|
||||
.action(statusCommand);
|
||||
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.description('Delete GitNexus index for current repo')
|
||||
.option('-f, --force', 'Skip confirmation prompt')
|
||||
.option('--all', 'Clean all indexed repos')
|
||||
.action(cleanCommand);
|
||||
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
|
||||
|
||||
program
|
||||
.command('wiki [path]')
|
||||
|
|
@ -75,12 +66,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(wikiCommand);
|
||||
.action(createLazyAction(() => import('./wiki.js'), 'wikiCommand'));
|
||||
|
||||
program
|
||||
.command('augment <pattern>')
|
||||
.description('Augment a search pattern with knowledge graph context (used by hooks)')
|
||||
.action(augmentCommand);
|
||||
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
|
||||
|
||||
// ─── Direct Tool Commands (no MCP overhead) ────────────────────────
|
||||
// These invoke LocalBackend directly for use in eval, scripts, and CI.
|
||||
|
|
@ -93,7 +84,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(queryCommand);
|
||||
.action(createLazyAction(() => import('./tool.js'), 'queryCommand'));
|
||||
|
||||
program
|
||||
.command('context [name]')
|
||||
|
|
@ -102,7 +93,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(contextCommand);
|
||||
.action(createLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
|
||||
program
|
||||
.command('impact <target>')
|
||||
|
|
@ -111,13 +102,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(impactCommand);
|
||||
.action(createLazyAction(() => import('./tool.js'), 'impactCommand'));
|
||||
|
||||
program
|
||||
.command('cypher <query>')
|
||||
.description('Execute raw Cypher query against the knowledge graph')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.action(cypherCommand);
|
||||
.action(createLazyAction(() => import('./tool.js'), 'cypherCommand'));
|
||||
|
||||
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
|
||||
|
||||
|
|
@ -126,6 +117,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(evalServerCommand);
|
||||
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
||||
|
||||
program.parse(process.argv);
|
||||
|
|
|
|||
26
gitnexus/src/cli/lazy-action.ts
Normal file
26
gitnexus/src/cli/lazy-action.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -168,16 +168,18 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
|||
// 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 = '${normalizedCli}';`
|
||||
`let cliPath = ${jsonCli};`
|
||||
);
|
||||
await fs.writeFile(dest, content, 'utf-8');
|
||||
} catch {
|
||||
// Script not found in source — skip
|
||||
}
|
||||
|
||||
const hookCmd = `node "${path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/')}"`;
|
||||
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
|
||||
const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`;
|
||||
|
||||
// Merge hook config into ~/.claude/settings.json
|
||||
const existing = await readJsonFile(settingsPath) || {};
|
||||
|
|
@ -186,25 +188,31 @@ 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.
|
||||
|
||||
// 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...',
|
||||
}],
|
||||
});
|
||||
// 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 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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)');
|
||||
result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)');
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Claude Code hooks: ${err.message}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ 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: maxSize,
|
||||
max: effectiveMax,
|
||||
dispose: (tree) => {
|
||||
try {
|
||||
// NOTE: web-tree-sitter has tree.delete(); native tree-sitter trees are GC-managed.
|
||||
|
|
@ -41,7 +42,7 @@ export const createASTCache = (maxSize: number = 50): ASTCache => {
|
|||
|
||||
stats: () => ({
|
||||
size: cache.size,
|
||||
maxSize: maxSize
|
||||
maxSize: effectiveMax
|
||||
})
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ 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';
|
||||
|
||||
|
|
@ -108,6 +111,15 @@ export const runPipelineFromRepo = async (
|
|||
|
||||
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[] = [];
|
||||
|
|
@ -140,10 +152,19 @@ export const runPipelineFromRepo = async (
|
|||
// Create worker pool once, reuse across chunks
|
||||
let workerPool: WorkerPool | undefined;
|
||||
try {
|
||||
const workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
|
||||
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;
|
||||
}
|
||||
}
|
||||
workerPool = createWorkerPool(workerUrl);
|
||||
} catch (err) {
|
||||
// Worker pool creation failed — sequential fallback
|
||||
if (isDev) console.warn('Worker pool creation failed, using sequential fallback:', (err as Error).message);
|
||||
}
|
||||
|
||||
let filesParsedSoFar = 0;
|
||||
|
|
|
|||
|
|
@ -333,7 +333,8 @@ 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';
|
||||
return generateId(label, `${filePath}:${funcName}`);
|
||||
const startLine = current.startPosition?.row ?? 0;
|
||||
return generateId(label, `${filePath}:${funcName}:${startLine}`);
|
||||
}
|
||||
|
||||
if (['function_declaration', 'function_definition', 'async_function_declaration',
|
||||
|
|
@ -369,7 +370,8 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
|
|||
}
|
||||
|
||||
if (funcName) {
|
||||
return generateId(label, `${filePath}:${funcName}`);
|
||||
const startLine = current.startPosition?.row ?? 0;
|
||||
return generateId(label, `${filePath}:${funcName}:${startLine}`);
|
||||
}
|
||||
}
|
||||
current = current.parent;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { Worker } from 'node:worker_threads';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export interface WorkerPool {
|
||||
/**
|
||||
|
|
@ -30,6 +32,13 @@ 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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -591,6 +591,7 @@ 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
|
||||
|
|
@ -746,8 +747,8 @@ export const queryFTS = async (
|
|||
throw new Error('KuzuDB not initialized. Call initKuzu first.');
|
||||
}
|
||||
|
||||
// Escape single quotes in query
|
||||
const escapedQuery = query.replace(/'/g, "''");
|
||||
// Escape backslashes and single quotes to prevent Cypher injection
|
||||
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
||||
|
||||
const cypher = `
|
||||
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := ${conjunctive})
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ async function queryFTSViaExecutor(
|
|||
query: string,
|
||||
limit: number,
|
||||
): Promise<Array<{ filePath: string; score: number }>> {
|
||||
const escapedQuery = query.replace(/'/g, "''");
|
||||
// Escape single quotes and backslashes to prevent Cypher injection
|
||||
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
||||
const cypher = `
|
||||
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := false)
|
||||
RETURN node, score
|
||||
|
|
|
|||
240
gitnexus/src/mcp/compatible-stdio-transport.ts
Normal file
240
gitnexus/src/mcp/compatible-stdio-transport.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -84,15 +84,14 @@ function evictLRU(): void {
|
|||
}
|
||||
|
||||
/**
|
||||
* Close all connections for a repo and remove it from the pool
|
||||
* 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.
|
||||
*/
|
||||
function closeOne(repoId: string): void {
|
||||
const entry = pool.get(repoId);
|
||||
if (!entry) return;
|
||||
for (const conn of entry.available) {
|
||||
try { conn.close(); } catch (e) { console.error('GitNexus [pool:close-conn]:', e instanceof Error ? e.message : e); }
|
||||
}
|
||||
try { entry.db.close(); } catch (e) { console.error('GitNexus [pool:close-db]:', e instanceof Error ? e.message : e); }
|
||||
pool.delete(repoId);
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +324,7 @@ export const closeKuzu = async (repoId?: string): Promise<void> => {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Check if a specific repo's pool is active
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
import { createRequire } from 'module';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { CompatibleStdioServerTransport } from './compatible-stdio-transport.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
|
|
@ -277,7 +277,7 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
|
|||
const server = createMCPServer(backend);
|
||||
|
||||
// Connect to stdio transport
|
||||
const transport = new StdioServerTransport();
|
||||
const transport = new CompatibleStdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
// Graceful shutdown helper
|
||||
|
|
|
|||
34
gitnexus/test/fixtures/local-backend-seed.ts
vendored
Normal file
34
gitnexus/test/fixtures/local-backend-seed.ts
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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'] },
|
||||
];
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
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
gitnexus/test/fixtures/mini-repo/src/logger.ts
vendored
Normal file
18
gitnexus/test/fixtures/mini-repo/src/logger.ts
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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
gitnexus/test/fixtures/mini-repo/src/middleware.ts
vendored
Normal file
11
gitnexus/test/fixtures/mini-repo/src/middleware.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
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);
|
||||
}
|
||||
31
gitnexus/test/fixtures/search-seed.ts
vendored
Normal file
31
gitnexus/test/fixtures/search-seed.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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
gitnexus/test/global-setup.ts
Normal file
60
gitnexus/test/global-setup.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* 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();
|
||||
};
|
||||
}
|
||||
|
|
@ -73,18 +73,18 @@ export function buildTestGraph(
|
|||
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: 'func:main', label: 'Function', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true },
|
||||
{ id: 'func:helper', label: 'Function', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true },
|
||||
{ id: 'class:App', label: 'Class', name: 'App', filePath: 'src/index.ts', startLine: 12, endLine: 30, isExported: true },
|
||||
{ id: 'folder:src', label: 'Folder', name: 'src', filePath: 'src' },
|
||||
{ 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: 'func:main', targetId: 'func:helper', type: 'CALLS' },
|
||||
{ sourceId: 'func:main', targetId: 'class:App', type: 'CALLS' },
|
||||
{ sourceId: 'file:src/index.ts', targetId: 'func:main', type: 'CONTAINS' },
|
||||
{ sourceId: 'file:src/utils.ts', targetId: 'func:helper', type: 'CONTAINS' },
|
||||
{ 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
gitnexus/test/helpers/test-indexed-db.ts
Normal file
168
gitnexus/test/helpers/test-indexed-db.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
129
gitnexus/test/integration/augmentation.test.ts
Normal file
129
gitnexus/test/integration/augmentation.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* 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;
|
||||
},
|
||||
});
|
||||
234
gitnexus/test/integration/cli-e2e.test.ts
Normal file
234
gitnexus/test/integration/cli-e2e.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* P1 Integration Tests: CLI End-to-End
|
||||
*
|
||||
* Tests CLI commands via child process spawn:
|
||||
* - statusCommand: verify stdout for unindexed repo
|
||||
* - analyzeCommand: verify pipeline runs and creates .gitnexus/ output
|
||||
*
|
||||
* Uses process.execPath (never 'node' string), no shell: true.
|
||||
* Accepts status === null (timeout) as valid on slow CI runners.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(testDir, '../..');
|
||||
const cliEntry = path.join(repoRoot, 'src/cli/index.ts');
|
||||
const MINI_REPO = path.resolve(testDir, '..', 'fixtures', 'mini-repo');
|
||||
|
||||
// Absolute file:// URL to tsx loader — needed when spawning CLI with cwd
|
||||
// outside the project tree (bare 'tsx' specifier won't resolve there).
|
||||
// Cannot use require.resolve('tsx/dist/loader.mjs') because the subpath is
|
||||
// not in tsx's package.json exports; resolve the package root then join.
|
||||
const _require = createRequire(import.meta.url);
|
||||
const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json'));
|
||||
const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href;
|
||||
|
||||
beforeAll(() => {
|
||||
// Initialize mini-repo as a git repo so the CLI analyze command
|
||||
// can run the full pipeline (it requires a .git directory).
|
||||
const gitDir = path.join(MINI_REPO, '.git');
|
||||
if (!fs.existsSync(gitDir)) {
|
||||
spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' });
|
||||
spawnSync('git', ['add', '-A'], { cwd: MINI_REPO, stdio: 'pipe' });
|
||||
spawnSync('git', ['commit', '-m', 'initial commit'], {
|
||||
cwd: MINI_REPO,
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Clean up .git/ and .gitnexus/ directories created during the test
|
||||
for (const dir of ['.git', '.gitnexus']) {
|
||||
const fullPath = path.join(MINI_REPO, dir);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function runCli(command: string, cwd: string, timeoutMs = 15000) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, command], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it
|
||||
// and skips the re-exec. The re-exec drops the tsx loader (--import tsx
|
||||
// is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files.
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Like runCli but accepts an arbitrary extra-args array so unhappy-path tests
|
||||
* can pass flags (e.g. --help) or omit a command entirely.
|
||||
*/
|
||||
function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, ...extraArgs], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('CLI end-to-end', () => {
|
||||
it('status command exits cleanly', () => {
|
||||
const result = runCli('status', MINI_REPO);
|
||||
|
||||
// Accept timeout as valid on slow CI
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const combined = result.stdout + result.stderr;
|
||||
// mini-repo may or may not be indexed depending on prior test runs
|
||||
expect(combined).toMatch(/Repository|not indexed/i);
|
||||
});
|
||||
|
||||
it('analyze command runs pipeline on mini-repo', () => {
|
||||
const result = runCli('analyze', MINI_REPO, 30000);
|
||||
|
||||
// Accept timeout as valid on slow CI
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status, [
|
||||
`analyze exited with code ${result.status}`,
|
||||
`stdout: ${result.stdout}`,
|
||||
`stderr: ${result.stderr}`,
|
||||
].join('\n')).toBe(0);
|
||||
|
||||
// Successful analyze should create .gitnexus/ output directory
|
||||
const gitnexusDir = path.join(MINI_REPO, '.gitnexus');
|
||||
expect(fs.existsSync(gitnexusDir)).toBe(true);
|
||||
expect(fs.statSync(gitnexusDir).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
describe('unhappy path', () => {
|
||||
it('exits with error when no command is given', () => {
|
||||
const result = runCliRaw([], MINI_REPO);
|
||||
|
||||
// Accept timeout as valid on slow CI
|
||||
if (result.status === null) return;
|
||||
|
||||
// Commander exits with code 1 when no subcommand is given and
|
||||
// prints a usage/error message to stderr.
|
||||
expect(result.status).toBe(1);
|
||||
const combined = result.stdout + result.stderr;
|
||||
expect(combined.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows help with --help flag', () => {
|
||||
const result = runCliRaw(['--help'], MINI_REPO);
|
||||
|
||||
// Accept timeout as valid on slow CI
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
// Commander writes --help output to stdout.
|
||||
expect(result.stdout).toMatch(/Usage:/i);
|
||||
// The program name and at least one known subcommand should appear.
|
||||
expect(result.stdout).toMatch(/gitnexus/i);
|
||||
expect(result.stdout).toMatch(/analyze|status|serve/i);
|
||||
});
|
||||
|
||||
it('fails with unknown command', () => {
|
||||
const result = runCliRaw(['nonexistent'], MINI_REPO);
|
||||
|
||||
// Accept timeout as valid on slow CI
|
||||
if (result.status === null) return;
|
||||
|
||||
// Commander exits with code 1 and prints an error to stderr for unknown commands.
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toMatch(/unknown command/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI error handling', () => {
|
||||
/**
|
||||
* Helper to spawn CLI from a cwd outside the project tree.
|
||||
* Uses the absolute file:// URL to tsx loader so the --import hook
|
||||
* resolves even when cwd has no node_modules.
|
||||
*/
|
||||
function runCliOutsideProject(args: string[], cwd: string, timeoutMs = 15000) {
|
||||
return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('status on non-indexed repo reports not indexed', () => {
|
||||
// MINI_REPO is inside the project tree so findRepo() walks up and
|
||||
// finds the parent project's .gitnexus. Use an isolated temp git
|
||||
// repo to guarantee no .gitnexus exists anywhere in the path.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-noindex-'));
|
||||
try {
|
||||
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['commit', '--allow-empty', '-m', 'init'], {
|
||||
cwd: tmpDir, stdio: 'pipe',
|
||||
env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test' },
|
||||
});
|
||||
|
||||
const result = runCliOutsideProject(['status'], tmpDir);
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/Repository not indexed/);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('status on non-git directory reports not a git repo', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-nogit-'));
|
||||
try {
|
||||
const result = runCliOutsideProject(['status'], tmpDir);
|
||||
if (result.status === null) return;
|
||||
|
||||
// status.ts doesn't set process.exitCode — just prints and returns
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/Not a git repository/);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('analyze on non-git directory fails with exit code 1', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-nogit-'));
|
||||
try {
|
||||
// Pass the non-git path as a separate argument via runCliRaw
|
||||
// (runCli passes the whole string as one arg which breaks path parsing)
|
||||
const result = runCliRaw(['analyze', tmpDir], repoRoot);
|
||||
if (result.status === null) return;
|
||||
|
||||
// analyze.ts sets process.exitCode = 1 for non-git paths
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toMatch(/not.*git repository/i);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -175,4 +175,24 @@ describe('streamAllCSVsToDisk', () => {
|
|||
expect(fileCsv).toBeDefined();
|
||||
expect(fileCsv!.rows).toBe(1);
|
||||
});
|
||||
|
||||
// ─── Unhappy paths ──────────────────────────────────────────────────
|
||||
|
||||
it('handles empty graph (zero nodes)', async () => {
|
||||
const graph = buildTestGraph([], []);
|
||||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
expect(result.nodeFiles.size).toBe(0);
|
||||
expect(result.relRows).toBe(0);
|
||||
});
|
||||
|
||||
it('handles node with empty string properties', async () => {
|
||||
const graph = buildTestGraph([
|
||||
{ id: 'file:empty', label: 'File', name: '', filePath: '' },
|
||||
]);
|
||||
|
||||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
const fileCsv = result.nodeFiles.get('File');
|
||||
expect(fileCsv).toBeDefined();
|
||||
expect(fileCsv!.rows).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
235
gitnexus/test/integration/enrichment.test.ts
Normal file
235
gitnexus/test/integration/enrichment.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* Integration Tests: Cluster Enricher
|
||||
*
|
||||
* enrichClusters / enrichClustersBatch with mock LLM
|
||||
* - Valid JSON response populates enrichments
|
||||
* - Invalid JSON response falls back to heuristic label
|
||||
* - Batch processing with enrichClustersBatch
|
||||
* - Empty members use heuristicLabel fallback
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
enrichClusters,
|
||||
enrichClustersBatch,
|
||||
type LLMClient,
|
||||
type ClusterMemberInfo,
|
||||
} from '../../src/core/ingestion/cluster-enricher.js';
|
||||
import type { CommunityNode } from '../../src/core/ingestion/community-processor.js';
|
||||
|
||||
describe('enrichment', () => {
|
||||
describe('enrichClusters', () => {
|
||||
const communities: CommunityNode[] = [
|
||||
{
|
||||
id: 'comm_0',
|
||||
label: 'Auth',
|
||||
heuristicLabel: 'Authentication',
|
||||
cohesion: 0.8,
|
||||
symbolCount: 3,
|
||||
},
|
||||
{
|
||||
id: 'comm_1',
|
||||
label: 'Utils',
|
||||
heuristicLabel: 'Utilities',
|
||||
cohesion: 0.5,
|
||||
symbolCount: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const memberMap = new Map<string, ClusterMemberInfo[]>([
|
||||
[
|
||||
'comm_0',
|
||||
[
|
||||
{ name: 'login', filePath: 'src/auth.ts', type: 'Function' },
|
||||
{ name: 'validate', filePath: 'src/auth.ts', type: 'Function' },
|
||||
{ name: 'AuthService', filePath: 'src/auth.ts', type: 'Class' },
|
||||
],
|
||||
],
|
||||
[
|
||||
'comm_1',
|
||||
[
|
||||
{ name: 'hash', filePath: 'src/utils.ts', type: 'Function' },
|
||||
{ name: 'format', filePath: 'src/utils.ts', type: 'Function' },
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
it('populates enrichments when LLM returns valid JSON', async () => {
|
||||
const mockLLM: LLMClient = {
|
||||
generate: vi.fn()
|
||||
.mockResolvedValueOnce('{"name": "Auth Module", "description": "Handles authentication"}')
|
||||
.mockResolvedValueOnce('{"name": "Utility Helpers", "description": "Common utilities"}'),
|
||||
};
|
||||
|
||||
const result = await enrichClusters(communities, memberMap, mockLLM);
|
||||
|
||||
expect(result.enrichments.size).toBe(2);
|
||||
|
||||
const auth = result.enrichments.get('comm_0')!;
|
||||
expect(auth.name).toBe('Auth Module');
|
||||
expect(auth.description).toBe('Handles authentication');
|
||||
|
||||
const utils = result.enrichments.get('comm_1')!;
|
||||
expect(utils.name).toBe('Utility Helpers');
|
||||
expect(utils.description).toBe('Common utilities');
|
||||
|
||||
expect(result.tokensUsed).toBeGreaterThan(0);
|
||||
expect(mockLLM.generate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('falls back to heuristic label when LLM returns invalid JSON', async () => {
|
||||
const badLLM: LLMClient = {
|
||||
generate: vi.fn().mockResolvedValue('this is not json at all'),
|
||||
};
|
||||
|
||||
const result = await enrichClusters(communities, memberMap, badLLM);
|
||||
|
||||
expect(result.enrichments.size).toBe(2);
|
||||
|
||||
// Invalid JSON -> parseEnrichmentResponse falls back to heuristicLabel
|
||||
const auth = result.enrichments.get('comm_0')!;
|
||||
expect(auth.name).toBe('Authentication');
|
||||
expect(auth.keywords).toEqual([]);
|
||||
expect(auth.description).toBe('');
|
||||
|
||||
const utils = result.enrichments.get('comm_1')!;
|
||||
expect(utils.name).toBe('Utilities');
|
||||
});
|
||||
|
||||
it('uses heuristicLabel fallback for clusters with empty members', async () => {
|
||||
const emptyMemberMap = new Map<string, ClusterMemberInfo[]>([
|
||||
['comm_0', []],
|
||||
['comm_1', []],
|
||||
]);
|
||||
|
||||
const mockLLM: LLMClient = {
|
||||
generate: vi.fn().mockResolvedValue('{"name": "Should Not Appear", "description": "nope"}'),
|
||||
};
|
||||
|
||||
const result = await enrichClusters(communities, emptyMemberMap, mockLLM);
|
||||
|
||||
expect(result.enrichments.size).toBe(2);
|
||||
|
||||
// Empty members -> skip LLM, use heuristic directly
|
||||
const auth = result.enrichments.get('comm_0')!;
|
||||
expect(auth.name).toBe('Authentication');
|
||||
expect(auth.keywords).toEqual([]);
|
||||
expect(auth.description).toBe('');
|
||||
|
||||
// LLM should never be called for empty members
|
||||
expect(mockLLM.generate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onProgress callback with correct current/total', async () => {
|
||||
const mockLLM: LLMClient = {
|
||||
generate: vi.fn().mockResolvedValue('{"name": "X", "description": "Y"}'),
|
||||
};
|
||||
const progress: Array<[number, number]> = [];
|
||||
|
||||
await enrichClusters(communities, memberMap, mockLLM, (current, total) => {
|
||||
progress.push([current, total]);
|
||||
});
|
||||
|
||||
expect(progress).toEqual([
|
||||
[1, 2],
|
||||
[2, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Unhappy paths ────────────────────────────────────────────────
|
||||
|
||||
it('falls back to heuristic when LLM returns empty string', async () => {
|
||||
const emptyLLM: LLMClient = {
|
||||
generate: vi.fn().mockResolvedValue(''),
|
||||
};
|
||||
|
||||
const result = await enrichClusters(communities, memberMap, emptyLLM);
|
||||
expect(result.enrichments.size).toBe(2);
|
||||
expect(result.enrichments.get('comm_0')!.name).toBe('Authentication');
|
||||
expect(result.enrichments.get('comm_1')!.name).toBe('Utilities');
|
||||
});
|
||||
|
||||
it('handles zero communities gracefully', async () => {
|
||||
const mockLLM: LLMClient = {
|
||||
generate: vi.fn(),
|
||||
};
|
||||
|
||||
const result = await enrichClusters([], new Map(), mockLLM);
|
||||
expect(result.enrichments.size).toBe(0);
|
||||
expect(mockLLM.generate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles LLM returning JSON with missing description field', async () => {
|
||||
const partialLLM: LLMClient = {
|
||||
generate: vi.fn().mockResolvedValue('{"name": "Auth Only"}'),
|
||||
};
|
||||
|
||||
const result = await enrichClusters(communities, memberMap, partialLLM);
|
||||
expect(result.enrichments.size).toBe(2);
|
||||
const auth = result.enrichments.get('comm_0')!;
|
||||
expect(auth.name).toBe('Auth Only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichClustersBatch', () => {
|
||||
const communities: CommunityNode[] = [
|
||||
{ id: 'comm_0', label: 'Auth', heuristicLabel: 'Authentication', cohesion: 0.8, symbolCount: 3 },
|
||||
{ id: 'comm_1', label: 'Utils', heuristicLabel: 'Utilities', cohesion: 0.5, symbolCount: 2 },
|
||||
{ id: 'comm_2', label: 'Router', heuristicLabel: 'Routing', cohesion: 0.6, symbolCount: 2 },
|
||||
];
|
||||
|
||||
const memberMap = new Map<string, ClusterMemberInfo[]>([
|
||||
['comm_0', [{ name: 'login', filePath: 'src/auth.ts', type: 'Function' }]],
|
||||
['comm_1', [{ name: 'hash', filePath: 'src/utils.ts', type: 'Function' }]],
|
||||
['comm_2', [{ name: 'route', filePath: 'src/router.ts', type: 'Function' }]],
|
||||
]);
|
||||
|
||||
it('processes all clusters in batches and returns enrichments', async () => {
|
||||
const batchResponse = JSON.stringify([
|
||||
{ id: 'comm_0', name: 'Auth Module', keywords: ['auth', 'login'], description: 'Authentication logic' },
|
||||
{ id: 'comm_1', name: 'Utility Helpers', keywords: ['utils'], description: 'Common utilities' },
|
||||
]);
|
||||
const batchResponse2 = JSON.stringify([
|
||||
{ id: 'comm_2', name: 'HTTP Router', keywords: ['routing'], description: 'Request routing' },
|
||||
]);
|
||||
|
||||
const mockLLM: LLMClient = {
|
||||
generate: vi.fn()
|
||||
.mockResolvedValueOnce(batchResponse)
|
||||
.mockResolvedValueOnce(batchResponse2),
|
||||
};
|
||||
|
||||
const result = await enrichClustersBatch(communities, memberMap, mockLLM, 2);
|
||||
|
||||
expect(result.enrichments.size).toBe(3);
|
||||
|
||||
const auth = result.enrichments.get('comm_0')!;
|
||||
expect(auth.name).toBe('Auth Module');
|
||||
expect(auth.keywords).toEqual(['auth', 'login']);
|
||||
expect(auth.description).toBe('Authentication logic');
|
||||
|
||||
const utils = result.enrichments.get('comm_1')!;
|
||||
expect(utils.name).toBe('Utility Helpers');
|
||||
|
||||
const router = result.enrichments.get('comm_2')!;
|
||||
expect(router.name).toBe('HTTP Router');
|
||||
|
||||
expect(result.tokensUsed).toBeGreaterThan(0);
|
||||
// 3 communities with batchSize=2 -> 2 LLM calls
|
||||
expect(mockLLM.generate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('falls back to heuristic labels on batch parse failure', async () => {
|
||||
const mockLLM: LLMClient = {
|
||||
generate: vi.fn().mockRejectedValue(new Error('LLM unavailable')),
|
||||
};
|
||||
|
||||
const result = await enrichClustersBatch(communities, memberMap, mockLLM, 5);
|
||||
|
||||
// All communities should get heuristic fallback
|
||||
expect(result.enrichments.size).toBe(3);
|
||||
expect(result.enrichments.get('comm_0')!.name).toBe('Authentication');
|
||||
expect(result.enrichments.get('comm_1')!.name).toBe('Utilities');
|
||||
expect(result.enrichments.get('comm_2')!.name).toBe('Routing');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
|
@ -70,6 +70,41 @@ describe('filesystem-walker', () => {
|
|||
await walkRepositoryPaths(tmpDir, onProgress);
|
||||
expect(onProgress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ─── Unhappy paths ────────────────────────────────────────────────
|
||||
|
||||
it('throws or returns empty for non-existent directory', async () => {
|
||||
try {
|
||||
const files = await walkRepositoryPaths('/nonexistent/path/xyz123');
|
||||
// If it doesn't throw, it should return empty
|
||||
expect(files).toEqual([]);
|
||||
} catch (err: any) {
|
||||
expect(err).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns empty for directory with only ignored files', async () => {
|
||||
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-empty-'));
|
||||
await fs.mkdir(path.join(emptyDir, '.git'), { recursive: true });
|
||||
await fs.writeFile(path.join(emptyDir, '.git', 'HEAD'), 'ref: refs/heads/main');
|
||||
|
||||
try {
|
||||
const files = await walkRepositoryPaths(emptyDir);
|
||||
expect(files).toEqual([]);
|
||||
} finally {
|
||||
await fs.rm(emptyDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns empty for truly empty directory', async () => {
|
||||
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-truly-empty-'));
|
||||
try {
|
||||
const files = await walkRepositoryPaths(emptyDir);
|
||||
expect(files).toEqual([]);
|
||||
} finally {
|
||||
await fs.rm(emptyDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFileContents', () => {
|
||||
|
|
@ -88,5 +123,18 @@ describe('filesystem-walker', () => {
|
|||
const contents = await readFileContents(tmpDir, ['nonexistent.ts']);
|
||||
expect(contents.size).toBe(0);
|
||||
});
|
||||
|
||||
// ─── Unhappy paths ────────────────────────────────────────────────
|
||||
|
||||
it('skips multiple non-existent files gracefully', async () => {
|
||||
const contents = await readFileContents(tmpDir, ['a.ts', 'b.ts', 'c.ts']);
|
||||
expect(contents.size).toBe(0);
|
||||
});
|
||||
|
||||
it('handles binary file content without crashing', async () => {
|
||||
const contents = await readFileContents(tmpDir, ['src/image.png']);
|
||||
// May return content or skip — should not throw
|
||||
expect(contents.size).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
411
gitnexus/test/integration/hooks-e2e.test.ts
Normal file
411
gitnexus/test/integration/hooks-e2e.test.ts
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
/**
|
||||
* Integration Tests: Claude Code Hooks End-to-End
|
||||
*
|
||||
* Tests the hook scripts with real git repos and .gitnexus directories.
|
||||
* Unlike unit/hooks.test.ts which tests source code patterns and simple
|
||||
* stdin/stdout, these tests verify actual behavior with filesystem state.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js';
|
||||
|
||||
// ─── Paths to both hook variants ────────────────────────────────────
|
||||
|
||||
const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs');
|
||||
const PLUGIN_HOOK = path.resolve(__dirname, '..', '..', '..', 'gitnexus-claude-plugin', 'hooks', 'gitnexus-hook.js');
|
||||
|
||||
const HOOKS = [
|
||||
{ name: 'CJS', path: CJS_HOOK },
|
||||
...(fs.existsSync(PLUGIN_HOOK) ? [{ name: 'Plugin', path: PLUGIN_HOOK }] : []),
|
||||
];
|
||||
|
||||
// ─── Temp git repo with .gitnexus ───────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
let gitNexusDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-e2e-'));
|
||||
gitNexusDir = path.join(tmpDir, '.gitnexus');
|
||||
fs.mkdirSync(gitNexusDir, { recursive: true });
|
||||
|
||||
// Initialize a real git repo
|
||||
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
// Create a file and commit so HEAD exists
|
||||
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello');
|
||||
spawnSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => {
|
||||
describe('PostToolUse staleness detection', () => {
|
||||
it('detects stale index when meta.json lastCommit differs from HEAD', () => {
|
||||
// Write meta.json with an old commit hash
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('stale');
|
||||
expect(output!.additionalContext).toContain('npx gitnexus analyze');
|
||||
});
|
||||
|
||||
it('stays silent when meta.json lastCommit matches HEAD', () => {
|
||||
// Get current HEAD
|
||||
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: tmpDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const head = headResult.stdout.trim();
|
||||
|
||||
// Write meta.json with matching commit
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: head, stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
|
||||
it('includes --embeddings flag when previous index had embeddings', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', stats: { embeddings: 42 } }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('--embeddings');
|
||||
});
|
||||
|
||||
it('treats missing meta.json as stale', () => {
|
||||
// Remove meta.json
|
||||
const metaPath = path.join(gitNexusDir, 'meta.json');
|
||||
if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('stale');
|
||||
});
|
||||
|
||||
it('ignores failed git commands (exit_code !== 0)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'cccccccccccccccccccccccccccccccccccccccc', stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 1 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores non-mutation git commands', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'dddddddddddddddddddddddddddddddddddddddd', stats: {} }),
|
||||
);
|
||||
|
||||
const nonMutations = ['git status', 'git log', 'git diff', 'git branch', 'git stash'];
|
||||
for (const cmd of nonMutations) {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: cmd },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('detects all 5 git mutation types', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', stats: {} }),
|
||||
);
|
||||
|
||||
const mutations = ['git commit -m "x"', 'git merge feature', 'git rebase main', 'git cherry-pick abc', 'git pull origin main'];
|
||||
for (const cmd of mutations) {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: cmd },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('stale');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PreToolUse — silent without gitnexus CLI', () => {
|
||||
// PreToolUse tries to spawn `gitnexus augment` which won't be available in CI.
|
||||
// Verify it fails gracefully (no output, no crash).
|
||||
|
||||
it('handles Grep pattern gracefully when CLI is unavailable', () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Grep',
|
||||
tool_input: { pattern: 'handleRequest' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
// Should not crash — status is 0 if it exits cleanly, or null if the
|
||||
// spawned `gitnexus augment` hangs and the 10s timeout kills the process.
|
||||
expect(result.status === 0 || result.status === null).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores patterns shorter than 3 chars', () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Grep',
|
||||
tool_input: { pattern: 'ab' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores non-search tools', () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/some/file.ts' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cwd validation', () => {
|
||||
it('rejects relative cwd silently for PostToolUse', () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: 'relative/path',
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects relative cwd silently for PreToolUse', () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Grep',
|
||||
tool_input: { pattern: 'testPattern' },
|
||||
cwd: 'relative/path',
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unhappy paths', () => {
|
||||
it('handles corrupted meta.json (invalid JSON) without crashing', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
'THIS IS NOT JSON {{{',
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
// Should not crash — either treats as stale or ignores
|
||||
expect(result.status === 0 || result.status === null).toBe(true);
|
||||
});
|
||||
|
||||
it('handles meta.json with missing lastCommit field', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.status === 0 || result.status === null).toBe(true);
|
||||
const output = parseHookOutput(result.stdout);
|
||||
// Missing lastCommit should be treated as stale
|
||||
if (output) {
|
||||
expect(output.additionalContext).toContain('stale');
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores unknown hook event name', () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'UnknownEvent',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty tool_input for PostToolUse without crashing', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'aaaa', stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: {},
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.status === 0 || result.status === null).toBe(true);
|
||||
const output = parseHookOutput(result.stdout);
|
||||
// No command means no git mutation detection — should be silent
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores non-Bash tool for PostToolUse', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'aaaa', stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/some/file.ts' },
|
||||
tool_output: {},
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('directory without .gitnexus', () => {
|
||||
// The hook walks up 5 parent directories looking for .gitnexus.
|
||||
// To guarantee none is found, create a deeply nested temp dir at the
|
||||
// filesystem root where no .gitnexus could exist in any ancestor.
|
||||
let noGitNexusDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
// Use a root-level temp path so parent traversal can't find .gitnexus
|
||||
const root = os.platform() === 'win32' ? 'C:\\' : '/tmp';
|
||||
const base = path.join(root, `no-gitnexus-${Date.now()}`);
|
||||
// Nest 6 levels deep (hook walks up 5) to ensure isolation
|
||||
noGitNexusDir = path.join(base, 'a', 'b', 'c', 'd', 'e', 'f');
|
||||
fs.mkdirSync(noGitNexusDir, { recursive: true });
|
||||
spawnSync('git', ['init'], { cwd: noGitNexusDir, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Clean up from the base directory
|
||||
const root = os.platform() === 'win32' ? 'C:\\' : '/tmp';
|
||||
const base = path.join(root, path.basename(path.resolve(noGitNexusDir, '..', '..', '..', '..', '..', '..')));
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ignores PostToolUse when no .gitnexus directory exists', () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: noGitNexusDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores PreToolUse when no .gitnexus directory exists', () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Grep',
|
||||
tool_input: { pattern: 'somePattern' },
|
||||
cwd: noGitNexusDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
138
gitnexus/test/integration/kuzu-core-adapter.test.ts
Normal file
138
gitnexus/test/integration/kuzu-core-adapter.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* P0 Integration Tests: Core KuzuDB Adapter
|
||||
*
|
||||
* Tests: loadGraphToKuzu CSV round-trip, createFTSIndex, getKuzuStats.
|
||||
*
|
||||
* IMPORTANT: All core adapter tests share ONE coreHandle and ONE coreInitKuzu
|
||||
* call because the core adapter is a module-level singleton. Calling
|
||||
* coreInitKuzu with a different path would close the previous native DB
|
||||
* handle, which segfaults in forked processes. Sharing a single handle
|
||||
* avoids this entirely.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { withTestKuzuDB } from '../helpers/test-indexed-db.js';
|
||||
|
||||
// ─── Core KuzuDB Adapter ─────────────────────────────────────────────
|
||||
|
||||
withTestKuzuDB('core-adapter', (handle) => {
|
||||
describe('core adapter', () => {
|
||||
it('loadGraphToKuzu: loads a minimal graph and node counts match', async () => {
|
||||
const { executeQuery: coreExecuteQuery } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
|
||||
// createMinimalTestGraph has 2 File, 2 Function, 1 Class, 1 Folder = 6 nodes
|
||||
const fileRows = await coreExecuteQuery('MATCH (n:File) RETURN n.id AS id');
|
||||
expect(fileRows).toHaveLength(2);
|
||||
|
||||
const funcRows = await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id');
|
||||
expect(funcRows).toHaveLength(2);
|
||||
|
||||
const classRows = await coreExecuteQuery('MATCH (n:Class) RETURN n.id AS id');
|
||||
expect(classRows).toHaveLength(1);
|
||||
|
||||
const folderRows = await coreExecuteQuery('MATCH (n:Folder) RETURN n.id AS id');
|
||||
expect(folderRows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('createFTSIndex: creates FTS index on Function table without error', async () => {
|
||||
const { createFTSIndex } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
|
||||
await expect(
|
||||
createFTSIndex('Function', 'function_fts', ['name', 'content']),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('getKuzuStats: returns correct node and edge counts for seeded data', async () => {
|
||||
const { getKuzuStats } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
|
||||
const stats = await getKuzuStats();
|
||||
|
||||
// createMinimalTestGraph: 6 nodes (2 File, 2 Function, 1 Class, 1 Folder)
|
||||
expect(stats.nodes).toBe(6);
|
||||
|
||||
// 4 relationships (2 CALLS, 2 CONTAINS)
|
||||
expect(stats.edges).toBe(4);
|
||||
});
|
||||
|
||||
describe('unhappy path', () => {
|
||||
it('throws on malformed Cypher query', async () => {
|
||||
const { executeQuery } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
|
||||
// Deliberately broken syntax: MATCH without a pattern clause
|
||||
await expect(executeQuery('MATCH RETURN 1')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('returns empty results for query matching no nodes', async () => {
|
||||
const { executeQuery } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
|
||||
// Valid Cypher, but the id will never exist in the seeded graph
|
||||
const rows = await executeQuery(
|
||||
"MATCH (n:Function) WHERE n.id = '__nonexistent_id__' RETURN n.id AS id",
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles query with non-existent table/node label', async () => {
|
||||
const { executeQuery } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
|
||||
// KuzuDB throws when the node table does not exist in the schema
|
||||
await expect(
|
||||
executeQuery('MATCH (n:GhostTable) RETURN n'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('createFTSIndex handles already-existing index gracefully', async () => {
|
||||
const { createFTSIndex } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
|
||||
// First call creates the index (may already exist from earlier test)
|
||||
await createFTSIndex('Function', 'function_fts_dup', ['name', 'content']);
|
||||
|
||||
// Second call with same params should NOT throw — createFTSIndex catches "already exists"
|
||||
await expect(
|
||||
createFTSIndex('Function', 'function_fts_dup', ['name', 'content']),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('getKuzuStats returns valid counts', async () => {
|
||||
const { getKuzuStats } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
|
||||
// getKuzuStats NEVER throws — it has silent catch blocks per table
|
||||
const stats = await getKuzuStats();
|
||||
expect(typeof stats.nodes).toBe('number');
|
||||
expect(typeof stats.edges).toBe('number');
|
||||
expect(stats.nodes).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.edges).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('executeQuery with empty string rejects', async () => {
|
||||
const { executeQuery } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
|
||||
// KuzuDB throws on empty query string
|
||||
await expect(executeQuery('')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('deleteNodesForFile with non-existent path returns zero deleted', async () => {
|
||||
const { deleteNodesForFile } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
|
||||
// deleteNodesForFile has per-query try/catch, returns {deletedNodes: 0} for missing paths
|
||||
const result = await deleteNodesForFile('/absolutely/nonexistent/path/file.ts');
|
||||
expect(result).toEqual({ deletedNodes: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
}, {
|
||||
afterSetup: async (handle) => {
|
||||
// Load a minimal graph via CSV round-trip (core adapter is already initialized by wrapper)
|
||||
const { loadGraphToKuzu } = await import('../../src/core/kuzu/kuzu-adapter.js');
|
||||
const { createMinimalTestGraph } = await import('../helpers/test-graph.js');
|
||||
|
||||
const graph = createMinimalTestGraph();
|
||||
const storagePath = path.join(handle.tmpHandle.dbPath, 'storage');
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
|
||||
await loadGraphToKuzu(graph, '/test/repo', storagePath);
|
||||
},
|
||||
});
|
||||
|
|
@ -5,11 +5,7 @@
|
|||
* Covers hardening fixes: parameterized queries, query timeout,
|
||||
* waiter queue timeout, idle eviction guards, stdout silencing race
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import kuzu from 'kuzu';
|
||||
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
initKuzu,
|
||||
executeQuery,
|
||||
|
|
@ -17,163 +13,175 @@ import {
|
|||
closeKuzu,
|
||||
isKuzuReady,
|
||||
} from '../../src/mcp/core/kuzu-adapter.js';
|
||||
import { NODE_SCHEMA_QUERIES, REL_SCHEMA_QUERIES } from '../../src/core/kuzu/schema.js';
|
||||
import { withTestKuzuDB } from '../helpers/test-indexed-db.js';
|
||||
|
||||
let tmpHandle: TestDBHandle;
|
||||
let dbPath: string;
|
||||
const REPO_ID = 'test-repo';
|
||||
|
||||
/**
|
||||
* Create a writable KuzuDB with schema and seed data.
|
||||
* The pool opens it read-only, so we must create it separately.
|
||||
*/
|
||||
async function createTestDB(dbDir: string): Promise<void> {
|
||||
const db = new kuzu.Database(dbDir);
|
||||
const conn = new kuzu.Connection(db);
|
||||
|
||||
// Create schema
|
||||
for (const q of NODE_SCHEMA_QUERIES) {
|
||||
await conn.query(q);
|
||||
}
|
||||
for (const q of REL_SCHEMA_QUERIES) {
|
||||
await conn.query(q);
|
||||
}
|
||||
|
||||
// Insert test data
|
||||
await conn.query(`CREATE (f:File {id: 'file:index.ts', name: 'index.ts', filePath: 'src/index.ts', content: ''})`);
|
||||
await conn.query(`CREATE (fn:Function {id: 'func:main', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`);
|
||||
await conn.query(`CREATE (fn2:Function {id: 'func:helper', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`);
|
||||
await conn.query(`
|
||||
MATCH (a:Function), (b:Function)
|
||||
const POOL_SEED_DATA = [
|
||||
`CREATE (f:File {id: 'file:index.ts', name: 'index.ts', filePath: 'src/index.ts', content: ''})`,
|
||||
`CREATE (fn:Function {id: 'func:main', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`,
|
||||
`CREATE (fn2:Function {id: 'func:helper', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`,
|
||||
`MATCH (a:Function), (b:Function)
|
||||
WHERE a.id = 'func:main' AND b.id = 'func:helper'
|
||||
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)
|
||||
`);
|
||||
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
|
||||
];
|
||||
|
||||
conn.close();
|
||||
db.close();
|
||||
}
|
||||
// ─── Pool lifecycle tests — test the pool adapter API directly ───────
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpHandle = await createTempDir('kuzu-pool-test-');
|
||||
dbPath = path.join(tmpHandle.dbPath, 'kuzu');
|
||||
// KuzuDB creates the directory itself — do NOT mkdir
|
||||
await createTestDB(dbPath);
|
||||
}, 30000);
|
||||
withTestKuzuDB('kuzu-pool', (handle) => {
|
||||
afterEach(async () => {
|
||||
try { await closeKuzu('test-repo'); } catch { /* best-effort */ }
|
||||
try { await closeKuzu('repo1'); } catch { /* best-effort */ }
|
||||
try { await closeKuzu('repo2'); } catch { /* best-effort */ }
|
||||
try { await closeKuzu(''); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// NOTE: We intentionally skip closeKuzu() here because KuzuDB native
|
||||
// cleanup in forked workers can cause segfaults on process exit.
|
||||
// The OS reclaims resources when the worker process terminates.
|
||||
try { await tmpHandle.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up specific repo IDs used in tests, not all
|
||||
try { await closeKuzu(REPO_ID); } catch { /* best-effort */ }
|
||||
try { await closeKuzu('repo1'); } catch { /* best-effort */ }
|
||||
try { await closeKuzu('repo2'); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
// ─── Lifecycle: init → query → close ─────────────────────────────────
|
||||
|
||||
describe('pool lifecycle', () => {
|
||||
it('initKuzu + executeQuery + closeKuzu', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
expect(isKuzuReady(REPO_ID)).toBe(true);
|
||||
|
||||
const rows = await executeQuery(REPO_ID, 'MATCH (n:Function) RETURN n.name AS name');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2);
|
||||
const names = rows.map((r: any) => r.name);
|
||||
expect(names).toContain('main');
|
||||
expect(names).toContain('helper');
|
||||
|
||||
await closeKuzu(REPO_ID);
|
||||
expect(isKuzuReady(REPO_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('initKuzu reuses existing pool entry', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
await initKuzu(REPO_ID, dbPath); // second call should be no-op
|
||||
expect(isKuzuReady(REPO_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it('closeKuzu is idempotent', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
await closeKuzu(REPO_ID);
|
||||
await closeKuzu(REPO_ID); // second close should not throw
|
||||
expect(isKuzuReady(REPO_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('closeKuzu with no args closes all repos', async () => {
|
||||
await initKuzu('repo1', dbPath);
|
||||
await initKuzu('repo2', dbPath);
|
||||
expect(isKuzuReady('repo1')).toBe(true);
|
||||
expect(isKuzuReady('repo2')).toBe(true);
|
||||
|
||||
await closeKuzu();
|
||||
expect(isKuzuReady('repo1')).toBe(false);
|
||||
expect(isKuzuReady('repo2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Parameterized queries ───────────────────────────────────────────
|
||||
|
||||
describe('executeParameterized', () => {
|
||||
it('works with parameterized query', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
const rows = await executeParameterized(
|
||||
REPO_ID,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
||||
{ name: 'main' },
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('main');
|
||||
});
|
||||
|
||||
it('injection attempt is harmless with parameterized query', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
const rows = await executeParameterized(
|
||||
REPO_ID,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
||||
{ name: "' OR 1=1 --" }, // SQL/Cypher injection attempt
|
||||
);
|
||||
// Should return 0 rows, not all rows
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error handling ──────────────────────────────────────────────────
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws when querying uninitialized repo', async () => {
|
||||
await expect(executeQuery('nonexistent-repo', 'MATCH (n) RETURN n'))
|
||||
.rejects.toThrow(/not initialized/);
|
||||
});
|
||||
|
||||
it('throws when db path does not exist', async () => {
|
||||
await expect(initKuzu('bad-repo', '/nonexistent/path/kuzu'))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it('read-only mode: write query throws', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
await expect(executeQuery(REPO_ID, "CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})"))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Relationship queries ────────────────────────────────────────────
|
||||
|
||||
describe('relationship queries', () => {
|
||||
it('can query relationships', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
const rows = await executeQuery(
|
||||
REPO_ID,
|
||||
`MATCH (a:Function)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name AS caller, b.name AS callee`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
const row = rows.find((r: any) => r.caller === 'main');
|
||||
expect(row).toBeDefined();
|
||||
expect(row.callee).toBe('helper');
|
||||
});
|
||||
// ─── Lifecycle: init → query → close ─────────────────────────────────
|
||||
|
||||
describe('pool lifecycle', () => {
|
||||
it('initKuzu + executeQuery + closeKuzu', async () => {
|
||||
await initKuzu('test-repo', handle.dbPath);
|
||||
expect(isKuzuReady('test-repo')).toBe(true);
|
||||
|
||||
const rows = await executeQuery('test-repo', 'MATCH (n:Function) RETURN n.name AS name');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2);
|
||||
const names = rows.map((r: any) => r.name);
|
||||
expect(names).toContain('main');
|
||||
expect(names).toContain('helper');
|
||||
|
||||
await closeKuzu('test-repo');
|
||||
expect(isKuzuReady('test-repo')).toBe(false);
|
||||
});
|
||||
|
||||
it('initKuzu reuses existing pool entry', async () => {
|
||||
await initKuzu('test-repo', handle.dbPath);
|
||||
await initKuzu('test-repo', handle.dbPath); // second call should be no-op
|
||||
expect(isKuzuReady('test-repo')).toBe(true);
|
||||
});
|
||||
|
||||
it('closeKuzu is idempotent', async () => {
|
||||
await initKuzu('test-repo', handle.dbPath);
|
||||
await closeKuzu('test-repo');
|
||||
await closeKuzu('test-repo'); // second close should not throw
|
||||
expect(isKuzuReady('test-repo')).toBe(false);
|
||||
});
|
||||
|
||||
it('closeKuzu with no args closes all repos', async () => {
|
||||
await initKuzu('repo1', handle.dbPath);
|
||||
await initKuzu('repo2', handle.dbPath);
|
||||
expect(isKuzuReady('repo1')).toBe(true);
|
||||
expect(isKuzuReady('repo2')).toBe(true);
|
||||
|
||||
await closeKuzu();
|
||||
expect(isKuzuReady('repo1')).toBe(false);
|
||||
expect(isKuzuReady('repo2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Parameterized queries ───────────────────────────────────────────
|
||||
|
||||
describe('executeParameterized', () => {
|
||||
it('works with parameterized query', async () => {
|
||||
await initKuzu('test-repo', handle.dbPath);
|
||||
const rows = await executeParameterized(
|
||||
'test-repo',
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
||||
{ name: 'main' },
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('main');
|
||||
});
|
||||
|
||||
it('injection attempt is harmless with parameterized query', async () => {
|
||||
await initKuzu('test-repo', handle.dbPath);
|
||||
const rows = await executeParameterized(
|
||||
'test-repo',
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
||||
{ name: "' OR 1=1 --" }, // SQL/Cypher injection attempt
|
||||
);
|
||||
// Should return 0 rows, not all rows
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error handling ──────────────────────────────────────────────────
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws when querying uninitialized repo', async () => {
|
||||
await expect(executeQuery('nonexistent-repo', 'MATCH (n) RETURN n'))
|
||||
.rejects.toThrow(/not initialized/);
|
||||
});
|
||||
|
||||
it('throws when db path does not exist', async () => {
|
||||
await expect(initKuzu('bad-repo', '/nonexistent/path/kuzu'))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it('read-only mode: write query throws', async () => {
|
||||
await initKuzu('test-repo', handle.dbPath);
|
||||
await expect(executeQuery('test-repo', "CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})"))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Relationship queries ────────────────────────────────────────────
|
||||
|
||||
describe('relationship queries', () => {
|
||||
it('can query relationships', async () => {
|
||||
await initKuzu('test-repo', handle.dbPath);
|
||||
const rows = await executeQuery(
|
||||
'test-repo',
|
||||
`MATCH (a:Function)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name AS caller, b.name AS callee`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
const row = rows.find((r: any) => r.caller === 'main');
|
||||
expect(row).toBeDefined();
|
||||
expect(row.callee).toBe('helper');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unhappy paths ──────────────────────────────────────────────────
|
||||
|
||||
describe('unhappy paths', () => {
|
||||
it('executeParameterized throws when repo is not initialized', async () => {
|
||||
await expect(executeParameterized('ghost-repo', 'MATCH (n) RETURN n', {}))
|
||||
.rejects.toThrow(/not initialized/);
|
||||
});
|
||||
|
||||
it('executeQuery rejects invalid Cypher syntax', async () => {
|
||||
await initKuzu('test-repo', handle.dbPath);
|
||||
await expect(executeQuery('test-repo', 'THIS IS NOT CYPHER'))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it('executeParameterized rejects when referenced parameter is missing', async () => {
|
||||
await initKuzu('test-repo', handle.dbPath);
|
||||
await expect(executeParameterized(
|
||||
'test-repo',
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n',
|
||||
{ wrong_param: 'main' },
|
||||
)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('closeKuzu with unknown repoId does not throw', async () => {
|
||||
await expect(closeKuzu('never-existed-repo')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('isKuzuReady returns false for unknown repoId', () => {
|
||||
expect(isKuzuReady('never-existed-repo')).toBe(false);
|
||||
});
|
||||
|
||||
it('initKuzu with empty string repoId stores entry under empty key', async () => {
|
||||
await initKuzu('', handle.dbPath);
|
||||
expect(isKuzuReady('')).toBe(true);
|
||||
await closeKuzu('');
|
||||
expect(isKuzuReady('')).toBe(false);
|
||||
});
|
||||
|
||||
it('executeQuery with empty query string rejects', async () => {
|
||||
await initKuzu('test-repo', handle.dbPath);
|
||||
await expect(executeQuery('test-repo', '')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
}, {
|
||||
seed: POOL_SEED_DATA,
|
||||
poolAdapter: true,
|
||||
});
|
||||
|
|
|
|||
170
gitnexus/test/integration/local-backend-calltool.test.ts
Normal file
170
gitnexus/test/integration/local-backend-calltool.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* P0 Integration Tests: Local Backend — callTool dispatch
|
||||
*
|
||||
* Tests the full LocalBackend.callTool() dispatch with a real KuzuDB
|
||||
* instance, verifying cypher, context, impact, and query tools work
|
||||
* end-to-end against seeded graph data with FTS indexes.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { withTestKuzuDB } from '../helpers/test-indexed-db.js';
|
||||
import { LOCAL_BACKEND_SEED_DATA, LOCAL_BACKEND_FTS_INDEXES } from '../fixtures/local-backend-seed.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// ─── Block 2: callTool dispatch tests ────────────────────────────────
|
||||
|
||||
withTestKuzuDB('local-backend-calltool', (handle) => {
|
||||
|
||||
describe('callTool dispatch with real DB', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(async () => {
|
||||
// backend is created in afterSetup and attached to the handle
|
||||
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
||||
if (!ext._backend) {
|
||||
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend to handle');
|
||||
}
|
||||
backend = ext._backend;
|
||||
});
|
||||
|
||||
it('cypher tool returns function names', async () => {
|
||||
const result = await backend.callTool('cypher', {
|
||||
query: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
|
||||
});
|
||||
// cypher tool wraps results as markdown
|
||||
expect(result).toHaveProperty('markdown');
|
||||
expect(result).toHaveProperty('row_count');
|
||||
expect(result.row_count).toBeGreaterThanOrEqual(3);
|
||||
expect(result.markdown).toContain('login');
|
||||
expect(result.markdown).toContain('validate');
|
||||
expect(result.markdown).toContain('hash');
|
||||
});
|
||||
|
||||
it('cypher tool blocks write queries', async () => {
|
||||
const result = await backend.callTool('cypher', {
|
||||
query: "CREATE (n:Function {id: 'x', name: 'x', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})",
|
||||
});
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toMatch(/write operations/i);
|
||||
});
|
||||
|
||||
it('context tool returns symbol info with callers and callees', async () => {
|
||||
const result = await backend.callTool('context', { name: 'login' });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).toBe('found');
|
||||
// Should have the symbol identity
|
||||
expect(result.symbol).toBeDefined();
|
||||
expect(result.symbol.name).toBe('login');
|
||||
expect(result.symbol.filePath).toBe('src/auth.ts');
|
||||
// login calls validate and hash — should appear in outgoing.calls
|
||||
expect(result.outgoing).toBeDefined();
|
||||
expect(result.outgoing.calls).toBeDefined();
|
||||
expect(result.outgoing.calls.length).toBeGreaterThanOrEqual(2);
|
||||
const calleeNames = result.outgoing.calls.map((c: any) => c.name);
|
||||
expect(calleeNames).toContain('validate');
|
||||
expect(calleeNames).toContain('hash');
|
||||
});
|
||||
|
||||
it('impact tool returns upstream dependents', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'validate',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
// validate is called by login, so login should appear at depth 1
|
||||
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
||||
expect(result.byDepth).toBeDefined();
|
||||
const directDeps = result.byDepth[1] || result.byDepth['1'] || [];
|
||||
expect(directDeps.length).toBeGreaterThanOrEqual(1);
|
||||
const depNames = directDeps.map((d: any) => d.name);
|
||||
expect(depNames).toContain('login');
|
||||
});
|
||||
|
||||
it('query tool returns results for keyword search', async () => {
|
||||
const result = await backend.callTool('query', { query: 'login' });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
// Should have some combination of processes, process_symbols, or definitions
|
||||
expect(result).toHaveProperty('processes');
|
||||
expect(result).toHaveProperty('definitions');
|
||||
// The search should find something (FTS or graph-based)
|
||||
const totalResults =
|
||||
(result.processes?.length || 0) +
|
||||
(result.process_symbols?.length || 0) +
|
||||
(result.definitions?.length || 0);
|
||||
expect(totalResults).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('unknown tool throws', async () => {
|
||||
await expect(
|
||||
backend.callTool('nonexistent_tool', {}),
|
||||
).rejects.toThrow(/unknown tool/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool parameter edge cases', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(async () => {
|
||||
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
||||
if (!ext._backend) {
|
||||
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend to handle');
|
||||
}
|
||||
backend = ext._backend;
|
||||
});
|
||||
|
||||
it('context tool returns error for nonexistent symbol', async () => {
|
||||
const result = await backend.callTool('context', { name: 'nonexistent_xyz_symbol_999' });
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('query tool returns error for empty query', async () => {
|
||||
const result = await backend.callTool('query', { query: '' });
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toMatch(/required/i);
|
||||
});
|
||||
|
||||
it('query tool returns error for missing query param', async () => {
|
||||
const result = await backend.callTool('query', {});
|
||||
expect(result).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('cypher tool returns error for invalid Cypher syntax', async () => {
|
||||
const result = await backend.callTool('cypher', { query: 'THIS IS NOT VALID CYPHER AT ALL' });
|
||||
expect(result).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('context tool returns error when no name or uid provided', async () => {
|
||||
const result = await backend.callTool('context', {});
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toMatch(/required/i);
|
||||
});
|
||||
});
|
||||
|
||||
}, {
|
||||
seed: LOCAL_BACKEND_SEED_DATA,
|
||||
ftsIndexes: LOCAL_BACKEND_FTS_INDEXES,
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
// Configure listRegisteredRepos mock with handle values
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'test-repo',
|
||||
path: '/test/repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
|
||||
},
|
||||
]);
|
||||
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
// Stash backend on handle so tests can access it
|
||||
(handle as any)._backend = backend;
|
||||
},
|
||||
});
|
||||
|
|
@ -13,242 +13,247 @@
|
|||
* #3 (path traversal), #4 (relation allowlist), #25 (regex lastIndex),
|
||||
* #26 (rename first-occurrence-only)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import kuzu from 'kuzu';
|
||||
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
initKuzu,
|
||||
executeQuery,
|
||||
executeParameterized,
|
||||
closeKuzu,
|
||||
} from '../../src/mcp/core/kuzu-adapter.js';
|
||||
import { NODE_SCHEMA_QUERIES, REL_SCHEMA_QUERIES } from '../../src/core/kuzu/schema.js';
|
||||
import {
|
||||
CYPHER_WRITE_RE,
|
||||
VALID_RELATION_TYPES,
|
||||
isWriteQuery,
|
||||
} from '../../src/mcp/local/local-backend.js';
|
||||
import { withTestKuzuDB } from '../helpers/test-indexed-db.js';
|
||||
import { LOCAL_BACKEND_SEED_DATA } from '../fixtures/local-backend-seed.js';
|
||||
|
||||
let tmpHandle: TestDBHandle;
|
||||
let dbPath: string;
|
||||
const REPO_ID = 'backend-test';
|
||||
// ─── Block 1: Pool adapter tests ─────────────────────────────────────
|
||||
|
||||
async function createTestDB(dbDir: string): Promise<void> {
|
||||
const db = new kuzu.Database(dbDir);
|
||||
const conn = new kuzu.Connection(db);
|
||||
withTestKuzuDB('local-backend', (handle) => {
|
||||
|
||||
for (const q of NODE_SCHEMA_QUERIES) {
|
||||
await conn.query(q);
|
||||
}
|
||||
for (const q of REL_SCHEMA_QUERIES) {
|
||||
await conn.query(q);
|
||||
}
|
||||
// ─── Cypher write blocking ───────────────────────────────────────────
|
||||
|
||||
// Insert test data: files, functions, classes, relationships
|
||||
await conn.query(`CREATE (f:File {id: 'file:auth.ts', name: 'auth.ts', filePath: 'src/auth.ts', content: 'auth module'})`);
|
||||
await conn.query(`CREATE (f:File {id: 'file:utils.ts', name: 'utils.ts', filePath: 'src/utils.ts', content: 'utils module'})`);
|
||||
await conn.query(`CREATE (fn:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 15, isExported: true, content: 'function login() {}', description: 'User login'})`);
|
||||
await conn.query(`CREATE (fn:Function {id: 'func:validate', name: 'validate', filePath: 'src/auth.ts', startLine: 17, endLine: 25, isExported: true, content: 'function validate() {}', description: 'Validate input'})`);
|
||||
await conn.query(`CREATE (fn:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash() {}', description: 'Hash utility'})`);
|
||||
await conn.query(`CREATE (c:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService {}', description: 'Authentication service'})`);
|
||||
await conn.query(`CREATE (c:Community {id: 'comm:auth', label: 'Auth', heuristicLabel: 'Authentication', keywords: ['auth', 'login'], description: 'Auth module', enrichedBy: 'heuristic', cohesion: 0.8, symbolCount: 3})`);
|
||||
await conn.query(`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'})`);
|
||||
describe('cypher write blocking', () => {
|
||||
const allWriteKeywords = ['CREATE', 'DELETE', 'SET', 'MERGE', 'REMOVE', 'DROP', 'ALTER', 'COPY', 'DETACH'];
|
||||
|
||||
// Relationships
|
||||
await conn.query(`
|
||||
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)
|
||||
`);
|
||||
await conn.query(`
|
||||
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)
|
||||
`);
|
||||
await conn.query(`
|
||||
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)
|
||||
`);
|
||||
await conn.query(`
|
||||
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)
|
||||
`);
|
||||
await conn.query(`
|
||||
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)
|
||||
`);
|
||||
for (const keyword of allWriteKeywords) {
|
||||
it(`blocks ${keyword} query`, () => {
|
||||
const blocked = isWriteQuery(`MATCH (n) ${keyword} n.name = "x"`);
|
||||
expect(blocked).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
conn.close();
|
||||
db.close();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpHandle = await createTempDir('backend-test-');
|
||||
dbPath = path.join(tmpHandle.dbPath, 'kuzu');
|
||||
// KuzuDB creates the directory itself — do NOT mkdir
|
||||
await createTestDB(dbPath);
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
// NOTE: We intentionally skip closeKuzu() here because KuzuDB native
|
||||
// cleanup in forked workers can cause segfaults on process exit.
|
||||
// The OS reclaims resources when the worker process terminates.
|
||||
try { await tmpHandle.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
// ─── Cypher write blocking ───────────────────────────────────────────
|
||||
|
||||
describe('cypher write blocking', () => {
|
||||
const allWriteKeywords = ['CREATE', 'DELETE', 'SET', 'MERGE', 'REMOVE', 'DROP', 'ALTER', 'COPY', 'DETACH'];
|
||||
|
||||
for (const keyword of allWriteKeywords) {
|
||||
it(`blocks ${keyword} query`, () => {
|
||||
const blocked = isWriteQuery(`MATCH (n) ${keyword} n.name = "x"`);
|
||||
expect(blocked).toBe(true);
|
||||
it('allows valid read queries through the pool', async () => {
|
||||
const rows = await executeQuery(handle.repoId, 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
}
|
||||
|
||||
it('allows valid read queries through the pool', async () => {
|
||||
const rows = await executeQuery(REPO_ID, 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Parameterized queries ───────────────────────────────────────────
|
||||
|
||||
describe('parameterized queries', () => {
|
||||
it('finds exact match with parameter', async () => {
|
||||
const rows = await executeParameterized(
|
||||
REPO_ID,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name, n.filePath AS filePath',
|
||||
{ name: 'login' },
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('login');
|
||||
expect(rows[0].filePath).toBe('src/auth.ts');
|
||||
});
|
||||
|
||||
it('injection is harmless', async () => {
|
||||
const rows = await executeParameterized(
|
||||
REPO_ID,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
||||
{ name: "login' OR '1'='1" },
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Relation type filtering ─────────────────────────────────────────
|
||||
|
||||
describe('relation type filtering', () => {
|
||||
it('only allows valid relation types in queries', () => {
|
||||
const validTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
||||
const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE'];
|
||||
|
||||
for (const t of validTypes) {
|
||||
expect(VALID_RELATION_TYPES.has(t)).toBe(true);
|
||||
}
|
||||
for (const t of invalidTypes) {
|
||||
expect(VALID_RELATION_TYPES.has(t)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('can query relationships with valid types', async () => {
|
||||
const rows = await executeQuery(
|
||||
REPO_ID,
|
||||
`MATCH (a:Function)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name AS caller, b.name AS callee ORDER BY b.name`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Process queries ─────────────────────────────────────────────────
|
||||
|
||||
describe('process queries', () => {
|
||||
it('can find processes', async () => {
|
||||
const rows = await executeQuery(REPO_ID, 'MATCH (p:Process) RETURN p.heuristicLabel AS label, p.stepCount AS steps');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].label).toBe('User Login');
|
||||
});
|
||||
|
||||
it('can trace process steps', async () => {
|
||||
const rows = await executeQuery(
|
||||
REPO_ID,
|
||||
`MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
WHERE p.id = 'proc:login-flow'
|
||||
RETURN s.name AS symbol, r.step AS step
|
||||
ORDER BY r.step`,
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].symbol).toBe('login');
|
||||
expect(rows[0].step).toBe(1);
|
||||
expect(rows[1].symbol).toBe('validate');
|
||||
expect(rows[1].step).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Community queries ───────────────────────────────────────────────
|
||||
|
||||
describe('community queries', () => {
|
||||
it('can find communities', async () => {
|
||||
const rows = await executeQuery(REPO_ID, 'MATCH (c:Community) RETURN c.heuristicLabel AS label');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].label).toBe('Authentication');
|
||||
});
|
||||
|
||||
it('can find community members', async () => {
|
||||
const rows = await executeQuery(
|
||||
REPO_ID,
|
||||
`MATCH (f)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
WHERE c.heuristicLabel = 'Authentication'
|
||||
RETURN f.name AS name`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].name).toBe('login');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Read-only enforcement ───────────────────────────────────────────
|
||||
|
||||
describe('read-only database', () => {
|
||||
it('rejects write operations at DB level', async () => {
|
||||
await expect(
|
||||
executeQuery(REPO_ID, `CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})`)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Regex lastIndex hardening (#25) ─────────────────────────────────
|
||||
|
||||
describe('regex lastIndex (hardening #25)', () => {
|
||||
it('CYPHER_WRITE_RE is non-global (no sticky lastIndex)', () => {
|
||||
expect(CYPHER_WRITE_RE.global).toBe(false);
|
||||
expect(CYPHER_WRITE_RE.sticky).toBe(false);
|
||||
});
|
||||
|
||||
it('works correctly across multiple consecutive calls', () => {
|
||||
// If the regex were global, lastIndex could cause false results
|
||||
const results = [
|
||||
isWriteQuery('CREATE (n)'), // true
|
||||
isWriteQuery('MATCH (n) RETURN n'), // false
|
||||
isWriteQuery('DELETE n'), // true
|
||||
isWriteQuery('MATCH (n) RETURN n'), // false
|
||||
isWriteQuery('SET n.x = 1'), // true
|
||||
];
|
||||
expect(results).toEqual([true, false, true, false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Content queries (include_content equivalent) ────────────────────
|
||||
|
||||
describe('content queries', () => {
|
||||
it('can retrieve symbol content', async () => {
|
||||
const rows = await executeQuery(
|
||||
REPO_ID,
|
||||
`MATCH (n:Function) WHERE n.name = 'login' RETURN n.content AS content`,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].content).toContain('function login');
|
||||
});
|
||||
|
||||
// ─── Parameterized queries ───────────────────────────────────────────
|
||||
|
||||
describe('parameterized queries', () => {
|
||||
it('finds exact match with parameter', async () => {
|
||||
const rows = await executeParameterized(
|
||||
handle.repoId,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name, n.filePath AS filePath',
|
||||
{ name: 'login' },
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('login');
|
||||
expect(rows[0].filePath).toBe('src/auth.ts');
|
||||
});
|
||||
|
||||
it('injection is harmless', async () => {
|
||||
const rows = await executeParameterized(
|
||||
handle.repoId,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
||||
{ name: "login' OR '1'='1" },
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Relation type filtering ─────────────────────────────────────────
|
||||
|
||||
describe('relation type filtering', () => {
|
||||
it('only allows valid relation types in queries', () => {
|
||||
const validTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
||||
const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE'];
|
||||
|
||||
for (const t of validTypes) {
|
||||
expect(VALID_RELATION_TYPES.has(t)).toBe(true);
|
||||
}
|
||||
for (const t of invalidTypes) {
|
||||
expect(VALID_RELATION_TYPES.has(t)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('can query relationships with valid types', async () => {
|
||||
const rows = await executeQuery(
|
||||
handle.repoId,
|
||||
`MATCH (a:Function)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name AS caller, b.name AS callee ORDER BY b.name`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Process queries ─────────────────────────────────────────────────
|
||||
|
||||
describe('process queries', () => {
|
||||
it('can find processes', async () => {
|
||||
const rows = await executeQuery(handle.repoId, 'MATCH (p:Process) RETURN p.heuristicLabel AS label, p.stepCount AS steps');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].label).toBe('User Login');
|
||||
});
|
||||
|
||||
it('can trace process steps', async () => {
|
||||
const rows = await executeQuery(
|
||||
handle.repoId,
|
||||
`MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
WHERE p.id = 'proc:login-flow'
|
||||
RETURN s.name AS symbol, r.step AS step
|
||||
ORDER BY r.step`,
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].symbol).toBe('login');
|
||||
expect(rows[0].step).toBe(1);
|
||||
expect(rows[1].symbol).toBe('validate');
|
||||
expect(rows[1].step).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Community queries ───────────────────────────────────────────────
|
||||
|
||||
describe('community queries', () => {
|
||||
it('can find communities', async () => {
|
||||
const rows = await executeQuery(handle.repoId, 'MATCH (c:Community) RETURN c.heuristicLabel AS label');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].label).toBe('Authentication');
|
||||
});
|
||||
|
||||
it('can find community members', async () => {
|
||||
const rows = await executeQuery(
|
||||
handle.repoId,
|
||||
`MATCH (f)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
WHERE c.heuristicLabel = 'Authentication'
|
||||
RETURN f.name AS name`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].name).toBe('login');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Read-only enforcement ───────────────────────────────────────────
|
||||
|
||||
describe('read-only database', () => {
|
||||
it('rejects write operations at DB level', async () => {
|
||||
await expect(
|
||||
executeQuery(handle.repoId, `CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})`)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Regex lastIndex hardening (#25) ─────────────────────────────────
|
||||
|
||||
describe('regex lastIndex (hardening #25)', () => {
|
||||
it('CYPHER_WRITE_RE is non-global (no sticky lastIndex)', () => {
|
||||
expect(CYPHER_WRITE_RE.global).toBe(false);
|
||||
expect(CYPHER_WRITE_RE.sticky).toBe(false);
|
||||
});
|
||||
|
||||
it('works correctly across multiple consecutive calls', () => {
|
||||
// If the regex were global, lastIndex could cause false results
|
||||
const results = [
|
||||
isWriteQuery('CREATE (n)'), // true
|
||||
isWriteQuery('MATCH (n) RETURN n'), // false
|
||||
isWriteQuery('DELETE n'), // true
|
||||
isWriteQuery('MATCH (n) RETURN n'), // false
|
||||
isWriteQuery('SET n.x = 1'), // true
|
||||
];
|
||||
expect(results).toEqual([true, false, true, false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Content queries (include_content equivalent) ────────────────────
|
||||
|
||||
describe('content queries', () => {
|
||||
it('can retrieve symbol content', async () => {
|
||||
const rows = await executeQuery(
|
||||
handle.repoId,
|
||||
`MATCH (n:Function) WHERE n.name = 'login' RETURN n.content AS content`,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].content).toContain('function login');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Write blocking edge cases ──────────────────────────────────────
|
||||
|
||||
describe('write blocking edge cases', () => {
|
||||
it('blocks lowercase write keywords (case-insensitive)', () => {
|
||||
expect(isWriteQuery('create (n:Function {id: "x"})')).toBe(true);
|
||||
expect(isWriteQuery('delete n')).toBe(true);
|
||||
expect(isWriteQuery('set n.name = "x"')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks write keyword in CREATED-like words (regex is keyword-boundary unaware)', () => {
|
||||
// CYPHER_WRITE_RE uses \b word boundaries — "CREATED" does NOT match "CREATE"
|
||||
const result = isWriteQuery("MATCH (n) WHERE n.name = 'CREATED' RETURN n");
|
||||
// The regex uses word boundaries so substring "CREATE" inside "CREATED" is NOT matched
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks multi-line queries with write keywords', () => {
|
||||
expect(isWriteQuery('MATCH (n)\nDELETE n')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isWriteQuery('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for whitespace-only query', () => {
|
||||
expect(isWriteQuery(' ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Query error handling via pool ──────────────────────────────────
|
||||
|
||||
describe('query error handling via pool', () => {
|
||||
it('returns empty rows for unknown node label', async () => {
|
||||
// KuzuDB throws a Binder exception for unknown node labels
|
||||
await expect(
|
||||
executeQuery(handle.repoId, 'MATCH (n:NonExistentTable) RETURN n.name AS name')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects syntactically invalid Cypher', async () => {
|
||||
await expect(executeQuery(handle.repoId, 'NOT VALID CYPHER AT ALL'))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Parameterized query edge cases ─────────────────────────────────
|
||||
|
||||
describe('parameterized query edge cases', () => {
|
||||
it('succeeds with empty params when query has no parameters', async () => {
|
||||
const rows = await executeParameterized(
|
||||
handle.repoId,
|
||||
'MATCH (n:Function) RETURN n.name AS name LIMIT 1',
|
||||
{},
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('returns empty rows when param value is null', async () => {
|
||||
const rows = await executeParameterized(
|
||||
handle.repoId,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
||||
{ name: null as any },
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
}, {
|
||||
seed: LOCAL_BACKEND_SEED_DATA,
|
||||
poolAdapter: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import fs from 'fs/promises';
|
|||
import path from 'path';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import { isNodeExported } from '../../src/core/ingestion/parsing-processor.js';
|
||||
import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js';
|
||||
import { getLanguageFromFilename } from '../../src/core/ingestion/utils.js';
|
||||
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
||||
|
||||
const FIXTURES_DIR = path.join(process.cwd(), 'test', 'fixtures', 'sample-code');
|
||||
|
||||
|
|
@ -32,180 +35,236 @@ function mockNode(type: string, text: string = '', parent?: any): any {
|
|||
|
||||
// ─── isNodeExported per-language ─────────────────────────────────────
|
||||
|
||||
describe('isNodeExported', () => {
|
||||
// TypeScript/JavaScript
|
||||
describe('typescript', () => {
|
||||
it('returns true when ancestor is export_statement', () => {
|
||||
const exportStmt = mockNode('export_statement', 'export function foo() {}');
|
||||
const fnDecl = mockNode('function_declaration', 'function foo() {}', exportStmt);
|
||||
const nameNode = mockNode('identifier', 'foo', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(true);
|
||||
describe('parsing', () => {
|
||||
describe('isNodeExported', () => {
|
||||
// TypeScript/JavaScript
|
||||
describe('typescript', () => {
|
||||
it('returns true when ancestor is export_statement', () => {
|
||||
const exportStmt = mockNode('export_statement', 'export function foo() {}');
|
||||
const fnDecl = mockNode('function_declaration', 'function foo() {}', exportStmt);
|
||||
const nameNode = mockNode('identifier', 'foo', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-exported function', () => {
|
||||
const fnDecl = mockNode('function_declaration', 'function foo() {}');
|
||||
const nameNode = mockNode('identifier', 'foo', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when text starts with "export "', () => {
|
||||
const parent = mockNode('lexical_declaration', 'export const foo = 1');
|
||||
const nameNode = mockNode('identifier', 'foo', parent);
|
||||
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false for non-exported function', () => {
|
||||
const fnDecl = mockNode('function_declaration', 'function foo() {}');
|
||||
const nameNode = mockNode('identifier', 'foo', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(false);
|
||||
// Python
|
||||
describe('python', () => {
|
||||
it('public function (no underscore prefix)', () => {
|
||||
const node = mockNode('identifier', 'public_function');
|
||||
expect(isNodeExported(node, 'public_function', 'python')).toBe(true);
|
||||
});
|
||||
|
||||
it('private function (underscore prefix)', () => {
|
||||
const node = mockNode('identifier', '_private_helper');
|
||||
expect(isNodeExported(node, '_private_helper', 'python')).toBe(false);
|
||||
});
|
||||
|
||||
it('dunder method is private', () => {
|
||||
const node = mockNode('identifier', '__init__');
|
||||
expect(isNodeExported(node, '__init__', 'python')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true when text starts with "export "', () => {
|
||||
const parent = mockNode('lexical_declaration', 'export const foo = 1');
|
||||
const nameNode = mockNode('identifier', 'foo', parent);
|
||||
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(true);
|
||||
// Go
|
||||
describe('go', () => {
|
||||
it('uppercase first letter is exported', () => {
|
||||
const node = mockNode('identifier', 'ExportedFunction');
|
||||
expect(isNodeExported(node, 'ExportedFunction', 'go')).toBe(true);
|
||||
});
|
||||
|
||||
it('lowercase first letter is unexported', () => {
|
||||
const node = mockNode('identifier', 'unexportedFunction');
|
||||
expect(isNodeExported(node, 'unexportedFunction', 'go')).toBe(false);
|
||||
});
|
||||
|
||||
it('empty name is not exported', () => {
|
||||
const node = mockNode('identifier', '');
|
||||
expect(isNodeExported(node, '', 'go')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Rust
|
||||
describe('rust', () => {
|
||||
it('pub function is exported', () => {
|
||||
const visMod = mockNode('visibility_modifier', 'pub');
|
||||
const fnDecl = mockNode('function_item', 'pub fn foo() {}', visMod);
|
||||
// For rust, isNodeExported walks up parents checking for visibility_modifier
|
||||
// The visMod is a parent of the nameNode
|
||||
const nameNode = mockNode('identifier', 'foo', visMod);
|
||||
expect(isNodeExported(nameNode, 'foo', 'rust')).toBe(true);
|
||||
});
|
||||
|
||||
it('non-pub function is not exported', () => {
|
||||
const fnDecl = mockNode('function_item', 'fn foo() {}');
|
||||
const nameNode = mockNode('identifier', 'foo', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'foo', 'rust')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// PHP (hardening fix #20)
|
||||
describe('php', () => {
|
||||
it('top-level function is exported (globally accessible)', () => {
|
||||
// PHP: top-level functions fall through all checks and return true
|
||||
const program = mockNode('program', '<?php function topLevel() {}');
|
||||
const fnDecl = mockNode('function_definition', 'function topLevel() {}', program);
|
||||
const nameNode = mockNode('name', 'topLevel', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'topLevel', 'php')).toBe(true);
|
||||
});
|
||||
|
||||
it('class declaration is exported', () => {
|
||||
const classDecl = mockNode('class_declaration', 'class Foo {}');
|
||||
const nameNode = mockNode('name', 'Foo', classDecl);
|
||||
expect(isNodeExported(nameNode, 'Foo', 'php')).toBe(true);
|
||||
});
|
||||
|
||||
it('public method has visibility_modifier = public', () => {
|
||||
const visMod = mockNode('visibility_modifier', 'public');
|
||||
const nameNode = mockNode('name', 'addUser', visMod);
|
||||
expect(isNodeExported(nameNode, 'addUser', 'php')).toBe(true);
|
||||
});
|
||||
|
||||
it('private method has visibility_modifier = private', () => {
|
||||
const visMod = mockNode('visibility_modifier', 'private');
|
||||
const nameNode = mockNode('name', 'validate', visMod);
|
||||
expect(isNodeExported(nameNode, 'validate', 'php')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Swift
|
||||
describe('swift', () => {
|
||||
it('public function is exported', () => {
|
||||
const visMod = mockNode('modifiers', 'public');
|
||||
const nameNode = mockNode('identifier', 'getCount', visMod);
|
||||
expect(isNodeExported(nameNode, 'getCount', 'swift')).toBe(true);
|
||||
});
|
||||
|
||||
it('open function is exported', () => {
|
||||
const visMod = mockNode('modifiers', 'open');
|
||||
const nameNode = mockNode('identifier', 'doStuff', visMod);
|
||||
expect(isNodeExported(nameNode, 'doStuff', 'swift')).toBe(true);
|
||||
});
|
||||
|
||||
it('non-public function is not exported', () => {
|
||||
const fnDecl = mockNode('function_declaration', 'func helper() {}');
|
||||
const nameNode = mockNode('identifier', 'helper', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'helper', 'swift')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// C/C++
|
||||
describe('c/cpp', () => {
|
||||
it('C functions are never exported', () => {
|
||||
const node = mockNode('identifier', 'add');
|
||||
expect(isNodeExported(node, 'add', 'c')).toBe(false);
|
||||
});
|
||||
|
||||
it('C++ functions are never exported', () => {
|
||||
const node = mockNode('identifier', 'helperFunction');
|
||||
expect(isNodeExported(node, 'helperFunction', 'cpp')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// C#
|
||||
describe('csharp', () => {
|
||||
it('public modifier means exported', () => {
|
||||
const modifier = mockNode('modifier', 'public');
|
||||
const nameNode = mockNode('identifier', 'Add', modifier);
|
||||
expect(isNodeExported(nameNode, 'Add', 'csharp')).toBe(true);
|
||||
});
|
||||
|
||||
it('no public modifier means not exported', () => {
|
||||
const classDecl = mockNode('class_declaration', 'class Helper {}');
|
||||
const nameNode = mockNode('identifier', 'Helper', classDecl);
|
||||
expect(isNodeExported(nameNode, 'Helper', 'csharp')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Unknown language
|
||||
describe('unknown language', () => {
|
||||
it('returns false for unknown language', () => {
|
||||
const node = mockNode('identifier', 'foo');
|
||||
expect(isNodeExported(node, 'foo', 'unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Python
|
||||
describe('python', () => {
|
||||
it('public function (no underscore prefix)', () => {
|
||||
const node = mockNode('identifier', 'public_function');
|
||||
expect(isNodeExported(node, 'public_function', 'python')).toBe(true);
|
||||
});
|
||||
// ─── Fixture files exist ─────────────────────────────────────────────
|
||||
|
||||
it('private function (underscore prefix)', () => {
|
||||
const node = mockNode('identifier', '_private_helper');
|
||||
expect(isNodeExported(node, '_private_helper', 'python')).toBe(false);
|
||||
});
|
||||
describe('fixture files', () => {
|
||||
const fixtures = ['simple.ts', 'simple.py', 'simple.go', 'simple.swift',
|
||||
'simple.php', 'simple.rs', 'simple.java', 'simple.c', 'simple.cpp', 'simple.cs'];
|
||||
|
||||
it('dunder method is private', () => {
|
||||
const node = mockNode('identifier', '__init__');
|
||||
expect(isNodeExported(node, '__init__', 'python')).toBe(false);
|
||||
});
|
||||
for (const fixture of fixtures) {
|
||||
it(`${fixture} exists and is non-empty`, async () => {
|
||||
const content = await fs.readFile(path.join(FIXTURES_DIR, fixture), 'utf-8');
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Go
|
||||
describe('go', () => {
|
||||
it('uppercase first letter is exported', () => {
|
||||
const node = mockNode('identifier', 'ExportedFunction');
|
||||
expect(isNodeExported(node, 'ExportedFunction', 'go')).toBe(true);
|
||||
// ─── Unhappy path ─────────────────────────────────────────────────────
|
||||
|
||||
describe('unhappy path', () => {
|
||||
it('returns empty AST or handles empty file content', async () => {
|
||||
const parser = await loadParser();
|
||||
await loadLanguage(SupportedLanguages.TypeScript, 'empty.ts');
|
||||
|
||||
// Parsing a zero-length string must not throw and must return a valid tree.
|
||||
const tree = parser.parse('');
|
||||
expect(tree).toBeDefined();
|
||||
expect(tree.rootNode).toBeDefined();
|
||||
|
||||
// An empty file produces a root node with no named children — no symbols.
|
||||
// isNodeExported on a bare node with no ancestors returns false regardless of language.
|
||||
const detachedNode = mockNode('identifier', 'foo');
|
||||
expect(isNodeExported(detachedNode, 'foo', 'typescript')).toBe(false);
|
||||
});
|
||||
|
||||
it('lowercase first letter is unexported', () => {
|
||||
const node = mockNode('identifier', 'unexportedFunction');
|
||||
expect(isNodeExported(node, 'unexportedFunction', 'go')).toBe(false);
|
||||
it('handles binary/non-UTF8 content gracefully', async () => {
|
||||
const parser = await loadParser();
|
||||
await loadLanguage(SupportedLanguages.TypeScript, 'binary.ts');
|
||||
|
||||
// Construct a string that contains the Unicode replacement character (U+FFFD)
|
||||
// and a mix of high-byte sequences that are not valid UTF-8 when treated as Latin-1.
|
||||
// JavaScript strings are UTF-16 internally, so this is always a valid string —
|
||||
// but it exercises tree-sitter's ability to handle unusual byte patterns.
|
||||
const binaryLikeContent = '\uFFFD\u0000\u0001\u001F' + '\xFF\xFE'.repeat(10) + '\uFFFD';
|
||||
|
||||
// Must not throw — tree-sitter should return an error-recovery tree.
|
||||
let tree: any;
|
||||
expect(() => {
|
||||
tree = parser.parse(binaryLikeContent);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(tree).toBeDefined();
|
||||
expect(tree.rootNode).toBeDefined();
|
||||
});
|
||||
|
||||
it('empty name is not exported', () => {
|
||||
const node = mockNode('identifier', '');
|
||||
expect(isNodeExported(node, '', 'go')).toBe(false);
|
||||
});
|
||||
});
|
||||
it('falls back gracefully for unsupported language', async () => {
|
||||
// getLanguageFromFilename returns null for extensions with no grammar mapping.
|
||||
const rubyLang = getLanguageFromFilename('script.rb');
|
||||
expect(rubyLang).toBeNull();
|
||||
|
||||
// Rust
|
||||
describe('rust', () => {
|
||||
it('pub function is exported', () => {
|
||||
const visMod = mockNode('visibility_modifier', 'pub');
|
||||
const fnDecl = mockNode('function_item', 'pub fn foo() {}', visMod);
|
||||
// For rust, isNodeExported walks up parents checking for visibility_modifier
|
||||
// The visMod is a parent of the nameNode
|
||||
const nameNode = mockNode('identifier', 'foo', visMod);
|
||||
expect(isNodeExported(nameNode, 'foo', 'rust')).toBe(true);
|
||||
});
|
||||
const luaLang = getLanguageFromFilename('module.lua');
|
||||
expect(luaLang).toBeNull();
|
||||
|
||||
it('non-pub function is not exported', () => {
|
||||
const fnDecl = mockNode('function_item', 'fn foo() {}');
|
||||
const nameNode = mockNode('identifier', 'foo', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'foo', 'rust')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// PHP (hardening fix #20)
|
||||
describe('php', () => {
|
||||
it('top-level function is exported (globally accessible)', () => {
|
||||
// PHP: top-level functions fall through all checks and return true
|
||||
const program = mockNode('program', '<?php function topLevel() {}');
|
||||
const fnDecl = mockNode('function_definition', 'function topLevel() {}', program);
|
||||
const nameNode = mockNode('name', 'topLevel', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'topLevel', 'php')).toBe(true);
|
||||
});
|
||||
|
||||
it('class declaration is exported', () => {
|
||||
const classDecl = mockNode('class_declaration', 'class Foo {}');
|
||||
const nameNode = mockNode('name', 'Foo', classDecl);
|
||||
expect(isNodeExported(nameNode, 'Foo', 'php')).toBe(true);
|
||||
});
|
||||
|
||||
it('public method has visibility_modifier = public', () => {
|
||||
const visMod = mockNode('visibility_modifier', 'public');
|
||||
const nameNode = mockNode('name', 'addUser', visMod);
|
||||
expect(isNodeExported(nameNode, 'addUser', 'php')).toBe(true);
|
||||
});
|
||||
|
||||
it('private method has visibility_modifier = private', () => {
|
||||
const visMod = mockNode('visibility_modifier', 'private');
|
||||
const nameNode = mockNode('name', 'validate', visMod);
|
||||
expect(isNodeExported(nameNode, 'validate', 'php')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Swift
|
||||
describe('swift', () => {
|
||||
it('public function is exported', () => {
|
||||
const visMod = mockNode('modifiers', 'public');
|
||||
const nameNode = mockNode('identifier', 'getCount', visMod);
|
||||
expect(isNodeExported(nameNode, 'getCount', 'swift')).toBe(true);
|
||||
});
|
||||
|
||||
it('open function is exported', () => {
|
||||
const visMod = mockNode('modifiers', 'open');
|
||||
const nameNode = mockNode('identifier', 'doStuff', visMod);
|
||||
expect(isNodeExported(nameNode, 'doStuff', 'swift')).toBe(true);
|
||||
});
|
||||
|
||||
it('non-public function is not exported', () => {
|
||||
const fnDecl = mockNode('function_declaration', 'func helper() {}');
|
||||
const nameNode = mockNode('identifier', 'helper', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'helper', 'swift')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// C/C++
|
||||
describe('c/cpp', () => {
|
||||
it('C functions are never exported', () => {
|
||||
const node = mockNode('identifier', 'add');
|
||||
expect(isNodeExported(node, 'add', 'c')).toBe(false);
|
||||
});
|
||||
|
||||
it('C++ functions are never exported', () => {
|
||||
const node = mockNode('identifier', 'helperFunction');
|
||||
expect(isNodeExported(node, 'helperFunction', 'cpp')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// C#
|
||||
describe('csharp', () => {
|
||||
it('public modifier means exported', () => {
|
||||
const modifier = mockNode('modifier', 'public');
|
||||
const nameNode = mockNode('identifier', 'Add', modifier);
|
||||
expect(isNodeExported(nameNode, 'Add', 'csharp')).toBe(true);
|
||||
});
|
||||
|
||||
it('no public modifier means not exported', () => {
|
||||
const classDecl = mockNode('class_declaration', 'class Helper {}');
|
||||
const nameNode = mockNode('identifier', 'Helper', classDecl);
|
||||
expect(isNodeExported(nameNode, 'Helper', 'csharp')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Unknown language
|
||||
describe('unknown language', () => {
|
||||
it('returns false for unknown language', () => {
|
||||
const node = mockNode('identifier', 'foo');
|
||||
expect(isNodeExported(node, 'foo', 'unknown')).toBe(false);
|
||||
// loadLanguage throws an explicit error for a language not in the grammar map.
|
||||
// Cast through unknown to simulate a caller passing an unrecognised language key.
|
||||
await expect(
|
||||
loadLanguage('erlang' as unknown as SupportedLanguages)
|
||||
).rejects.toThrow('Unsupported language');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fixture files exist ─────────────────────────────────────────────
|
||||
|
||||
describe('fixture files', () => {
|
||||
const fixtures = ['simple.ts', 'simple.py', 'simple.go', 'simple.swift',
|
||||
'simple.php', 'simple.rs', 'simple.java', 'simple.c', 'simple.cpp', 'simple.cs'];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
it(`${fixture} exists and is non-empty`, async () => {
|
||||
const content = await fs.readFile(path.join(FIXTURES_DIR, fixture), 'utf-8');
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,23 +1,38 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
/**
|
||||
* P1 Integration Tests: Pipeline End-to-End
|
||||
*
|
||||
* Runs the full ingestion pipeline once on a mini-repo fixture and
|
||||
* validates the resulting knowledge graph: file/symbol nodes, CALLS
|
||||
* edges, IMPORTS edges, community detection, and process detection.
|
||||
*
|
||||
* Pipeline runs once in beforeAll; each it() asserts against the cached result.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineProgress } from '../../src/types/pipeline.js';
|
||||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||||
|
||||
const MINI_REPO = path.resolve(__dirname, '..', 'fixtures', 'mini-repo');
|
||||
|
||||
describe('pipeline end-to-end', () => {
|
||||
it('indexes a mini repo and produces a valid graph', async () => {
|
||||
const progressCalls: PipelineProgress[] = [];
|
||||
const onProgress = (p: PipelineProgress) => progressCalls.push(p);
|
||||
let result: PipelineResult;
|
||||
const phases = new Set<string>();
|
||||
|
||||
const result = await runPipelineFromRepo(MINI_REPO, onProgress);
|
||||
// Run pipeline ONCE in beforeAll — each it() asserts against the cached result
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(MINI_REPO, (p: PipelineProgress) => phases.add(p.phase));
|
||||
}, 60000);
|
||||
|
||||
it('indexes a mini repo and produces a valid graph', () => {
|
||||
// --- Graph should have nodes ---
|
||||
expect(result.graph.nodeCount).toBeGreaterThan(0);
|
||||
expect(result.graph.relationshipCount).toBeGreaterThan(0);
|
||||
|
||||
// --- Should find the 5 TypeScript files ---
|
||||
expect(result.totalFileCount).toBe(5);
|
||||
// --- Should find at least 7 TypeScript files (may include AGENTS.md, CLAUDE.md, etc.) ---
|
||||
expect(result.totalFileCount).toBeGreaterThanOrEqual(7);
|
||||
|
||||
// --- Verify File nodes exist for each source file ---
|
||||
const fileNodes: string[] = [];
|
||||
|
|
@ -29,6 +44,8 @@ describe('pipeline end-to-end', () => {
|
|||
expect(fileNodes).toContain('src/db.ts');
|
||||
expect(fileNodes).toContain('src/formatter.ts');
|
||||
expect(fileNodes).toContain('src/index.ts');
|
||||
expect(fileNodes).toContain('src/logger.ts');
|
||||
expect(fileNodes).toContain('src/middleware.ts');
|
||||
|
||||
// --- Verify symbol nodes were created (functions, classes) ---
|
||||
const symbolNames: string[] = [];
|
||||
|
|
@ -42,6 +59,8 @@ describe('pipeline end-to-end', () => {
|
|||
expect(symbolNames).toContain('saveToDb');
|
||||
expect(symbolNames).toContain('formatResponse');
|
||||
expect(symbolNames).toContain('RequestHandler');
|
||||
expect(symbolNames).toContain('processRequest');
|
||||
expect(symbolNames).toContain('createLogEntry');
|
||||
|
||||
// --- Verify relationships exist ---
|
||||
const relTypes = new Set<string>();
|
||||
|
|
@ -82,11 +101,9 @@ describe('pipeline end-to-end', () => {
|
|||
expect(importsCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('detects communities', async () => {
|
||||
const result = await runPipelineFromRepo(MINI_REPO, () => {});
|
||||
|
||||
it('detects communities', () => {
|
||||
expect(result.communityResult).toBeDefined();
|
||||
expect(result.communityResult.stats.totalCommunities).toBeGreaterThan(0);
|
||||
expect(result.communityResult?.stats.totalCommunities).toBeGreaterThan(0);
|
||||
|
||||
// Community nodes should be in the graph
|
||||
const communityNodes: string[] = [];
|
||||
|
|
@ -103,47 +120,39 @@ describe('pipeline end-to-end', () => {
|
|||
expect(memberOfCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('detects execution flows (processes)', async () => {
|
||||
const result = await runPipelineFromRepo(MINI_REPO, () => {});
|
||||
|
||||
it('detects execution flows (processes)', () => {
|
||||
expect(result.processResult).toBeDefined();
|
||||
expect(result.processResult?.stats.totalProcesses).toBeGreaterThan(0);
|
||||
|
||||
// With a 4-function call chain (handler -> validator -> db -> formatter),
|
||||
// there should be at least one process detected
|
||||
if (result.processResult.stats.totalProcesses > 0) {
|
||||
const process = result.processResult.processes[0];
|
||||
const proc = result.processResult?.processes[0] ?? { id: '', stepCount: 0, trace: [], entryPointId: '', terminalId: '', processType: '' };
|
||||
|
||||
// Each process should have valid structure
|
||||
expect(process.id).toBeTruthy();
|
||||
expect(process.stepCount).toBeGreaterThanOrEqual(3); // minSteps default
|
||||
expect(process.trace.length).toBe(process.stepCount);
|
||||
expect(process.entryPointId).toBeTruthy();
|
||||
expect(process.terminalId).toBeTruthy();
|
||||
expect(process.processType).toMatch(/^(intra_community|cross_community)$/);
|
||||
// Each process should have valid structure
|
||||
expect(proc.id).toBeTruthy();
|
||||
expect(proc.stepCount).toBeGreaterThanOrEqual(3); // minSteps default
|
||||
expect(proc.trace.length).toBe(proc.stepCount);
|
||||
expect(proc.entryPointId).toBeTruthy();
|
||||
expect(proc.terminalId).toBeTruthy();
|
||||
expect(proc.processType).toMatch(/^(intra_community|cross_community)$/);
|
||||
|
||||
// Process nodes should be in the graph
|
||||
const processNode = result.graph.getNode(process.id);
|
||||
expect(processNode).toBeDefined();
|
||||
expect(processNode!.label).toBe('Process');
|
||||
// Process nodes should be in the graph
|
||||
const processNode = result.graph.getNode(proc.id);
|
||||
expect(processNode).toBeDefined();
|
||||
expect(processNode!.label).toBe('Process');
|
||||
|
||||
// STEP_IN_PROCESS relationships should exist
|
||||
let stepCount = 0;
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
if (rel.type === 'STEP_IN_PROCESS' && rel.targetId === process.id) {
|
||||
stepCount++;
|
||||
expect(rel.step).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
// STEP_IN_PROCESS relationships should exist with sequential ordering
|
||||
const steps: number[] = [];
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
if (rel.type === 'STEP_IN_PROCESS' && rel.targetId === proc.id) {
|
||||
steps.push(rel.step);
|
||||
}
|
||||
expect(stepCount).toBe(process.stepCount);
|
||||
}
|
||||
expect(steps.length).toBe(proc.stepCount);
|
||||
// Steps should be sequential 1, 2, 3, ...
|
||||
const sorted = [...steps].sort((a, b) => a - b);
|
||||
sorted.forEach((s, i) => expect(s).toBe(i + 1));
|
||||
});
|
||||
|
||||
it('reports progress through all 6 phases', async () => {
|
||||
const phases = new Set<string>();
|
||||
const onProgress = (p: PipelineProgress) => phases.add(p.phase);
|
||||
|
||||
await runPipelineFromRepo(MINI_REPO, onProgress);
|
||||
|
||||
it('reports progress through all 6 phases', () => {
|
||||
expect(phases).toContain('extracting');
|
||||
expect(phases).toContain('structure');
|
||||
expect(phases).toContain('parsing');
|
||||
|
|
@ -152,8 +161,31 @@ describe('pipeline end-to-end', () => {
|
|||
expect(phases).toContain('complete');
|
||||
});
|
||||
|
||||
it('returns correct repoPath in result', async () => {
|
||||
const result = await runPipelineFromRepo(MINI_REPO, () => {});
|
||||
it('returns correct repoPath in result', () => {
|
||||
expect(result.repoPath).toBe(MINI_REPO);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Pipeline error handling ──────────────────────────────────────────
|
||||
|
||||
describe('pipeline error handling', () => {
|
||||
it('returns empty result for non-existent repo path', async () => {
|
||||
const result = await runPipelineFromRepo(
|
||||
'/nonexistent/path/xyz123',
|
||||
() => {},
|
||||
);
|
||||
expect(result.totalFileCount).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
it('handles empty directory gracefully', async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `gn-pipeline-empty-${Date.now()}`);
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
try {
|
||||
const result = await runPipelineFromRepo(tmpDir, () => {});
|
||||
// Empty repo should produce empty or minimal graph
|
||||
expect(result.totalFileCount).toBe(0);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
|
|
|||
121
gitnexus/test/integration/search-core.test.ts
Normal file
121
gitnexus/test/integration/search-core.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* P0 Integration Tests: BM25/FTS Search against real KuzuDB
|
||||
*
|
||||
* Tests: searchFTSFromKuzu via core adapter (no repoId) path against
|
||||
* indexed test data. Verifies ranked result ordering, score merging,
|
||||
* and empty-match behavior.
|
||||
*
|
||||
* Uses withTestKuzuDB wrapper for full lifecycle management.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { withTestKuzuDB } from '../helpers/test-indexed-db.js';
|
||||
import { searchFTSFromKuzu } from '../../src/core/search/bm25-index.js';
|
||||
import { SEARCH_SEED_DATA, SEARCH_FTS_INDEXES } from '../fixtures/search-seed.js';
|
||||
|
||||
// ─── Core adapter path (no repoId) ──────────────────────────────────
|
||||
|
||||
withTestKuzuDB('search-core', (_handle) => {
|
||||
describe('searchFTSFromKuzu — core adapter (no repoId)', () => {
|
||||
it('returns ranked results for a matching query', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 10);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
for (const r of results) {
|
||||
expect(r).toHaveProperty('filePath');
|
||||
expect(r).toHaveProperty('score');
|
||||
expect(r).toHaveProperty('rank');
|
||||
expect(typeof r.filePath).toBe('string');
|
||||
expect(typeof r.score).toBe('number');
|
||||
expect(typeof r.rank).toBe('number');
|
||||
expect(r.score).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Ranks should be sequential starting from 1
|
||||
results.forEach((r, i) => {
|
||||
expect(r.rank).toBe(i + 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('results are ordered by descending score', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 10);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
|
||||
}
|
||||
});
|
||||
|
||||
it('auth-related files rank higher than unrelated files', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 10);
|
||||
const filePaths = results.map((r) => r.filePath);
|
||||
|
||||
expect(filePaths).toContain('src/auth.ts');
|
||||
|
||||
const authIdx = filePaths.indexOf('src/auth.ts');
|
||||
const utilsIdx = filePaths.indexOf('src/utils.ts');
|
||||
if (utilsIdx !== -1) {
|
||||
expect(authIdx).toBeLessThan(utilsIdx);
|
||||
}
|
||||
});
|
||||
|
||||
it('merges scores from multiple node types for the same filePath', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 20);
|
||||
|
||||
const authResult = results.find((r) => r.filePath === 'src/auth.ts');
|
||||
expect(authResult).toBeDefined();
|
||||
|
||||
const routerResult = results.find((r) => r.filePath === 'src/router.ts');
|
||||
if (routerResult) {
|
||||
expect(authResult!.score).toBeGreaterThan(routerResult.score);
|
||||
}
|
||||
});
|
||||
|
||||
it('respects limit parameter', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 2);
|
||||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('returns empty array for a non-matching query', async () => {
|
||||
const results = await searchFTSFromKuzu('xyzzyplughtwisty', 10);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unhappy paths ──────────────────────────────────────────────────
|
||||
|
||||
describe('unhappy paths', () => {
|
||||
it('returns empty array for empty query string', async () => {
|
||||
const results = await searchFTSFromKuzu('', 10);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for whitespace-only query', async () => {
|
||||
const results = await searchFTSFromKuzu(' ', 10);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles special characters in query gracefully', async () => {
|
||||
const results = await searchFTSFromKuzu('user* OR auth+', 10);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles limit of 0', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 0);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles negative limit gracefully', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', -1);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles very large limit', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 100000);
|
||||
expect(results.length).toBeLessThanOrEqual(100000);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
}, {
|
||||
seed: SEARCH_SEED_DATA,
|
||||
ftsIndexes: SEARCH_FTS_INDEXES,
|
||||
});
|
||||
81
gitnexus/test/integration/search-pool.test.ts
Normal file
81
gitnexus/test/integration/search-pool.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* P0 Integration Tests: BM25/FTS Search against real KuzuDB
|
||||
*
|
||||
* Tests: searchFTSFromKuzu via MCP pool adapter (with repoId) path
|
||||
* against indexed test data. Verifies ranked result ordering and
|
||||
* empty-match behavior through the pool adapter.
|
||||
*
|
||||
* Uses withTestKuzuDB wrapper for full lifecycle management.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { withTestKuzuDB } from '../helpers/test-indexed-db.js';
|
||||
import { searchFTSFromKuzu } from '../../src/core/search/bm25-index.js';
|
||||
import { SEARCH_SEED_DATA, SEARCH_FTS_INDEXES } from '../fixtures/search-seed.js';
|
||||
|
||||
// ─── MCP pool adapter path (with repoId) ────────────────────────────
|
||||
|
||||
withTestKuzuDB('search-pool', (handle) => {
|
||||
describe('searchFTSFromKuzu — MCP pool adapter (with repoId)', () => {
|
||||
it('returns ranked results via pool adapter', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 10, handle.repoId);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
for (const r of results) {
|
||||
expect(r).toHaveProperty('filePath');
|
||||
expect(r).toHaveProperty('score');
|
||||
expect(r).toHaveProperty('rank');
|
||||
expect(r.score).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const filePaths = results.map((r) => r.filePath);
|
||||
expect(filePaths).toContain('src/auth.ts');
|
||||
});
|
||||
|
||||
it('results are ordered by descending score via pool adapter', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 10, handle.repoId);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns empty array for non-matching query via pool adapter', async () => {
|
||||
const results = await searchFTSFromKuzu('xyzzyplughtwisty', 10, handle.repoId);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('respects limit parameter via pool adapter', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 1, handle.repoId);
|
||||
expect(results.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unhappy paths ──────────────────────────────────────────────────
|
||||
|
||||
describe('unhappy paths', () => {
|
||||
it('returns empty array for empty query via pool', async () => {
|
||||
const results = await searchFTSFromKuzu('', 10, handle.repoId);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for whitespace-only query via pool', async () => {
|
||||
const results = await searchFTSFromKuzu(' ', 10, handle.repoId);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles special characters in query via pool', async () => {
|
||||
const results = await searchFTSFromKuzu('user* OR auth+', 10, handle.repoId);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles limit of 0 via pool', async () => {
|
||||
const results = await searchFTSFromKuzu('user authentication', 0, handle.repoId);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
}, {
|
||||
seed: SEARCH_SEED_DATA,
|
||||
ftsIndexes: SEARCH_FTS_INDEXES,
|
||||
poolAdapter: true,
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import path from 'path';
|
|||
import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js';
|
||||
import { LANGUAGE_QUERIES } from '../../src/core/ingestion/tree-sitter-queries.js';
|
||||
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
||||
import { getLanguageFromFilename } from '../../src/core/ingestion/utils.js';
|
||||
import Parser from 'tree-sitter';
|
||||
|
||||
const fixturesDir = path.resolve(__dirname, '..', 'fixtures', 'sample-code');
|
||||
|
|
@ -216,6 +217,23 @@ describe('Tree-sitter multi-language parsing', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('unhappy path', () => {
|
||||
it('returns null/undefined for unsupported file extensions', () => {
|
||||
expect(getLanguageFromFilename('archive.xyz')).toBeNull();
|
||||
expect(getLanguageFromFilename('data.unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty string file path', () => {
|
||||
expect(getLanguageFromFilename('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null/undefined for binary file extensions', () => {
|
||||
expect(getLanguageFromFilename('program.exe')).toBeNull();
|
||||
expect(getLanguageFromFilename('library.dll')).toBeNull();
|
||||
expect(getLanguageFromFilename('object.so')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-language assertions', () => {
|
||||
it('all supported languages produce at least one definition from fixtures', async () => {
|
||||
const langFixtures: [SupportedLanguages, string, string?][] = [
|
||||
|
|
@ -245,4 +263,28 @@ describe('Tree-sitter multi-language parsing', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser edge cases', () => {
|
||||
it('loadLanguage throws for unsupported language', async () => {
|
||||
await expect(loadLanguage('brainfuck' as any)).rejects.toThrow(/unsupported language/i);
|
||||
});
|
||||
|
||||
it('parsing empty file content produces empty matches', async () => {
|
||||
await loadLanguage(SupportedLanguages.TypeScript, 'empty.ts');
|
||||
const tree = parser.parse('');
|
||||
expect(tree.rootNode).toBeDefined();
|
||||
|
||||
const lang = parser.getLanguage();
|
||||
const query = new Parser.Query(lang, LANGUAGE_QUERIES[SupportedLanguages.TypeScript]);
|
||||
const matches = query.matches(tree.rootNode);
|
||||
expect(matches).toEqual([]);
|
||||
});
|
||||
|
||||
it('parsing malformed code does not crash', async () => {
|
||||
await loadLanguage(SupportedLanguages.TypeScript, 'malformed.ts');
|
||||
const tree = parser.parse('function {{{ class >>><< if(( end');
|
||||
expect(tree.rootNode).toBeDefined();
|
||||
expect(tree.rootNode.hasError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
176
gitnexus/test/integration/worker-pool.test.ts
Normal file
176
gitnexus/test/integration/worker-pool.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Integration Tests: Worker Pool & Parse Worker
|
||||
*
|
||||
* Verifies that the worker pool can spawn real worker threads using the
|
||||
* compiled dist/ parse-worker.js and process files correctly.
|
||||
* This is critical for cross-platform CI where vitest runs from src/
|
||||
* but workers need compiled .js files.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createWorkerPool, WorkerPool } from '../../src/core/ingestion/workers/worker-pool.js';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const DIST_WORKER = path.resolve(__dirname, '..', '..', 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
|
||||
const hasDistWorker = fs.existsSync(DIST_WORKER);
|
||||
|
||||
describe('worker pool integration', () => {
|
||||
let pool: WorkerPool | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (pool) {
|
||||
await pool.terminate();
|
||||
pool = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!hasDistWorker)('creates a worker pool from dist/ worker', () => {
|
||||
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
|
||||
pool = createWorkerPool(workerUrl, 1);
|
||||
expect(pool.size).toBe(1);
|
||||
});
|
||||
|
||||
it.skipIf(!hasDistWorker)('dispatches an empty batch without error', async () => {
|
||||
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
|
||||
pool = createWorkerPool(workerUrl, 1);
|
||||
const results = await pool.dispatch([]);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(!hasDistWorker)('parses a single TypeScript file through worker', async () => {
|
||||
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
|
||||
pool = createWorkerPool(workerUrl, 1);
|
||||
|
||||
const fixtureFile = path.resolve(__dirname, '..', 'fixtures', 'mini-repo', 'src', 'validator.ts');
|
||||
const content = fs.readFileSync(fixtureFile, 'utf-8');
|
||||
|
||||
const results = await pool.dispatch<any, any>([
|
||||
{ path: 'src/validator.ts', content },
|
||||
]);
|
||||
|
||||
// Worker returns an array of results (one per worker chunk)
|
||||
expect(results).toHaveLength(1);
|
||||
const result = results[0];
|
||||
expect(result.fileCount).toBe(1);
|
||||
expect(result.nodes.length).toBeGreaterThan(0);
|
||||
|
||||
// Should find the validateInput function
|
||||
const names = result.nodes.map((n: any) => n.properties.name);
|
||||
expect(names).toContain('validateInput');
|
||||
});
|
||||
|
||||
it.skipIf(!hasDistWorker)('parses multiple files across workers', async () => {
|
||||
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
|
||||
pool = createWorkerPool(workerUrl, 2);
|
||||
|
||||
const fixturesDir = path.resolve(__dirname, '..', 'fixtures', 'mini-repo', 'src');
|
||||
const files = fs.readdirSync(fixturesDir)
|
||||
.filter(f => f.endsWith('.ts'))
|
||||
.map(f => ({
|
||||
path: `src/${f}`,
|
||||
content: fs.readFileSync(path.join(fixturesDir, f), 'utf-8'),
|
||||
}));
|
||||
|
||||
expect(files.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
const results = await pool.dispatch<any, any>(files);
|
||||
|
||||
// Each worker chunk returns a result
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
// Total files parsed should match input
|
||||
const totalParsed = results.reduce((sum: number, r: any) => sum + r.fileCount, 0);
|
||||
expect(totalParsed).toBe(files.length);
|
||||
|
||||
// Should find symbols from multiple files
|
||||
const allNames = results.flatMap((r: any) => r.nodes.map((n: any) => n.properties.name));
|
||||
expect(allNames).toContain('handleRequest');
|
||||
expect(allNames).toContain('validateInput');
|
||||
expect(allNames).toContain('saveToDb');
|
||||
expect(allNames).toContain('formatResponse');
|
||||
});
|
||||
|
||||
it.skipIf(!hasDistWorker)('reports progress during parsing', async () => {
|
||||
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
|
||||
pool = createWorkerPool(workerUrl, 1);
|
||||
|
||||
const fixturesDir = path.resolve(__dirname, '..', 'fixtures', 'mini-repo', 'src');
|
||||
const files = fs.readdirSync(fixturesDir)
|
||||
.filter(f => f.endsWith('.ts'))
|
||||
.map(f => ({
|
||||
path: `src/${f}`,
|
||||
content: fs.readFileSync(path.join(fixturesDir, f), 'utf-8'),
|
||||
}));
|
||||
|
||||
const progressCalls: number[] = [];
|
||||
await pool.dispatch<any, any>(files, (filesProcessed) => {
|
||||
progressCalls.push(filesProcessed);
|
||||
});
|
||||
|
||||
// Progress callbacks are best-effort — with a small batch the worker may
|
||||
// process all files before the progress message is delivered. Just verify
|
||||
// that if progress was reported, the values are sensible.
|
||||
if (progressCalls.length > 0) {
|
||||
expect(progressCalls[progressCalls.length - 1]).toBe(files.length);
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!hasDistWorker)('terminates cleanly', async () => {
|
||||
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
|
||||
pool = createWorkerPool(workerUrl, 2);
|
||||
await pool.terminate();
|
||||
pool = undefined; // already terminated
|
||||
});
|
||||
|
||||
it('fails gracefully with invalid worker path', () => {
|
||||
const badUrl = pathToFileURL('/nonexistent/worker.js') as URL;
|
||||
// createWorkerPool validates the worker script exists before spawning
|
||||
expect(() => {
|
||||
pool = createWorkerPool(badUrl, 1);
|
||||
}).toThrow(/Worker script not found/);
|
||||
});
|
||||
|
||||
// ─── Unhappy paths ──────────────────────────────────────────────────
|
||||
|
||||
it.skipIf(!hasDistWorker)('dispatch after terminate rejects', async () => {
|
||||
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
|
||||
pool = createWorkerPool(workerUrl, 1);
|
||||
const terminatedPool = pool;
|
||||
await terminatedPool.terminate();
|
||||
pool = undefined; // already terminated — prevent afterEach double-terminate
|
||||
|
||||
await expect(terminatedPool.dispatch([{ path: 'x.ts', content: 'const x = 1;' }]))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it.skipIf(!hasDistWorker)('double terminate does not throw', async () => {
|
||||
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
|
||||
pool = createWorkerPool(workerUrl, 1);
|
||||
await pool.terminate();
|
||||
await expect(pool.terminate()).resolves.toBeUndefined();
|
||||
pool = undefined;
|
||||
});
|
||||
|
||||
it.skipIf(!hasDistWorker)('dispatches entries with empty content string without crashing', async () => {
|
||||
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
|
||||
pool = createWorkerPool(workerUrl, 1);
|
||||
|
||||
const results = await pool.dispatch<any, any>([
|
||||
{ path: 'empty.ts', content: '' },
|
||||
]);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
const result = results[0];
|
||||
expect(typeof result.fileCount).toBe('number');
|
||||
expect(result.fileCount).toBeGreaterThanOrEqual(0);
|
||||
expect(Array.isArray(result.nodes)).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(!hasDistWorker)('createWorkerPool with size 0 creates pool with zero workers', () => {
|
||||
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
|
||||
const zeroPool = createWorkerPool(workerUrl, 0);
|
||||
expect(zeroPool.size).toBe(0);
|
||||
return zeroPool.terminate();
|
||||
});
|
||||
});
|
||||
29
gitnexus/test/setup.ts
Normal file
29
gitnexus/test/setup.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Vitest per-file setup file (runs inside each forked worker).
|
||||
*
|
||||
* Unref's all active handles after each test file so the event loop can
|
||||
* drain naturally. For non-native test files this is sufficient to let
|
||||
* the fork exit. For KuzuDB test files, native C++ handles may not expose
|
||||
* .unref() — CI handles this via process isolation (one vitest invocation
|
||||
* per KuzuDB test file) so the OS reclaims everything on process exit.
|
||||
*
|
||||
* IMPORTANT: We do NOT import kuzu-adapter here. Importing it would load
|
||||
* the native addon even in non-KuzuDB test files, registering persistent
|
||||
* handles that prevent the fork from exiting.
|
||||
*
|
||||
* IMPORTANT: We do NOT call process.exit() here. On Linux, process.exit()
|
||||
* triggers N-API destructor hooks in the KuzuDB native addon that segfault
|
||||
* (SIGSEGV), crashing the fork before it can send results back via IPC.
|
||||
*/
|
||||
import { afterAll } from 'vitest';
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
const handles = (process as any)._getActiveHandles?.();
|
||||
if (handles) {
|
||||
for (const h of handles) {
|
||||
if (typeof h.unref === 'function') h.unref();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
|
@ -82,5 +82,14 @@ describe('ASTCache', () => {
|
|||
const defaultCache = createASTCache();
|
||||
expect(defaultCache.stats().maxSize).toBe(50);
|
||||
});
|
||||
|
||||
it('clamps maxSize of 0 to 1 to prevent LRU cache error', () => {
|
||||
const zeroCache = createASTCache(0);
|
||||
expect(zeroCache.stats().maxSize).toBe(1);
|
||||
// Should still function correctly
|
||||
const tree = mockTree('test');
|
||||
zeroCache.set('a.ts', tree);
|
||||
expect(zeroCache.get('a.ts')).toBe(tree);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
45
gitnexus/test/unit/cli-index-help.test.ts
Normal file
45
gitnexus/test/unit/cli-index-help.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(testDir, '../..');
|
||||
const cliEntry = path.join(repoRoot, 'src/cli/index.ts');
|
||||
|
||||
function runHelp(command: string) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, command, '--help'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
describe('CLI help surface', () => {
|
||||
it('query help keeps advanced search options without importing analyze deps', () => {
|
||||
const result = runHelp('query');
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('--context <text>');
|
||||
expect(result.stdout).toContain('--goal <text>');
|
||||
expect(result.stdout).toContain('--content');
|
||||
expect(result.stderr).not.toContain('tree-sitter-kotlin');
|
||||
});
|
||||
|
||||
it('context help keeps optional name and disambiguation flags', () => {
|
||||
const result = runHelp('context');
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('context [options] [name]');
|
||||
expect(result.stdout).toContain('--uid <uid>');
|
||||
expect(result.stdout).toContain('--file <path>');
|
||||
});
|
||||
|
||||
it('impact help keeps repo and include-tests flags', () => {
|
||||
const result = runHelp('impact');
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('--depth <n>');
|
||||
expect(result.stdout).toContain('--include-tests');
|
||||
expect(result.stdout).toContain('--repo <name>');
|
||||
});
|
||||
});
|
||||
259
gitnexus/test/unit/compatible-stdio-transport.test.ts
Normal file
259
gitnexus/test/unit/compatible-stdio-transport.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { PassThrough } from 'node:stream';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CompatibleStdioServerTransport } from '../../src/mcp/compatible-stdio-transport.js';
|
||||
|
||||
function onceMessage(transport: CompatibleStdioServerTransport): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transport.onmessage = (message) => resolve(message);
|
||||
transport.onerror = (error) => reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
describe('CompatibleStdioServerTransport', () => {
|
||||
let stdin: PassThrough;
|
||||
let stdout: PassThrough;
|
||||
let transport: CompatibleStdioServerTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
stdin = new PassThrough();
|
||||
stdout = new PassThrough();
|
||||
transport = new CompatibleStdioServerTransport(stdin, stdout);
|
||||
});
|
||||
|
||||
it('parses Content-Length framed initialize requests', async () => {
|
||||
await transport.start();
|
||||
const messagePromise = onceMessage(transport);
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'codex', version: '0.1' },
|
||||
},
|
||||
});
|
||||
|
||||
stdin.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
|
||||
|
||||
await expect(messagePromise).resolves.toMatchObject({
|
||||
method: 'initialize',
|
||||
params: { clientInfo: { name: 'codex' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses newline-delimited initialize requests', async () => {
|
||||
await transport.start();
|
||||
const messagePromise = onceMessage(transport);
|
||||
stdin.write(`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'cursor', version: '0.1' },
|
||||
},
|
||||
})}\n`);
|
||||
|
||||
await expect(messagePromise).resolves.toMatchObject({
|
||||
method: 'initialize',
|
||||
params: { clientInfo: { name: 'cursor' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('responds with Content-Length framing after Content-Length input', async () => {
|
||||
await transport.start();
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'codex', version: '0.1' },
|
||||
},
|
||||
});
|
||||
|
||||
const messagePromise = onceMessage(transport);
|
||||
stdin.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\n\n${body}`);
|
||||
await messagePromise;
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
stdout.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
|
||||
expect(raw).toMatch(/^Content-Length: \d+\r\n\r\n/);
|
||||
expect(raw).toContain('"ok":true');
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('reports malformed Content-Length headers once without looping forever', async () => {
|
||||
await transport.start();
|
||||
const onError = vi.fn();
|
||||
transport.onerror = onError;
|
||||
|
||||
stdin.write('Content-Length:\r\n\r\n{}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('recovers after discarding a malformed Content-Length frame', async () => {
|
||||
await transport.start();
|
||||
const onError = vi.fn();
|
||||
transport.onerror = onError;
|
||||
|
||||
stdin.write('Content-Length:\r\n\r\n{}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'recovery-client', version: '0.1' },
|
||||
},
|
||||
});
|
||||
const messagePromise = onceMessage(transport);
|
||||
stdin.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
|
||||
|
||||
await expect(messagePromise).resolves.toMatchObject({
|
||||
method: 'initialize',
|
||||
params: { clientInfo: { name: 'recovery-client' } },
|
||||
});
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ─── Security hardening regressions ──────────────────────────────
|
||||
|
||||
it('rejects Content-Length values exceeding the buffer cap', async () => {
|
||||
await transport.start();
|
||||
const onError = vi.fn();
|
||||
transport.onerror = onError;
|
||||
|
||||
// 20 MB — exceeds the 10 MB MAX_BUFFER_SIZE
|
||||
stdin.write('Content-Length: 20971520\r\n\r\n{}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError.mock.calls[0]?.[0]?.message).toMatch(/exceeds maximum/i);
|
||||
});
|
||||
|
||||
it('errors when read buffer exceeds maximum size in newline mode', async () => {
|
||||
await transport.start();
|
||||
const onError = vi.fn();
|
||||
transport.onerror = onError;
|
||||
|
||||
// Send a JSON-starting chunk (triggers newline mode) with no newline,
|
||||
// then keep appending until we exceed the 10 MB cap
|
||||
const chunkSize = 1024 * 1024; // 1 MB
|
||||
const chunk = Buffer.alloc(chunkSize, 0x61); // 'a' repeated
|
||||
// First byte must be '{' to trigger newline framing detection
|
||||
const first = Buffer.from('{' + 'a'.repeat(chunkSize - 1));
|
||||
stdin.write(first);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
stdin.write(chunk);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
expect(onError).toHaveBeenCalled();
|
||||
const hasMaxSizeError = onError.mock.calls.some(
|
||||
(call) => call[0] instanceof Error && /maximum size/i.test(call[0].message),
|
||||
);
|
||||
expect(hasMaxSizeError).toBe(true);
|
||||
});
|
||||
|
||||
it('handles many consecutive empty lines without stack overflow', async () => {
|
||||
await transport.start();
|
||||
const onError = vi.fn();
|
||||
transport.onerror = onError;
|
||||
|
||||
// First, seed the framing mode with a valid newline-delimited message
|
||||
const seed = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'seed', version: '0.1' },
|
||||
},
|
||||
});
|
||||
const seedPromise = onceMessage(transport);
|
||||
stdin.write(seed + '\n');
|
||||
await seedPromise;
|
||||
|
||||
// Now send 15K empty lines followed by a real message — this would
|
||||
// stack-overflow with the old recursive readNewlineMessage
|
||||
const followup = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'notifications/initialized',
|
||||
params: {},
|
||||
});
|
||||
|
||||
const messagePromise = onceMessage(transport);
|
||||
stdin.write('\n'.repeat(15_000) + followup + '\n');
|
||||
|
||||
await expect(messagePromise).resolves.toMatchObject({
|
||||
method: 'notifications/initialized',
|
||||
});
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects send() after transport is closed', async () => {
|
||||
await transport.start();
|
||||
await transport.close();
|
||||
|
||||
await expect(
|
||||
transport.send({ jsonrpc: '2.0', id: 1, result: { ok: true } }),
|
||||
).rejects.toThrow(/closed/i);
|
||||
});
|
||||
|
||||
it('does not detect content-length framing from short ambiguous prefix', async () => {
|
||||
await transport.start();
|
||||
const onError = vi.fn();
|
||||
transport.onerror = onError;
|
||||
|
||||
// Write only "cont" — fewer than 14 bytes, should NOT trigger
|
||||
// content-length detection. Transport should wait for more data.
|
||||
stdin.write(Buffer.from('cont'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
// No message and no error — transport is waiting for more data
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds with newline framing after newline input', async () => {
|
||||
await transport.start();
|
||||
const messagePromise = onceMessage(transport);
|
||||
stdin.write(`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'cursor', version: '0.1' },
|
||||
},
|
||||
})}\n`);
|
||||
await messagePromise;
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
stdout.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
|
||||
expect(raw).toBe('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n');
|
||||
});
|
||||
});
|
||||
536
gitnexus/test/unit/hooks.test.ts
Normal file
536
gitnexus/test/unit/hooks.test.ts
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
/**
|
||||
* Regression Tests: Claude Code Hooks
|
||||
*
|
||||
* Tests the hook scripts (gitnexus-hook.cjs and gitnexus-hook.js) that run
|
||||
* as PreToolUse and PostToolUse hooks in Claude Code.
|
||||
*
|
||||
* Covers:
|
||||
* - extractPattern: pattern extraction from Grep/Glob/Bash tool inputs
|
||||
* - findGitNexusDir: .gitnexus directory discovery
|
||||
* - handlePostToolUse: staleness detection after git mutations
|
||||
* - cwd validation: rejects relative paths (defense-in-depth)
|
||||
* - shell injection: verifies no shell: true in spawnSync calls
|
||||
* - dispatch map: correct handler routing
|
||||
* - cross-platform: Windows .cmd extension handling
|
||||
*
|
||||
* Since the hooks are CJS scripts that call main() on load, we test them
|
||||
* by spawning them as child processes with controlled stdin JSON.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js';
|
||||
|
||||
// ─── Paths to both hook variants ────────────────────────────────────
|
||||
|
||||
const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs');
|
||||
const PLUGIN_HOOK = path.resolve(__dirname, '..', '..', '..', 'gitnexus-claude-plugin', 'hooks', 'gitnexus-hook.js');
|
||||
|
||||
// ─── Test fixtures: temporary .gitnexus directory ───────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
let gitNexusDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-test-'));
|
||||
gitNexusDir = path.join(tmpDir, '.gitnexus');
|
||||
fs.mkdirSync(gitNexusDir, { recursive: true });
|
||||
|
||||
// Initialize a bare git repo so git rev-parse HEAD works
|
||||
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello');
|
||||
spawnSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Helper to get HEAD commit hash ─────────────────────────────────
|
||||
|
||||
function getHeadCommit(): string {
|
||||
const result = spawnSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: tmpDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return (result.stdout || '').trim();
|
||||
}
|
||||
|
||||
// ─── Both hook files should exist ───────────────────────────────────
|
||||
|
||||
describe('Hook files exist', () => {
|
||||
it('CJS hook exists', () => {
|
||||
expect(fs.existsSync(CJS_HOOK)).toBe(true);
|
||||
});
|
||||
|
||||
it('Plugin hook exists', () => {
|
||||
expect(fs.existsSync(PLUGIN_HOOK)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Source code regression: no shell: true ──────────────────────────
|
||||
|
||||
describe('Shell injection regression', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label} hook has no shell: true in spawnSync calls`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
// Match spawnSync calls with shell option set to true or a variable
|
||||
// Allowed: comments mentioning shell: true, string literals
|
||||
const lines = source.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Skip comments and string literals
|
||||
if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
|
||||
// Check for shell: true or shell: isWin in actual code
|
||||
if (/shell:\s*(true|isWin)/.test(line)) {
|
||||
throw new Error(`${label} hook line ${i + 1} has shell injection risk: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Source code regression: .cmd extensions for Windows ─────────────
|
||||
|
||||
describe('Windows .cmd extension handling', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label} hook uses .cmd extensions for Windows npx`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain("npx.cmd");
|
||||
});
|
||||
}
|
||||
|
||||
it('Plugin hook uses .cmd extension for Windows gitnexus binary', () => {
|
||||
const source = fs.readFileSync(PLUGIN_HOOK, 'utf-8');
|
||||
expect(source).toContain("gitnexus.cmd");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Source code regression: cwd validation ─────────────────────────
|
||||
|
||||
describe('cwd validation guards', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label} hook validates cwd is absolute path`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
const cwdChecks = (source.match(/path\.isAbsolute\(cwd\)/g) || []).length;
|
||||
// Should have at least 2 checks (one in PreToolUse, one in PostToolUse)
|
||||
expect(cwdChecks).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Source code regression: sendHookResponse used consistently ──────
|
||||
|
||||
describe('sendHookResponse consistency', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label} hook uses sendHookResponse in both handlers`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
const calls = (source.match(/sendHookResponse\(/g) || []).length;
|
||||
// At least 3: definition + PreToolUse call + PostToolUse call
|
||||
expect(calls).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it(`${label} hook does not inline hookSpecificOutput JSON in handlers`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
// Count inline hookSpecificOutput usage (should only be in sendHookResponse definition)
|
||||
const inlineCount = (source.match(/hookSpecificOutput/g) || []).length;
|
||||
// Exactly 1 occurrence: inside the sendHookResponse function body
|
||||
expect(inlineCount).toBe(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Source code regression: dispatch map pattern ────────────────────
|
||||
|
||||
describe('Dispatch map pattern', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label} hook uses dispatch map instead of if/else`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain('const handlers = {');
|
||||
expect(source).toContain('PreToolUse: handlePreToolUse');
|
||||
expect(source).toContain('PostToolUse: handlePostToolUse');
|
||||
// Should NOT have if/else dispatch in main()
|
||||
expect(source).not.toMatch(/if\s*\(hookEvent\s*===\s*'PreToolUse'\)/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Source code regression: debug error truncation ──────────────────
|
||||
|
||||
describe('Debug error message truncation', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label} hook truncates error messages to 200 chars`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain('.slice(0, 200)');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── extractPattern regression (via source analysis) ────────────────
|
||||
|
||||
describe('extractPattern coverage', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label} hook extracts pattern from Grep tool input`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain("toolName === 'Grep'");
|
||||
expect(source).toContain('toolInput.pattern');
|
||||
});
|
||||
|
||||
it(`${label} hook extracts pattern from Glob tool input`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain("toolName === 'Glob'");
|
||||
});
|
||||
|
||||
it(`${label} hook extracts pattern from Bash grep/rg commands`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toMatch(/\\brg\\b.*\\bgrep\\b/);
|
||||
});
|
||||
|
||||
it(`${label} hook rejects patterns shorter than 3 chars`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain('cleaned.length >= 3');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── PostToolUse: git mutation regex coverage ───────────────────────
|
||||
|
||||
describe('Git mutation regex', () => {
|
||||
const GIT_REGEX = /\\bgit\\s\+\(commit\|merge\|rebase\|cherry-pick\|pull\)/;
|
||||
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label} hook detects git commit`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain('commit');
|
||||
});
|
||||
|
||||
it(`${label} hook detects git merge`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain('merge');
|
||||
});
|
||||
|
||||
it(`${label} hook detects git rebase`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain('rebase');
|
||||
});
|
||||
|
||||
it(`${label} hook detects git cherry-pick`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain('cherry-pick');
|
||||
});
|
||||
|
||||
it(`${label} hook detects git pull`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
// 'pull' in the regex alternation
|
||||
expect(source).toMatch(/commit\|merge\|rebase\|cherry-pick\|pull/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Integration: PostToolUse staleness detection ───────────────────
|
||||
|
||||
describe('PostToolUse staleness detection (integration)', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label}: emits stale notification when HEAD differs from meta`, () => {
|
||||
// Write meta.json with a different commit
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'aaaaaaa0000000000000000000000000deadbeef', stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.hookEventName).toBe('PostToolUse');
|
||||
expect(output!.additionalContext).toContain('stale');
|
||||
expect(output!.additionalContext).toContain('aaaaaaa');
|
||||
});
|
||||
|
||||
it(`${label}: silent when HEAD matches meta lastCommit`, () => {
|
||||
const head = getHeadCommit();
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: head, stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it(`${label}: silent when tool is not Bash`, () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Grep',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it(`${label}: silent when command is not a git mutation`, () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git status' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it(`${label}: silent when exit code is non-zero`, () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "fail"' },
|
||||
tool_output: { exit_code: 1 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it(`${label}: includes --embeddings in suggestion when meta had embeddings`, () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'deadbeef', stats: { embeddings: 42 } }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git merge feature' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('--embeddings');
|
||||
});
|
||||
|
||||
it(`${label}: omits --embeddings when meta had no embeddings`, () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'deadbeef', stats: { embeddings: 0 } }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).not.toContain('--embeddings');
|
||||
});
|
||||
|
||||
it(`${label}: detects git rebase as a mutation`, () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git rebase main' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('stale');
|
||||
});
|
||||
|
||||
it(`${label}: detects git cherry-pick as a mutation`, () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git cherry-pick abc123' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
});
|
||||
|
||||
it(`${label}: detects git pull as a mutation`, () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git pull origin main' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Integration: cwd validation rejects relative paths ─────────────
|
||||
|
||||
describe('cwd validation (integration)', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label}: PostToolUse silent when cwd is relative`, () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: 'relative/path',
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it(`${label}: PreToolUse silent when cwd is relative`, () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Grep',
|
||||
tool_input: { pattern: 'validateUser' },
|
||||
cwd: 'relative/path',
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Integration: dispatch map routes correctly ─────────────────────
|
||||
|
||||
describe('Dispatch map routing (integration)', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label}: unknown hook_event_name produces no output`, () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'UnknownEvent',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'echo hello' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it(`${label}: empty hook_event_name produces no output`, () => {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: '',
|
||||
tool_name: 'Bash',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it(`${label}: missing hook_event_name produces no output`, () => {
|
||||
const result = runHook(hookPath, {
|
||||
tool_name: 'Bash',
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it(`${label}: invalid JSON input exits cleanly`, () => {
|
||||
const result = spawnSync(process.execPath, [hookPath], {
|
||||
input: 'not json at all',
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it(`${label}: empty stdin exits cleanly`, () => {
|
||||
const result = spawnSync(process.execPath, [hookPath], {
|
||||
input: '',
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Integration: PostToolUse with missing meta.json ────────────────
|
||||
|
||||
describe('PostToolUse with missing/corrupt meta.json', () => {
|
||||
for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) {
|
||||
it(`${label}: emits stale when meta.json does not exist`, () => {
|
||||
const metaPath = path.join(gitNexusDir, 'meta.json');
|
||||
const hadMeta = fs.existsSync(metaPath);
|
||||
if (hadMeta) fs.unlinkSync(metaPath);
|
||||
|
||||
try {
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('never');
|
||||
} finally {
|
||||
// Restore meta.json for subsequent tests
|
||||
fs.writeFileSync(metaPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
|
||||
}
|
||||
});
|
||||
|
||||
it(`${label}: emits stale when meta.json is corrupt`, () => {
|
||||
const metaPath = path.join(gitNexusDir, 'meta.json');
|
||||
fs.writeFileSync(metaPath, 'not valid json!!!');
|
||||
|
||||
const result = runHook(hookPath, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_output: { exit_code: 0 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('never');
|
||||
|
||||
// Restore
|
||||
fs.writeFileSync(metaPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
|
||||
});
|
||||
}
|
||||
});
|
||||
21
gitnexus/test/unit/lazy-action.test.ts
Normal file
21
gitnexus/test/unit/lazy-action.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createLazyAction } from '../../src/cli/lazy-action.js';
|
||||
|
||||
describe('createLazyAction', () => {
|
||||
it('does not import target module until invoked', async () => {
|
||||
const loader = vi.fn(async () => ({
|
||||
run: vi.fn(async () => 'ok'),
|
||||
}));
|
||||
|
||||
const action = createLazyAction(loader, 'run');
|
||||
|
||||
expect(loader).not.toHaveBeenCalled();
|
||||
await expect(action('arg-1')).resolves.toBeUndefined();
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws a clear error when export is not a function', async () => {
|
||||
const action = createLazyAction(async () => ({ notAFunction: 'string-value' }), 'notAFunction');
|
||||
await expect(action()).rejects.toThrow('notAFunction');
|
||||
});
|
||||
});
|
||||
35
gitnexus/test/utils/hook-test-helpers.ts
Normal file
35
gitnexus/test/utils/hook-test-helpers.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Shared helpers for hook test files (unit + integration).
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
export function runHook(
|
||||
hookPath: string,
|
||||
input: Record<string, any>,
|
||||
cwd?: string,
|
||||
): { stdout: string; stderr: string; status: number | null } {
|
||||
const result = spawnSync(process.execPath, [hookPath], {
|
||||
input: JSON.stringify(input),
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseHookOutput(
|
||||
stdout: string,
|
||||
): { hookEventName?: string; additionalContext?: string } | null {
|
||||
if (!stdout.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
return parsed.hookSpecificOutput || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/vitest.d.ts
vendored
Normal file
7
gitnexus/test/vitest.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import 'vitest';
|
||||
|
||||
declare module 'vitest' {
|
||||
export interface ProvidedContext {
|
||||
kuzuDbPath: string;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,14 @@ import { defineConfig } from 'vitest/config';
|
|||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globalSetup: ['test/global-setup.ts'],
|
||||
include: ['test/**/*.test.ts'],
|
||||
testTimeout: 30000,
|
||||
pool: 'forks',
|
||||
singleFork: true, // run all tests in a single fork to avoid KuzuDB native cleanup crashes
|
||||
globals: true,
|
||||
teardownTimeout: 1000,
|
||||
dangerouslyIgnoreUnhandledErrors: true, // KuzuDB native destructor segfaults on fork exit — not a test failure
|
||||
setupFiles: ['test/setup.ts'],
|
||||
teardownTimeout: 3000,
|
||||
dangerouslyIgnoreUnhandledErrors: true, // KuzuDB N-API destructor segfaults on fork exit — not a test failure
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
|
|
@ -17,12 +18,14 @@ export default defineConfig({
|
|||
'src/server/**', // HTTP server (requires network)
|
||||
'src/core/wiki/**', // Wiki generation (requires LLM)
|
||||
],
|
||||
// Ratchet these up as coverage improves — CI will fail if a PR drops below
|
||||
// Auto-ratchet: vitest bumps thresholds when coverage exceeds them.
|
||||
// CI will fail if a PR drops below these floors.
|
||||
thresholds: {
|
||||
statements: 25,
|
||||
branches: 22,
|
||||
functions: 25,
|
||||
lines: 25,
|
||||
statements: 26,
|
||||
branches: 23,
|
||||
functions: 28,
|
||||
lines: 27,
|
||||
autoUpdate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue