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..45c0fe58f --- /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@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..9237465ff --- /dev/null +++ b/.github/workflows/ci-integration.yml @@ -0,0 +1,104 @@ +name: Integration Tests + +on: + workflow_call: + +permissions: + contents: read + +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@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 + ) + for f in "${files[@]}"; do + echo "::group::$f" + npx vitest run --reporter=verbose --pool=forks "$f" + echo "::endgroup::" + done + + # Non-kuzu groups: run all files in a single vitest invocation + - name: Run integration tests — ${{ matrix.test-group }} + if: matrix.test-group != 'kuzu-db' + run: npx vitest run --reporter=verbose ${{ matrix.test-glob }} + working-directory: gitnexus + + # ── Unified status gate ────────────────────────────────────────────── + # Branch protection should require THIS job, not the matrix jobs directly. + status: + name: integration (all groups) + needs: test-matrix + if: always() + runs-on: ubuntu-latest + steps: + - name: Check all matrix jobs passed + run: | + result="${{ needs.test-matrix.result }}" + 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..eebc68160 --- /dev/null +++ b/.github/workflows/ci-quality.yml @@ -0,0 +1,16 @@ +name: Quality Checks + +on: + workflow_call: + +permissions: + contents: read + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-gitnexus + - run: npx tsc --noEmit + working-directory: gitnexus diff --git a/.github/workflows/ci-unit-tests.yml b/.github/workflows/ci-unit-tests.yml new file mode 100644 index 000000000..8c7fb329f --- /dev/null +++ b/.github/workflows/ci-unit-tests.yml @@ -0,0 +1,59 @@ +name: Unit Tests + +on: + workflow_call: + +permissions: + contents: read + pull-requests: write + +jobs: + unit-tests: + name: unit (ubuntu / coverage) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@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: Coverage report + if: always() + uses: davelosert/vitest-coverage-report-action@v2 + with: + working-directory: gitnexus + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + gitnexus/coverage/coverage-summary.json + gitnexus/test-results.json + retention-days: 5 + + cross-platform: + name: unit (${{ matrix.os }}) + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@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 62a8c5169..6088b06aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,68 +7,207 @@ on: branches: [main] workflow_call: +permissions: + contents: read + pull-requests: write + +# ── 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 unit-tests: + uses: ./.github/workflows/ci-unit-tests.yml + + integration: + uses: ./.github/workflows/ci-integration.yml + + # ── PR test & coverage report ──────────────────────────────────── + # Downloads coverage artifacts from unit tests and posts a summary + # comment on the PR with test results and coverage metrics. + pr-report: + name: PR Report + if: always() && github.event_name == 'pull_request' + needs: [quality, unit-tests, integration] + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Download test reports + uses: actions/download-artifact@v4 + with: + name: test-reports + path: reports + continue-on-error: true + + - name: Debug artifact contents + run: find reports -type f 2>/dev/null || echo "No reports directory" + continue-on-error: true + + - name: Build report + id: report + run: | + # ── Locate coverage file (artifact path may vary) ── + COV_FILE=$(find reports -name "coverage-summary.json" -type f 2>/dev/null | head -1) + if [ -n "$COV_FILE" ]; then + STMTS=$(jq -r '.total.statements.pct' "$COV_FILE") + BRANCH=$(jq -r '.total.branches.pct' "$COV_FILE") + FUNCS=$(jq -r '.total.functions.pct' "$COV_FILE") + LINES=$(jq -r '.total.lines.pct' "$COV_FILE") + STMTS_COV=$(jq -r '"\(.total.statements.covered)/\(.total.statements.total)"' "$COV_FILE") + BRANCH_COV=$(jq -r '"\(.total.branches.covered)/\(.total.branches.total)"' "$COV_FILE") + FUNCS_COV=$(jq -r '"\(.total.functions.covered)/\(.total.functions.total)"' "$COV_FILE") + LINES_COV=$(jq -r '"\(.total.lines.covered)/\(.total.lines.total)"' "$COV_FILE") + else + STMTS="N/A"; BRANCH="N/A"; FUNCS="N/A"; LINES="N/A" + STMTS_COV=""; BRANCH_COV=""; FUNCS_COV=""; LINES_COV="" + fi + + # ── Locate test results ── + RESULTS_FILE=$(find reports -name "test-results.json" -type f 2>/dev/null | head -1) + if [ -n "$RESULTS_FILE" ]; then + TOTAL=$(jq -r '.numTotalTests' "$RESULTS_FILE") + PASSED=$(jq -r '.numPassedTests' "$RESULTS_FILE") + FAILED=$(jq -r '.numFailedTests' "$RESULTS_FILE") + SKIPPED=$(jq -r '.numPendingTests' "$RESULTS_FILE") + SUITES=$(jq -r '.numTotalTestSuites' "$RESULTS_FILE") + DURATION=$(jq -r '((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor' "$RESULTS_FILE" 2>/dev/null || echo "N/A") + else + TOTAL="N/A"; PASSED="N/A"; FAILED="N/A"; SKIPPED="N/A" + SUITES="N/A"; DURATION="N/A" + fi + + # ── Coverage thresholds (from vitest.config.ts P0 settings) ── + THRESH_STMTS=26; THRESH_BRANCH=23; THRESH_FUNCS=28; THRESH_LINES=27 + + # ── 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=$(echo "$pct / 5" | bc 2>/dev/null || echo 0) + local empty=$((20 - filled)) + local bar="" + for ((i=0; i= $thresh" | bc 2>/dev/null)" = "1" ]; then + echo "🟢 ${bar}" + else + echo "🔴 ${bar}" + fi + } + + QUALITY="${{ needs.quality.result }}" + UNIT="${{ needs.unit-tests.result }}" + INTEG="${{ needs.integration.result }}" + + # ── Overall status ── + if [[ "$QUALITY" == "success" && "$UNIT" == "success" && "$INTEG" == "success" ]]; then + OVERALL="✅ **All checks passed**" + else + OVERALL="❌ **Some checks failed**" + fi + + # ── Build markdown ── + { + echo "body<" + 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](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details." + echo "" + fi + + echo "---" + echo "📋 [View full run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · Generated by CI" + echo "REPORT_EOF" + } >> "$GITHUB_OUTPUT" + + - name: Comment on PR + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: ci-report + message: ${{ steps.report.outputs.body }} + + # ── 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 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 - - cross-platform-unit: - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: gitnexus/package-lock.json - - run: npm ci - working-directory: gitnexus - - run: npx vitest run test/unit - working-directory: gitnexus - - cross-platform-integration: - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - runs-on: ${{ matrix.os }} - 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: npm run build - working-directory: gitnexus - - run: npx vitest run test/integration - working-directory: gitnexus + - name: Check all jobs passed + run: | + echo "Quality: ${{ needs.quality.result }}" + echo "Unit Tests: ${{ needs.unit-tests.result }}" + echo "Integration: ${{ needs.integration.result }}" + if [[ "${{ needs.quality.result }}" != "success" ]] || + [[ "${{ needs.unit-tests.result }}" != "success" ]] || + [[ "${{ needs.integration.result }}" != "success" ]]; then + echo "::error::One or more CI jobs failed" + exit 1 + fi 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 b0e0f7696..bfccccd32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (1573 symbols, 4146 relationships, 120 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +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. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7320532e7..d43b3a1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index b0e0f7696..bfccccd32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (1573 symbols, 4146 relationships, 120 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +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. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index f8a98f860..e91471b3c 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.3.9", + "version": "1.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.3.9", + "version": "1.3.11", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { diff --git a/gitnexus/package.json b/gitnexus/package.json index 89ae891f0..4e5b9bb5b 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.3.10", + "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/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/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/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 index be267d6f3..a53e646dd 100644 --- a/gitnexus/test/integration/hooks-e2e.test.ts +++ b/gitnexus/test/integration/hooks-e2e.test.ts @@ -265,6 +265,101 @@ describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => { }); }); + 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 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 index 1af2b3ca7..d86c2cb58 100644 --- a/gitnexus/test/integration/worker-pool.test.ts +++ b/gitnexus/test/integration/worker-pool.test.ts @@ -125,10 +125,52 @@ describe('worker pool integration', () => { it('fails gracefully with invalid worker path', () => { const badUrl = pathToFileURL('/nonexistent/worker.js') as URL; - // createWorkerPool creates workers eagerly — the Worker constructor - // may throw or the worker may exit with an error + // createWorkerPool validates the worker script exists before spawning expect(() => { pool = createWorkerPool(badUrl, 1); - }).not.toThrow(); // Workers fail asynchronously, not in constructor + }).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/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, }, }, },