diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md index 746d18270..9510b97ac 100644 --- a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -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: ""}) → Find related execution flows -2. gitnexus_context({name: ""}) → 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: ""}) → Find related execution flows +2. gitnexus_context({name: ""}) → 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 +``` diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md index 62375c3dd..927a4e4b6 100644 --- a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -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: ""}) → Find related execution flows -4. gitnexus_context({name: ""}) → 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: ""}) → Find related execution flows +4. gitnexus_context({name: ""}) → 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 +``` diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md index 77eb7954a..e19af280c 100644 --- a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -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 +``` diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md index 100aa23ae..f48cc01bd 100644 --- a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -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 +``` diff --git a/.github/actions/setup-gitnexus/action.yml b/.github/actions/setup-gitnexus/action.yml new file mode 100644 index 000000000..fedd2efa8 --- /dev/null +++ b/.github/actions/setup-gitnexus/action.yml @@ -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 diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..1cffb3667 --- /dev/null +++ b/.github/release.yml @@ -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 diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml new file mode 100644 index 000000000..5f0071a50 --- /dev/null +++ b/.github/workflows/ci-integration.yml @@ -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 diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml new file mode 100644 index 000000000..d20e824ab --- /dev/null +++ b/.github/workflows/ci-quality.yml @@ -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 diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml new file mode 100644 index 000000000..bcd933f98 --- /dev/null +++ b/.github/workflows/ci-report.yml @@ -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= $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</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 "
" + echo "Coverage breakdown by test suite" + 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 "
" + echo "" + echo "
" + echo "Coverage thresholds are auto-ratcheted — they only go up" + 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 "
" + 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 "
" + echo "Coverage thresholds are auto-ratcheted — they only go up" + 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 "
" + echo "" + else + echo "### Code Coverage" + echo "" + echo "⚠️ Coverage data unavailable - check the [unit test job](${RUN_URL}) for details." + echo "" + fi + + echo "---" + echo "📋 [View full run](${RUN_URL}) · Generated by CI" + 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 }} diff --git a/.github/workflows/ci-unit-tests.yml b/.github/workflows/ci-unit-tests.yml new file mode 100644 index 000000000..686afd324 --- /dev/null +++ b/.github/workflows/ci-unit-tests.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21c8b19bb..ea9c8ca93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4d..afa25cffd 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -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 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267f1..a4768cfbb 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -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:*)' - diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5bade4347..13d00954e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.gitignore b/.gitignore index eb3d8e310..014528d6f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index aad422632..bfccccd32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,25 +1,78 @@ -# 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: ""})` — find execution flows related to the issue +2. `gitnexus_context({name: ""})` — 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` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d43b3a1e1 --- /dev/null +++ b/CHANGELOG.md @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index aad422632..bfccccd32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,25 +1,78 @@ -# 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: ""})` — find execution flows related to the issue +2. `gitnexus_context({name: ""})` — 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` diff --git a/README.md b/README.md index 6a236f47e..77d8e5967 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 813db5571..4c45c92d9 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -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 } } diff --git a/gitnexus-claude-plugin/hooks/hooks.json b/gitnexus-claude-plugin/hooks/hooks.json index 2e9cb49b2..f9ed9f84a 100644 --- a/gitnexus-claude-plugin/hooks/hooks.json +++ b/gitnexus-claude-plugin/hooks/hooks.json @@ -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..." + } + ] + } ] } } diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 64f0112a0..55e694fa2 100644 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -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)); + } } } diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index dea98cc70..e91471b3c 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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": { diff --git a/gitnexus/package.json b/gitnexus/package.json index 857076678..4e5b9bb5b 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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", diff --git a/gitnexus/skills/gitnexus-cli.md b/gitnexus/skills/gitnexus-cli.md index 3ae9c18e5..c9e0af341 100644 --- a/gitnexus/skills/gitnexus-cli.md +++ b/gitnexus/skills/gitnexus-cli.md @@ -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 diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 298e25e95..087320472 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -28,38 +28,110 @@ const GITNEXUS_END_MARKER = ''; /** * 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: ""})\` — find execution flows related to the issue +2. \`gitnexus_context({name: ""})\` — 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}`; } diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 7505169bb..c6a7c3b07 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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); }; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 10268db83..7ca2ce430 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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 number', '4747') .option('--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 ', 'LLM API key (saved to ~/.gitnexus/config.json)') .option('--concurrency ', '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 ') .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 ', 'What you want to find') .option('-l, --limit ', '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 ', 'Direct symbol UID (zero-ambiguity lookup)') .option('-f, --file ', 'File path to disambiguate common names') .option('--content', 'Include full symbol source code') - .action(contextCommand); + .action(createLazyAction(() => import('./tool.js'), 'contextCommand')); program .command('impact ') @@ -111,13 +102,13 @@ program .option('-r, --repo ', 'Target repository') .option('--depth ', 'Max relationship depth (default: 3)') .option('--include-tests', 'Include test files in results') - .action(impactCommand); + .action(createLazyAction(() => import('./tool.js'), 'impactCommand')); program .command('cypher ') .description('Execute raw Cypher query against the knowledge graph') .option('-r, --repo ', '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 number', '4848') .option('--idle-timeout ', 'Auto-shutdown after N seconds idle (0 = disabled)', '0') - .action(evalServerCommand); + .action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); program.parse(process.argv); diff --git a/gitnexus/src/cli/lazy-action.ts b/gitnexus/src/cli/lazy-action.ts new file mode 100644 index 000000000..d6c7189ed --- /dev/null +++ b/gitnexus/src/cli/lazy-action.ts @@ -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, + TKey extends string & keyof TModule, +>( + loader: () => Promise, + exportName: TKey, +): (...args: unknown[]) => Promise { + return async (...args: unknown[]): Promise => { + const module = await loader(); + const action = module[exportName]; + if (!isCallable(action)) { + throw new Error(`Lazy action export not found: ${exportName}`); + } + await action(...args); + }; +} diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 98d5fe7c6..79cd6eba5 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -168,16 +168,18 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { // 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 { // 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}`); } diff --git a/gitnexus/src/core/ingestion/ast-cache.ts b/gitnexus/src/core/ingestion/ast-cache.ts index 0ae105120..011b9df65 100644 --- a/gitnexus/src/core/ingestion/ast-cache.ts +++ b/gitnexus/src/core/ingestion/ast-cache.ts @@ -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({ - 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 }) }; }; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 6dba2355f..4a487661b 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 826282c9f..a346c105a 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/workers/worker-pool.ts b/gitnexus/src/core/ingestion/workers/worker-pool.ts index 1c1d7cae8..3f0006370 100644 --- a/gitnexus/src/core/ingestion/workers/worker-pool.ts +++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts @@ -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[] = []; diff --git a/gitnexus/src/core/kuzu/kuzu-adapter.ts b/gitnexus/src/core/kuzu/kuzu-adapter.ts index fa3279636..34347634e 100644 --- a/gitnexus/src/core/kuzu/kuzu-adapter.ts +++ b/gitnexus/src/core/kuzu/kuzu-adapter.ts @@ -591,6 +591,7 @@ export const closeKuzu = async (): Promise => { 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}) diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts index d20ef1b80..1aa4836cc 100644 --- a/gitnexus/src/core/search/bm25-index.ts +++ b/gitnexus/src/core/search/bm25-index.ts @@ -24,7 +24,8 @@ async function queryFTSViaExecutor( query: string, limit: number, ): Promise> { - 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 diff --git a/gitnexus/src/mcp/compatible-stdio-transport.ts b/gitnexus/src/mcp/compatible-stdio-transport.ts new file mode 100644 index 000000000..8912c5818 --- /dev/null +++ b/gitnexus/src/mcp/compatible-stdio-transport.ts @@ -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((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(); + }); + } + }); + } +} diff --git a/gitnexus/src/mcp/core/kuzu-adapter.ts b/gitnexus/src/mcp/core/kuzu-adapter.ts index 13a4b7270..e852096d8 100644 --- a/gitnexus/src/mcp/core/kuzu-adapter.ts +++ b/gitnexus/src/mcp/core/kuzu-adapter.ts @@ -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 => { } }; + /** * Check if a specific repo's pool is active */ diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index 0d5490e17..81a68c212 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -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 { const server = createMCPServer(backend); // Connect to stdio transport - const transport = new StdioServerTransport(); + const transport = new CompatibleStdioServerTransport(); await server.connect(transport); // Graceful shutdown helper diff --git a/gitnexus/test/fixtures/local-backend-seed.ts b/gitnexus/test/fixtures/local-backend-seed.ts new file mode 100644 index 000000000..f99311068 --- /dev/null +++ b/gitnexus/test/fixtures/local-backend-seed.ts @@ -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'] }, +]; diff --git a/gitnexus/test/fixtures/mini-repo/src/index.ts b/gitnexus/test/fixtures/mini-repo/src/index.ts index 62926d648..1eb88011f 100644 --- a/gitnexus/test/fixtures/mini-repo/src/index.ts +++ b/gitnexus/test/fixtures/mini-repo/src/index.ts @@ -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'; diff --git a/gitnexus/test/fixtures/mini-repo/src/logger.ts b/gitnexus/test/fixtures/mini-repo/src/logger.ts new file mode 100644 index 000000000..6e9ac1438 --- /dev/null +++ b/gitnexus/test/fixtures/mini-repo/src/logger.ts @@ -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); +} diff --git a/gitnexus/test/fixtures/mini-repo/src/middleware.ts b/gitnexus/test/fixtures/mini-repo/src/middleware.ts new file mode 100644 index 000000000..3870ed490 --- /dev/null +++ b/gitnexus/test/fixtures/mini-repo/src/middleware.ts @@ -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); +} diff --git a/gitnexus/test/fixtures/search-seed.ts b/gitnexus/test/fixtures/search-seed.ts new file mode 100644 index 000000000..14d2c32ce --- /dev/null +++ b/gitnexus/test/fixtures/search-seed.ts @@ -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'] }, +]; diff --git a/gitnexus/test/global-setup.ts b/gitnexus/test/global-setup.ts new file mode 100644 index 000000000..968bd4fbc --- /dev/null +++ b/gitnexus/test/global-setup.ts @@ -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(); + }; +} diff --git a/gitnexus/test/helpers/test-graph.ts b/gitnexus/test/helpers/test-graph.ts index ad305dfda..953b22b95 100644 --- a/gitnexus/test/helpers/test-graph.ts +++ b/gitnexus/test/helpers/test-graph.ts @@ -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' }, ], ); } diff --git a/gitnexus/test/helpers/test-indexed-db.ts b/gitnexus/test/helpers/test-indexed-db.ts new file mode 100644 index 000000000..3833abaa4 --- /dev/null +++ b/gitnexus/test/helpers/test-indexed-db.ts @@ -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. + */ +/// +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; +} + +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; + /** 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); + }); +} diff --git a/gitnexus/test/integration/augmentation.test.ts b/gitnexus/test/integration/augmentation.test.ts new file mode 100644 index 000000000..81aca91a0 --- /dev/null +++ b/gitnexus/test/integration/augmentation.test.ts @@ -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; + +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).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; + }, +}); diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts new file mode 100644 index 000000000..50a01fd6e --- /dev/null +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -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 }); + } + }); + + }); +}); diff --git a/gitnexus/test/integration/csv-pipeline.test.ts b/gitnexus/test/integration/csv-pipeline.test.ts index be0d78533..332a2ac78 100644 --- a/gitnexus/test/integration/csv-pipeline.test.ts +++ b/gitnexus/test/integration/csv-pipeline.test.ts @@ -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); + }); }); diff --git a/gitnexus/test/integration/enrichment.test.ts b/gitnexus/test/integration/enrichment.test.ts new file mode 100644 index 000000000..5b54eb278 --- /dev/null +++ b/gitnexus/test/integration/enrichment.test.ts @@ -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([ + [ + '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([ + ['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([ + ['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'); + }); + }); +}); diff --git a/gitnexus/test/integration/filesystem-walker.test.ts b/gitnexus/test/integration/filesystem-walker.test.ts index c2dac4d04..0096aedb2 100644 --- a/gitnexus/test/integration/filesystem-walker.test.ts +++ b/gitnexus/test/integration/filesystem-walker.test.ts @@ -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); + }); }); }); diff --git a/gitnexus/test/integration/hooks-e2e.test.ts b/gitnexus/test/integration/hooks-e2e.test.ts new file mode 100644 index 000000000..a53e646dd --- /dev/null +++ b/gitnexus/test/integration/hooks-e2e.test.ts @@ -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(); + }); + }); +}); diff --git a/gitnexus/test/integration/kuzu-core-adapter.test.ts b/gitnexus/test/integration/kuzu-core-adapter.test.ts new file mode 100644 index 000000000..ec2a758d6 --- /dev/null +++ b/gitnexus/test/integration/kuzu-core-adapter.test.ts @@ -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); + }, +}); diff --git a/gitnexus/test/integration/kuzu-pool.test.ts b/gitnexus/test/integration/kuzu-pool.test.ts index 0b7ab57f8..e877aabc7 100644 --- a/gitnexus/test/integration/kuzu-pool.test.ts +++ b/gitnexus/test/integration/kuzu-pool.test.ts @@ -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 { - 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, }); diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts new file mode 100644 index 000000000..d856b0e27 --- /dev/null +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -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; + }, +}); diff --git a/gitnexus/test/integration/local-backend.test.ts b/gitnexus/test/integration/local-backend.test.ts index 1f7450121..bd635a9d0 100644 --- a/gitnexus/test/integration/local-backend.test.ts +++ b/gitnexus/test/integration/local-backend.test.ts @@ -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 { - 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, }); diff --git a/gitnexus/test/integration/parsing.test.ts b/gitnexus/test/integration/parsing.test.ts index 46a005320..741f3919c 100644 --- a/gitnexus/test/integration/parsing.test.ts +++ b/gitnexus/test/integration/parsing.test.ts @@ -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', ' { + 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', ' { - 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); - }); - } -}); diff --git a/gitnexus/test/integration/pipeline.test.ts b/gitnexus/test/integration/pipeline.test.ts index aa4c85098..0fa8e0b3c 100644 --- a/gitnexus/test/integration/pipeline.test.ts +++ b/gitnexus/test/integration/pipeline.test.ts @@ -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(); - 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(); @@ -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(); - 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); +}); diff --git a/gitnexus/test/integration/search-core.test.ts b/gitnexus/test/integration/search-core.test.ts new file mode 100644 index 000000000..609c01dbf --- /dev/null +++ b/gitnexus/test/integration/search-core.test.ts @@ -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, +}); diff --git a/gitnexus/test/integration/search-pool.test.ts b/gitnexus/test/integration/search-pool.test.ts new file mode 100644 index 000000000..c6bee5559 --- /dev/null +++ b/gitnexus/test/integration/search-pool.test.ts @@ -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, +}); diff --git a/gitnexus/test/integration/tree-sitter-languages.test.ts b/gitnexus/test/integration/tree-sitter-languages.test.ts index 4f2e59d10..4fa225d91 100644 --- a/gitnexus/test/integration/tree-sitter-languages.test.ts +++ b/gitnexus/test/integration/tree-sitter-languages.test.ts @@ -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); + }); + }); }); diff --git a/gitnexus/test/integration/worker-pool.test.ts b/gitnexus/test/integration/worker-pool.test.ts new file mode 100644 index 000000000..d86c2cb58 --- /dev/null +++ b/gitnexus/test/integration/worker-pool.test.ts @@ -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([ + { 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(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(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([ + { 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(); + }); +}); diff --git a/gitnexus/test/setup.ts b/gitnexus/test/setup.ts new file mode 100644 index 000000000..1e7e5e591 --- /dev/null +++ b/gitnexus/test/setup.ts @@ -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 {} +}); diff --git a/gitnexus/test/unit/ast-cache.test.ts b/gitnexus/test/unit/ast-cache.test.ts index 823982bc7..66cc6e3c3 100644 --- a/gitnexus/test/unit/ast-cache.test.ts +++ b/gitnexus/test/unit/ast-cache.test.ts @@ -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); + }); }); }); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts new file mode 100644 index 000000000..83eed651c --- /dev/null +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -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 '); + expect(result.stdout).toContain('--goal '); + 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 '); + expect(result.stdout).toContain('--file '); + }); + + it('impact help keeps repo and include-tests flags', () => { + const result = runHelp('impact'); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('--depth '); + expect(result.stdout).toContain('--include-tests'); + expect(result.stdout).toContain('--repo '); + }); +}); diff --git a/gitnexus/test/unit/compatible-stdio-transport.test.ts b/gitnexus/test/unit/compatible-stdio-transport.test.ts new file mode 100644 index 000000000..04c75a7b7 --- /dev/null +++ b/gitnexus/test/unit/compatible-stdio-transport.test.ts @@ -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 { + 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'); + }); +}); diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts new file mode 100644 index 000000000..667151e6f --- /dev/null +++ b/gitnexus/test/unit/hooks.test.ts @@ -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: {} })); + }); + } +}); diff --git a/gitnexus/test/unit/lazy-action.test.ts b/gitnexus/test/unit/lazy-action.test.ts new file mode 100644 index 000000000..73b1b92b8 --- /dev/null +++ b/gitnexus/test/unit/lazy-action.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/utils/hook-test-helpers.ts b/gitnexus/test/utils/hook-test-helpers.ts new file mode 100644 index 000000000..6f5c5fbfd --- /dev/null +++ b/gitnexus/test/utils/hook-test-helpers.ts @@ -0,0 +1,35 @@ +/** + * Shared helpers for hook test files (unit + integration). + */ +import { spawnSync } from 'child_process'; + +export function runHook( + hookPath: string, + input: Record, + 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; + } +} diff --git a/gitnexus/test/vitest.d.ts b/gitnexus/test/vitest.d.ts new file mode 100644 index 000000000..cc2ff9782 --- /dev/null +++ b/gitnexus/test/vitest.d.ts @@ -0,0 +1,7 @@ +import 'vitest'; + +declare module 'vitest' { + export interface ProvidedContext { + kuzuDbPath: string; + } +} diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 836c0dd21..b1d4e9581 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -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, }, }, },