commit 5f98960d221338773cf8d4efaa9e808e32042c71 Author: wehub-resource-sync Date: Mon Jul 13 12:02:56 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.claude/skills/add-lang/SKILL.md b/.claude/skills/add-lang/SKILL.md new file mode 100644 index 0000000..37cbdce --- /dev/null +++ b/.claude/skills/add-lang/SKILL.md @@ -0,0 +1,219 @@ +--- +name: add-lang +description: Add tree-sitter language support to codegraph end-to-end — wire the grammar + extractor, write tests, then benchmark extraction quality and retrieval value on 3 popular real-world repos. Use when the user runs /add-lang or asks to add/support a new language (e.g. Lua, Elixir, Zig, OCaml) in codegraph. +--- + +# Add a language to CodeGraph + +Wire a new tree-sitter language into codegraph's extraction pipeline, prove it +extracts real symbols on popular repos, and prove it beats no-codegraph for an +agent. Runs **fully autonomously** — pick repos, benchmark, update docs, then +report. **Never commit, push, publish, or tag** (house rule); leave all changes +for the user to review. + +The argument is the language token used throughout the `Language` union, e.g. +`lua`, `elixir`, `zig`. If none was given, ask which language. Use the lowercase +single-token form everywhere (`csharp`, not `c#`). + +## Prerequisites +- Run from the codegraph repo root. `node`, `git`, `gh`, and a logged-in + `claude` CLI (the benchmark spawns real `claude -p` runs). +- The benchmark uses the local dev build — Step 8 builds + links it on PATH. + +## Workflow + +Copy this checklist and work through it in order: +``` +- [ ] 1. Resolve language; bail early if already supported (just benchmark) +- [ ] 2. Find a grammar + health-check it (ABI / heap corruption) +- [ ] 3. Discover the grammar's AST node types (dump-ast.mjs) +- [ ] 4. Wire the language (4 files; sometimes a 5th core touch) +- [ ] 5. Build + verify-extraction loop until PASS +- [ ] 6. Add extraction tests; make them green +- [ ] 7. Auto-pick 3 popular repos by size tier; add to corpus.json +- [ ] 8. Benchmark all 3: extraction + with/without A/B +- [ ] 9. Update README + CHANGELOG +- [ ] 10. Report; do NOT commit +``` + +### Step 1 — Resolve + short-circuit + +Check whether the language is already wired: look for the token in the +`LANGUAGES` const (`src/types.ts`) and the `EXTRACTORS` map +(`src/extraction/languages/index.ts`). If it is already supported (e.g. +`typescript`, `rust`), **skip Steps 2–6** and go straight to benchmarking +(Steps 7–8) to validate/measure it — note in the report that no code changed. + +### Step 2 — Find a grammar, then health-check it + +```bash +ls node_modules/tree-sitter-wasms/out/ | grep -i # csharp -> c_sharp +``` +- **Present** → likely off-the-shelf; `grammars.ts` resolves it from + `tree-sitter-wasms` automatically. (Many languages: elixir, zig, ocaml, + solidity, toml, yaml, …) +- **Absent** → vendor a `.wasm` into `src/extraction/wasm/` (like `pascal` / + `scala` / `lua`) and add the token to the vendored branch in Step 4. + +**Always health-check before writing an extractor — a *present* grammar can +still be unusable:** +```bash +node scripts/add-lang/check-grammar.mjs path/to/valid-sample. +``` +It prints the grammar's ABI version and parses a valid sample many times in a +multi-grammar runtime. If it **FAILs** (ERROR trees on valid code — an old ABI +corrupting the shared WASM heap, which silently drops nested calls/imports on +every file after the first; e.g. the tree-sitter-wasms **Lua** grammar is ABI 13 +and fails), do NOT use that wasm. **Vendor a newer (ABI 14/15) build instead:** +```bash +npm pack @tree-sitter-grammars/tree-sitter- # often ships a prebuilt *.wasm +# or build one: npx tree-sitter build --wasm (needs Docker/emscripten) +cp .wasm src/extraction/wasm/tree-sitter-.wasm +``` +then add the token to the vendored branch in Step 4 and re-run check-grammar on +the vendored path until it PASSes. **If you cannot obtain a healthy wasm, STOP +and tell the user.** + +### Step 3 — Discover AST node types + +Get a representative source file (write a small sample covering functions, +classes/structs, imports, enums; or `curl` a raw file from a known repo), then: +```bash +node scripts/add-lang/dump-ast.mjs path/to/sample. +# vendored grammar: pass the wasm path instead of the token +node scripts/add-lang/dump-ast.mjs src/extraction/wasm/tree-sitter-.wasm sample. +``` +The frequency table + field names (`name:`, `parameters:`, `body:`, +`return_type:`) tell you what to map. Open the existing extractor closest to the +language's paradigm as a model: `rust.ts`/`scala.ts` (functional, traits), +`java.ts`/`csharp.ts` (OO), `python.ts`/`ruby.ts` (scripting), `go.ts` +(top-level methods + receivers). + +### Step 4 — Wire the language (4 files) + +These are exact, fragile wiring — match the existing style precisely: + +1. **`src/types.ts`** — TWO edits: + - add `'',` to the `LANGUAGES` const (before `'unknown'`); + - add `'**/*.',` to `DEFAULT_CONFIG.include`. **Don't skip this** — it's + the file-scan allowlist; without the glob, `codegraph init` finds **0 + files** even though detection/extraction are wired. +2. **`src/extraction/grammars.ts`** — three maps: + - `WASM_GRAMMAR_FILES`: `: 'tree-sitter-.wasm',` + - `EXTENSION_MAP`: each file extension → `''` (e.g. `'.lua': 'lua',`) + - `getLanguageDisplayName`: `: '',` + - **vendored only**: add `` to the + `(lang === 'pascal' || lang === 'scala' || …)` wasm-path branch. +3. **`src/extraction/languages/.ts`** — new file exporting + `export const Extractor: LanguageExtractor = { … }`. Map the node types + from Step 3. Required fields: `functionTypes`, `classTypes`, `methodTypes`, + `interfaceTypes`, `structTypes`, `enumTypes`, `typeAliasTypes`, + `importTypes`, `callTypes`, `variableTypes`, `nameField`, `bodyField`, + `paramsField`. Add hooks as the grammar needs them (`getSignature`, + `getVisibility`, `isExported`, `extractImport`, `visitNode`, `getReceiverType`, + `interfaceKind`, `enumMemberTypes`, etc. — see + `src/extraction/tree-sitter-types.ts`). +4. **`src/extraction/languages/index.ts`** — `import { Extractor } from + './';` and add `: Extractor,` to `EXTRACTORS`. + +**Sometimes a 5th, core touch in `src/extraction/tree-sitter.ts`** — variable +extraction has per-language branches in `extractVariable` (the generic fallback +only finds direct `identifier`/`variable_declarator` children). If the grammar +nests declared names (e.g. Lua's `variable_declaration → variable_list`), add a +`} else if (this.language === '')` branch there, mirroring the existing +ts/python/go ones. Import forms that aren't a distinct node (Lua/Ruby `require` +is a *call*) are handled in the extractor's `visitNode` hook instead. + +### Step 5 — Build + verify loop + +```bash +npm run build # tsc + copy-assets (copies any vendored *.wasm into dist/) +``` +Index a small sample repo and check extraction: +```bash +( cd && codegraph init -i ) +node scripts/add-lang/verify-extraction.mjs +``` +`verify-extraction.mjs` fails (exit 1) if the language isn't detected or only +`file`/`import` nodes were produced — the classic symptom of wrong node-type +names. On FAIL or a thin WARN: re-run `dump-ast.mjs` on a richer file, fix the +mappings in `.ts`, `npm run build`, re-index, re-verify. **Repeat until +PASS.** + +### Step 6 — Tests + +Add to `__tests__/extraction.test.ts`, modeled on the `Rust Extraction` block: +- a `detectLanguage` assertion in `describe('Language Detection')` +- a `describe(' Extraction')` block asserting functions/classes/imports + are extracted from an inline source string. +```bash +npx vitest run __tests__/extraction.test.ts +``` +Green before continuing. + +### Step 7 — Auto-pick 3 repos + corpus + +Pick **without asking**. Find candidates, then curate 3 that are genuinely +``-dominant, one per size tier: +```bash +gh search repos --language= --sort=stars --limit 40 \ + --json fullName,stargazerCount,description +``` +Tiers (match `corpus.json`): **Small** <~150 files · **Medium** ~150–1500 · +**Large** >~1500. Skip repos that are tagged `` but mostly another +language. Write one cross-file architecture **question** per repo (the kind that +needs tracing across files). Add a `""` block to +`.claude/skills/agent-eval/corpus.json` (fields: `name`, `repo`, `size`, +`files`, `question`) so `/agent-eval` can reuse them. + +### Step 8 — Benchmark all 3 (extraction + A/B) + +Make the dev build the codegraph on PATH **once**, then loop: +```bash +npm run build && ./scripts/local-install.sh +scripts/add-lang/bench.sh "" headless # ×3 +``` +`bench.sh` clones (shared `/tmp/codegraph-corpus`), wipes + indexes, runs +`verify-extraction.mjs`, then the with/without retrieval A/B via +`scripts/agent-eval/run-all.sh` (skips the paid A/B if extraction is broken). +Read each `parse-run.mjs` summary printed by `run-all.sh`: tool calls, file +`Read`s, Grep/Bash, codegraph-tool calls, duration, and **cost** — for both the +`with` and `without` arms. After the loop, restore the dev link if needed: +`./scripts/local-install.sh`. + +### Step 9 — Docs + CHANGELOG + +- **README.md**: add `` to the "19+ Languages" feature bullet, and add a + row to the **Supported Languages** table: + `| | \`.ext\` | Full support (classes, methods, …) |`. +- **CHANGELOG.md**: add an `## [Unreleased]` section at the top (above the + latest version) with `### Added` → a user-perspective bullet, e.g. + *"CodeGraph now indexes **** (`.ext`) — functions, classes, imports, and + call edges."* If `## [Unreleased]` already exists, append under it. (It's + folded into the next versioned block at release time.) + +### Step 10 — Report (do NOT commit) + +Summarize for review: +- **Files changed**: the 4 wiring edits + new extractor + tests + README + + CHANGELOG + corpus.json (+ any vendored `.wasm`). +- **Extraction** per repo: files / nodes / edges / `verify-extraction` result. +- **A/B** per repo: `with` vs `without` (tool calls, file Reads, cost) and a + one-line verdict — did codegraph reduce effort, and did both arms reach a + correct answer? +- **Gaps / follow-ups** (node types not yet mapped, resolution edges missing, + framework routes, etc.). + +Hand the changes to the user. **Do not** run `git commit`/`push` or publish — +releases go through the GitHub Actions Release workflow. + +## Notes +- The A/B spawns real **paid** `claude -p` runs (opus, `--max-budget-usd`), + 2 arms × 3 repos. The corpus dir `/tmp/codegraph-corpus` is shared with + `/agent-eval`, so clones are reused across runs. +- Any new `*.wasm` must live in `src/extraction/wasm/` — `copy-assets` (run by + `npm run build`) ships it; otherwise it won't be in `dist/`. +- An index must be served by the **same** binary that built it. Step 8 builds + + links the dev build first, so this holds. +- If a grammar can't be obtained, or extraction can't reach PASS, **STOP and + report** — don't ship a half-wired language. diff --git a/.claude/skills/agent-eval/SKILL.md b/.claude/skills/agent-eval/SKILL.md new file mode 100644 index 0000000..2e894a7 --- /dev/null +++ b/.claude/skills/agent-eval/SKILL.md @@ -0,0 +1,74 @@ +--- +name: agent-eval +description: Benchmark CodeGraph retrieval quality on a real codebase by comparing agent behavior with vs without CodeGraph. Use when the user runs /agent-eval or asks to test, benchmark, audit, or validate a codegraph version (the local dev build or a published npm version) against a language's repo. +--- + +# CodeGraph Quality Audit + +Measures how much CodeGraph helps an agent versus plain grep/read, for a chosen +codegraph version on a chosen real-world repo. Drives the harness in +`scripts/agent-eval/`. + +## Prerequisites +- `tmux` 3+, a logged-in `claude` CLI, `node`, `git` (macOS/Linux). +- Run from the codegraph repo root. + +## Workflow + +Copy this checklist: +``` +- [ ] 1. Pick version (local or npm) +- [ ] 2. Pick language +- [ ] 3. Pick repo by size +- [ ] 4. Pick harness (headless / tmux / both) +- [ ] 5. Run audit.sh in the background +- [ ] 6. Report results +``` + +**Step 1 — version.** Ask with `AskUserQuestion`: which codegraph version to test. +Offer "Local dev build" and "Latest published"; the free-text "Other" lets the +user type a specific version (e.g. `0.7.10`). Map the answer to a VERSION token: +- "Local dev build" → `local` +- "Latest published" → `latest` +- a typed version → that string (e.g. `0.7.10`) + +**Step 2 — language.** Read `.claude/skills/agent-eval/corpus.json`. Ask with +`AskUserQuestion` which language to test, listing the languages that have entries. + +**Step 3 — repo.** From the chosen language's entries, ask which repo. Label each +option with its size and file count, e.g. `excalidraw — Medium (~600 files)`. +Each entry carries the `repo` URL and a representative `question`. + +**Step 4 — harness.** Ask with `AskUserQuestion` which harness to run, and map +the answer to a MODE token: +- "Headless" → `headless` — `claude -p` with stream-json: exact tokens/cost and a + clean tool sequence (2 runs, fast, no TTY). +- "Interactive (tmux)" → `tmux` — drives the real Claude TUI in tmux: faithful + Explore-subagent behavior, metrics from session logs (2 runs, slower). +- "Both" → `all` — headless + interactive (4 runs). + +**Step 5 — run.** Launch in the background (sets the version, clones if missing, +wipes + re-indexes, runs the chosen arms — several minutes): +```bash +scripts/agent-eval/audit.sh "" +``` + +**Step 6 — report.** When the job finishes, read the log and report per arm: +- Headless (`parse-run.mjs`): total tool calls, file `Read`s, Grep/Bash, + codegraph-tool calls, duration, **total cost**. +- Interactive (`parse-session.mjs`): the `VERDICT: codegraph_explore used Nx | + Read N | Grep/Bash N` and `TOKENS:` lines. + +Lead with cost + tool/Read counts — they are the reliable signals; raw token +in/out are confounded by subagent delegation and prompt caching. State whether +codegraph reduced effort and whether both arms reached a correct answer. + +## Notes +- The index is rebuilt every run (`audit.sh` wipes `.codegraph`) — different + versions extract differently, so an index must be served by the same binary + that built it. +- `audit.sh` temporarily mutates the global `codegraph` install for the test, + then restores your dev link via `local-install.sh`. +- Corpus repos are cloned to `/tmp/codegraph-corpus` (reused if already present). +- Add or edit repos in `corpus.json` (fields: `name`, `repo`, `size`, `files`, + `question`). diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json new file mode 100644 index 0000000..150b4a6 --- /dev/null +++ b/.claude/skills/agent-eval/corpus.json @@ -0,0 +1,611 @@ +{ + "_comment": "Test corpus for /agent-eval. Add entries freely. size: Small (<~150 files), Medium (~150-1500), Large (>~1500). 'question' is a representative architectural question that exercises cross-file understanding.", + "TypeScript": [ + { + "name": "ky", + "repo": "https://github.com/sindresorhus/ky", + "size": "Small", + "files": "~25", + "question": "How does ky implement request retries and timeouts?" + }, + { + "name": "excalidraw", + "repo": "https://github.com/excalidraw/excalidraw", + "size": "Medium", + "files": "~600", + "question": "How does Excalidraw render and update canvas elements?" + }, + { + "name": "vscode", + "repo": "https://github.com/microsoft/vscode", + "size": "Large", + "files": "~10000", + "question": "How does the extension host communicate with the main process?" + } + ], + "JavaScript": [ + { + "name": "express", + "repo": "https://github.com/expressjs/express", + "size": "Small", + "files": "~50", + "question": "How does Express route a request through its middleware stack?" + } + ], + "Go": [ + { + "name": "cobra", + "repo": "https://github.com/spf13/cobra", + "size": "Small", + "files": "~50", + "question": "How does cobra parse commands and flags?" + }, + { + "name": "gin", + "repo": "https://github.com/gin-gonic/gin", + "size": "Medium", + "files": "~150", + "question": "How does gin route requests through its middleware chain?" + }, + { + "name": "terraform", + "repo": "https://github.com/hashicorp/terraform", + "size": "Large", + "files": "~4000", + "question": "How does Terraform build and walk the resource dependency graph?" + }, + { + "name": "cosmos-sdk", + "repo": "https://github.com/cosmos/cosmos-sdk", + "size": "Large", + "files": "~5000", + "question": "How does a bank module MsgSend message reach the account balance update? Trace the cross-module call path from the bank keeper's Send handler through to the account/balance store update." + } + ], + "Python": [ + { + "name": "click", + "repo": "https://github.com/pallets/click", + "size": "Small", + "files": "~60", + "question": "How does click parse command-line arguments into commands?" + }, + { + "name": "flask", + "repo": "https://github.com/pallets/flask", + "size": "Medium", + "files": "~90", + "question": "How does Flask dispatch a request to a view function?" + }, + { + "name": "django", + "repo": "https://github.com/django/django", + "size": "Large", + "files": "~2700", + "question": "How does Django's ORM build and execute a query from a QuerySet?" + } + ], + "Rust": [ + { + "name": "clap", + "repo": "https://github.com/clap-rs/clap", + "size": "Medium", + "files": "~200", + "question": "How does clap parse arguments against a derived command definition?" + }, + { + "name": "tokio", + "repo": "https://github.com/tokio-rs/tokio", + "size": "Large", + "files": "~700", + "question": "How does tokio schedule and run async tasks on its runtime?" + }, + { + "name": "deno", + "repo": "https://github.com/denoland/deno", + "size": "Large", + "files": "~1500", + "question": "How does Deno load and execute a TypeScript module?" + } + ], + "Java": [ + { + "name": "gson", + "repo": "https://github.com/google/gson", + "size": "Medium", + "files": "~200", + "question": "How does Gson serialize an object to JSON?" + }, + { + "name": "okhttp", + "repo": "https://github.com/square/okhttp", + "size": "Medium", + "files": "~640", + "question": "How does OkHttp process a request through its interceptor chain?" + }, + { + "name": "guava", + "repo": "https://github.com/google/guava", + "size": "Large", + "files": "~3000", + "question": "How does Guava's CacheBuilder build and configure a cache?" + } + ], + "Kotlin": [ + { + "name": "koin", + "repo": "https://github.com/InsertKoinIO/koin", + "size": "Medium", + "files": "~300", + "question": "How does Koin resolve and inject dependencies?" + }, + { + "name": "leakcanary", + "repo": "https://github.com/square/leakcanary", + "size": "Medium", + "files": "~250", + "question": "How does LeakCanary detect and analyze a memory leak?" + } + ], + "Swift": [ + { + "name": "alamofire", + "repo": "https://github.com/Alamofire/Alamofire", + "size": "Small", + "files": "~100", + "question": "How does Alamofire build, send, and validate a request?" + } + ], + "C#": [ + { + "name": "serilog", + "repo": "https://github.com/serilog/serilog", + "size": "Medium", + "files": "~250", + "question": "How does Serilog route a log event to its sinks?" + }, + { + "name": "jellyfin", + "repo": "https://github.com/jellyfin/jellyfin", + "size": "Large", + "files": "~2500", + "question": "How does Jellyfin scan and identify items in a media library?" + } + ], + "Ruby": [ + { + "name": "sinatra", + "repo": "https://github.com/sinatra/sinatra", + "size": "Small", + "files": "~60", + "question": "How does Sinatra match a request to a route handler?" + }, + { + "name": "discourse", + "repo": "https://github.com/discourse/discourse", + "size": "Large", + "files": "~3000", + "question": "How does Discourse create and render a new post?" + } + ], + "PHP": [ + { + "name": "slim", + "repo": "https://github.com/slimphp/Slim", + "size": "Small", + "files": "~80", + "question": "How does Slim handle a request through its middleware?" + }, + { + "name": "laravel", + "repo": "https://github.com/laravel/framework", + "size": "Large", + "files": "~3000", + "question": "How does Laravel resolve and dispatch a route to a controller?" + } + ], + "C": [ + { + "name": "redis", + "repo": "https://github.com/redis/redis", + "size": "Large", + "files": "~600", + "question": "How does Redis parse and dispatch a client command?" + } + ], + "C++": [ + { + "name": "json", + "repo": "https://github.com/nlohmann/json", + "size": "Small", + "files": "~100", + "question": "How does nlohmann::json parse a JSON string into a value?" + }, + { + "name": "grpc", + "repo": "https://github.com/grpc/grpc", + "size": "Large", + "files": "~3000", + "question": "How does gRPC dispatch an incoming RPC to its handler?" + } + ], + "Dart": [ + { + "name": "flutter", + "repo": "https://github.com/flutter/flutter", + "size": "Large", + "files": "~6000", + "question": "How does Flutter build and lay out a widget tree?" + } + ], + "Svelte": [ + { + "name": "shadcn-svelte", + "repo": "https://github.com/huntabyte/shadcn-svelte", + "size": "Medium", + "files": "~600", + "question": "How do shadcn-svelte components compose and apply their styling?" + } + ], + "Lua": [ + { + "name": "lualine.nvim", + "repo": "https://github.com/nvim-lualine/lualine.nvim", + "size": "Small", + "files": "~120", + "question": "How does lualine assemble and render its statusline sections and components?" + }, + { + "name": "telescope.nvim", + "repo": "https://github.com/nvim-telescope/telescope.nvim", + "size": "Medium", + "files": "~80", + "question": "How does Telescope wire a picker to its finder, sorter, and previewer?" + }, + { + "name": "kong", + "repo": "https://github.com/Kong/kong", + "size": "Large", + "files": "~1330", + "question": "How does Kong execute plugins across a request's lifecycle phases?" + } + ], + "Luau": [ + { + "name": "Knit", + "repo": "https://github.com/Sleitnick/Knit", + "size": "Small", + "files": "~10", + "question": "How does Knit register services and expose them to clients?" + }, + { + "name": "vide", + "repo": "https://github.com/centau/vide", + "size": "Small", + "files": "~40", + "question": "How does vide track reactive sources and re-run effects when state changes?" + }, + { + "name": "Fusion", + "repo": "https://github.com/dphfox/Fusion", + "size": "Medium", + "files": "~115", + "question": "How does Fusion build and update its reactive UI graph from state objects?" + } + ], + "Objective-C": [ + { + "name": "Masonry", + "repo": "https://github.com/SnapKit/Masonry", + "size": "Small", + "files": "~50", + "question": "How does Masonry build and activate Auto Layout constraints from its block DSL?" + }, + { + "name": "FMDB", + "repo": "https://github.com/ccgus/fmdb", + "size": "Medium", + "files": "~80", + "question": "How does FMDB execute a prepared SQL statement and bind parameters?" + }, + { + "name": "SDWebImage", + "repo": "https://github.com/SDWebImage/SDWebImage", + "size": "Large", + "files": "~400", + "question": "How does SDWebImage download, cache, and decode an image for a UIImageView?" + } + ], + "Mixed iOS (Swift+ObjC)": [ + { + "name": "Charts", + "repo": "https://github.com/danielgindi/Charts", + "size": "Small", + "files": "~270", + "question": "How does the ChartsDemo ObjC demo controller drive the Swift Charts library to animate and notify a data update?" + }, + { + "name": "realm-swift", + "repo": "https://github.com/realm/realm-swift", + "size": "Medium", + "files": "~370", + "question": "How does a Swift `Realm.write { realm.add(obj) }` reach the Objective-C persistence layer?" + }, + { + "name": "wikipedia-ios", + "repo": "https://github.com/wikimedia/wikipedia-ios", + "size": "Large", + "files": "~1700", + "question": "How does tapping a search result reach the article-fetch network call across the Swift / ObjC boundary?" + } + ], + "React Native (legacy bridge + TurboModule)": [ + { + "name": "@react-native-async-storage", + "repo": "https://github.com/react-native-async-storage/async-storage", + "size": "Small", + "files": "~60", + "question": "How does `setItem` in JS reach the native `legacy_multiSet` implementation?" + }, + { + "name": "react-native-svg", + "repo": "https://github.com/software-mansion/react-native-svg", + "size": "Medium", + "files": "~700", + "question": "How does a JS `Svg.getTotalLength(...)` reach the iOS / Android native implementation via TurboModule?" + }, + { + "name": "react-native-firebase", + "repo": "https://github.com/invertase/react-native-firebase", + "size": "Large", + "files": "~1100", + "question": "How does a native iOS push notification reach the JS `messaging().onMessage(...)` listener?" + } + ], + "Expo Modules": [ + { + "name": "expo-haptics", + "repo": "https://github.com/expo/expo/tree/main/packages/expo-haptics", + "size": "Small", + "files": "~15", + "question": "How does `Haptics.notificationAsync(...)` in JS reach `UINotificationFeedbackGenerator` in the Swift Module?" + }, + { + "name": "expo-camera", + "repo": "https://github.com/expo/expo/tree/main/packages/expo-camera", + "size": "Medium", + "files": "~70", + "question": "How does a JS `CameraView.takePictureAsync(options)` reach the native AVCaptureSession / CameraDevice call?" + } + ], + "React Native Fabric (view components)": [ + { + "name": "react-native-segmented-control", + "repo": "https://github.com/react-native-segmented-control/segmented-control", + "size": "Small", + "files": "~25", + "question": "How does JSX `` reach the native onChange handler on iOS/Android?" + }, + { + "name": "react-native-screens", + "repo": "https://github.com/software-mansion/react-native-screens", + "size": "Medium", + "files": "~1200", + "question": "How does JSX `` reach the native RNSScreenStackView component?" + }, + { + "name": "react-native-skia", + "repo": "https://github.com/Shopify/react-native-skia", + "size": "Large", + "files": "~1000", + "question": "How does a `` JSX usage reach the iOS / Android native renderer?" + } + ], + "R": [ + { + "name": "AnomalyDetection", + "repo": "https://github.com/twitter/AnomalyDetection", + "size": "Small", + "files": "~24", + "question": "How does AnomalyDetectionTs go from the exported entry function to the underlying S-H-ESD statistical test? Name the functions on the path in order." + }, + { + "name": "dplyr", + "repo": "https://github.com/tidyverse/dplyr", + "size": "Medium", + "files": "~450", + "question": "When mutate() is called on a grouped data frame, which functions handle the grouping and expression evaluation, in order, from mutate() down?" + }, + { + "name": "ggplot2", + "repo": "https://github.com/tidyverse/ggplot2", + "size": "Large", + "files": "~1150", + "question": "When a ggplot object is printed, how does the plot actually get built and drawn — trace the path from print/plot to where geoms render. Name the key functions in order." + } + ], + "COBOL": [ + { + "name": "cics-genapp", + "repo": "https://github.com/cicsdev/cics-genapp", + "size": "Small", + "files": "~60", + "question": "When a new insurance policy is added through the LGTESTP1 CICS test harness, which programs handle the request on the way to the DB2 insert? Trace the path and name the programs in order." + }, + { + "name": "carddemo", + "repo": "https://github.com/aws-samples/aws-mainframe-modernization-carddemo", + "size": "Medium", + "files": "~75", + "question": "How does the online bill-payment flow work — from the COBIL00C screen program to the transaction record being written? Name the paragraphs performed in order and the copybooks that define the records involved." + }, + { + "name": "CobolCraft", + "repo": "https://github.com/meyfa/CobolCraft", + "size": "Medium", + "files": "~270", + "question": "How does an incoming player chat message travel from packet handling to being broadcast to the other connected players? Name the programs on the path in order." + } + ], + "VB.NET": [ + { + "name": "policyplus", + "repo": "https://github.com/Fleex255/PolicyPlus", + "size": "Small", + "files": "~94", + "question": "When the user toggles a policy to Enabled in the policy-setting editor and clicks OK, how does the new state end up written into the loaded policy source (POL file or registry)? Trace the path from the EditSetting dialog to the concrete write." + }, + { + "name": "scrawler", + "repo": "https://github.com/AAndyProgram/SCrawler", + "size": "Medium", + "files": "~320", + "question": "When a user download is started for a Reddit user, how does the request flow from the user-level download entry point through the shared downloader base into the Reddit site plugin, and where do downloaded media items get appended to the user's content list?" + }, + { + "name": "staxrip", + "repo": "https://github.com/staxrip/staxrip", + "size": "Medium", + "files": "~145", + "question": "When a job finishes video encoding, how does staxrip decide which muxer runs and how does the muxer command line get built and executed? Trace from job processing to the mkvmerge invocation." + } + ], + "Erlang": [ + { + "name": "cowboy", + "repo": "https://github.com/ninenines/cowboy", + "size": "Small", + "files": "~190", + "question": "How does an incoming HTTP request travel from cowboy's connection process to a user-defined handler's init/2 callback? Trace the path through the stream handler and middleware chain." + }, + { + "name": "ejabberd", + "repo": "https://github.com/processone/ejabberd", + "size": "Medium", + "files": "~410", + "question": "When a client sends a chat message, how does the stanza get from the receiving C2S process to the recipient's session on the same node? Trace the path through the router and session manager." + }, + { + "name": "emqx", + "repo": "https://github.com/emqx/emqx", + "size": "Large", + "files": "~2450", + "question": "How does a PUBLISH packet from an MQTT client reach the sessions of matching subscribers? Trace the flow from the connection/channel layer through the broker's routing to session delivery." + } + ], + "Solidity": [ + { + "name": "solmate", + "repo": "https://github.com/transmissions11/solmate", + "size": "Small", + "files": "~60", + "question": "How does solmate's ERC20 transferFrom enforce allowance and update balances? Trace the flow including the permit() signature path." + }, + { + "name": "solady", + "repo": "https://github.com/Vectorized/solady", + "size": "Medium", + "files": "~270", + "question": "How does solady's ERC20 implementation handle a permit() call — from signature recovery through nonce update to allowance write?" + }, + { + "name": "openzeppelin-contracts", + "repo": "https://github.com/OpenZeppelin/openzeppelin-contracts", + "size": "Large", + "files": "~400", + "question": "How does an OpenZeppelin AccessControl-protected function check the caller's role? Trace from the onlyRole modifier through hasRole to the role storage." + } + ], + "CUDA": [ + { + "name": "llm.c", + "repo": "https://github.com/karpathy/llm.c", + "size": "Small", + "files": "~76", + "question": "How does the attention forward pass reach the GPU in the CUDA training path? Trace from attention_forward to the kernels it launches, and explain where softmax happens." + }, + { + "name": "flash-attention", + "repo": "https://github.com/Dao-AILab/flash-attention", + "size": "Medium", + "files": "~900", + "question": "How does a Python call to flash_attn_func reach the CUDA kernel that computes forward attention? Trace the dispatch path from the Python API through the C++ binding to the kernel launch." + }, + { + "name": "cutlass", + "repo": "https://github.com/NVIDIA/cutlass", + "size": "Large", + "files": "~2700", + "question": "When a cutlass device-level GEMM (cutlass::gemm::device::Gemm) is invoked, how does it reach the GPU kernel? Trace from the operator() call to the kernel entry point and its launch site." + } + ], + "Terraform": [ + { + "name": "terraform-aws-vpc", + "repo": "https://github.com/terraform-aws-modules/terraform-aws-vpc", + "size": "Small", + "files": "~77", + "question": "How does the private_subnets variable shape the NAT gateway setup? Trace from the variable through the subnet and NAT gateway resources to the outputs that expose the private subnets." + }, + { + "name": "cloud-foundation-fabric", + "repo": "https://github.com/GoogleCloudPlatform/cloud-foundation-fabric", + "size": "Medium", + "files": "~990", + "question": "In the project module (modules/project), how does the iam variable turn into actual IAM bindings on the project, and what depends on the module's project_id output elsewhere in the repo?" + }, + { + "name": "terraform-aws-components", + "repo": "https://github.com/cloudposse/terraform-aws-components", + "size": "Large", + "files": "~1800", + "question": "In the eks/cluster component, how does the cluster IAM role get created and reach the EKS cluster resource, and which outputs expose cluster identity to other components?" + } + ], + "ArkTS": [ + { + "name": "HarmoneyOpenEye", + "repo": "https://github.com/WinWang/HarmoneyOpenEye", + "size": "Small", + "files": "~82", + "question": "How does the home page get its feed data from the network layer, and how does that data end up rendered as the list on screen? Trace the flow from the HTTP request through the view model into the home page UI." + }, + { + "name": "CoolMallArkTS", + "repo": "https://github.com/Joker-x-dev/CoolMallArkTS", + "size": "Medium", + "files": "~528", + "question": "When the user adds a product to the cart from the goods detail page, how does the item travel from the UI action to persistent storage? Trace the flow across the feature and core modules." + }, + { + "name": "applications_app_samples", + "repo": "https://github.com/openharmony/applications_app_samples", + "size": "Large", + "files": "~9500", + "question": "In the OrangeShopping sample app, how does the product detail page's bottom bar (add to cart / buy) lead to the order placement flow? Trace from the bottom navigation component to where the order is created." + } + ], + "Nix": [ + { + "name": "agenix", + "repo": "https://github.com/ryantm/agenix", + "size": "Small", + "files": "~13", + "question": "When a NixOS system activates, how does a secret declared under age.secrets get decrypted and installed at its runtime path? Trace the flow from the age.secrets option definition to the activation machinery that performs the decryption." + }, + { + "name": "nix-darwin", + "repo": "https://github.com/nix-darwin/nix-darwin", + "size": "Medium", + "files": "~207", + "question": "How does setting services.yabai.enable = true become a running launchd service? Trace the flow from the yabai module's options to the launchd daemon definition that generates the plist." + }, + { + "name": "home-manager", + "repo": "https://github.com/nix-community/home-manager", + "size": "Large", + "files": "~3390", + "question": "How does programs.git.enable produce the final git config file in the user's home directory? Trace the flow from the git program module to the home-files machinery that links generated files into place." + } + ] +} diff --git a/.cursor/rules/codegraph.mdc b/.cursor/rules/codegraph.mdc new file mode 100644 index 0000000..17d144a --- /dev/null +++ b/.cursor/rules/codegraph.mdc @@ -0,0 +1,24 @@ +--- +description: CodeGraph MCP usage guide — one tool, codegraph_explore +alwaysApply: true +--- + +## CodeGraph + +This project has a CodeGraph MCP server configured, exposing a single tool: `codegraph_explore`. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot. + +### Use codegraph_explore instead of reading files + +Reach for `codegraph_explore` before grep/find or Read for any **structural** question — how does X work, how does X reach Y, what calls what, where is X defined, or surveying an area. It takes a natural-language question or a bag of symbol/file names and returns the relevant symbols' **verbatim, line-numbered source** grouped by file (the same `\t` shape Read gives you, safe to Edit from), plus the call paths between them — including dynamic-dispatch hops (callbacks, React re-render, JSX children) grep can't follow — and a blast-radius summary of what depends on them. Name a file or symbol in the query to read its current source. + +### Rules of thumb + +- **Answer directly — don't delegate exploration.** ONE `codegraph_explore` usually answers the whole question; follow up with another `codegraph_explore` naming more specific symbols if you need more. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer. +- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context. +- **Don't grep or Read first** to find or understand indexed code — one `codegraph_explore` returns the relevant source in a single round-trip. Reach for raw Read/Grep only to confirm a specific detail codegraph didn't cover, or for what it doesn't index (configs, docs). +- **Index lag — check the staleness banner, don't guess a wait.** When a codegraph response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Files NOT in that banner are fresh and codegraph is authoritative for them. + +### If `.codegraph/` doesn't exist + +The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"* + diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5e7e5a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.codegraph +.kommandr +docs +assets diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 0000000..b66dde9 --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,43 @@ +name: Deploy site to GitHub Pages + +on: + push: + branches: [main] + paths: + - 'site/**' + - '.github/workflows/deploy-site.yml' + workflow_dispatch: + +# Allow GITHUB_TOKEN to deploy to Pages and verify the deployment origin. +permissions: + contents: read + pages: write + id-token: write + +# One deploy at a time; let an in-progress run finish. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Build with Astro + uses: withastro/action@v3 + with: + path: site + node-version: 22 + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..88bce26 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,204 @@ +name: Release + +# Manually triggered ("Run workflow"). On trigger it: +# 1. reads the version from package.json, +# 2. promotes `## [Unreleased]` content into `## []` in +# CHANGELOG.md (and commits + pushes that change back to main), so +# the published release notes are never sparse just because the +# maintainer didn't pre-stage the [] block by hand, +# 3. builds a self-contained bundle for every platform (one runner — there's no +# native compilation, so cross-packaging is fine), +# 4. creates the GitHub Release (tag v) with all archives, using the +# release notes from CHANGELOG.md, +# 5. publishes the npm thin-installer (shim + per-platform packages). +# +# Before triggering: bump package.json. CHANGELOG.md entries can live under +# `## [Unreleased]` — step 2 takes care of moving them. Set the NPM_TOKEN secret. +on: + workflow_dispatch: {} + +permissions: + contents: write # create the GitHub Release + tag, push the CHANGELOG promote + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + # Default checkout is detached at a SHA; we need an actual branch + # so the CHANGELOG-promote commit knows where to push. + ref: ${{ github.ref }} + # Authenticate as the maintainer (admin), not as github-actions[bot]. + # The "Require PR approval for main branch" ruleset only lets the + # Admin repo role bypass — and GitHub blocks adding the GitHub + # Actions integration to bypass_actors on user-owned (non-org) + # repos with "Actor GitHub Actions integration must be part of + # the ruleset source or owner organization." So the auto-promote + # and auto-sync `git push origin HEAD:main` steps below both fail + # under the default GITHUB_TOKEN. Using a fine-grained PAT owned + # by the admin makes the push go through cleanly. Set the + # RELEASE_PAT secret with: contents:write on this repo, no other + # scopes. Rotate per your token policy; the workflow only runs + # on manual dispatch so the blast radius is small. + token: ${{ secrets.RELEASE_PAT }} + - uses: actions/setup-node@v6 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Sync package-lock.json if version drifted + # When the maintainer bumps the version on package.json only — for + # example via a GitHub web-UI edit — `npm ci` would refuse to run + # with `EUSAGE: npm ci can only install packages when your + # package.json and package-lock.json … are in sync`. This step + # rewrites just the lock-file's version fields (top-level + the + # `packages.""` entry) to match package.json, then auto-commits + # and pushes the result so on-disk truth on `main` stays + # consistent. Idempotent: if the lock file already matches, no + # commit is made. + run: | + set -euo pipefail + PKG_V=$(node -p "require('./package.json').version") + LOCK_V=$(node -p "require('./package-lock.json').version") + if [ "$PKG_V" = "$LOCK_V" ]; then + echo "package-lock.json already at $PKG_V — nothing to sync." + exit 0 + fi + echo "Lock-file version drift: lock=$LOCK_V, package=$PKG_V. Syncing." + # `--package-lock-only` rewrites only the lock file, doesn't + # touch node_modules or actually install anything. Cheap. + npm install --package-lock-only --ignore-scripts + # Sanity: lockfile should now report the package version. + NEW_LOCK_V=$(node -p "require('./package-lock.json').version") + if [ "$NEW_LOCK_V" != "$PKG_V" ]; then + echo "::error::lock-file still at $NEW_LOCK_V after sync attempt; expected $PKG_V"; exit 1 + fi + if git diff --quiet -- package-lock.json; then + echo "lock file unchanged after sync? bailing"; exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package-lock.json + git commit -m "release: sync package-lock.json to ${PKG_V}" -m "[skip ci] Auto-generated by Release workflow." + git push origin "HEAD:${GITHUB_REF#refs/heads/}" + + - run: npm ci + - name: Ensure zip/unzip + run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip + + - name: Resolve version + id: ver + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Promote [Unreleased] → [] in CHANGELOG.md + # Idempotent: a no-op if [Unreleased] is empty OR if the previous + # run already moved everything. Auto-commit + push the change back + # so the version block on main is the source of truth going + # forward (and so subsequent extract-release-notes.mjs calls + # surface the full content even if this run is re-triggered). + run: | + set -euo pipefail + V="${{ steps.ver.outputs.version }}" + before=$(git rev-parse HEAD) + node scripts/prepare-release.mjs "$V" + if git diff --quiet -- CHANGELOG.md; then + echo "CHANGELOG.md unchanged — nothing to commit." + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "docs(changelog): promote [Unreleased] into [${V}]" -m "[skip ci] Auto-generated by Release workflow." + # Push to the branch the workflow was triggered on (main). + git push origin "HEAD:${GITHUB_REF#refs/heads/}" + fi + + - name: Build all platform bundles + run: | + for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64 win32-arm64; do + bash scripts/build-bundle.sh "$t" + done + ls -lh release + + - name: Generate SHA256SUMS + # Published as a release asset; the npm launcher verifies downloaded + # bundles against it (basenames only, so its path.basename match works). + run: | + ( cd release && sha256sum codegraph-* > SHA256SUMS ) + cat release/SHA256SUMS + + - name: Release notes from CHANGELOG.md + # The [] block was guaranteed-populated by the + # "Promote" step above, so the [Unreleased] fallback should + # never be needed in practice. Kept for defense-in-depth. + run: | + V="${{ steps.ver.outputs.version }}" + node scripts/extract-release-notes.mjs "$V" > notes.md 2>/dev/null \ + || node scripts/extract-release-notes.mjs Unreleased > notes.md 2>/dev/null || true + if [ ! -s notes.md ]; then + echo "::error::No release notes in CHANGELOG.md for [$V] or [Unreleased]." + exit 1 + fi + echo "----- release notes -----"; cat notes.md + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="v${{ steps.ver.outputs.version }}" + # Idempotent: create the release once, otherwise (re-run) refresh assets. + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" release/codegraph-* release/SHA256SUMS --clobber + else + gh release create "$TAG" release/codegraph-* release/SHA256SUMS --title "$TAG" --notes-file notes.md + fi + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + V="${{ steps.ver.outputs.version }}" + bash scripts/pack-npm.sh "$V" + # Platform packages first, then the main shim (which depends on them). + # Skip any already on the registry so a re-run only fills in gaps. + for dir in release/npm/codegraph-* release/npm/main; do + name=$(node -p "require('./$dir/package.json').name") + if npm view "$name@$V" version >/dev/null 2>&1; then + echo "skip $name@$V (already published)" + else + echo "publishing $name@$V" + ( cd "$dir" && npm publish --access public ) + fi + done + + - name: Verify every package is actually on the registry + run: | + V="${{ steps.ver.outputs.version }}" + # npm publish can print success without persisting; confirm against the + # registry (with retries for propagation) so green means really shipped. + for dir in release/npm/codegraph-* release/npm/main; do + name=$(node -p "require('./$dir/package.json').name") + ok= + for i in 1 2 3 4 5 6; do + if npm view "$name@$V" version >/dev/null 2>&1; then ok=1; break; fi + echo "waiting for $name@$V to appear ($i)…"; sleep 10 + done + [ -n "$ok" ] || { echo "::error::$name@$V never appeared on the registry"; exit 1; } + echo "verified $name@$V" + done + + - name: Sync packages to npmmirror + # npmmirror/cnpm mirror lazily and frequently never pull the per-platform + # optionalDependencies on their own, so `npm i` there fails with + # "no prebuilt bundle" (issue #303). Nudge a sync now so mirror users get + # the bundle without waiting. Best-effort — the launcher also self-heals + # from GitHub Releases — so a mirror hiccup never fails the release. + continue-on-error: true + run: | + for dir in release/npm/codegraph-* release/npm/main; do + name=$(node -p "require('./$dir/package.json').name") + enc=$(node -p "encodeURIComponent(require('./$dir/package.json').name)") + echo "sync $name" + curl -s -X PUT "https://registry.npmmirror.com/-/package/$enc/syncs" || true + echo + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4efcff6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +.cmem + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Test coverage +/coverage/ +.nyc_output/ + +# Environment +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* + +# TypeScript build info +*.tsbuildinfo + +# SQLite WAL mode files +*.db-wal +*.db-shm + +# Local Claude settings +.claude/settings.local.json +.claude/scheduled_tasks.lock +.claude/handoffs/ + +# Parallels Windows VM SSH/connection config (local machine, see CLAUDE.md) +.parallels + +# Confidential business / product / strategy docs — must NOT land in the +# public engine repo (see the IP boundary in CLAUDE.md) +docs/business/ + +# CodeGraph data directories (in test projects) +.codegraph/ + +test_frameworks + +# Test language repos for manual testing +test-languages/ + +nul +release/ + +.antigravitycli/ + +# Local-only: browser-based tmux session launcher (see tmux-web/README.md) +tmux-web/ + +assets/__pycache__/ + +assets/generate-waitlist.py + +.kommandr/ + +# Local scratch tests (never commit) +__tests__/zz-scratch* diff --git a/BUNDLING.md b/BUNDLING.md new file mode 100644 index 0000000..dc21ab5 --- /dev/null +++ b/BUNDLING.md @@ -0,0 +1,74 @@ +# Distribution: self-contained bundles + +CodeGraph ships a **vendored Node runtime** alongside the app. Because Node 22.5+ +has a built-in real SQLite (`node:sqlite`, with WAL + FTS5), bundling Node means: + +- **No native build** — `better-sqlite3` is gone, so there are zero native addons + to compile or rebuild. +- **No wasm fallback** — and therefore no more `database is locked` (issue #238). +- **No Node-version dependence** — the app always runs on the bundled Node, + whatever the user has (or doesn't have) installed. + +## What's in a bundle + +Built by [`scripts/build-bundle.sh`](scripts/build-bundle.sh) — one archive per +platform, identical recipe (only the Node download differs): + +``` +codegraph-/ + node | node.exe # official Node runtime for + lib/ + dist/ # compiled app (+ tree-sitter .wasm grammars, schema.sql) + node_modules/ # production deps only (pure JS / wasm — portable) + bin/ + codegraph | codegraph.cmd # launcher → runs the bundled Node with the app +``` + +Targets: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`, +`win32-arm64`. Unix targets produce `.tar.gz` (shell launcher); Windows produces +`.zip` (`node.exe` + a `.cmd` launcher). + +```bash +scripts/build-bundle.sh linux-x64 # -> release/codegraph-linux-x64.tar.gz +scripts/build-bundle.sh win32-x64 # -> release/codegraph-win32-x64.zip +``` + +Because dropping better-sqlite3 left **zero native addons**, building a bundle is +pure file-packaging — **any** target builds on **any** OS (the whole matrix builds +on one Linux runner). Cross-compilation isn't a concern; only *run-testing* a +bundle needs the target platform (or emulation, e.g. `docker run --platform +linux/amd64`). + +## Install channels (all deliver the same bundle) + +1. **`curl | sh`** ([`install.sh`](install.sh)) — no Node required; ideal for a + fresh Linux VPS over SSH. Detects os/arch, pulls the archive from GitHub + Releases, symlinks `codegraph` onto PATH. Re-run to upgrade; `--uninstall` to + remove. +2. **npm** ([`scripts/npm-shim.js`](scripts/npm-shim.js)) — preserves + `npm i -g @colbymchenry/codegraph`. The main package is a tiny shim; the + bundles ship as per-platform `optionalDependencies` + (`@colbymchenry/codegraph-` with `os`/`cpu`), so npm installs only the + matching one. The shim — run by the user's Node — execs the bundle, so the + real work runs on the bundled Node 24. Works even on old Node. On Windows it + invokes the bundled `node.exe` against the app entry directly (not the `.cmd` + launcher) — modern Node throws `EINVAL` when asked to spawn a `.cmd`/`.bat`. +3. **Windows** ([`install.ps1`](install.ps1)) — `irm … | iex`; same flow as + install.sh (detect arch, pull the `.zip` from Releases, add to PATH). +4. **Homebrew / Scoop** — TODO (tap + cask pointing at the Release archives). + +## Release pipeline + +[`.github/workflows/release.yml`](.github/workflows/release.yml) — manually +triggered. Reads the version from `package.json`, builds every platform bundle on +one runner, creates the GitHub Release (notes from `CHANGELOG.md`), and publishes +the npm shim + per-platform packages. Requires the `NPM_TOKEN` repo secret. + +Still TODO: +- **Code signing** — the main gap for "download & run": macOS Gatekeeper needs a + Developer ID + notarization; Windows needs Authenticode. Homebrew softens the + macOS case (handles quarantine). +- Retire the now-vestigial Node-version gate in `src/bin/codegraph.ts` — the + bundle always runs Node 24, and the npm shim does no tree-sitter work. +- Re-wire `npm uninstall` cleanup (the agent-config `preuninstall`) through the + shim — the generated main package doesn't carry it. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..22fb4c5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,652 @@ +# Changelog + +All notable changes to CodeGraph are documented here. Each entry also ships as +a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged +`vX.Y.Z`, which is where most people will look. + +This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixes + +- Callers and impact analysis no longer silently under-count a function that calls the same callee many times. When one caller contained several call sites to the same callee and an internal resolution batch boundary happened to split them, cleanup after the first batch removed the later sites' pending rows before they were ever attempted — their edges were never created, deterministically, and which edges went missing shifted with unrelated changes to the project's total reference count. Post-pass cleanup now targets the exact database row each processed reference came from. Found while validating the operator-call fix on nlohmann/json, where `write_cbor`'s 11 calls to `to_char_type` indexed as 10. (#1269) +- C++ explicit operator calls — `a.operator+(b)`, `p->operator+(b)`, `a.operator[](3)`, and the other symbolic forms — now produce a `calls` edge to the operator method, so an operator invoked only through the explicit syntax no longer looks uncalled in callers and impact analysis. tree-sitter parses these call sites with the operator name stranded in an error node (never as a normal member access), so the call's target was silently read as just the receiver variable; the operator name is now recovered from the error node and resolved through receiver-type inference like any other member call — a same-named operator on an unrelated class can never capture the edge. Infix uses (`a + b`, `a[i]`) need real type inference and are tracked separately. (#1247) + +## [1.4.1] - 2026-07-10 + +### New Features + +- The MCP server now notices when a newer CodeGraph release exists and tells you — a long-running server used to drift behind releases silently until something broke. On startup it checks the latest release in the background (never blocking, at most once a day, cached across all servers on the machine) and surfaces a one-line "update available — run `codegraph upgrade`" notice in the server log, in the instructions your agent sees on connect, and in `codegraph_status`. Nothing updates by itself, and being offline just means no notice. Opt out with `CODEGRAPH_NO_UPDATE_CHECK=1`; `DO_NOT_TRACK=1` disables it too. (#1243) + +### Fixes + +- `codegraph upgrade` on a Windows npm install actually runs npm again — modern Node refuses to launch `npm.cmd` directly, so the upgrade failed with a spawn error before doing anything. npm is now invoked the way a terminal would run it. (#1238) +- `codegraph uninstall` now actually uninstalls CodeGraph. It used to remove only the agent configurations and leave every installed binary behind, so `codegraph` still ran afterward — especially confusing when both an npm global install and a standalone install were present and removing one still left the other answering on PATH. Uninstall now finds every install on the machine (the standalone bundle, the npm global package, the launcher link) and removes them all, after showing you exactly what it found and asking first (`--yes` skips the prompt). Machine-level settings like your telemetry choice are preserved, a source checkout is never touched, and the new `--keep-cli` flag restores the old configs-only behavior. (#1071) +- `codegraph_explore` no longer lets ordinary English words in a natural-language question hijack the ranking when they happen to match a code symbol's name. A question like "how does the upgrade flow check the latest version" used to treat "check" as a symbol the agent asked for by name, rank that unrelated definition's file first, and crowd the files the question is actually about out of the answer entirely. Precisely written symbol names (camelCase, PascalCase, snake_case, qualified names) still get top billing exactly as before, as do plain-word symbol bags whose words belong together in the same file. +- PHP method calls made through a class property — `$this->dep->method()`, the dominant call shape in constructor-injection codebases (Symfony, Laravel) — now resolve to the method on the property's declared type, so callers and impact analysis see production call sites instead of reporting a DI-heavy method as uncalled or test-only. Promoted constructor parameters, typed properties, classic constructor assignment (including multi-line signatures), and typed setter injection all count; interface-typed properties resolve to the interface method, and inherited methods resolve through the type hierarchy. Only property-shaped declarations are consulted — a same-named local variable or parameter elsewhere can never mistype the property — and a property whose type can't be recovered statically stays unlinked rather than guessed. Thanks @w0lan. (#1220) +- `codegraph upgrade` now also refreshes what previous versions installed into your agents — the CodeGraph section in CLAUDE.md / AGENTS.md / GEMINI.md and the MCP entry — so upgrading no longer leaves agents following instructions written for tools that have since been renamed or removed. Refresh-only: agents you never configured are not touched, and your permission and hook choices are preserved. Also available manually as `codegraph install --refresh`, and skippable with `CODEGRAPH_NO_INSTALL_REFRESH=1`. (#1238) +- `codegraph upgrade` on an npm install now upgrades through npm again instead of quietly creating a second copy that never wins the PATH race — previously `codegraph --version` kept reporting the old version forever, no matter how many times you upgraded. (#1238) +- After every upgrade, CodeGraph now checks that the `codegraph` command your terminal resolves actually serves the freshly installed version — confirming you don't need a new terminal, or telling you exactly which stale install is shadowing the new one. (#1071) +- The safety watchdog no longer kills a healthy index on severely degraded storage. It used to judge liveness purely by the event loop, so one long database write on a struggling disk looked identical to a hung process and could get a valid, in-progress index terminated. During `codegraph index`/`codegraph init` the watchdog now also checks whether the index files on disk are advancing before it acts: slow-but-progressing work is left alone (bounded by a hard cap), while a genuinely hung process is still killed exactly as fast as before. (#1231) +- Incremental sync now picks up cross-file relationships that only become resolvable after an edit — for example, when a file gains an export that another, unchanged file was already importing or calling. Previously the reference in the unchanged file was never revisited, so callers, impact, and flow results silently omitted the new edge (while status reported a clean index) until a full re-index. References that can't be resolved yet are now remembered and automatically retried whenever a change introduces a symbol that could satisfy them — this also covers a class gaining a new method that other files already call. Thanks @loadcosmos for the report with a minimal reproduction. (#1240) +- The reverse case is fixed too: when an edit removes or moves a symbol (or deletes its file), callers in unchanged files now re-resolve during the same sync — rebinding to the symbol's new home when it moved, or waiting to reconnect automatically when it comes back — instead of silently losing their relationship until a full re-index. (#1240) + +## [1.4.0] - 2026-07-10 + +### New Features + +- Indexing is dramatically faster on slow storage — mechanical HDDs, network folders, and virtualized disks. The database no longer folds its write journal back into the main file thousands of times during a bulk index (that folding was ~95% of all disk activity); it now streams writes sequentially and folds them back in a few large, coalesced passes that run off the main thread. In a disk-throttled benchmark matching the reported hardware, a mid-size Java project went from over 25 minutes to under a minute, and there is no change on fast disks. Opt out with `CODEGRAPH_NO_WAL_DEFER=1`; tune the fold-back threshold with `CODEGRAPH_WAL_VALVE_MB`. (#1231) +- New `CODEGRAPH_PARSE_TIMEOUT_MS` environment variable to raise the per-file parse budget on unusually slow storage, the same way `CODEGRAPH_PARSE_WORKERS` already tunes the worker count. (#1231) + +### Fixes + +- Indexing on slow storage (mechanical HDDs, network folders) no longer collapses into false "parse timeout" failures. When disk writes stalled the coordinating thread, parses that had already finished — including empty files — were being misjudged as hung, their workers killed, and the files silently dropped from the index. A parse result is now judged by the worker's own clock, so a stalled coordinator accepts the finished result instead of killing the worker; only a genuinely hung parse is terminated (after a wider grace window). Files that do hit the timeout are retried at the end of indexing instead of being silently lost. Thanks @KnifeOfLife for the exceptional report. (#1231) +- Parse workers now receive their grammar files from memory instead of each re-reading them from disk on spawn, eliminating a feedback loop on slow disks where every worker restart added more disk contention — and making worker restarts cheaper everywhere. (#1231) + +## [1.3.1] - 2026-07-09 + +### Fixes + +- Fixed `codegraph init` grinding for 20+ minutes (or getting killed by the safety watchdog) near the end of the "Resolving refs" step on large PHP and JavaScript codebases — a regression since 0.9.x reported on a 12,000-file project that used to index in about a minute. One of the dynamic-dispatch analysis passes only ever applies to Swift/Kotlin closure collections, but it was scanning every function in every language, and on `.push(`-heavy JavaScript (or any codebase with big generated functions, especially with non-ASCII text) its per-match bookkeeping went quadratic. The pass now skips languages it can't apply to, does its line accounting in constant time, and stays responsive even inside a single pathological function — the graph produced is identical. Thanks @sniperrenren for the report. (#1235) Two failure modes are fixed: on multi-million-symbol projects (e.g. the Linux kernel, ~95,000 files) the final analysis phase ran out of memory and crashed the process outright, and on large projects on slower machines (reported on a 24,000-file Java project on Windows) the same phase could stall long enough that the safety watchdog killed a healthy, still-progressing index at ~98% (#1212). The whole phase now streams its work instead of holding whole-graph snapshots in memory, keeps the process responsive throughout, and skips analysis passes for languages a project doesn't contain — which also makes the tail of indexing noticeably faster on single-language repos. The resulting graph is identical, and a genuinely wedged process is still detected and killed. +- Indexing and `codegraph sync` stay responsive through their heaviest internal steps on huge projects: the post-index database maintenance (which on a multi-gigabyte index could stall the process for minutes and get a fully successful index killed by the safety watchdog at the finish line) now runs on a background thread, storing a giant generated file no longer freezes the process mid-extraction, and the reference-resolution bookkeeping between progress updates is broken into small responsive steps. The resulting graph is byte-for-byte identical. +- Fixed a race that could leave a freshly-attached MCP session permanently silent: when a client's first messages arrived glued together during the daemon's connection handshake (roughly one attach in five on a busy machine), the daemon could drop them and stop reading that connection entirely — every tool call from that session then hung with no reply. The handshake now hands the connection over losslessly, and the fix is validated by hammering the previously-flaky attach test 25× under load. +- The first tool call after the shared daemon starts no longer waits behind the query workers' cold start (which can take many seconds on a busy machine) — it's served directly until the first worker is warm, so a fresh session answers immediately. + +## [1.3.0] - 2026-07-07 + +### New Features + +- CodeGraph now indexes **Nix** (`.nix`) — flakes, NixOS and home-manager modules, overlays, and package sets join the graph: `let` and attrset bindings, functions (simple, destructured `{ pkgs, ... }`, and curried), and `inherit` bindings all become searchable symbols, with call edges between bindings. File-level wiring follows the ways Nix actually connects files: `import ./relative/path.nix` (with `import ./dir` reaching the directory's `default.nix`), NixOS module `imports = [ ./hardware.nix ../common ]` lists, flake-style `modules = [ ./configuration.nix ]` lists, and the nixpkgs `callPackage ./pkgs/foo { }` idiom — so "what does this configuration actually pull in" and "what uses this module" are answerable on real setups. Dynamic references (`import `, variable paths, flake-input module references) are deliberately left unlinked rather than guessed. Thanks @TyceHerrman. (#324, #332, #648) +- The NixOS module system's option wiring is bridged, so flow questions cross the module boundary instead of going dark at it: a config write like `launchd.user.agents.myapp = { ... }` or `home.file.".gitconfig" = { ... }` links to the module that declares that option (`options.launchd.user.agents = mkOption { ... }` — flat and nested declaration spellings both count, quoted keys like `system.defaults.NSGlobalDomain."com.apple.dock"` match their exact declaration), which makes "how does enabling this service produce the launchd daemon / generated config file" traceable end-to-end and "what sets this option" answerable across modules, tests included. Precision is deliberately conservative: interpolated `${...}` paths, options declared in more than one module, and submodule-internal option namespaces stay unlinked rather than guessed, and every bridged hop is labeled as heuristic module-system wiring rather than shown as a plain reference. +- CodeGraph now indexes **ArkTS** (`.ets`) — the language of HarmonyOS / OpenHarmony apps. Everything TypeScript gets extracted (classes, interfaces, enums, type aliases, imports/exports, call edges), plus ArkTS's own constructs: `@Component` / `@ComponentV2` structs with their decorators (`@Entry`, `@State`, `@Prop`, `@Link`, `@Local`, `@Param`, …) captured and searchable, `build()` view trees linked parent→child so "which pages render this component" is answerable, chained attributes connected to the `@Extend`/`@Styles` functions they invoke, `@Builder` methods and functions wired into the call graph, and `.onClick(this.handler)`-style event bindings linked to their handler methods. Modular HarmonyOS projects resolve across module boundaries too: a bare `import { CartRepository } from "data"` follows the `oh-package.json5` `file:` dependency to the right module — honoring each module's declared `main` entry, from `.ets` and `.ts` consumers alike — while ambiguous names in multi-app monorepos deliberately stay unlinked rather than guessed, and mixed `.ets`/`.ts` codebases cross-link freely. Validated on real HarmonyOS apps including the official OpenHarmony samples monorepo. (#396, #512, #648, #890) +- ArkUI's dynamic hops are bridged so flow questions cross them instead of going dark, each labeled as dynamic dispatch rather than shown as a plain call: methods that assign a reactive property (`@State`, `@Local`, …) link to the component's `build()` (the re-render hop — assignment-gated, so a method that merely reads state gets no edge); `emitter.emit(eventId)` links to the matching `emitter.on/once` subscriber when both sides share a statically-recoverable event key (numeric ids pair within one file only, named constants within one module, so unrelated samples in a monorepo never cross-link); and `router.pushUrl({ url: 'pages/Detail' })` links to the target page's `@Entry` struct, with ambiguous urls left unlinked rather than guessed. +- Interrupted or incomplete indexing is now visible instead of silent: a run killed mid-index (crash, out-of-memory, watchdog) leaves a marker that `codegraph status` reports as a truncated index, a completed run that dropped files reports itself as partial — both in the human output and in `status --json` — and `codegraph index` prints a warning with the exact counts when its result doesn't add up to what the scan discovered. +- CodeGraph now indexes **Terraform and OpenTofu** (`.tf`, `.tfvars`, `.tofu`) — resources, data sources, modules, variables, outputs, providers, and every `locals` attribute become symbols (e.g. `aws_s3_bucket.my_bucket`, `var.region`, `module.vpc`, `local.prefix`), and uses like `var.region`, `module.vpc.id`, `data.aws_caller_identity.current`, or `aws_s3_bucket.my.arn` are wired up cross-file, so search, callers, and impact queries return real results on infrastructure repos instead of nothing. Module calls are bridged across the module boundary: a `module` block's inputs link to the child module's variables, `module.vpc.vpc_id` reaches the child's `output "vpc_id"` definition, and the block's local `source` path links to the module's files — so "what breaks if I change this module's variable" reaches every caller instead of dead-ending at the declaration (registry and git sources are deliberately left as visible boundaries rather than guessed). Cross-component wiring through the cloudposse/atmos `remote-state` module connects too: `module.vpc.outputs.vpc_cidr` in one component reaches the `vpc` component's own output when the component name is statically declared (a literal, or a variable with a literal default) and exactly one directory matches — anything dynamic or ambiguous stays unlinked. Aliased providers are first-class: `provider "aws" { alias = "east" }` gets its own symbol, and `provider = aws.east` on a resource (or a module's `providers` map) links to that configuration, found up the module tree the way Terraform actually inherits it. `moved`/`import`/`removed` state-migration blocks and `check` assertions reference the resources they name, so a refactor's paper trail is part of the graph. `.tfvars` assignments link to the variables they set, including var-files kept in a subdirectory. Resolution follows Terraform's real per-directory scoping, so same-named variables across modules never cross-link and "what depends on `var.project_id`" in a multi-module repo never mixes in unrelated modules. Thanks @Javviviii2. (#83, #310, #648) +- CodeGraph now indexes **CUDA** (`.cu`, `.cuh`) — kernels, device/host functions, structs, and classes become symbols, and the host→kernel call edge survives the `<<>>` launch syntax, so questions like "how does this call reach the GPU kernel?" trace across the CPU/GPU boundary instead of going dark at the launch site. Real-world launch styles all connect: templated launches (`my_kernel<<>>(args)`), launches through a local function pointer (`auto kernel = &my_kernel<...>; ... kernel<<>>(args)` — each branch-assigned target linked), brace-initialized launch configs (`<<>>`), and kernels defined through a name-in-first-argument macro (flash-attention's `DEFINE_FLASH_FORWARD_KERNEL(kernel_name, ...) { ... }` style), which now index under their real kernel names. CUDA that lives in plain `.h`/`.hpp` headers — where much real-world device code sits, launch-template headers included — is recognized by content and indexed the same way. Validated on llm.c, flash-attention, and NVIDIA CUTLASS. (#387, #648) +- C++ symbols defined inside `namespace` blocks now carry the namespace in their qualified name (`flash::compute_attn`, C++17 `namespace a::b {` included), and namespace-qualified calls (`ns::fn(...)`) resolve to their definitions — previously such calls never linked at all, which hid much of the call graph in namespace-heavy C++ codebases from callers and impact analysis. +- C++ calls that spell out template arguments (`fn(args)`) now link to the function they instantiate, the same normalization templated base classes already had. +- CodeGraph now indexes **Solidity** (`.sol`) — contracts, libraries, interfaces, structs, enums, modifiers, events, errors, and state variables become first-class symbols, with call edges that follow `emit`, `revert`, modifier guards (`onlyOwner`-style, including base-constructor chains like `constructor() ERC20(...)`), and library/method calls (including `using` directives). `import` directives resolve to the imported file, so cross-contract questions like "trace `transferFrom` through allowance and balance updates" or "how does `onlyRole` reach the role storage?" work out of the box on real Solidity codebases (validated on solmate, solady, and OpenZeppelin Contracts). Thanks @naiba. (#374, #648) +- Erlang behaviour dispatch is now followed through the graph: a framework call through a variable module — cowboy's `Handler:init`/`Middleware:execute` folds, a plugin manager's `Mod:callback(...)` — links to the repo's implementations of the behaviour that declares that callback, so flow traces and impact cross the OTP callback boundary instead of stopping at it. The links are precision-gated: the callback arity must match, exactly one behaviour may own that callback shape (a collision stays unlinked rather than guessed), the implementer must actually export the callback, and the fan-out is bounded — a behaviour with hundreds of implementers stays a visibly dynamic boundary. Every bridged hop is labeled as dynamic dispatch with its wiring site, never shown as a plain static call. +- CodeGraph now indexes **Erlang** (`.erl`, `.hrl`) — functions, with clauses and arities of the same name grouped as one symbol spanning all of them, plus records with their fields, `-type`/`-opaque` aliases, `-define` macros, and `-spec` signatures attached to every function. Cross-module `mod:fn(...)` calls resolve to the target module's function, `fun name/arity` values are captured as references (so callback registrations like `lists:foreach(fun submit/1, ...)` link up), `-include`/`-include_lib` connect to the header files they pull in, `-behaviour` declarations link a callback module to its behaviour (and only ever to a module — a same-named macro or function elsewhere in the repo is never mistaken for one), and `-export` lists (plus `-compile(export_all)`) drive each function's public/private flag. OTP's indirection idioms are followed where the target is static: `spawn`/`apply`/`proc_lib`/`timer`/`rpc` calls that name their target as `(Module, Function, Args)` arguments produce call edges, and `gen_server:call`/`cast` connects to the target module's `handle_call`/`handle_cast` — its own when targeting `?MODULE` (including the `-define(SERVER, ?MODULE)` idiom), and the named module when a registered name follows OTP's name-the-server-after-its-module convention (`gen_server:call(other_mod, ...)`, directly or through a `-define(STORE, other_mod)` macro); a registered name that matches no module stays unlinked. Macros participate in the graph too: a `-define` body's calls belong to the macro, each `?MACRO(...)` use site links into the call chain (and bare `?CONSTANT` reads are tracked as references), so a call path hidden behind a macro — `set_password → ?SQL_UPSERT_T → sql_query_t` — traces end-to-end and "where is this macro used" is answerable. escripts index like any module (the shebang line is understood), and OTP application resource files (`.app.src`, `.app`) join the graph: `{mod, ...}` links an app to its callback module and `{applications, [...]}` connects umbrella sibling apps — resolving only ever to modules, so an OTP app name like `ssl` is never mistaken for a same-named function. Truly dynamic dispatch (`Mod:handle(...)`, message sends, var-module spawns) is deliberately left unlinked rather than guessed. `codegraph_explore` also understands Erlang-native symbol spelling in queries — `mod:fn/3` and `init/2` find the symbols they name. (#635, #648) +- CodeGraph now indexes **Visual Basic .NET** (`.vb`) — classes, Modules, interfaces, structures, enums, properties, events, `MustOverride` abstract members, and `Declare` P/Invoke signatures, with `Inherits`/`Implements` hierarchy edges, call edges (resolved through VB's ambiguous call-vs-index parentheses), and `New`/`As New` instantiation links. Real-world VB styles parse cleanly: WinForms designer files, interpolated and multi-line strings, XML literals (embedded `<%= %>` expressions included), single-line and multi-line LINQ queries, multi-line lambdas, `Handles`/`WithEvents` event wiring, Custom Events, date literals, classic type-character identifiers (`i%`, `name$`), and non-English (Unicode) identifiers. (#648, #639, #170) +- CodeGraph now indexes **COBOL** (`.cbl`, `.cob`, `.cpy`) — programs, sections and paragraphs with `PERFORM`/`GO TO` call edges, `CALL` cross-program calls, `COPY` copybook imports (standalone copybooks included), and DATA DIVISION records with 88-level condition names, in both fixed and free source format. Impact queries work on data items: every `MOVE`/`ADD`/`COMPUTE`/`SUBTRACT` write-site links back to the field it changes, so "what touches this copybook field" answers across programs. CICS flows connect too: `EXEC CICS LINK`/`XCTL` program targets, `EXEC SQL INCLUDE` copybooks, and pseudo-conversational `RETURN TRANSID(...)` hops resolve to the program owning the transaction id. (#590, #648) +- CodeGraph now indexes **CFML** (`.cfc`, `.cfm`, `.cfs`) — both the classic tag-based style (``/``) and modern bare-script `component { ... }` syntax, including `extends`/`implements`, embedded `` blocks (at any nesting depth, including inside ``/``/``), call edges, and calls embedded in `#hash#` expressions inside `` SQL bodies. Files saved with a UTF-8 byte-order mark and tags with unquoted attribute values — both common in long-lived CFML codebases — are handled too. Thanks @ghedwards. (#1118) +- CFML inheritance written as a component path now links to the right component. `extends="coldbox.system.web.Controller"` names its supertype by dotted path and `extends="../base"` by relative path (the FW/1 style) — both previously produced no inheritance edge at all, which on framework-style CFML apps hid most of the type hierarchy from impact and blast-radius analysis (on ColdBox's own core, over 90% of inheritance was invisible). Resolution is deliberately conservative: the target's directory layout must corroborate the declared path — so a supertype that lives in an out-of-repo library (testbox, mxunit, an installed framework) correctly stays unlinked rather than being guessed at, and an ambiguous path produces no edge rather than a wrong one. (#1152) +- CFML method calls made through a local variable, typed argument, or component property now resolve to the right method — the same receiver-type inference the other object-oriented languages already had. `var svc = new UserService(); svc.save()`, `createObject("component", "path.UserService")`, a typed `` or cfscript parameter, and `variables.`/`this.`-scoped fields — including the pseudoconstructor pattern (`variables.svc = new UserService()` in `init()`) and WireBox-injected properties (`property name="svc" inject="UserService"`) — all now link the call to the declared component's method, with methods inherited from a supertype resolved through the inheritance links above. This makes callers, impact/blast-radius, and `codegraph_explore` flow traces follow CFML service calls instead of dropping them or guessing among same-named methods. +- The Claude Code context hook now recognizes prompts that describe code in plain words — in any language — by checking the prompt's words against the symbol names actually in your project's index. Asking about "the state machine des commandes" finds `OrderStateMachine` with no keyword involved. Confidence decides how much gets injected: structural questions and prompts naming a real symbol still get full context up front; a plain-words match gets a short pointer to the matching symbols so the agent queries them itself; everything else stays silent, exactly as before. +- Anonymous usage telemetry now counts how often the context hook injected context, offered a hint, or stayed silent — fixed counter names only; the prompt's content is never stored or sent. This makes the hook's accuracy measurable instead of guessed. The counters record what actually happened, not what was attempted: a lookup that errors or comes back empty counts as a distinct silent outcome, never as delivered context (#1143, thanks @inth3shadows). +- Metal shader files (`.metal`) are now indexed. Metal Shading Language is close enough to C++ that vertex/fragment/kernel functions, structs, type aliases, and the calls between them all land in the graph — so shader pipelines in Apple-platform projects show up in impact analysis and flow traces instead of being silently skipped. Metal's `[[buffer(0)]]`-style attribute annotations are handled so they can't corrupt what gets extracted. Thanks @FluxKo for the report. (#1121) +- CodeGraph now indexes legacy **iBatis 2** SQL maps (``), not just MyBatis 3 `` files. `\n' + + ' SELECT FROM users WHERE id = #{id}\n' + + ' \n' + + ' \n' + + ' UPDATE users SET name=#{name}, email=#{email} WHERE id=#{id}\n' + + ' \n' + + '\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const methods = cg.getNodesByKind('method'); + const getByIdJava = methods.find((m) => m.name === 'getById' && m.language === 'java'); + const getByIdXml = methods.find((m) => m.name === 'getById' && m.language === 'xml'); + const updateJava = methods.find((m) => m.name === 'updateUser' && m.language === 'java'); + const updateXml = methods.find((m) => m.name === 'updateUser' && m.language === 'xml'); + const sqlFrag = methods.find((m) => m.name === 'userCols' && m.language === 'xml'); + expect(getByIdJava).toBeDefined(); + expect(getByIdXml).toBeDefined(); + expect(updateJava).toBeDefined(); + expect(updateXml).toBeDefined(); + expect(sqlFrag).toBeDefined(); + + // XML statement qualified name must be `::` so the + // synthesizer can match against the Java method's `::` + // suffix — this is the load-bearing contract between extractor + synthesis. + expect(getByIdXml!.qualifiedName).toBe('com.example.dao.UserDAOMapper::getById'); + + // Bridge: Java mapper method -> XML statement, kind 'calls'. + const j2xGet = cg.getOutgoingEdges(getByIdJava!.id).find((e) => e.target === getByIdXml!.id); + expect(j2xGet, 'Java getById should reach the XML -> in same mapper. + const incEdge = cg.getOutgoingEdges(getByIdXml!.id).find((e) => e.target === sqlFrag!.id); + expect(incEdge, ' should reach the fragment').toBeDefined(); + + cg.close(); + }); + + it('covers legacy iBatis statements and keeps same-line vendor-split pairs (#1182)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ibatis-')); + const xmlDir = path.join(tmpDir, 'src/main/resources/sqlmaps'); + fs.mkdirSync(xmlDir, { recursive: true }); + + // iBatis 2 sqlMap with an explicit namespace. + fs.writeFileSync( + path.join(xmlDir, 'Account.xml'), + '\n' + + '\n' + + "\n" + + " id, name, email\n" + + " \n" + + " INSERT INTO account (id) VALUES (#id#)\n" + + ' \n' + + '\n' + ); + // Namespace-less sqlMap whose ids carry the qualifier as `Map.statement`. + fs.writeFileSync( + path.join(xmlDir, 'LegacyDao.xml'), + '\n' + + ' \n' + + '\n' + ); + // MyBatis mapper with a vendor-split databaseId pair written on ONE line — + // same qualifiedName + same start line. Before the id-hash fold both nodes + // hashed identically and INSERT OR REPLACE dropped one. + fs.writeFileSync( + path.join(xmlDir, 'VendorMapper.xml'), + '\n' + + '\n' + + '\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const xmlMethods = cg.getNodesByKind('method').filter((n) => n.language === 'xml'); + const qnames = xmlMethods.map((n) => n.qualifiedName); + + // iBatis statements now land in the graph (was zero coverage before #1182). + expect(qnames).toContain('Account::getById'); + expect(qnames).toContain('Account::insert'); + expect(qnames).toContain('Account::cols'); + expect(qnames).toContain('LegacyDao::findAll'); + // The commented-out statement produced no node. + expect(qnames).not.toContain('Account::disabled'); + + // resolves to the fragment in the same map. + const getById = xmlMethods.find((n) => n.qualifiedName === 'Account::getById'); + const cols = xmlMethods.find((n) => n.qualifiedName === 'Account::cols'); + expect(getById).toBeDefined(); + expect(cols).toBeDefined(); + const incEdge = cg.getOutgoingEdges(getById!.id).find((e) => e.target === cols!.id); + expect(incEdge, "iBatis should reach the fragment").toBeDefined(); + + // Both vendor-split statements survive the DB write (the collision fix). + const findUser = xmlMethods.filter((n) => n.name === 'findUser'); + expect(findUser, 'both databaseId variants of findUser should survive').toHaveLength(2); + expect(new Set(findUser.map((n) => n.id)).size).toBe(2); + + cg.close(); + }); + + it('binds @Value / @ConfigurationProperties to YAML + .properties keys (incl. relaxed binding)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-spring-config-')); + const javaDir = path.join(tmpDir, 'src/main/java/com/example'); + const resDir = path.join(tmpDir, 'src/main/resources'); + fs.mkdirSync(javaDir, { recursive: true }); + fs.mkdirSync(resDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'pom.xml'), + 'org.springframework.bootspring-boot-starter\n' + ); + fs.writeFileSync( + path.join(resDir, 'application.yml'), + 'app:\n' + + ' cache:\n' + + ' name:\n' + + ' user-token: "example-service:auth:token"\n' + + ' enabled: true\n' + + 'db:\n' + + ' url: "jdbc:mysql://localhost/x"\n' + ); + fs.writeFileSync( + path.join(resDir, 'application.properties'), + 'app.retry-count=3\n' + ); + fs.writeFileSync( + path.join(javaDir, 'CacheConfig.java'), + 'package com.example;\n' + + 'import org.springframework.beans.factory.annotation.Value;\n' + + 'public class CacheConfig {\n' + + ' @Value("${app.cache.name.user-token}") private String tokenCacheName;\n' + + ' @Value("${app.cache.enabled:true}") private boolean enabled;\n' + + ' // relaxed binding: java camelCase, properties kebab-case\n' + + ' @Value("${app.retryCount}") private int retry;\n' + + '}\n' + ); + fs.writeFileSync( + path.join(javaDir, 'CacheProperties.java'), + 'package com.example;\n' + + 'import org.springframework.boot.context.properties.ConfigurationProperties;\n' + + '@ConfigurationProperties(prefix = "app.cache")\n' + + 'public class CacheProperties { private boolean enabled; }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // YAML/properties leaf keys: one constant node per dotted path. + const cfgKeys = cg + .getNodesByKind('constant') + .filter((n) => n.language === 'yaml' || n.language === 'properties'); + const cfgByQn = (qn: string) => cfgKeys.find((n) => n.qualifiedName === qn); + expect(cfgByQn('app.cache.name.user-token')).toBeDefined(); + expect(cfgByQn('app.cache.enabled')).toBeDefined(); + expect(cfgByQn('db.url')).toBeDefined(); + expect(cfgByQn('app.retry-count')).toBeDefined(); + + // @Value("${app.cache.name.user-token}") -> the YAML leaf key. + const valueBindings = cg + .getNodesByKind('constant') + .filter((n) => n.id.startsWith('spring-value:')); + const userToken = valueBindings.find((n) => n.name === 'app.cache.name.user-token'); + expect(userToken).toBeDefined(); + const userTokenEdges = cg.getOutgoingEdges(userToken!.id); + const userTokenTarget = userTokenEdges.find((e) => + cfgKeys.some((c) => c.id === e.target && c.qualifiedName === 'app.cache.name.user-token'), + ); + expect(userTokenTarget, '@Value should reference the YAML leaf key').toBeDefined(); + + // Default-value form `${k:default}` — strip the `:default` and bind the key. + const enabledBind = valueBindings.find((n) => n.name === 'app.cache.enabled'); + expect(enabledBind).toBeDefined(); + expect(cg.getOutgoingEdges(enabledBind!.id).some((e) => { + const t = cfgByQn('app.cache.enabled'); + return t && e.target === t.id; + })).toBe(true); + + // Relaxed binding: `app.retryCount` (camel) -> `app.retry-count` (kebab). + const retryBind = valueBindings.find((n) => n.name === 'app.retryCount'); + expect(retryBind).toBeDefined(); + expect(cg.getOutgoingEdges(retryBind!.id).some((e) => { + const t = cfgByQn('app.retry-count'); + return t && e.target === t.id; + })).toBe(true); + + // @ConfigurationProperties(prefix="app.cache") -> a key under that prefix. + const cpBindings = cg + .getNodesByKind('constant') + .filter((n) => n.id.startsWith('spring-cp:')); + const cpAppCache = cpBindings.find((n) => n.name === 'app.cache'); + expect(cpAppCache).toBeDefined(); + const cpEdges = cg.getOutgoingEdges(cpAppCache!.id); + expect(cpEdges.length).toBeGreaterThan(0); + + cg.close(); + }); + + it('binds a config key only for `references` refs, never a same-named method call (#1180)', async () => { + // `service.process` is BOTH a yaml key and a `service.process()` method call. + // canonicalConfigKey collapses them to the same token, so before #1180 the + // method call (kind `calls`) fell into the Spring config-key branch and + // mis-resolved to the YAML constant at 0.9 confidence — a wrong edge, and the + // uncached constant scan that made large Java/Kotlin indexes take ~1h. The + // branch is now gated to `references` (only @Value/@ConfigurationProperties). + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-spring-kindgate-')); + const javaDir = path.join(tmpDir, 'src/main/java/com/example'); + const resDir = path.join(tmpDir, 'src/main/resources'); + fs.mkdirSync(javaDir, { recursive: true }); + fs.mkdirSync(resDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'pom.xml'), + 'org.springframework.bootspring-boot-starter\n' + ); + fs.writeFileSync(path.join(resDir, 'application.yml'), 'service:\n process: "enabled"\n'); + fs.writeFileSync( + path.join(javaDir, 'Worker.java'), + 'package com.example;\n' + + 'import org.springframework.beans.factory.annotation.Value;\n' + + 'class Processor { void process() {} }\n' + + 'public class Worker {\n' + + ' private Processor service;\n' + + ' @Value("${service.process}") private String sp;\n' + + ' void run() { service.process(); }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const yamlKey = cg + .getNodesByKind('constant') + .find((n) => n.language === 'yaml' && n.qualifiedName === 'service.process'); + expect(yamlKey, 'yaml key service.process should be indexed').toBeDefined(); + + // `references` ref (@Value) DOES bind to the config key. + const valueBind = cg + .getNodesByKind('constant') + .find((n) => n.id.startsWith('spring-value:') && n.name === 'service.process'); + expect(valueBind).toBeDefined(); + expect( + cg.getOutgoingEdges(valueBind!.id).some((e) => e.target === yamlKey!.id), + '@Value should still bind to the yaml key', + ).toBe(true); + + // `calls` ref (service.process()) must NOT bind to the config key. + const run = cg.getNodesByKind('method').find((n) => n.name === 'run'); + expect(run).toBeDefined(); + expect( + cg.getOutgoingEdges(run!.id).some((e) => e.target === yamlKey!.id), + 'a method call must never resolve to a config-key constant', + ).toBe(false); + + cg.close(); + }); + + it('emits only a file node for non-MyBatis XML (pom.xml, beans.xml, log4j.xml)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-xml-non-mybatis-')); + fs.writeFileSync( + path.join(tmpDir, 'pom.xml'), + 'xy\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'log4j.xml'), + '\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + // No method nodes — non-mapper XML produces no symbols (just file rows). + expect(cg.getNodesByKind('method').filter((n) => n.language === 'xml').length).toBe(0); + cg.close(); + }); + + it('resolves a `this.field.method()` call to a unique implementation class', async () => { + // Standalone test of the extractor `this.` strip: even without Spring annotations, + // `this.svc.run()` where `svc` is typed as a concrete class should route to that + // class's method. This is the general Java fix, Spring is only one consumer. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-java-this-field-')); + fs.writeFileSync( + path.join(tmpDir, 'App.java'), + 'class Svc { public void run() { } }\n' + + 'class App {\n' + + ' private Svc svc;\n' + + ' public void go() { this.svc.run(); }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const methods = cg.getNodesByKind('method'); + const go = methods.find((m) => m.name === 'go'); + const run = methods.find((m) => m.name === 'run'); + expect(go && run).toBeTruthy(); + + const edge = cg.getOutgoingEdges(go!.id).find((e) => e.target === run!.id); + expect(edge, '`this.svc.run()` should resolve to Svc.run').toBeDefined(); + + cg.close(); + }); +}); + +describe('JVM FQN imports — end-to-end', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('resolves a Kotlin import when the file name differs from the class name', async () => { + // Bar lives in Models.kt — the filesystem-based Java-style path lookup + // (com/example/Bar.kt) misses this; only FQN-via-qualifiedName finds it. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-')); + fs.writeFileSync( + path.join(tmpDir, 'Models.kt'), + 'package com.example\n\nclass Bar {\n fun greet(): String = "hi"\n}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'Caller.kt'), + 'package com.example.app\n\nimport com.example.Bar\n\nclass App {\n fun run() { Bar().greet() }\n}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const bar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::Bar'); + expect(bar, 'Bar should be extracted with package-qualified name').toBeDefined(); + + const importNode = cg.getNodesByKind('import').find((n) => n.name === 'com.example.Bar'); + expect(importNode, 'import statement node should exist').toBeDefined(); + + // The imports edge may originate from the import node OR from a parent + // scope (file / namespace) — accept either, but require that an + // imports-kind edge to Bar exists. + const reachesBar = cg + .getIncomingEdges(bar!.id) + .find((e) => e.kind === 'imports'); + expect(reachesBar, 'an imports edge should resolve to Bar via FQN').toBeDefined(); + + cg.close(); + }); + + it('resolves a Kotlin top-level function import', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-')); + fs.writeFileSync( + path.join(tmpDir, 'Utils.kt'), + 'package com.example\n\nfun util(): Int = 42\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'Caller.kt'), + 'package com.example.app\n\nimport com.example.util\n\nfun main() { util() }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const util = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'com.example::util'); + expect(util, 'top-level util() should be extracted under com.example').toBeDefined(); + + const edge = cg.getIncomingEdges(util!.id).find((e) => e.kind === 'imports'); + expect(edge, 'imports edge should reach the top-level function by FQN').toBeDefined(); + }); + + it('resolves cross-language: Kotlin importing a Java class', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-')); + fs.writeFileSync( + path.join(tmpDir, 'JavaBar.java'), + 'package com.example;\n\npublic class JavaBar {\n public String greet() { return "hi"; }\n}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'Caller.kt'), + 'package com.example.app\n\nimport com.example.JavaBar\n\nfun main() { JavaBar().greet() }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const javaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::JavaBar'); + expect(javaBar, 'JavaBar should be extracted under com.example regardless of language').toBeDefined(); + + const edge = cg.getIncomingEdges(javaBar!.id).find((e) => e.kind === 'imports'); + expect(edge, 'Kotlin caller should resolve its import to the Java class').toBeDefined(); + }); + + it('disambiguates a class-name collision across packages', async () => { + // Two `Bar` classes in different packages — each importer should reach + // ITS Bar, not the other one. This is the central failure mode that + // name-matcher alone cannot disambiguate. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-')); + fs.writeFileSync( + path.join(tmpDir, 'AlphaBar.kt'), + 'package com.example.alpha\n\nclass Bar { fun who() = "alpha" }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'BetaBar.kt'), + 'package com.example.beta\n\nclass Bar { fun who() = "beta" }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'CallerA.kt'), + 'package app\n\nimport com.example.alpha.Bar\n\nfun a() { Bar().who() }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'CallerB.kt'), + 'package app\n\nimport com.example.beta.Bar\n\nfun b() { Bar().who() }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const alphaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example.alpha::Bar'); + const betaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example.beta::Bar'); + expect(alphaBar).toBeDefined(); + expect(betaBar).toBeDefined(); + expect(alphaBar!.id).not.toBe(betaBar!.id); + + // Each Bar receives exactly one imports edge — from its own caller. + const alphaIncoming = cg.getIncomingEdges(alphaBar!.id).filter((e) => e.kind === 'imports'); + const betaIncoming = cg.getIncomingEdges(betaBar!.id).filter((e) => e.kind === 'imports'); + expect(alphaIncoming.length).toBeGreaterThan(0); + expect(betaIncoming.length).toBeGreaterThan(0); + + // Sanity: the edges don't cross — alpha's incoming sources don't include + // beta's filePath and vice versa. + const sourceFiles = (edges: typeof alphaIncoming) => + edges.map((e) => cg.getNode(e.source)?.filePath).filter(Boolean); + expect(sourceFiles(alphaIncoming).some((p) => p?.includes('CallerA.kt'))).toBe(true); + expect(sourceFiles(betaIncoming).some((p) => p?.includes('CallerB.kt'))).toBe(true); + }); +}); + +describe('Java anonymous-class override synthesis — end-to-end', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('bridges an abstract base method to overrides inside `new Base() { ... }`', async () => { + // Mirrors guava Splitter: a factory returns `new BaseIter() { + // @Override int separatorStart(...) { ... } }`. Without anon-class + // extraction the override is invisible — Phase 5.5 interface-impl + // has no class to bridge — and an agent investigating `BaseIter.separatorStart` + // can't see its real implementation without reading the file. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-anon-java-')); + fs.writeFileSync( + path.join(tmpDir, 'Splitter.java'), + 'package com.example;\n' + + '\n' + + 'abstract class BaseIter {\n' + + ' abstract int separatorStart(int start);\n' + + '}\n' + + '\n' + + 'public class Splitter {\n' + + ' public BaseIter make() {\n' + + ' return new BaseIter() {\n' + + ' @Override\n' + + ' int separatorStart(int start) { return start + 1; }\n' + + ' };\n' + + ' }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // The anon class is extracted and contains the override. + const anonClass = cg + .getNodesByKind('class') + .find((n) => /BaseIter\$anon@/.test(n.name)); + expect(anonClass, 'anonymous BaseIter subclass should be a class node').toBeDefined(); + + const baseAbstract = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'com.example::BaseIter::separatorStart'); + const anonOverride = cg + .getNodesByKind('method') + .find( + (n) => + n.name === 'separatorStart' && + n.qualifiedName.includes('$anon@') && + n.qualifiedName.startsWith('com.example::Splitter::make::') + ); + expect(baseAbstract, 'base abstract method should be in the graph').toBeDefined(); + expect(anonOverride, 'anon-class override should be in the graph').toBeDefined(); + + // Phase 5.5 interface-impl: the abstract method has a synthesized + // `calls` edge to the anon override. Without this hop the agent + // would have to Read the file to discover the implementation. + const synthEdge = cg + .getOutgoingEdges(baseAbstract!.id) + .find((e) => e.target === anonOverride!.id && e.kind === 'calls'); + expect(synthEdge, 'BaseIter.separatorStart should bridge to anon.separatorStart').toBeDefined(); + expect(synthEdge!.provenance).toBe('heuristic'); + expect((synthEdge!.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy).toBe( + 'interface-impl' + ); + + cg.close(); + }); +}); + +describe('Go gRPC stub→impl synthesis', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('bridges UnimplementedMsgServer methods to the hand-written keeper impl', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-grpc-')); + // Mimic protoc-gen-go-grpc output: `*_grpc.pb.go` carrying the + // UnimplementedMsgServer stub. + fs.writeFileSync( + path.join(tmpDir, 'tx_grpc.pb.go'), + 'package banktypes\n\n' + + 'type UnimplementedMsgServer struct{}\n\n' + + 'func (UnimplementedMsgServer) Send(ctx context.Context, req *MsgSend) (*MsgSendResponse, error) { return nil, nil }\n' + + 'func (UnimplementedMsgServer) MultiSend(ctx context.Context, req *MsgMultiSend) (*MsgMultiSendResponse, error) { return nil, nil }\n' + + 'func (UnimplementedMsgServer) mustEmbedUnimplementedMsgServer() {}\n' + + 'func (UnimplementedMsgServer) testEmbeddedByValue() {}\n' + ); + // Hand-written impl in a non-generated file — what an agent actually + // wants the trace to land on. + fs.writeFileSync( + path.join(tmpDir, 'msg_server.go'), + 'package keeper\n\n' + + 'type msgServer struct{ k Keeper }\n\n' + + 'func (m msgServer) Send(ctx context.Context, req *MsgSend) (*MsgSendResponse, error) {\n' + + ' return m.k.SendCoins(ctx, req.From, req.To, req.Amount)\n' + + '}\n' + + 'func (m msgServer) MultiSend(ctx context.Context, req *MsgMultiSend) (*MsgMultiSendResponse, error) {\n' + + ' return nil, nil\n' + + '}\n' + ); + + let cg: CodeGraph | undefined; + try { + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const stubSend = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName.endsWith('UnimplementedMsgServer::Send')); + const implSend = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName.endsWith('msgServer::Send')); + expect(stubSend, 'UnimplementedMsgServer.Send should be indexed').toBeDefined(); + expect(implSend, 'msgServer.Send should be indexed').toBeDefined(); + + const bridge = cg + .getOutgoingEdges(stubSend!.id) + .find((e) => e.target === implSend!.id && e.kind === 'calls'); + expect(bridge, 'stub Send should bridge to impl Send').toBeDefined(); + expect(bridge!.provenance).toBe('heuristic'); + expect((bridge!.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy).toBe( + 'go-grpc-stub-impl' + ); + } finally { + cg?.close(); + } + }); + + it('does not bridge to candidates living in another generated file', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-grpc-sib-')); + // `*_grpc.pb.go` also contains a sibling `msgClient` struct that + // happens to satisfy the same method set. We must NOT bridge to it — + // it's not the hand-written impl, just the gRPC client wrapper. + fs.writeFileSync( + path.join(tmpDir, 'tx_grpc.pb.go'), + 'package banktypes\n\n' + + 'type UnimplementedMsgServer struct{}\n' + + 'func (UnimplementedMsgServer) Send() {}\n' + + 'func (UnimplementedMsgServer) MultiSend() {}\n\n' + + 'type msgClient struct{}\n' + + 'func (m msgClient) Send() {}\n' + + 'func (m msgClient) MultiSend() {}\n' + ); + + let cg: CodeGraph | undefined; + try { + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const stub = cg + .getNodesByKind('struct') + .find((n) => n.name === 'UnimplementedMsgServer'); + expect(stub).toBeDefined(); + const bridges = cg + .getNodesByKind('method') + .filter((n) => n.qualifiedName.endsWith('UnimplementedMsgServer::Send')) + .flatMap((stubSend) => cg!.getOutgoingEdges(stubSend.id)) + .filter( + (e) => + e.kind === 'calls' && + (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy === + 'go-grpc-stub-impl', + ); + expect(bridges, 'no bridge to msgClient (also generated)').toHaveLength(0); + } finally { + cg?.close(); + } + }); +}); + +describe('React Router end-to-end route extraction (.tsx/.jsx)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + // Regression for the resolver language-gate bug: the `react` resolver's + // `extract()` was filtered out of the .tsx/.jsx grammars, so `` routes + // — which only live in JSX files — were never indexed through the real + // indexing path (the unit tests call extract() directly and so missed this). + it('indexes }> routes from a .tsx file and links them to the component', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + '{"dependencies":{"react":"^18.0.0","react-router-dom":"^6.0.0"}}' + ); + fs.writeFileSync( + path.join(tmpDir, 'Home.tsx'), + 'export function Home() { return null; }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'routes.tsx'), + `import { Routes, Route } from 'react-router-dom'; +import { Home } from './Home'; +export function AppRoutes() { + return ( + + } /> + + ); +} +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + try { + // The route node from the .tsx file exists (the bug: it didn't). + const route = cg.getNodesByKind('route').find((n) => n.name === '/home'); + expect(route, '/home route from .tsx should be indexed').toBeDefined(); + + // ...and it links to the Home component. + const home = cg.getNodesByName('Home').find((n) => n.kind === 'function'); + expect(home).toBeDefined(); + const toHome = cg.getOutgoingEdges(route!.id).find((e) => e.target === home!.id); + expect(toHome, 'route → Home component edge').toBeDefined(); + } finally { + cg.close(); + } + }); +}); + +describe('Terraform end-to-end module-boundary resolution', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + function writeMultiModuleRepo(root: string) { + fs.mkdirSync(path.join(root, 'modules/vpc'), { recursive: true }); + fs.mkdirSync(path.join(root, 'modules/other'), { recursive: true }); + fs.mkdirSync(path.join(root, 'envs'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'main.tf'), + 'variable "vpc_cidr" {\n type = string\n}\n\n' + + 'module "vpc" {\n source = "./modules/vpc"\n cidr = var.vpc_cidr\n}\n\n' + + 'module "registry_thing" {\n source = "terraform-aws-modules/s3-bucket/aws"\n bucket = "x"\n}\n\n' + + 'output "vpc_id" {\n value = module.vpc.vpc_id\n}\n' + ); + fs.writeFileSync( + path.join(root, 'modules/vpc/variables.tf'), + 'variable "cidr" {\n type = string\n}\n' + ); + fs.writeFileSync( + path.join(root, 'modules/vpc/main.tf'), + 'resource "aws_vpc" "this" {\n cidr_block = var.cidr\n}\n' + ); + fs.writeFileSync( + path.join(root, 'modules/vpc/outputs.tf'), + 'output "vpc_id" {\n value = aws_vpc.this.id\n}\n' + ); + // Same-named variable in an UNRELATED module — must never receive edges + // from outside its own directory. + fs.writeFileSync( + path.join(root, 'modules/other/variables.tf'), + 'variable "cidr" {\n type = string\n}\nvariable "orphan_ref_target" {}\n' + ); + // References a variable that has no same-dir declaration: must stay unlinked. + fs.writeFileSync( + path.join(root, 'modules/other/main.tf'), + 'resource "aws_eip" "e" {\n tags = { Name = var.undeclared_here_elsewhere_yes }\n}\n' + ); + fs.writeFileSync(path.join(root, 'envs/prod.tfvars'), 'vpc_cidr = "10.0.0.0/16"\n'); + } + + it('bridges module inputs/outputs/source and enforces directory scoping', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-terraform-')); + writeMultiModuleRepo(tmpDir); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + try { + const byQname = (q: string, file?: string) => + cg + .getNodesByName(q.split('.').pop()!) + .filter((n) => n.qualifiedName === q && (!file || n.filePath === file)); + + const moduleDecl = byQname('module.vpc')[0]; + expect(moduleDecl, 'module.vpc declaration node').toBeDefined(); + const childCidr = byQname('var.cidr', 'modules/vpc/variables.tf')[0]; + expect(childCidr, "child module's var.cidr").toBeDefined(); + const childOutput = byQname('output.vpc_id', 'modules/vpc/outputs.tf')[0]; + expect(childOutput, "child module's output.vpc_id").toBeDefined(); + const rootOutput = byQname('output.vpc_id', 'main.tf')[0]; + expect(rootOutput, 'root output.vpc_id').toBeDefined(); + + const declEdges = cg.getOutgoingEdges(moduleDecl!.id); + // Input wiring: module block → child variable (cross-directory). + expect( + declEdges.find((e) => e.target === childCidr!.id), + 'module.vpc → child var.cidr input edge' + ).toBeDefined(); + // Source wiring: module block → child entry file. + const fileNode = cg + .getNodesInFile('modules/vpc/main.tf') + .find((n) => n.kind === 'file'); + expect(fileNode).toBeDefined(); + const importEdge = declEdges.find((e) => e.target === fileNode!.id); + expect(importEdge, 'module.vpc → modules/vpc/main.tf imports edge').toBeDefined(); + expect(importEdge!.kind).toBe('imports'); + + // Output bridge: root output → child output (not just the declaration). + const rootOutEdges = cg.getOutgoingEdges(rootOutput!.id); + expect( + rootOutEdges.find((e) => e.target === childOutput!.id), + 'root output.vpc_id → child output.vpc_id' + ).toBeDefined(); + expect( + rootOutEdges.find((e) => e.target === moduleDecl!.id), + 'root output.vpc_id → module.vpc declaration' + ).toBeDefined(); + + // tfvars assignment walks up to the ROOT variable. + const rootVar = byQname('var.vpc_cidr', 'main.tf')[0]; + expect(rootVar).toBeDefined(); + const tfvarsFile = cg.getNodesInFile('envs/prod.tfvars').find((n) => n.kind === 'file'); + expect(tfvarsFile).toBeDefined(); + expect( + cg.getOutgoingEdges(tfvarsFile!.id).find((e) => e.target === rootVar!.id), + 'envs/prod.tfvars → var.vpc_cidr' + ).toBeDefined(); + + // Directory scoping: the unrelated module's same-named var.cidr gets + // NO incoming edges from outside its own directory… + const otherCidr = byQname('var.cidr', 'modules/other/variables.tf')[0]; + expect(otherCidr).toBeDefined(); + const incomingOther = cg.getIncomingEdges(otherCidr!.id).filter((e) => e.kind !== 'contains'); + expect(incomingOther, 'unrelated module var.cidr must stay isolated').toHaveLength(0); + + // …and a reference with no same-dir declaration stays unlinked rather + // than borrowing another module's declaration. + const orphanEdges = cg + .getNodesInFile('modules/other/main.tf') + .filter((n) => n.qualifiedName === 'aws_eip.e') + .flatMap((n) => cg.getOutgoingEdges(n.id)) + .filter((e) => e.kind === 'references'); + const orphanTargets = orphanEdges.map((e) => cg.getNode(e.target)?.qualifiedName); + expect(orphanTargets).not.toContain('var.undeclared_here_elsewhere_yes'); + + // Registry-sourced module: inputs stay unresolved (no guessed edges). + const registryDecl = byQname('module.registry_thing')[0]; + expect(registryDecl).toBeDefined(); + const registryEdges = cg + .getOutgoingEdges(registryDecl!.id) + .filter((e) => e.kind !== 'contains'); + expect(registryEdges, 'registry module must not link anywhere').toHaveLength(0); + } finally { + cg.close(); + } + }); +}); + +describe('Terraform follow-ups: remote-state bridge, provider alias, moved blocks', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('bridges atmos remote-state to the target component, resolves provider aliases up the tree, links moved blocks', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-terraform-fu-')); + // Component producing state. + fs.mkdirSync(path.join(tmpDir, 'components/terraform/vpc'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'components/terraform/vpc/outputs.tf'), + 'output "vpc_id" {\n value = "vpc-123"\n}\n' + ); + // Component consuming it via the cloudposse remote-state module. + fs.mkdirSync(path.join(tmpDir, 'components/terraform/eks/cluster'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'components/terraform/eks/cluster/remote-state.tf'), + 'module "vpc" {\n' + + ' source = "cloudposse/stack-config/yaml//modules/remote-state"\n' + + ' component = var.vpc_component_name\n' + + '}\n' + + 'variable "vpc_component_name" {\n' + + ' type = string\n' + + ' default = "vpc"\n' + + '}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'components/terraform/eks/cluster/main.tf'), + 'resource "aws_eks_cluster" "this" {\n vpc_id = module.vpc.outputs.vpc_id\n}\n' + ); + // Ambiguous component name — two directories called "dns" with the same + // output; the bridge must refuse to pick one. + fs.mkdirSync(path.join(tmpDir, 'components/terraform/dns'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'legacy/dns'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'components/terraform/dns/outputs.tf'), 'output "zone_id" {\n value = "z1"\n}\n'); + fs.writeFileSync(path.join(tmpDir, 'legacy/dns/outputs.tf'), 'output "zone_id" {\n value = "z2"\n}\n'); + fs.writeFileSync( + path.join(tmpDir, 'components/terraform/eks/cluster/dns.tf'), + 'module "dns" {\n' + + ' source = "cloudposse/stack-config/yaml//modules/remote-state"\n' + + ' component = "dns"\n' + + '}\n' + + 'output "zone" {\n value = module.dns.outputs.zone_id\n}\n' + ); + // Provider alias declared at the root, selected inside a module dir. + fs.writeFileSync( + path.join(tmpDir, 'providers.tf'), + 'provider "aws" {\n region = "us-east-1"\n}\n' + + 'provider "aws" {\n alias = "east"\n region = "us-east-2"\n}\n' + ); + fs.mkdirSync(path.join(tmpDir, 'modules/app'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'modules/app/main.tf'), + 'resource "aws_s3_bucket" "b" {\n provider = aws.east\n bucket = "x"\n}\n' + ); + // Moved block referencing a live resource. + fs.writeFileSync( + path.join(tmpDir, 'main.tf'), + 'resource "aws_instance" "renamed" {}\n' + + 'moved {\n from = aws_instance.old\n to = aws_instance.renamed\n}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + try { + const byQname = (q: string, file?: string) => + cg + .getNodesByName(q.split('.').pop()!) + .filter((n) => n.qualifiedName === q && (!file || n.filePath === file)); + + // 1. remote-state bridge: consumer resource → producer component's output. + const consumer = byQname('aws_eks_cluster.this')[0] ?? + cg.getNodesInFile('components/terraform/eks/cluster/main.tf').find((n) => n.qualifiedName === 'aws_eks_cluster.this'); + expect(consumer, 'consumer resource').toBeDefined(); + const producerOut = byQname('output.vpc_id', 'components/terraform/vpc/outputs.tf')[0]; + expect(producerOut, "producer component's output").toBeDefined(); + expect( + cg.getOutgoingEdges(consumer!.id).find((e) => e.target === producerOut!.id), + 'remote-state bridge edge eks/cluster → vpc output' + ).toBeDefined(); + + // 2. Ambiguous component name → no bridge edge to either candidate. + const zoneOut = byQname('output.zone', 'components/terraform/eks/cluster/dns.tf')[0]; + expect(zoneOut).toBeDefined(); + const zoneTargets = cg + .getOutgoingEdges(zoneOut!.id) + .map((e) => cg.getNode(e.target)) + .filter((n) => n?.qualifiedName === 'output.zone_id'); + expect(zoneTargets, 'ambiguous component must not be guessed').toHaveLength(0); + + // 3. Provider alias: nodes are distinct, and the selection inside the + // module resolves up the tree to the aliased configuration. + const provNodes = cg.getNodesInFile('providers.tf'); + const aliased = provNodes.find((n) => n.qualifiedName === 'provider.aws.east'); + const defaultProv = provNodes.find((n) => n.qualifiedName === 'provider.aws'); + expect(aliased, 'aliased provider node').toBeDefined(); + expect(defaultProv, 'default provider node').toBeDefined(); + const bucket = cg.getNodesInFile('modules/app/main.tf').find((n) => n.qualifiedName === 'aws_s3_bucket.b'); + expect(bucket).toBeDefined(); + const bucketEdges = cg.getOutgoingEdges(bucket!.id); + expect( + bucketEdges.find((e) => e.target === aliased!.id), + 'provider = aws.east → aliased provider (ancestor walk)' + ).toBeDefined(); + expect(bucketEdges.find((e) => e.target === defaultProv!.id), 'must not link the default provider').toBeUndefined(); + + // 4. moved block: the file references the live resource. + const renamed = cg.getNodesInFile('main.tf').find((n) => n.qualifiedName === 'aws_instance.renamed'); + expect(renamed).toBeDefined(); + const rootFile = cg.getNodesInFile('main.tf').find((n) => n.kind === 'file'); + expect( + cg.getOutgoingEdges(rootFile!.id).find((e) => e.target === renamed!.id), + 'moved block → live resource edge' + ).toBeDefined(); + } finally { + cg.close(); + } + }); +}); diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts new file mode 100644 index 0000000..d77dcac --- /dev/null +++ b/__tests__/frameworks.test.ts @@ -0,0 +1,1743 @@ +import { describe, it, expect } from 'vitest'; +import type { FrameworkResolver, UnresolvedRef } from '../src/resolution/types'; +import type { Node } from '../src/types'; + +describe('FrameworkResolver.extract interface', () => { + it('extract() returns { nodes, references }', () => { + const resolver: FrameworkResolver = { + name: 'fake', + detect: () => true, + resolve: () => null, + languages: ['python'], + extract: (_filePath: string, _content: string) => ({ + nodes: [] as Node[], + references: [] as UnresolvedRef[], + }), + }; + const result = resolver.extract!('foo.py', ''); + expect(result).toEqual({ nodes: [], references: [] }); + }); +}); + +import { getApplicableFrameworks } from '../src/resolution/frameworks'; +import type { FrameworkResolver } from '../src/resolution/types'; + +describe('getApplicableFrameworks', () => { + const pyFw: FrameworkResolver = { name: 'py', languages: ['python'], detect: () => true, resolve: () => null }; + const jsFw: FrameworkResolver = { name: 'js', languages: ['javascript', 'typescript'], detect: () => true, resolve: () => null }; + const anyFw: FrameworkResolver = { name: 'any', detect: () => true, resolve: () => null }; + + it('filters by language', () => { + const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'python'); + expect(result.map(r => r.name)).toEqual(['py', 'any']); + }); + + it('returns anyFw-only when language has no matches', () => { + const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'rust'); + expect(result.map(r => r.name)).toEqual(['any']); + }); +}); + +import { djangoResolver } from '../src/resolution/frameworks/python'; + +describe('djangoResolver.extract', () => { + it('extracts route node and reference for path() with CBV.as_view()', () => { + const src = ` +from django.urls import path +from users.views import UserListView + +urlpatterns = [ + path('users/', UserListView.as_view(), name='user-list'), +] +`; + const { nodes, references } = djangoResolver.extract!('users/urls.py', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('users/'); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('UserListView'); + expect(references[0].referenceKind).toBe('references'); + expect(references[0].fromNodeId).toBe(nodes[0].id); + }); + + it('extracts route for path() with dotted module.Class.as_view()', () => { + const src = `from django.urls import path\nfrom api.v1 import views as api_v1_views\nurlpatterns = [path('api/', api_v1_views.UserListView.as_view())]\n`; + const { nodes, references } = djangoResolver.extract!('api/urls.py', src); + expect(nodes).toHaveLength(1); + expect(references[0].referenceName).toBe('UserListView'); + }); + + it('extracts route for path() with bare function view', () => { + const src = `from django.urls import path\nurlpatterns = [path('home/', home_view, name='home')]\n`; + const { nodes, references } = djangoResolver.extract!('home/urls.py', src); + expect(references[0].referenceName).toBe('home_view'); + }); + + it('extracts route for path() with include()', () => { + const src = `from django.urls import path, include\nurlpatterns = [path('api/', include('api.urls'))]\n`; + const { nodes, references } = djangoResolver.extract!('root/urls.py', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(references[0].referenceName).toBe('api.urls'); + expect(references[0].referenceKind).toBe('imports'); + }); + + it('extracts routes for re_path and url', () => { + const src = `from django.urls import re_path, url\nurlpatterns = [re_path(r'^users/$', UserView), url(r'^old/$', OldView)]\n`; + const { nodes } = djangoResolver.extract!('legacy/urls.py', src); + expect(nodes).toHaveLength(2); + expect(nodes.map(n => n.name)).toEqual(['^users/$', '^old/$']); + }); + + it('returns empty result for a non-urls.py python file', () => { + const src = `def foo(): return 1\n`; + const { nodes, references } = djangoResolver.extract!('views.py', src); + expect(nodes).toEqual([]); + expect(references).toEqual([]); + }); +}); + +import { flaskResolver, fastapiResolver } from '../src/resolution/frameworks/python'; + +describe('flaskResolver.extract', () => { + it('extracts route and reference from @app.route', () => { + const src = ` +@app.route('/users') +def list_users(): + return [] +`; + const { nodes, references } = flaskResolver.extract!('app.py', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('list_users'); + }); + + it('extracts blueprint routes', () => { + const src = ` +@users_bp.route('/', methods=['POST']) +def create_user(id): + pass +`; + const { nodes, references } = flaskResolver.extract!('routes.py', src); + expect(nodes[0].name).toBe('POST /'); + expect(references[0].referenceName).toBe('create_user'); + }); + + it('resolves the handler across an intervening decorator (@login_required)', () => { + const src = ` +@bp.route('/profile') +@login_required +def profile(): + return render_template('profile.html') +`; + const { nodes, references } = flaskResolver.extract!('routes.py', src); + expect(nodes[0].name).toBe('GET /profile'); + expect(references[0].referenceName).toBe('profile'); + }); + + it('extracts stacked @x.route decorators bound to one view', () => { + const src = ` +@bp.route('/', methods=['GET', 'POST']) +@bp.route('/index', methods=['GET', 'POST']) +@login_required +def index(): + return render_template('index.html') +`; + const { nodes, references } = flaskResolver.extract!('routes.py', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /', 'GET /index']); + expect(references.map((r) => r.referenceName)).toEqual(['index', 'index']); + }); + + it('extracts the method from a tuple methods=(...) (not just a list)', () => { + const src = ` +@blueprint.route('/api/articles', methods=('POST',)) +def make_article(): + pass +`; + const { nodes, references } = flaskResolver.extract!('views.py', src); + expect(nodes[0].name).toBe('POST /api/articles'); + expect(references[0].referenceName).toBe('make_article'); + }); + + it('extracts Flask-RESTful api.add_resource(Resource, paths) → the Resource class', () => { + const src = ` +api.add_resource(TodoResource, '/todos/') +api.add_org_resource(AlertResource, '/api/alerts/', endpoint='alert') +`; + const { nodes, references } = flaskResolver.extract!('api.py', src); + expect(nodes.map((n) => n.name)).toEqual(['ANY /todos/', 'ANY /api/alerts/']); + expect(references.map((r) => r.referenceName)).toEqual(['TodoResource', 'AlertResource']); + }); +}); + +describe('fastapiResolver.extract', () => { + it('extracts route and reference from @app.get', () => { + const src = ` +@app.get('/users') +async def list_users(): + return [] +`; + const { nodes, references } = fastapiResolver.extract!('main.py', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('list_users'); + }); + + it('extracts route from router.post', () => { + const src = ` +@router.post('/items') +def create_item(item: Item): + pass +`; + const { nodes, references } = fastapiResolver.extract!('items.py', src); + expect(nodes[0].name).toBe('POST /items'); + expect(references[0].referenceName).toBe('create_item'); + }); + + it('extracts a route mounted at the router/prefix root (empty path)', () => { + const src = ` +@router.get("", response_model=ListOfArticles, name="articles:list") +async def list_articles(): + return [] +`; + const { nodes, references } = fastapiResolver.extract!('articles.py', src); + expect(nodes[0].name).toBe('GET /'); + expect(references[0].referenceName).toBe('list_articles'); + }); + + it('extracts a multi-line decorator with an empty path', () => { + const src = ` +@router.post( + "", + status_code=201, + response_model=ArticleInResponse, +) +async def create_article(): + pass +`; + const { nodes, references } = fastapiResolver.extract!('articles.py', src); + expect(nodes[0].name).toBe('POST /'); + expect(references[0].referenceName).toBe('create_article'); + }); +}); + +import { expressResolver } from '../src/resolution/frameworks/express'; + +describe('expressResolver.extract', () => { + it('extracts route with inline handler reference', () => { + const src = `app.get('/users', listUsers);\n`; + const { nodes, references } = expressResolver.extract!('routes.ts', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('listUsers'); + }); + + it('extracts route with router.post and middleware chain', () => { + const src = `router.post('/items', auth, createItem);\n`; + const { nodes, references } = expressResolver.extract!('items.ts', src); + expect(nodes[0].name).toBe('POST /items'); + // Multiple handlers: prefer the LAST one (convention: middleware first, handler last) + expect(references[0].referenceName).toBe('createItem'); + }); + + it('extracts route with controller method reference', () => { + const src = `app.get('/x', userController.list);\n`; + const { nodes, references } = expressResolver.extract!('routes.ts', src); + expect(references[0].referenceName).toBe('list'); + }); +}); + +import { nestjsResolver } from '../src/resolution/frameworks/nestjs'; + +describe('nestjsResolver.extract — HTTP', () => { + it('joins @Controller prefix with @Get and links the handler', () => { + const src = ` +@Controller('users') +export class UsersController { + @Get() + findAll() { return []; } +} +`; + const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('findAll'); + expect(references[0].referenceKind).toBe('references'); + expect(references[0].fromNodeId).toBe(nodes[0].id); + }); + + it('joins controller prefix with a method-level path param', () => { + const src = ` +@Controller('cats') +export class CatsController { + @Get(':id') + findOne(@Param('id') id: string) { return id; } +} +`; + const { nodes, references } = nestjsResolver.extract!('cats.controller.ts', src); + expect(nodes[0].name).toBe('GET /cats/:id'); + expect(references[0].referenceName).toBe('findOne'); + }); + + it('handles an empty @Controller() and empty @Post()', () => { + const src = ` +@Controller() +export class AppController { + @Post() + create() {} +} +`; + const { nodes, references } = nestjsResolver.extract!('app.controller.ts', src); + expect(nodes[0].name).toBe('POST /'); + expect(references[0].referenceName).toBe('create'); + }); + + it('covers HTTP verbs and skips intervening method decorators', () => { + const src = ` +@Controller('todos') +export class TodosController { + @Put(':id') + @UseGuards(AuthGuard) + update(@Param('id') id: string) {} + + @Delete(':id') + async remove(@Param('id') id: string) {} +} +`; + const { nodes, references } = nestjsResolver.extract!('todos.controller.ts', src); + expect(nodes.map((n) => n.name)).toEqual(['PUT /todos/:id', 'DELETE /todos/:id']); + expect(references.map((r) => r.referenceName)).toEqual(['update', 'remove']); + }); + + it('attributes methods to the right controller when a file has two', () => { + const src = ` +@Controller('a') +export class AController { + @Get('x') + ax() {} +} + +@Controller('b') +export class BController { + @Get('y') + by() {} +} +`; + const { nodes } = nestjsResolver.extract!('multi.controller.ts', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /a/x', 'GET /b/y']); + }); +}); + +describe('nestjsResolver.extract — GraphQL', () => { + it('emits QUERY/MUTATION nodes from a resolver, defaulting to the method name', () => { + const src = ` +@Resolver(() => User) +export class UsersResolver { + @Query(() => [User]) + users() { return []; } + + @Mutation(() => User) + createUser(@Args('input') input: CreateUserInput) {} +} +`; + const { nodes, references } = nestjsResolver.extract!('users.resolver.ts', src); + expect(nodes.map((n) => n.name)).toEqual(['QUERY users', 'MUTATION createUser']); + expect(references.map((r) => r.referenceName)).toEqual(['users', 'createUser']); + }); + + it('uses an explicit operation name when given', () => { + const src = ` +@Resolver() +export class CatsResolver { + @Query(() => Cat, { name: 'cat' }) + getCat() {} +} +`; + const { nodes } = nestjsResolver.extract!('cats.resolver.ts', src); + expect(nodes[0].name).toBe('QUERY cat'); + }); + + it('does NOT treat the REST @Query() parameter decorator as a GraphQL op', () => { + const src = ` +@Controller('search') +export class SearchController { + @Get() + search(@Query() query: SearchDto) { return query; } +} +`; + const { nodes } = nestjsResolver.extract!('search.controller.ts', src); + // Only the HTTP route — the @Query() param decorator must be ignored. + expect(nodes.map((n) => n.name)).toEqual(['GET /search']); + }); +}); + +describe('nestjsResolver.extract — microservices & websockets', () => { + it('extracts @MessagePattern and @EventPattern handlers', () => { + const src = ` +@Controller() +export class MathController { + @MessagePattern({ cmd: 'sum' }) + accumulate(data: number[]) {} + + @EventPattern('user.created') + handleUserCreated(data: any) {} +} +`; + const { nodes, references } = nestjsResolver.extract!('math.controller.ts', src); + expect(nodes.map((n) => n.name)).toEqual(['MESSAGE sum', 'EVENT user.created']); + expect(references.map((r) => r.referenceName)).toEqual(['accumulate', 'handleUserCreated']); + }); + + it('extracts @SubscribeMessage handlers with the gateway namespace', () => { + const src = ` +@WebSocketGateway({ namespace: 'chat' }) +export class ChatGateway { + @SubscribeMessage('message') + handleMessage(@MessageBody() data: string) {} +} +`; + const { nodes, references } = nestjsResolver.extract!('chat.gateway.ts', src); + expect(nodes[0].name).toBe('WS chat:message'); + expect(references[0].referenceName).toBe('handleMessage'); + }); + + it('extracts @SubscribeMessage without a namespace', () => { + const src = ` +@WebSocketGateway() +export class EventsGateway { + @SubscribeMessage('events') + onEvent() {} +} +`; + const { nodes } = nestjsResolver.extract!('events.gateway.ts', src); + expect(nodes[0].name).toBe('WS events'); + }); + + it('returns empty for a non-JS/TS file', () => { + const { nodes, references } = nestjsResolver.extract!('thing.py', '@Controller("x")'); + expect(nodes).toEqual([]); + expect(references).toEqual([]); + }); +}); + +describe('nestjsResolver.detect', () => { + const baseContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + it('detects @nestjs/* in package.json', () => { + const context = { + ...baseContext, + readFile: (p: string) => + p === 'package.json' + ? JSON.stringify({ dependencies: { '@nestjs/common': '^10.0.0' } }) + : null, + }; + expect(nestjsResolver.detect(context as any)).toBe(true); + }); + + it('detects @Controller in a *.controller.ts file when package.json is absent', () => { + const context = { + ...baseContext, + getAllFiles: () => ['src/users.controller.ts'], + readFile: (p: string) => + p === 'src/users.controller.ts' + ? `@Controller('users')\nexport class UsersController {}` + : null, + }; + expect(nestjsResolver.detect(context as any)).toBe(true); + }); + + it('returns false for a non-Nest project', () => { + const context = { + ...baseContext, + readFile: (p: string) => + p === 'package.json' ? JSON.stringify({ dependencies: { express: '^4' } }) : null, + }; + expect(nestjsResolver.detect(context as any)).toBe(false); + }); +}); + +describe('nestjsResolver.resolve', () => { + const baseContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + it('resolves an injected *Service reference to the class in a *.service.ts file', () => { + const svcNode: Node = { + id: 'class:src/users/users.service.ts:UsersService:3', + kind: 'class', + name: 'UsersService', + qualifiedName: 'src/users/users.service.ts::UsersService', + filePath: 'src/users/users.service.ts', + language: 'typescript', + startLine: 3, + endLine: 3, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + const context = { + ...baseContext, + getNodesByName: (n: string) => (n === 'UsersService' ? [svcNode] : []), + }; + const ref = { + fromNodeId: 'class:src/users/users.controller.ts:UsersController:5', + referenceName: 'UsersService', + referenceKind: 'references' as const, + line: 6, + column: 4, + filePath: 'src/users/users.controller.ts', + language: 'typescript' as const, + }; + const result = nestjsResolver.resolve(ref, context as any); + expect(result?.targetNodeId).toBe(svcNode.id); + expect(result?.resolvedBy).toBe('framework'); + expect(result?.confidence).toBeGreaterThanOrEqual(0.85); + }); + + it('returns null for a name without a provider suffix', () => { + const ref = { + fromNodeId: 'x', + referenceName: 'doThing', + referenceKind: 'references' as const, + line: 1, + column: 1, + filePath: 'a.ts', + language: 'typescript' as const, + }; + expect(nestjsResolver.resolve(ref, baseContext as any)).toBeNull(); + }); +}); + +describe('nestjsResolver.postExtract — RouterModule', () => { + function mkClass(name: string, filePath: string, startLine: number, endLine: number): Node { + return { + id: `class:${filePath}:${startLine}:${name}`, + kind: 'class', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language: 'typescript', + startLine, + endLine, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; + } + + function mkRoute( + filePath: string, + line: number, + method: string, + path: string, + nameOverride?: string + ): Node { + return { + id: `route:${filePath}:${line}:${method}:${path}`, + kind: 'route', + name: nameOverride ?? `${method} ${path}`, + qualifiedName: `${filePath}::${method}:${path}`, + filePath, + language: 'typescript', + startLine: line, + endLine: line, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; + } + + function makeContext(opts: { + files?: Record; + nodes?: Node[]; + }) { + const files = opts.files ?? {}; + const all = opts.nodes ?? []; + return { + getNodesInFile: (fp: string) => all.filter((n) => n.filePath === fp), + getNodesByName: (name: string) => all.filter((n) => n.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: (kind: Node['kind']) => all.filter((n) => n.kind === kind), + fileExists: (fp: string) => files[fp] !== undefined, + readFile: (fp: string) => files[fp] ?? null, + getProjectRoot: () => '/test', + getAllFiles: () => Object.keys(files), + getNodesByLowerName: () => [], + getImportMappings: () => [], + } as any; + } + + it('prepends RouterModule prefix to a controller route (top-level register)', () => { + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + @Module({ + imports: [ + RouterModule.register([ + { path: 'admin', module: AdminModule }, + ]), + ], + }) + export class AppModule {} + + @Module({ controllers: [AdminController] }) + export class AdminModule {} + `, + }, + nodes: [ + mkClass('AdminController', 'src/admin/admin.controller.ts', 1, 10), + mkRoute('src/admin/admin.controller.ts', 3, 'GET', '/'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(1); + expect(updates[0]!.name).toBe('GET /admin'); + // id and qualifiedName must be preserved so existing route→handler edges + // stay intact and the pass remains idempotent on a second run. + expect(updates[0]!.id).toBe('route:src/admin/admin.controller.ts:3:GET:/'); + expect(updates[0]!.qualifiedName).toBe('src/admin/admin.controller.ts::GET:/'); + }); + + it('resolves nested children — the issue #459 example', () => { + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + @Module({ + imports: [ + AdminModule, + UsersModule, + RouterModule.register([ + { + path: 'admin', + module: AdminModule, + children: [ + { path: 'users', module: UsersModule }, + ], + }, + ]), + ], + }) + export class AppModule {} + `, + 'src/users/users.module.ts': ` + @Module({ controllers: [UsersController] }) + export class UsersModule {} + `, + }, + nodes: [ + mkClass('UsersController', 'src/users/users.controller.ts', 1, 10), + mkRoute('src/users/users.controller.ts', 3, 'GET', '/'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(1); + expect(updates[0]!.name).toBe('GET /admin/users'); + }); + + it('joins module prefix with a non-empty @Controller path and method params', () => { + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + RouterModule.register([{ path: 'admin', module: UsersModule }]) + + @Module({ controllers: [UsersController] }) + export class UsersModule {} + `, + }, + nodes: [ + mkClass('UsersController', 'src/users.controller.ts', 1, 10), + // Existing extract emitted GET /users/:id from @Controller('users') + @Get(':id') + mkRoute('src/users.controller.ts', 3, 'GET', '/users/:id'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(1); + expect(updates[0]!.name).toBe('GET /admin/users/:id'); + }); + + it('is idempotent — a second run returns no updates', () => { + // Simulate the state after one round of postExtract: name is already + // 'GET /admin', but qualifiedName still encodes the original 'GET:/'. + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + RouterModule.register([{ path: 'admin', module: UsersModule }]) + @Module({ controllers: [UsersController] }) + export class UsersModule {} + `, + }, + nodes: [ + mkClass('UsersController', 'src/users.controller.ts', 1, 10), + mkRoute('src/users.controller.ts', 3, 'GET', '/', 'GET /admin'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(0); + }); + + it('is a no-op when the project does not use RouterModule', () => { + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + @Module({ controllers: [UsersController] }) + export class AppModule {} + `, + }, + nodes: [ + mkClass('UsersController', 'src/users.controller.ts', 1, 10), + mkRoute('src/users.controller.ts', 3, 'GET', '/'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(0); + }); + + it('attributes routes to the right controller when one file has two', () => { + // Two controllers in one file, declared in two different modules with + // two different module prefixes. The route's startLine has to match the + // class scope, not just the file path. + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + RouterModule.register([ + { path: 'p1', module: AModule }, + { path: 'p2', module: BModule }, + ]) + @Module({ controllers: [AController] }) export class AModule {} + @Module({ controllers: [BController] }) export class BModule {} + `, + }, + nodes: [ + mkClass('AController', 'src/multi.controller.ts', 1, 5), + mkClass('BController', 'src/multi.controller.ts', 7, 12), + mkRoute('src/multi.controller.ts', 3, 'GET', '/a/x'), + mkRoute('src/multi.controller.ts', 9, 'GET', '/b/y'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(2); + const byId = new Map(updates.map((u) => [u.id, u.name])); + expect(byId.get('route:src/multi.controller.ts:3:GET:/a/x')).toBe('GET /p1/a/x'); + expect(byId.get('route:src/multi.controller.ts:9:GET:/b/y')).toBe('GET /p2/b/y'); + }); + + it('merges RouterModule registrations spread across multiple module files', () => { + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + RouterModule.register([{ path: 'a', module: AModule }]) + @Module({ controllers: [AController] }) export class AModule {} + `, + 'src/feature.module.ts': ` + RouterModule.forChild([{ path: 'b', module: BModule }]) + @Module({ controllers: [BController] }) export class BModule {} + `, + }, + nodes: [ + mkClass('AController', 'src/a.controller.ts', 1, 5), + mkClass('BController', 'src/b.controller.ts', 1, 5), + mkRoute('src/a.controller.ts', 3, 'GET', '/'), + mkRoute('src/b.controller.ts', 3, 'GET', '/'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(2); + const byId = new Map(updates.map((u) => [u.id, u.name])); + expect(byId.get('route:src/a.controller.ts:3:GET:/')).toBe('GET /a'); + expect(byId.get('route:src/b.controller.ts:3:GET:/')).toBe('GET /b'); + }); + + it('silently skips controllers whose class node is not in the graph', () => { + // RouterModule declares a prefix for a module, but the @Module that + // would link it to a controller is missing — common during partial + // re-extraction. Must not throw. + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + RouterModule.register([{ path: 'orphans', module: GhostModule }]) + @Module({ controllers: [GhostController] }) export class GhostModule {} + `, + }, + nodes: [], // no class or route nodes + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(0); + }); +}); + +import { laravelResolver } from '../src/resolution/frameworks/laravel'; + +describe('laravelResolver.extract', () => { + it('extracts route with controller tuple syntax', () => { + const src = `Route::get('/users', [UserController::class, 'index']);\n`; + const { nodes, references } = laravelResolver.extract!('routes/web.php', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('UserController@index'); + }); + + it('extracts route with Controller@action syntax', () => { + const src = `Route::post('/users', 'UserController@store');\n`; + const { nodes, references } = laravelResolver.extract!('routes/web.php', src); + expect(references[0].referenceName).toBe('UserController@store'); + }); + + it('extracts resource route', () => { + const src = `Route::resource('users', UserController::class);\n`; + const { nodes, references } = laravelResolver.extract!('routes/web.php', src); + expect(nodes[0].kind).toBe('route'); + expect(references[0].referenceName).toBe('UserController'); + }); +}); + +import { railsResolver } from '../src/resolution/frameworks/ruby'; + +describe('railsResolver.extract', () => { + it('extracts route with controller#action syntax', () => { + const src = `get '/users', to: 'users#index'\n`; + const { nodes, references } = railsResolver.extract!('config/routes.rb', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('users#index'); + }); + + it('extracts route without to: keyword', () => { + const src = `post '/items' => 'items#create'\n`; + const { nodes, references } = railsResolver.extract!('config/routes.rb', src); + expect(references[0].referenceName).toBe('items#create'); + }); +}); + +import { springResolver } from '../src/resolution/frameworks/java'; + +describe('springResolver.extract', () => { + it('extracts route with @GetMapping and next method', () => { + const src = ` +@GetMapping("/users") +public List listUsers() { + return users; +} +`; + const { nodes, references } = springResolver.extract!('UserController.java', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('listUsers'); + }); + + it('extracts a Kotlin @GetMapping with a fun handler', () => { + const src = ` +@GetMapping("/vets") +fun showVetList(model: MutableMap): String { + return "vets" +} +`; + const { nodes, references } = springResolver.extract!('VetController.kt', src); + expect(nodes[0].name).toBe('GET /vets'); + expect(references[0].referenceName).toBe('showVetList'); + expect(nodes[0].language).toBe('kotlin'); + }); + + it('joins a Kotlin class @RequestMapping prefix and skips a stacked annotation', () => { + const src = ` +@RestController +@RequestMapping("/owners") +class OwnerController { + @GetMapping("/{ownerId}") + @ResponseBody + fun showOwner(@PathVariable ownerId: Int): String { + return "owner" + } +} +`; + const { nodes, references } = springResolver.extract!('OwnerController.kt', src); + expect(nodes[0].name).toBe('GET /owners/{ownerId}'); + expect(references[0].referenceName).toBe('showOwner'); + }); +}); + +import { playResolver } from '../src/resolution/frameworks/play'; +import { isSourceFile, isPlayRoutesFile } from '../src/extraction/grammars'; + +describe('playResolver.extract (conf/routes)', () => { + it('extracts METHOD /path Controller.action routes, dropping the package + args', () => { + const src = `# Routes +GET / controllers.Application.index +GET /computers controllers.Application.list(p: Int ?= 0, s: Int ?= 2) +POST /computers controllers.Application.save +-> /v1/posts v1.post.PostRouter +`; + const { nodes, references } = playResolver.extract!('conf/routes', src); + expect(nodes.map((n) => n.name)).toEqual([ + 'GET /', + 'GET /computers', + 'POST /computers', + ]); // the `->` include is skipped + expect(references.map((r) => r.referenceName)).toEqual([ + 'Application.index', + 'Application.list', + 'Application.save', + ]); + }); + + it('only runs on Play routes files', () => { + expect(playResolver.extract!('app/Foo.scala', 'GET / controllers.X.y').nodes).toHaveLength(0); + }); +}); + +describe('Play routes file detection', () => { + it('recognizes conf/routes (extensionless) and *.routes as source files', () => { + expect(isPlayRoutesFile('conf/routes')).toBe(true); + expect(isPlayRoutesFile('myapp/conf/routes')).toBe(true); + expect(isPlayRoutesFile('conf/admin.routes')).toBe(true); + expect(isSourceFile('conf/routes')).toBe(true); + expect(isPlayRoutesFile('src/routes.ts')).toBe(false); + }); +}); + +import { goResolver } from '../src/resolution/frameworks/go'; + +describe('goResolver.extract', () => { + it('extracts route from r.GET', () => { + const src = `r.GET("/users", listUsers)\n`; + const { nodes, references } = goResolver.extract!('main.go', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('listUsers'); + }); + + it('extracts route from router.HandleFunc', () => { + const src = `router.HandleFunc("/items", createItem)\n`; + const { nodes, references } = goResolver.extract!('main.go', src); + expect(references[0].referenceName).toBe('createItem'); + }); + + it('extracts gorilla/mux HandleFunc on a subrouter var, ignoring chained .Methods()', () => { + // `s` is a PathPrefix().Subrouter() var — any receiver is matched; the + // trailing .Methods("GET") doesn't break the handler capture. + const src = `s.HandleFunc("/users/{id}", listUsers).Methods("GET")\n`; + const { references } = goResolver.extract!('routes.go', src); + expect(references[0].referenceName).toBe('listUsers'); + }); +}); + +import { goframeResolver } from '../src/resolution/frameworks/goframe'; + +describe('goframeResolver', () => { + it('detects GoFrame from a gogf/gf dependency in go.mod', () => { + const ctx: any = { + readFile: (f: string) => + f === 'go.mod' ? 'module example.com/app\nrequire github.com/gogf/gf/v2 v2.7.0\n' : null, + }; + expect(goframeResolver.detect(ctx)).toBe(true); + const noGf: any = { readFile: (f: string) => (f === 'go.mod' ? 'module example.com/app\n' : null) }; + expect(goframeResolver.detect(noGf)).toBe(false); + }); + + it('extracts a route node from a g.Meta request struct (method upper-cased)', () => { + const src = `package v1 +import "github.com/gogf/gf/v2/frame/g" +type SignInReq struct { + g.Meta \`path:"/user/sign-in" method:"post" tags:"User" summary:"Sign in"\` + Passport string +} +type SignInRes struct{} +`; + const { nodes } = goframeResolver.extract!('api/user/v1/user_sign_in.go', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('POST /user/sign-in'); + // The package-qualified request type is encoded for the synthesizer join. + expect(nodes[0].qualifiedName).toContain('::goframe-route:v1.SignInReq'); + }); + + it('is independent of g.Meta tag attribute order', () => { + const src = `type DeptSearchReq struct { + g.Meta \`path:"/dept/list" tags:"Dept" method:"get" summary:"列表"\` +}`; + const { nodes } = goframeResolver.extract!('api/system/dept.go', src); + expect(nodes[0].name).toBe('GET /dept/list'); + expect(nodes[0].qualifiedName).toContain('::goframe-route:DeptSearchReq'); + }); + + it('skips a response g.Meta that has no path (mime-only) and other non-route metadata', () => { + const src = `type ListRes struct { + g.Meta \`mime:"application/json"\` + Items []string +}`; + const { nodes } = goframeResolver.extract!('api/x.go', src); + expect(nodes).toHaveLength(0); + }); + + it('defaults method to ANY when method: is omitted', () => { + const src = `type PingReq struct { + g.Meta \`path:"/ping"\` +}`; + const { nodes } = goframeResolver.extract!('api/ping.go', src); + expect(nodes[0].name).toBe('ANY /ping'); + }); + + it('extracts every request struct in a multi-route api file', () => { + const src = `type DeptListReq struct { g.Meta \`path:"/dept/list" method:"get"\` } +type DeptListRes struct { g.Meta \`mime:"application/json"\` } +type DeptAddReq struct { g.Meta \`path:"/dept/add" method:"post"\` } +type DeptAddRes struct {} +`; + const { nodes } = goframeResolver.extract!('api/dept.go', src); + expect(nodes.map((n) => n.name).sort()).toEqual(['GET /dept/list', 'POST /dept/add']); + }); + + it('returns nothing for a non-go file or a file without g.Meta', () => { + expect(goframeResolver.extract!('main.ts', 'const x = 1').nodes).toHaveLength(0); + expect(goframeResolver.extract!('main.go', 'package main\nfunc main() {}\n').nodes).toHaveLength(0); + }); +}); + +import { rustResolver } from '../src/resolution/frameworks/rust'; + +describe('rustResolver.extract', () => { + it('extracts route from axum .route with get()', () => { + const src = `let app = Router::new().route("/users", get(list_users));\n`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('list_users'); + }); + + it('extracts every method from a chained axum .route (get().put())', () => { + const src = `let app = Router::new().route("/user", get(get_current_user).put(update_user));\n`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /user', 'PUT /user']); + expect(references.map((r) => r.referenceName)).toEqual([ + 'get_current_user', + 'update_user', + ]); + }); + + it('extracts a multi-line axum .route with a namespaced handler', () => { + const src = ` +let app = Router::new() + .route( + "/articles/feed", + get(listing::feed_articles), + ); +`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes[0].name).toBe('GET /articles/feed'); + expect(references[0].referenceName).toBe('feed_articles'); + }); + + it('extracts actix web::resource().route(web::METHOD().to(handler))', () => { + const src = `App::new().service(web::resource("/user/{id}").route(web::get().to(get_user)))\n`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes[0].name).toBe('GET /user/{id}'); + expect(references[0].referenceName).toBe('get_user'); + }); + + it('extracts actix web::resource("/").to(handler) (all methods)', () => { + const src = `App::new().service(web::resource("/").to(index))\n`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes[0].name).toBe('ANY /'); + expect(references[0].referenceName).toBe('index'); + }); + + it('extracts actix App-level .route("/path", web::METHOD().to(handler))', () => { + const src = `App::new().route("/health", web::get().to(health_check))\n`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes[0].name).toBe('GET /health'); + expect(references[0].referenceName).toBe('health_check'); + }); +}); + +describe('rustResolver.resolve cargo workspace crates', () => { + it('resolves crate name from workspace member lib.rs', () => { + const workspaceCargo = ` +[workspace] +members = ["crates/mytool-core", "crates/mytool-fetcher"] +`; + const coreCargo = ` +[package] +name = "mytool-core" +version = "0.1.0" +`; + const libNode: Node = { + id: 'module:crates/mytool-core/src/lib.rs:mytool_core:1', + kind: 'module', + name: 'mytool_core', + qualifiedName: 'crates/mytool-core/src/lib.rs::mytool_core', + filePath: 'crates/mytool-core/src/lib.rs', + language: 'rust', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + + const context = { + getNodesInFile: (fp: string) => (fp === 'crates/mytool-core/src/lib.rs' ? [libNode] : []), + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p: string) => ( + p === 'Cargo.toml' || + p === 'crates/mytool-core/Cargo.toml' || + p === 'crates/mytool-core/src/lib.rs' + ), + readFile: (p: string) => { + if (p === 'Cargo.toml') return workspaceCargo; + if (p === 'crates/mytool-core/Cargo.toml') return coreCargo; + return null; + }, + getProjectRoot: () => '/test', + getAllFiles: () => [ + 'Cargo.toml', + 'crates/mytool-core/Cargo.toml', + 'crates/mytool-core/src/lib.rs', + ], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + const ref = { + fromNodeId: 'fn:crates/mytool-fetcher/src/main.rs:main:1', + referenceName: 'mytool_core', + referenceKind: 'references' as const, + line: 1, + column: 1, + filePath: 'crates/mytool-fetcher/src/main.rs', + language: 'rust' as const, + }; + + const result = rustResolver.resolve(ref, context); + expect(result?.targetNodeId).toBe(libNode.id); + expect(result?.resolvedBy).toBe('framework'); + // Workspace-manifest hits are unambiguous and must beat name-matcher's + // self-file matches (0.7) so cross-crate `imports` edges materialize. + expect(result?.confidence).toBeGreaterThanOrEqual(0.9); + }); + + it('resolves crate name from workspace member main.rs when lib.rs is absent', () => { + const workspaceCargo = ` +[workspace] +members = [ + "crates/mytool-runner", +] +`; + const runnerCargo = ` +[package] +name = "mytool-runner" +version = "0.1.0" +`; + const mainNode: Node = { + id: 'module:crates/mytool-runner/src/main.rs:mytool_runner:1', + kind: 'module', + name: 'mytool_runner', + qualifiedName: 'crates/mytool-runner/src/main.rs::mytool_runner', + filePath: 'crates/mytool-runner/src/main.rs', + language: 'rust', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + + const context = { + getNodesInFile: (fp: string) => (fp === 'crates/mytool-runner/src/main.rs' ? [mainNode] : []), + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p: string) => ( + p === 'Cargo.toml' || + p === 'crates/mytool-runner/Cargo.toml' || + p === 'crates/mytool-runner/src/main.rs' + ), + readFile: (p: string) => { + if (p === 'Cargo.toml') return workspaceCargo; + if (p === 'crates/mytool-runner/Cargo.toml') return runnerCargo; + return null; + }, + getProjectRoot: () => '/test', + getAllFiles: () => [ + 'Cargo.toml', + 'crates/mytool-runner/Cargo.toml', + 'crates/mytool-runner/src/main.rs', + ], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + const ref = { + fromNodeId: 'fn:crates/mytool-runner/src/main.rs:main:1', + referenceName: 'mytool_runner', + referenceKind: 'references' as const, + line: 1, + column: 1, + filePath: 'crates/mytool-runner/src/main.rs', + language: 'rust' as const, + }; + + const result = rustResolver.resolve(ref, context); + expect(result?.targetNodeId).toBe(mainNode.id); + expect(result?.resolvedBy).toBe('framework'); + }); + + it('resolves crate name when members uses a glob (crates/*)', () => { + const workspaceCargo = ` +[workspace] +members = ["crates/*"] +`; + const fooCargo = ` +[package] +name = "mytool-foo" +version = "0.1.0" +`; + const barCargo = ` +[package] +name = "mytool-bar" +version = "0.1.0" +`; + const fooLib: Node = { + id: 'module:crates/mytool-foo/src/lib.rs:mytool_foo:1', + kind: 'module', + name: 'mytool_foo', + qualifiedName: 'crates/mytool-foo/src/lib.rs::mytool_foo', + filePath: 'crates/mytool-foo/src/lib.rs', + language: 'rust', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + const barLib: Node = { + id: 'module:crates/mytool-bar/src/lib.rs:mytool_bar:1', + kind: 'module', + name: 'mytool_bar', + qualifiedName: 'crates/mytool-bar/src/lib.rs::mytool_bar', + filePath: 'crates/mytool-bar/src/lib.rs', + language: 'rust', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + + const filesByPath: Record = { + 'Cargo.toml': workspaceCargo, + 'crates/mytool-foo/Cargo.toml': fooCargo, + 'crates/mytool-bar/Cargo.toml': barCargo, + }; + const nodesByFile: Record = { + 'crates/mytool-foo/src/lib.rs': [fooLib], + 'crates/mytool-bar/src/lib.rs': [barLib], + }; + const dirsByPath: Record = { + '.': ['crates'], + crates: ['mytool-foo', 'mytool-bar'], + 'crates/mytool-foo': ['src'], + 'crates/mytool-bar': ['src'], + }; + + const context = { + getNodesInFile: (fp: string) => nodesByFile[fp] ?? [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p: string) => ( + Object.prototype.hasOwnProperty.call(filesByPath, p) || + Object.prototype.hasOwnProperty.call(nodesByFile, p) + ), + readFile: (p: string) => filesByPath[p] ?? null, + getProjectRoot: () => '/test', + getAllFiles: () => [ + 'Cargo.toml', + ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'), + ...Object.keys(nodesByFile), + ], + getNodesByLowerName: () => [], + getImportMappings: () => [], + listDirectories: (rel: string) => dirsByPath[rel] ?? [], + }; + + const fooRef = { + fromNodeId: 'fn:crates/mytool-bar/src/lib.rs:other:1', + referenceName: 'mytool_foo', + referenceKind: 'references' as const, + line: 1, + column: 1, + filePath: 'crates/mytool-bar/src/lib.rs', + language: 'rust' as const, + }; + const barRef = { + fromNodeId: 'fn:crates/mytool-foo/src/lib.rs:other:1', + referenceName: 'mytool_bar', + referenceKind: 'references' as const, + line: 1, + column: 1, + filePath: 'crates/mytool-foo/src/lib.rs', + language: 'rust' as const, + }; + + expect(rustResolver.resolve(fooRef, context)?.targetNodeId).toBe(fooLib.id); + expect(rustResolver.resolve(barRef, context)?.targetNodeId).toBe(barLib.id); + }); + + it('resolves crate name when members uses a name glob at root (helix-*)', () => { + const workspaceCargo = ` +[workspace] +members = ["helix-*"] +`; + const coreCargo = ` +[package] +name = "helix-core" +version = "0.1.0" +`; + const coreLib: Node = { + id: 'module:helix-core/src/lib.rs:helix_core:1', + kind: 'module', + name: 'helix_core', + qualifiedName: 'helix-core/src/lib.rs::helix_core', + filePath: 'helix-core/src/lib.rs', + language: 'rust', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + + const filesByPath: Record = { + 'Cargo.toml': workspaceCargo, + 'helix-core/Cargo.toml': coreCargo, + }; + const nodesByFile: Record = { + 'helix-core/src/lib.rs': [coreLib], + }; + const dirsByPath: Record = { + '.': ['helix-core', 'docs', 'target'], + 'helix-core': ['src'], + }; + + const context = { + getNodesInFile: (fp: string) => nodesByFile[fp] ?? [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p: string) => ( + Object.prototype.hasOwnProperty.call(filesByPath, p) || + Object.prototype.hasOwnProperty.call(nodesByFile, p) + ), + readFile: (p: string) => filesByPath[p] ?? null, + getProjectRoot: () => '/test', + getAllFiles: () => [ + 'Cargo.toml', + ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'), + ...Object.keys(nodesByFile), + ], + getNodesByLowerName: () => [], + getImportMappings: () => [], + listDirectories: (rel: string) => dirsByPath[rel] ?? [], + }; + + const ref = { + fromNodeId: 'fn:helix-core/src/lib.rs:other:1', + referenceName: 'helix_core', + referenceKind: 'references' as const, + line: 1, + column: 1, + filePath: 'helix-core/src/lib.rs', + language: 'rust' as const, + }; + + expect(rustResolver.resolve(ref, context)?.targetNodeId).toBe(coreLib.id); + }); +}); + +import { aspnetResolver } from '../src/resolution/frameworks/csharp'; + +describe('aspnetResolver.extract', () => { + it('extracts route from [HttpGet] attribute', () => { + const src = ` +[HttpGet("/users")] +public IActionResult ListUsers() +{ + return Ok(); +} +`; + const { nodes, references } = aspnetResolver.extract!('UserController.cs', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('ListUsers'); + }); +}); + +import { vaporResolver } from '../src/resolution/frameworks/swift'; + +describe('vaporResolver.extract', () => { + it('extracts route from app.get with use:', () => { + const src = `app.get("users", use: listUsers)\n`; + const { nodes, references } = vaporResolver.extract!('routes.swift', src); + expect(nodes[0].name).toBe('GET /users'); + expect(references[0].referenceName).toBe('listUsers'); + }); + + it('extracts grouped RouteCollection routes with the group prefix and no path arg', () => { + const src = ` +func boot(routes: RoutesBuilder) throws { + let todos = routes.grouped("todos") + todos.get(use: index) + todos.post(use: create) + todos.group(":todoID") { todo in + todo.delete(use: delete) + } +} +`; + const { nodes, references } = vaporResolver.extract!('TodoController.swift', src); + expect(nodes.map((n) => n.name).sort()).toEqual([ + 'DELETE /todos/:todoID', + 'GET /todos', + 'POST /todos', + ]); + expect(references.map((r) => r.referenceName).sort()).toEqual([ + 'create', + 'delete', + 'index', + ]); + }); + + it('handles use: self.handler and non-string path segments', () => { + const src = `router.get("users", User.parameter, "edit", use: self.editUserHandler)\n`; + const { nodes, references } = vaporResolver.extract!('UserController.swift', src); + expect(nodes[0].name).toBe('GET /users/edit'); + expect(references[0].referenceName).toBe('editUserHandler'); + }); + + it('ignores non-route .get calls that lack use: (e.g. Environment.get)', () => { + const src = `let host = Environment.get("DATABASE_HOST") ?? "localhost"\n`; + const { nodes } = vaporResolver.extract!('configure.swift', src); + expect(nodes).toHaveLength(0); + }); +}); + +import { reactResolver } from '../src/resolution/frameworks/react'; +import { svelteResolver } from '../src/resolution/frameworks/svelte'; +import { astroResolver } from '../src/resolution/frameworks/astro'; + +describe('reactResolver.extract — React Router', () => { + it('extracts a v6 }>', () => { + const src = `}/>`; + const { nodes, references } = reactResolver.extract!('App.tsx', src); + const route = nodes.find((n) => n.kind === 'route'); + expect(route?.name).toBe('/users'); + expect(references[0]?.referenceName).toBe('UsersPage'); + }); + + it('extracts a v5 with attributes in any order', () => { + const src = ``; + const { nodes, references } = reactResolver.extract!('App.jsx', src); + const route = nodes.find((n) => n.kind === 'route'); + expect(route?.name).toBe('/login'); + expect(references[0]?.referenceName).toBe('Login'); + }); + + it('does not treat the container as a route', () => { + const src = `}/>`; + const routes = reactResolver.extract!('App.tsx', src).nodes.filter((n) => n.kind === 'route'); + expect(routes).toHaveLength(1); + expect(routes[0]?.name).toBe('/x'); + }); + + it('extracts createBrowserRouter object routes ({ path, element/Component })', () => { + const src = `const router = createBrowserRouter([ + { path: "/dashboard", element: }, + { path: "/login", Component: Login }, + ]);`; + const { nodes, references } = reactResolver.extract!('router.tsx', src); + const routes = nodes.filter((n) => n.kind === 'route'); + expect(routes.map((n) => n.name).sort()).toEqual(['/dashboard', '/login']); + expect(references.map((r) => r.referenceName).sort()).toEqual(['Dashboard', 'Login']); + }); + + it('does not treat config files or a nextjs-pages dir as Next.js routes', () => { + const cfg = reactResolver.extract!('apps/nextjs-pages/next.config.mjs', 'export default {}'); + expect(cfg.nodes.filter((n) => n.kind === 'route')).toHaveLength(0); + const vite = reactResolver.extract!('src/pages/vite.config.ts', 'export default {}'); + expect(vite.nodes.filter((n) => n.kind === 'route')).toHaveLength(0); + // a real page still works + const page = reactResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}'); + expect(page.nodes.filter((n) => n.kind === 'route').map((n) => n.name)).toEqual(['/about']); + }); +}); + +describe('svelteResolver.extract (smoke)', () => { + it('returns { nodes, references } shape', () => { + const result = svelteResolver.extract!('+page.svelte', ''); + expect(result).toHaveProperty('nodes'); + expect(result).toHaveProperty('references'); + }); +}); + +describe('astroResolver.extract — src/pages file-based routing', () => { + const routeNames = (filePath: string): string[] => + astroResolver.extract!(filePath, '').nodes.filter((n) => n.kind === 'route').map((n) => n.name); + + it('maps index.astro to /', () => { + expect(routeNames('src/pages/index.astro')).toEqual(['/']); + }); + + it('maps nested index and plain pages', () => { + expect(routeNames('src/pages/blog/index.astro')).toEqual(['/blog']); + expect(routeNames('src/pages/about.astro')).toEqual(['/about']); + }); + + it('converts [param] and [...rest] syntax', () => { + expect(routeNames('src/pages/blog/[slug].astro')).toEqual(['/blog/:slug']); + expect(routeNames('src/pages/[...path].astro')).toEqual(['/*path']); + }); + + it('maps .ts endpoints under src/pages to routes', () => { + expect(routeNames('src/pages/api/posts.ts')).toEqual(['/api/posts']); + expect(routeNames('src/pages/rss.xml.js')).toEqual(['/rss.xml']); + }); + + it('excludes underscore-prefixed segments and config files', () => { + expect(routeNames('src/pages/_partial.astro')).toEqual([]); + expect(routeNames('src/pages/blog/_components/Card.astro')).toEqual([]); + expect(routeNames('src/pages/vite.config.ts')).toEqual([]); + }); + + it('ignores .astro files outside src/pages', () => { + expect(routeNames('src/components/Button.astro')).toEqual([]); + expect(routeNames('docs/pages/guide.astro')).toEqual([]); + }); +}); + +describe('astroResolver.resolve — Astro global and virtual modules', () => { + const ctx = {} as never; + const baseRef = { + fromNodeId: 'component:a', + line: 1, + column: 0, + filePath: 'src/pages/index.astro', + language: 'astro', + }; + + it('claims Astro.* global references as framework-provided', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'Astro.props', referenceKind: 'references' } as never, + ctx + ); + expect(res?.resolvedBy).toBe('framework'); + expect(res?.confidence).toBe(1.0); + }); + + it('claims astro:content virtual module imports', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'astro:content', referenceKind: 'imports' } as never, + ctx + ); + expect(res?.resolvedBy).toBe('framework'); + }); + + it('leaves ordinary names alone', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'astrolabe', referenceKind: 'calls' } as never, + { getNodesByName: () => [] } as never + ); + expect(res).toBeNull(); + }); +}); + +// Regression tests: commented-out and docstring route examples must NOT +// surface as phantom route nodes. These would have failed before the +// strip-comments wiring (the regex would happily scan comments/docstrings). +describe('framework extractors ignore commented-out routes', () => { + it('django: skips line-comment and docstring routes', () => { + const src = ` +# urls.py example: +# path('/admin/', AdminPanel.as_view()) +""" +Other routing example: + path('/users/', UserListView.as_view()) +""" +urlpatterns = [path('/real/', RealView.as_view())] +`; + const result = djangoResolver.extract!('app/urls.py', src); + const urls = result.nodes.map((n) => n.name); + expect(urls).toEqual(['/real/']); + }); + + it('flask: skips commented-out @app.route', () => { + const src = ` +# @app.route('/fake') +# def fake_view(): +# return '' + +@app.route('/real') +def real_view(): + return '' +`; + const { nodes, references } = flaskResolver.extract!('app.py', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['real_view']); + }); + + it('fastapi: skips docstring example routes', () => { + const src = ` +""" +Example: + @app.get('/in-docstring') + async def doc(): + pass +""" +@app.get('/real') +async def real_handler(): + return {} +`; + const { nodes, references } = fastapiResolver.extract!('main.py', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['real_handler']); + }); + + it('express: skips // and /* */ commented routes', () => { + const src = ` +// app.get('/fake', fakeHandler); +/* router.post('/also-fake', otherHandler); */ +app.get('/real', realHandler); +`; + const { nodes, references } = expressResolver.extract!('routes.ts', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['realHandler']); + }); + + it('laravel: skips // # and /* */ commented Route::* calls', () => { + const src = ` n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['RealController@index']); + }); + + it('rails: skips =begin/=end and # commented routes', () => { + const src = ` +# get '/fake', to: 'fake#index' +=begin +get '/also-fake', to: 'fake#show' +=end +get '/real', to: 'real#index' +`; + const { nodes, references } = railsResolver.extract!('config/routes.rb', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['real#index']); + }); + + it('spring: skips // and /* */ commented @GetMapping', () => { + const src = ` +// @GetMapping("/fake") +// public List fake() { return null; } + +/* @PostMapping("/also-fake") + public void alsoFake() {} */ + +@GetMapping("/real") +public List listUsers() { return users; } +`; + const { nodes, references } = springResolver.extract!('UserController.java', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); + }); + + it('go: skips // and /* */ commented router.METHOD calls', () => { + const src = ` +// r.GET("/fake", fakeHandler) +/* r.POST("/also-fake", anotherHandler) */ +r.GET("/real", listUsers) +`; + const { nodes, references } = goResolver.extract!('main.go', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); + }); + + it('rust: skips // and nested /* */ commented .route() calls', () => { + const src = ` +// .route("/fake", get(fake_handler)) +/* outer /* inner .route("/inner-fake", get(x)) */ still .route("/outer-fake", get(y)) */ +let app = Router::new().route("/real", get(list_users)); +`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['list_users']); + }); + + it('aspnet: skips // and /* */ commented [HttpGet] attributes', () => { + const src = ` +// [HttpGet("/fake")] +// public IActionResult Fake() { return Ok(); } + +/* [HttpPost("/also-fake")] + public IActionResult AlsoFake() { return Ok(); } */ + +[HttpGet("/real")] +public IActionResult ListUsers() { return Ok(); } +`; + const { nodes, references } = aspnetResolver.extract!('UserController.cs', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['ListUsers']); + }); + + it('vapor: skips // and /* */ commented app.METHOD calls', () => { + const src = ` +// app.get("fake", use: fakeHandler) +/* app.post("also-fake", use: anotherHandler) */ +app.get("real", use: listUsers) +`; + const { nodes, references } = vaporResolver.extract!('routes.swift', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); + expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); + }); + + it('nestjs: skips // and /* */ commented decorators', () => { + const src = ` +@Controller('users') +export class UsersController { + // @Get('fake') + // fake() {} + /* @Post('also-fake') + alsoFake() {} */ + @Get('real') + real() {} +} +`; + const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /users/real']); + expect(references.map((r) => r.referenceName)).toEqual(['real']); + }); +}); diff --git a/__tests__/frontload-hook.test.ts b/__tests__/frontload-hook.test.ts new file mode 100644 index 0000000..c8698cf --- /dev/null +++ b/__tests__/frontload-hook.test.ts @@ -0,0 +1,323 @@ +/** + * Front-load hook project resolution (#964). + * + * The Claude `UserPromptSubmit` front-load hook must inject CodeGraph context + * for the RIGHT project — including the monorepo case where the agent's cwd is + * an un-indexed workspace root and the index lives in a sub-project. These test + * `planFrontload` / `findIndexedSubprojectRoots` directly (the hook's decision + * logic), since the end-to-end hook is validated by a live agent run, not a + * unit test. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens } from '../src/directory'; + +/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */ +function mkIndexed(dir: string): string { + fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.codegraph', 'codegraph.db'), ''); + return dir; +} +/** A workspace-root manifest so the down-scan gate (looksLikeProjectRoot) passes. */ +function mkWorkspaceRoot(dir: string): string { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), '{"private":true,"workspaces":["packages/*"]}'); + return dir; +} + +describe('planFrontload — front-load hook project resolution (#964)', () => { + let tmp: string; + beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + it('cwd is itself indexed → front-load cwd (the common single-project case)', () => { + mkIndexed(tmp); + const plan = planFrontload(tmp, 'how does login work'); + expect(plan.exploreRoot).toBe(tmp); + expect(plan.viaSubScan).toBe(false); + expect(plan.nudgeProjects).toEqual([]); + }); + + it('a nested file under an indexed project resolves up to that project', () => { + mkIndexed(tmp); + const nested = path.join(tmp, 'src', 'deep'); + fs.mkdirSync(nested, { recursive: true }); + expect(planFrontload(nested, 'trace the flow').exploreRoot).toBe(tmp); + }); + + it('un-indexed workspace root with ONE indexed sub-project → front-load it (the #964 case)', () => { + mkWorkspaceRoot(tmp); + const api = mkIndexed(path.join(tmp, 'packages', 'api')); + const plan = planFrontload(tmp, 'how does the request get handled'); + expect(plan.exploreRoot).toBe(api); + expect(plan.viaSubScan).toBe(true); + expect(plan.nudgeProjects).toEqual([]); + }); + + it('multiple indexed sub-projects, prompt names one by path → front-load it, nudge the rest', () => { + mkWorkspaceRoot(tmp); + const api = mkIndexed(path.join(tmp, 'packages', 'api')); + const web = mkIndexed(path.join(tmp, 'packages', 'web')); + const plan = planFrontload(tmp, 'in packages/api, how does the handler validate the token?'); + expect(plan.exploreRoot).toBe(api); + expect(plan.viaSubScan).toBe(true); + expect(plan.nudgeProjects).toEqual([web]); + }); + + it('multiple indexed sub-projects, prompt names one by package name → front-load it', () => { + mkWorkspaceRoot(tmp); + mkIndexed(path.join(tmp, 'packages', 'api')); + const web = mkIndexed(path.join(tmp, 'packages', 'web')); + const plan = planFrontload(tmp, 'how does the web frontend render the dashboard?'); + expect(plan.exploreRoot).toBe(web); + }); + + it('multiple indexed sub-projects, NO clear match → nudge the full list, do not guess', () => { + mkWorkspaceRoot(tmp); + const api = mkIndexed(path.join(tmp, 'packages', 'api')); + const web = mkIndexed(path.join(tmp, 'packages', 'web')); + const plan = planFrontload(tmp, 'how does authentication work end to end?'); + expect(plan.exploreRoot).toBeNull(); + expect(plan.viaSubScan).toBe(true); + expect(plan.nudgeProjects.sort()).toEqual([api, web].sort()); + }); + + it('un-indexed dir that is NOT a workspace root → no-op (guards $HOME-style crawls)', () => { + // Indexed project exists below, but cwd has no manifest, so the down-scan is skipped. + mkIndexed(path.join(tmp, 'some', 'project')); + const plan = planFrontload(tmp, 'how does it work'); + expect(plan.exploreRoot).toBeNull(); + expect(plan.nudgeProjects).toEqual([]); + }); + + it('nothing indexed anywhere → no-op', () => { + mkWorkspaceRoot(tmp); + fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true }); + const plan = planFrontload(tmp, 'how does it work'); + expect(plan.exploreRoot).toBeNull(); + expect(plan.nudgeProjects).toEqual([]); + }); +}); + +describe('findIndexedSubprojectRoots', () => { + let tmp: string; + beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-subscan-'))); }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + it('finds indexed projects a couple levels down and skips node_modules/.git', () => { + mkIndexed(path.join(tmp, 'packages', 'api')); + mkIndexed(path.join(tmp, 'services', 'auth')); + // Decoys that must NOT be scanned into. + mkIndexed(path.join(tmp, 'node_modules', 'dep')); + mkIndexed(path.join(tmp, '.git', 'x')); + const found = findIndexedSubprojectRoots(tmp).map((p) => path.relative(tmp, p)).sort(); + expect(found).toEqual([path.join('packages', 'api'), path.join('services', 'auth')].sort()); + }); + + it('does not descend INTO an indexed project (a project\'s sub-dirs are not separate projects)', () => { + const api = mkIndexed(path.join(tmp, 'packages', 'api')); + mkIndexed(path.join(api, 'submodule')); // nested index under an already-indexed project + const found = findIndexedSubprojectRoots(tmp); + expect(found).toEqual([api]); + }); + + it('respects the depth bound', () => { + mkIndexed(path.join(tmp, 'a', 'b', 'c', 'd', 'e', 'deep')); + expect(findIndexedSubprojectRoots(tmp, { maxDepth: 2 })).toEqual([]); + }); +}); + +describe('hasStructuralKeyword — keyword signal fires the hook directly (#994)', () => { + it('English keywords match with word boundaries so "flow" ≠ "flower"', () => { + expect(hasStructuralKeyword('how does article publish work')).toBe(true); + expect(hasStructuralKeyword('where is the token validated')).toBe(true); + expect(hasStructuralKeyword('trace the request flow')).toBe(true); + expect(hasStructuralKeyword('what calls parseToken')).toBe(true); + expect(hasStructuralKeyword('water the flower')).toBe(false); // "flow" in "flower" + }); + + it('Chinese keywords match WITHOUT `\\b` — the #994 fix (were silently dropped)', () => { + expect(hasStructuralKeyword('介绍文章发布流程')).toBe(true); // introduce / flow + expect(hasStructuralKeyword('登录是如何实现的')).toBe(true); // how / implement + expect(hasStructuralKeyword('这个函数的调用链')).toBe(true); // call (chain) + expect(hasStructuralKeyword('支付模块依赖哪些服务')).toBe(true); // depend + expect(hasStructuralKeyword('修复这个拼写错误')).toBe(false); // "fix this typo" + }); + + it('a bare code-token is NOT a keyword — it needs graph verification', () => { + expect(hasStructuralKeyword('看看 get_user 这段逻辑')).toBe(false); + expect(hasStructuralKeyword('I really love JavaScript')).toBe(false); + }); +}); + +describe('hasStructuralKeyword — Latin-script languages, Cyrillic, JA/KO (#1126)', () => { + it('French structural prompts fire — including the prompts from the report', () => { + expect(hasStructuralKeyword('comment marche la state machine des commandes ?')).toBe(true); + expect(hasStructuralKeyword("explique l'architecture du module de stock")).toBe(true); + expect(hasStructuralKeyword('qui appelle cette fonction de parsing ?')).toBe(true); + expect(hasStructuralKeyword('de quoi dépend le module de paiement ?')).toBe(true); + }); + + it('accented keyword edges match — ASCII `\\b` could never bound "où"', () => { + expect(hasStructuralKeyword('où est validé le token ?')).toBe(true); + expect(hasStructuralKeyword("d'où vient cette valeur ?")).toBe(true); + }); + + it('Spanish / Portuguese / German / Italian fire', () => { + expect(hasStructuralKeyword('¿cómo funciona la máquina de estados de pedidos?')).toBe(true); + expect(hasStructuralKeyword('¿qué rompe este cambio?')).toBe(true); + expect(hasStructuralKeyword('como funciona a máquina de estados dos pedidos?')).toBe(true); + expect(hasStructuralKeyword('qual é a arquitetura do módulo de estoque?')).toBe(true); + expect(hasStructuralKeyword('wie funktioniert die Zustandsmaschine für Bestellungen?')).toBe(true); + expect(hasStructuralKeyword('wovon hängt das Zahlungsmodul ab?')).toBe(true); + expect(hasStructuralKeyword('come funziona la macchina a stati degli ordini?')).toBe(true); + expect(hasStructuralKeyword('spiegami la struttura del modulo ordini')).toBe(true); + }); + + it('Russian / Japanese / Korean / traditional Chinese fire', () => { + expect(hasStructuralKeyword('как работает конечный автомат заказов?')).toBe(true); + expect(hasStructuralKeyword('от чего зависит модуль оплаты?')).toBe(true); + expect(hasStructuralKeyword('注文のステートマシンの仕組みを説明して')).toBe(true); + expect(hasStructuralKeyword('この関数の呼び出しの流れは?')).toBe(true); + expect(hasStructuralKeyword('주문 상태 머신은 어떻게 작동하나요?')).toBe(true); + expect(hasStructuralKeyword('訂單狀態機的架構是怎麼實現的?')).toBe(true); + }); + + it('English derived forms fire — "architecture"/"dependencies" failed the old exact-word list', () => { + expect(hasStructuralKeyword('explain the architecture of the stock module')).toBe(true); + expect(hasStructuralKeyword('what are the dependencies of the parser?')).toBe(true); + }); + + it('second-tier languages fire — VI/TR/ID/PL/UA/NL/CS/RO/HU/EL/SV/NO/FI/HI', () => { + expect(hasStructuralKeyword('state machine của đơn hàng hoạt động thế nào?')).toBe(true); // Vietnamese + expect(hasStructuralKeyword('sipariş durum makinesi nasıl çalışıyor?')).toBe(true); // Turkish + expect(hasStructuralKeyword('bu fonksiyonun bağımlılıkları neler?')).toBe(true); // Turkish (stem) + expect(hasStructuralKeyword('bagaimana cara kerja mesin status pesanan?')).toBe(true); // Indonesian + expect(hasStructuralKeyword('jak działa maszyna stanów zamówień?')).toBe(true); // Polish + expect(hasStructuralKeyword('co wywołuje tę funkcję?')).toBe(true); // Polish (stem) + expect(hasStructuralKeyword('як працює кінцевий автомат замовлень?')).toBe(true); // Ukrainian + expect(hasStructuralKeyword('від чого залежить модуль оплати?')).toBe(true); // Ukrainian (stem) + expect(hasStructuralKeyword('hoe werkt de state machine van bestellingen?')).toBe(true); // Dutch + expect(hasStructuralKeyword('jak funguje stavový automat objednávek?')).toBe(true); // Czech + expect(hasStructuralKeyword('cum funcționează mașina de stări a comenzilor?')).toBe(true); // Romanian + expect(hasStructuralKeyword('hogyan működik a rendelések állapotgépe?')).toBe(true); // Hungarian + expect(hasStructuralKeyword('πώς λειτουργεί η μηχανή καταστάσεων παραγγελιών;')).toBe(true); // Greek + expect(hasStructuralKeyword('hur fungerar orderns tillståndsmaskin?')).toBe(true); // Swedish + expect(hasStructuralKeyword('hvordan fungerer ordrenes tilstandsmaskin?')).toBe(true); // Norwegian/Danish + expect(hasStructuralKeyword('miten tilausten tilakone toimii?')).toBe(true); // Finnish + expect(hasStructuralKeyword('ऑर्डर स्टेट मशीन कैसे काम करती है?')).toBe(true); // Hindi + }); + + it('RTL scripts and Thai fire — proclitics/unsegmented text uses substring matching', () => { + expect(hasStructuralKeyword('كيف تعمل آلة حالات الطلبات؟')).toBe(true); // Arabic + expect(hasStructuralKeyword('وكيف يعتمد هذا على قاعدة البيانات؟')).toBe(true); // Arabic, proclitic و attached + expect(hasStructuralKeyword('ماشین وضعیت سفارش‌ها چگونه کار می‌کند؟')).toBe(true); // Farsi + expect(hasStructuralKeyword('איך עובדת מכונת המצבים של ההזמנות?')).toBe(true); // Hebrew + expect(hasStructuralKeyword('สถาปัตยกรรมของระบบทำงานอย่างไร')).toBe(true); // Thai + }); + + it('terms that collide with English or code words are deliberately excluded', () => { + expect(hasStructuralKeyword('pad the buffer with zeros')).toBe(false); // NL pad=path skipped + expect(hasStructuralKeyword('declare a var for the count')).toBe(false); // SV var=where skipped + expect(hasStructuralKeyword('refresh the token')).toBe(false); // CS tok=flow skipped + expect(hasStructuralKeyword('run the llama model locally')).toBe(false); // ES bare llama skipped + expect(hasStructuralKeyword('come back to this later')).toBe(false); // IT bare come skipped + }); + + it('stems match only at word start — no mid-word false positives', () => { + expect(hasStructuralKeyword('restructure this paragraph')).toBe(false); // "structur" mid-word + expect(hasStructuralKeyword('an independent module')).toBe(false); // "depend" mid-word + expect(hasStructuralKeyword('water the flower')).toBe(false); // unchanged guarantee + }); + + it('bounded stems reject ordinary-English completions (#1138)', () => { + expect(hasStructuralKeyword('he has a callus on his palm')).toBe(false); + expect(hasStructuralKeyword('a lovely calligraphy font')).toBe(false); + expect(hasStructuralKeyword('Connecticut is a state')).toBe(false); + expect(hasStructuralKeyword('connective tissue damage')).toBe(false); + expect(hasStructuralKeyword('she is very affectionate')).toBe(false); + expect(hasStructuralKeyword('Tracey went home early')).toBe(false); + }); + + it('bounded stems keep every structural derived form (#1138)', () => { + // call + expect(hasStructuralKeyword('list the callers of parseToken')).toBe(true); + expect(hasStructuralKeyword('what callbacks fire on save')).toBe(true); + expect(hasStructuralKeyword('is submitOrder callable from the worker')).toBe(true); + expect(hasStructuralKeyword('find every call site of dispose')).toBe(true); + expect(hasStructuralKeyword('who called setupRouter')).toBe(true); + // trace ("tracing" is covered by the exact-word list — the e drops) + expect(hasStructuralKeyword('trace the request')).toBe(true); + expect(hasStructuralKeyword('we traced it to the cache layer')).toBe(true); + expect(hasStructuralKeyword('add tracing to the pipeline')).toBe(true); + // affect / connect + expect(hasStructuralKeyword('which modules are affected by this change')).toBe(true); + expect(hasStructuralKeyword('how do the connections get pooled')).toBe(true); + expect(hasStructuralKeyword('the connector registers itself at boot')).toBe(true); + }); + + it('non-structural prose stays a no-op in every covered language', () => { + expect(hasStructuralKeyword('corrige cette faute de frappe')).toBe(false); // FR "fix this typo" + expect(hasStructuralKeyword('arregla este error tipográfico')).toBe(false); // ES + expect(hasStructuralKeyword('behebe diesen Tippfehler')).toBe(false); // DE + expect(hasStructuralKeyword('исправь эту опечатку')).toBe(false); // RU + expect(hasStructuralKeyword('このタイプミスを直して')).toBe(false); // JA + expect(hasStructuralKeyword('이 오타를 수정해줘')).toBe(false); // KO + expect(hasStructuralKeyword('sửa lỗi chính tả này')).toBe(false); // VI + expect(hasStructuralKeyword('bu yazım hatasını düzelt')).toBe(false); // TR + expect(hasStructuralKeyword('popraw tę literówkę')).toBe(false); // PL + expect(hasStructuralKeyword('صحح هذا الخطأ الإملائي')).toBe(false); // AR + }); +}); + +describe('extractCodeTokens — candidate symbols the hook verifies against the graph', () => { + it('pulls camelCase / PascalCase / snake_case / call / member tokens', () => { + expect(extractCodeTokens('prepareArticlePublish 的调用链')).toContain('prepareArticlePublish'); + expect(extractCodeTokens('看看 get_user 这段逻辑')).toContain('get_user'); // snake_case + expect(extractCodeTokens('render() 在哪触发')).toContain('render'); // call form + expect(extractCodeTokens('user.login 做了什么').sort()).toEqual(['login', 'user']); // member access + expect(extractCodeTokens('看看 UserService')).toContain('UserService'); // PascalCase class kept + }); + + it('a tech brand is extracted as a CANDIDATE — the hook’s graph check is what rejects it', () => { + // This is the #994 follow-up: "JavaScript" is identifier-shaped, so it surfaces + // here as a candidate; the hook only fires if it's a real symbol in the index. + expect(extractCodeTokens('I really love JavaScript')).toEqual(['JavaScript']); + expect(extractCodeTokens('thoughts on GitHub vs GitLab').sort()).toEqual(['GitHub', 'GitLab']); + }); + + it('ordinary prose and doc/data filenames yield no tokens', () => { + expect(extractCodeTokens('fix typo in readme')).toEqual([]); + expect(extractCodeTokens('fix the typo in README.md')).toEqual([]); // doc filename excluded + expect(extractCodeTokens('bump the version in package.json')).toEqual([]); + expect(extractCodeTokens('water the flower')).toEqual([]); + }); +}); + +describe('isStructuralPrompt — cheap candidate gate (keyword OR code-token)', () => { + it('fires on a keyword prompt in any language', () => { + expect(isStructuralPrompt('how does article publish work')).toBe(true); + expect(isStructuralPrompt('介绍文章发布流程')).toBe(true); + }); + + it('fires on a code-token prompt with no keyword', () => { + expect(isStructuralPrompt('看看 get_user 这段逻辑')).toBe(true); + expect(isStructuralPrompt('where is prepareArticlePublish 定义')).toBe(true); + expect(isStructuralPrompt('user.login 做了什么')).toBe(true); + }); + + it('a tech brand passes the CHEAP gate as a candidate — the hook then graph-verifies it', () => { + // Layering, not a bug: isStructuralPrompt is shape-only, so a token-shaped brand + // is a candidate here; the hook rejects it as a non-symbol (proven by the CLI e2e). + expect(isStructuralPrompt('I really love JavaScript')).toBe(true); + }); + + it('non-structural prose stays a no-op — in either language', () => { + expect(isStructuralPrompt('fix typo in readme')).toBe(false); + expect(isStructuralPrompt('修复这个拼写错误')).toBe(false); + expect(isStructuralPrompt('water the flower')).toBe(false); + expect(isStructuralPrompt('')).toBe(false); + }); +}); diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts new file mode 100644 index 0000000..993b686 --- /dev/null +++ b/__tests__/function-ref.test.ts @@ -0,0 +1,790 @@ +/** + * Function-as-value capture tests (#756) — registration-linking for callbacks. + * + * A function name used as a VALUE (passed as an argument, assigned to a + * field/function pointer, placed in a struct/object initializer or function + * table) must produce a `references` edge from the registration site to the + * function, so `callers`/`impact` surface where a callback is wired up. + * + * Safety properties verified here, per the dynamic-dispatch discipline + * ("a wrong edge is worse than none"): + * - decoy: an ambiguous cross-file name (no import, ≥2 definitions) → NO edge + * - same-file priority: a same-file definition beats a same-named decoy + * - kind filter: a class/variable passed as a value never gets a + * function-ref edge + * - self: a function passing itself → no self-loop + * - drain: all resolvable function_ref rows leave unresolved_refs (no + * batched-resolver runaway), and re-index is idempotent + */ + +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import type { Edge } from '../src/types'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +/** Incoming edges to `name`'s node that came from function-as-value capture. */ +function fnRefEdgesInto(cg: CodeGraph, name: string): Edge[] { + const targets = cg.getNodesByName(name); + const edges: Edge[] = []; + for (const t of targets) { + for (const e of cg.getIncomingEdges(t.id)) { + if (e.kind === 'references' && e.metadata?.fnRef === true) { + edges.push(e); + } + } + } + return edges; +} + +/** Names of the source nodes of the given edges, sorted. */ +function sourceNames(cg: CodeGraph, edges: Edge[]): string[] { + const names: string[] = []; + for (const e of edges) { + const n = cg.getNode(e.source); + if (n) names.push(n.name); + } + return names.sort(); +} + +describe('Function-as-value capture (#756)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('C: registration sites produce references edges (the #756 scenario)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-c-')); + fs.writeFileSync( + path.join(tmpDir, 'driver.c'), + [ + 'struct ops { void (*recv_cb)(int); void (*send_cb)(int); };', + 'typedef void (*cb_t)(int);', + '', + 'static void my_recv_cb(int x) { (void)x; }', + 'static void my_send_cb(int x) { (void)x; }', + '', + 'void register_handler(void (*cb)(int)) { cb(1); }', + '', + 'void direct_caller(void) { my_recv_cb(5); }', + '', + 'void arg_registrar(void) { register_handler(my_recv_cb); }', + 'void addr_registrar(void) { register_handler(&my_recv_cb); }', + 'void assign_registrar(struct ops *o) { o->recv_cb = my_recv_cb; }', + '', + 'static struct ops global_ops = { .recv_cb = my_recv_cb, .send_cb = my_send_cb };', + 'static cb_t cb_table[] = { my_recv_cb, my_send_cb };', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const intoRecv = fnRefEdgesInto(cg, 'my_recv_cb'); + expect(sourceNames(cg, intoRecv)).toEqual([ + 'addr_registrar', + 'arg_registrar', + 'assign_registrar', + 'driver.c', // file-scope: designated init + positional table (deduped per source) + ]); + + // The direct call is still a `calls` edge — unchanged by this feature. + const recv = cg.getNodesByName('my_recv_cb')[0]!; + const callEdges = cg + .getIncomingEdges(recv.id) + .filter((e) => e.kind === 'calls'); + expect(sourceNames(cg, callEdges)).toEqual(['direct_caller']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('TypeScript: arg / object / array / member / assignment forms', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ts-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'export function targetCb(x: number): void { console.log(x); }', + 'function registerHandler(cb: (x: number) => void): void { cb(1); }', + '', + 'export function argRegistrar(): void { registerHandler(targetCb); }', + 'export function timerRegistrar(): void { setTimeout(targetCb, 100); }', + 'export function objRegistrar(): unknown { return { recv: targetCb }; }', + 'export function arrRegistrar(): unknown { return [targetCb]; }', + '', + 'class Emitter { cb: ((x: number) => void) | null = null; }', + 'export function assignRegistrar(e: Emitter): void { e.cb = targetCb; }', + '', + 'interface Btn { on(ev: string, cb: () => void): void; }', + 'export class Comp {', + ' handleClick(): void {}', + ' wire(btn: Btn): void { btn.on("click", this.handleClick); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'targetCb'))).toEqual([ + 'argRegistrar', + 'arrRegistrar', + 'assignRegistrar', + 'objRegistrar', + 'timerRegistrar', + ]); + // `this.handleClick` resolves class-scoped (#808): the target must be a + // method of the ENCLOSING class, in the same file. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'handleClick'))).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('resolves an imported callback across files via its import', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-import-')); + fs.writeFileSync( + path.join(tmpDir, 'handlers.ts'), + 'export function onMessage(x: number): void { console.log(x); }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'wiring.ts'), + [ + "import { onMessage } from './handlers';", + 'export function wire(bus: { on(cb: (x: number) => void): void }): void {', + ' bus.on(onMessage);', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'onMessage'); + expect(sourceNames(cg, edges)).toContain('wire'); + // The edge must target the handlers.ts definition. + const target = cg.getNode(edges[0]!.target); + expect(target?.filePath.endsWith('handlers.ts')).toBe(true); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('DECOY: ambiguous cross-file name without an import resolves to NO edge', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-decoy-')); + // Two same-named functions in different files… + fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export function process(x: number): void {}\n'); + fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'export function process(x: number): void {}\n'); + // …and a registrar that names `process` WITHOUT importing it. The name + // still passes the extraction gate only if imported/defined here — it is + // neither, so this asserts the gate; even if it leaked through, the + // ambiguity rule (unique-only cross-file) must yield no edge. + fs.writeFileSync( + path.join(tmpDir, 'c.ts'), + 'export function wire(bus: { on(cb: unknown): void }, process: unknown): void { bus.on(process); }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'process'); + expect(sourceNames(cg, edges)).not.toContain('wire'); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SAME-FILE PRIORITY: a same-file definition beats a same-named decoy elsewhere', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-samefile-')); + fs.writeFileSync(path.join(tmpDir, 'decoy.c'), 'void my_cb(int x) { (void)x; }\n'); + fs.writeFileSync( + path.join(tmpDir, 'real.c'), + [ + 'static void my_cb(int x) { (void)x; }', + 'void register_handler(void (*cb)(int)) { cb(1); }', + 'void wire(void) { register_handler(my_cb); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const wires = fnRefEdgesInto(cg, 'my_cb').filter((e) => { + const src = cg.getNode(e.source); + return src?.name === 'wire'; + }); + expect(wires).toHaveLength(1); + const target = cg.getNode(wires[0]!.target); + expect(target?.filePath.endsWith('real.c')).toBe(true); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('KIND FILTER: a class passed as a value gets no function-ref edge', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-kind-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'export class Strategy { run(): void {} }', + 'export function consume(x: unknown): void { void x; }', + 'export function wire(): void { consume(Strategy); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const strategy = cg.getNodesByName('Strategy').find((n) => n.kind === 'class')!; + const fnRef = cg + .getIncomingEdges(strategy.id) + .filter((e) => e.metadata?.fnRef === true); + expect(fnRef).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SELF: a function registering itself produces no self-loop', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-self-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'declare function schedule(cb: () => void): void;', + 'export function retry(): void { schedule(retry); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const retry = cg.getNodesByName('retry')[0]!; + const selfLoops = cg + .getIncomingEdges(retry.id) + .filter((e) => e.source === retry.id && e.metadata?.fnRef === true); + expect(selfLoops).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('C++: &Cls::method member pointers resolve scoped; bare ids are free-function-only', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-cpp-')); + fs.writeFileSync( + path.join(tmpDir, 'widget.cpp'), + [ + 'struct Widget {', + ' void on_click(int x);', + '};', + 'void Widget::on_click(int x) { (void)x; }', + 'struct Decoy {', + ' void on_click(int x);', + '};', + 'void Decoy::on_click(int x) { (void)x; }', + 'void free_cb(int x) { (void)x; }', + 'void bare_fn(int x) { (void)x; }', + 'void reg(void* p) { (void)p; }', + 'void wire() {', + ' auto p = &Widget::on_click;', // qualified — must hit Widget, not Decoy + ' reg(p);', + ' reg(&free_cb);', // explicit address-of — captured + ' reg(bare_fn);', // bare id in args — NOT captured for C++ (addressOfOnly) + '}', + // A method named like a local: passing the LOCAL must not resolve to + // the method (cpp args accept only explicit & forms). + 'struct Buf { char* out(); };', + 'void copy_to(void* out_) { (void)out_; }', + 'void caller(char* out) { copy_to(out); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + // Qualified member pointer resolves to Widget::on_click specifically. + const onClicks = cg.getNodesByName('on_click'); + const widgetOnClick = onClicks.find((n) => n.qualifiedName.includes('Widget'))!; + const decoyOnClick = onClicks.find((n) => n.qualifiedName.includes('Decoy'))!; + const intoWidget = cg + .getIncomingEdges(widgetOnClick.id) + .filter((e) => e.metadata?.fnRef === true); + expect(intoWidget).toHaveLength(1); + expect(cg.getNode(intoWidget[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(decoyOnClick.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + + // Explicit &fn resolves; bare identifier in C++ args does NOT (the + // generic-name collision class: fmt's `begin`/`out`/`size` params). + expect(sourceNames(cg, fnRefEdgesInto(cg, 'free_cb'))).toContain('wire'); + expect(fnRefEdgesInto(cg, 'bare_fn')).toHaveLength(0); + + // The local `out` param must NOT produce an edge to Buf::out. + const outMethod = cg.getNodesByName('out').find((n) => n.kind === 'method'); + if (outMethod) { + expect( + cg.getIncomingEdges(outMethod.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('Pascal: := event wiring, @addr and bare args', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pas-')); + fs.writeFileSync( + path.join(tmpDir, 'main.pas'), + [ + 'unit Main;', + 'interface', + 'type', + ' TCallback = procedure(X: Integer);', + ' THolder = class', + ' public', + ' OnFire: TCallback;', + ' procedure Wire;', + ' end;', + 'procedure TargetCb(X: Integer);', + 'procedure RegisterHandler(Cb: TCallback);', + 'procedure ArgRegistrar;', + 'procedure AddrRegistrar;', + 'implementation', + 'procedure TargetCb(X: Integer);', + 'begin', + ' WriteLn(X);', + 'end;', + 'procedure RegisterHandler(Cb: TCallback);', + 'begin', + ' Cb(1);', + 'end;', + 'procedure ArgRegistrar;', + 'begin', + ' RegisterHandler(TargetCb);', + 'end;', + 'procedure AddrRegistrar;', + 'begin', + ' RegisterHandler(@TargetCb);', + 'end;', + 'procedure THolder.Wire;', + 'begin', + ' OnFire := TargetCb;', + 'end;', + 'end.', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'TargetCb'))).toEqual([ + 'AddrRegistrar', + 'ArgRegistrar', + 'Wire', + ]); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('THIS-MEMBER SCOPING: this.X resolves only to the enclosing class, never elsewhere', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-thisx-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'declare const bus: { on(ev: string, cb: () => void): void };', + // Decoy: a same-named method on an UNRELATED class. + 'export class Decoy { refresh(): void {} }', + 'export class Panel {', + ' views: number[] = [];', // property (post-#808), shares no name + ' refresh(): void {}', + ' wire(): void {', + ' bus.on("update", this.refresh);', // → Panel::refresh, not Decoy::refresh + ' bus.on("data", this.views as never);', // property → NO edge + ' bus.on("gone", this.missing as never);', // unknown member → NO edge + ' }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const refreshes = cg.getNodesByName('refresh'); + const panelRefresh = refreshes.find((n) => n.qualifiedName.includes('Panel'))!; + const decoyRefresh = refreshes.find((n) => n.qualifiedName.includes('Decoy'))!; + + const intoPanel = cg + .getIncomingEdges(panelRefresh.id) + .filter((e) => e.metadata?.fnRef === true); + expect(intoPanel).toHaveLength(1); + expect(cg.getNode(intoPanel[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(decoyRefresh.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + + // The property and the unknown member produce nothing. + const views = cg.getNodesByName('views').find((n) => n.kind === 'property'); + if (views) { + expect( + cg.getIncomingEdges(views.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('INHERITED this.X: resolves on a supertype via the second pass, never on unrelated classes', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-inherit-')); + fs.writeFileSync( + path.join(tmpDir, 'base.ts'), + 'export class FormBase { handleSubmit(): void {} }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'unrelated.ts'), + 'export class Unrelated { handleSubmit(): void {} }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'login.ts'), + [ + "import { FormBase } from './base';", + 'declare const bus: { on(ev: string, cb: () => void): void };', + 'export class LoginForm extends FormBase {', + ' wire(): void { bus.on("submit", this.handleSubmit); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const handleSubmits = cg.getNodesByName('handleSubmit'); + const baseM = handleSubmits.find((n) => n.qualifiedName.includes('FormBase'))!; + const unrelatedM = handleSubmits.find((n) => n.qualifiedName.includes('Unrelated'))!; + + const intoBase = cg.getIncomingEdges(baseM.id).filter((e) => e.metadata?.fnRef === true); + expect(intoBase).toHaveLength(1); + expect(cg.getNode(intoBase[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(unrelatedM.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('JAVA: Type::method cross-file, this::/super:: scoped, variable:: yields nothing', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-java-')); + fs.writeFileSync( + path.join(tmpDir, 'Handlers.java'), + [ + 'package com.example;', + 'public class Handlers {', + ' public static void onMessage(int x) { System.out.println(x); }', + '}', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'BaseForm.java'), + ['package com.example;', 'public class BaseForm {', ' void baseHandler(int x) {}', '}'].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'Main.java'), + [ + 'package com.example;', + 'import com.example.Handlers;', + 'import java.util.function.IntConsumer;', + 'public class Main extends BaseForm {', + ' static void registerHandler(IntConsumer cb) { cb.accept(1); }', + ' void run0() {}', + ' void crossFile() { registerHandler(Handlers::onMessage); }', + ' void thisRef() { registerHandler(this::run0); }', + ' void superRef() { registerHandler(super::baseHandler); }', + ' void varRef(Main m) { registerHandler(m::run0); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'onMessage'))).toEqual(['crossFile']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'baseHandler'))).toEqual(['superRef']); + // this::run0 resolves class-scoped; m::run0 (variable receiver) must NOT + // add a second edge — exactly one source. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'run0'))).toEqual(['thisRef']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('KOTLIN: companion-object refs resolve cross-file without imports; decoy companion untouched', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ktcomp-')); + // Same package, no imports — the Java/Kotlin reality the name gate can't + // see, which is why qualified `Type::member` candidates skip it. + fs.writeFileSync( + path.join(tmpDir, 'Handlers.kt'), + [ + 'class KtHandlers {', + ' companion object {', + ' fun handle(x: Int) {}', + ' }', + '}', + 'class Decoy {', + ' companion object {', + ' fun handle(x: Int) {}', + ' }', + '}', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'Wirer.kt'), + [ + 'fun register(cb: Any) {}', + 'class Wirer {', + ' fun wire() { register(KtHandlers::handle) }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const handles = cg.getNodesByName('handle'); + const target = handles.find((n) => n.qualifiedName.includes('KtHandlers'))!; + const decoy = handles.find((n) => n.qualifiedName.includes('Decoy'))!; + const into = cg.getIncomingEdges(target.id).filter((e) => e.metadata?.fnRef === true); + expect(into).toHaveLength(1); + expect(cg.getNode(into[0]!.source)?.name).toBe('wire'); + expect(cg.getIncomingEdges(decoy.id).filter((e) => e.metadata?.fnRef === true)).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SWIFT SCOPING: bare ids hit only the enclosing type’s methods; top-level bare hits functions only', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-swiftscope-')); + fs.writeFileSync( + path.join(tmpDir, 'main.swift'), + [ + 'func register(_ cb: (Int) -> Void) { cb(1) }', + 'class Monitor {', + ' func report(_ x: Int) {}', + ' func wire() { register(report) }', // implicit self → Monitor::report + '}', + 'class Other {', + // `report` here is a PARAMETER; Monitor::report must not win. + ' func use(report: (Int) -> Void) { register(report) }', + '}', + 'func topLevel() { register(report) }', // no implicit self → no method target + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'report'); + expect(sourceNames(cg, edges)).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-')); + // Handler defined in its own file… + fs.writeFileSync(path.join(tmpDir, 't_string.c'), 'void getCommand(int c) { (void)c; }\n'); + // …and registered in a table in ANOTHER file, with no import mechanism (C). + fs.writeFileSync( + path.join(tmpDir, 'server.c'), + [ + 'struct cmd { const char *name; void (*proc)(int); };', + 'static struct cmd commandTable[] = {', + ' { "get", getCommand },', + '};', + ].join('\n') + ); + // Ambiguity safety: two files define dupCmd; a third table references it → + // NO edge (unique-or-drop). + fs.writeFileSync(path.join(tmpDir, 'dup_a.c'), 'void dupCmd(int c) { (void)c; }\n'); + fs.writeFileSync(path.join(tmpDir, 'dup_b.c'), 'void dupCmd(int c) { (void)c; }\n'); + fs.writeFileSync( + path.join(tmpDir, 'other.c'), + [ + 'struct cmd2 { void (*proc)(int); };', + 'static struct cmd2 otherTable[] = { { dupCmd } };', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + // Cross-file unique handler resolves from the table's file. + const intoGet = fnRefEdgesInto(cg, 'getCommand'); + expect(sourceNames(cg, intoGet)).toEqual(['server.c']); + const target = cg.getNode(intoGet[0]!.target); + expect(target?.filePath.endsWith('t_string.c')).toBe(true); + + // Ambiguous handler resolves to NOTHING — silent beats wrong. + expect(fnRefEdgesInto(cg, 'dupCmd')).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('PHP: HOF string callables, [$this,…] and [Cls::class,…] arrays; non-HOF strings ignored', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-php-')); + fs.writeFileSync( + path.join(tmpDir, 'handlers.php'), + " $b; }\n" + ); + fs.writeFileSync( + path.join(tmpDir, 'main.php'), + [ + ' { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-rubyhooks-')); + fs.writeFileSync( + path.join(tmpDir, 'posts_controller.rb'), + [ + 'class ApplicationController', + ' def authenticate; end', + 'end', + '', + 'class PostsController < ApplicationController', + ' before_action :authenticate', // inherited → ApplicationController + ' after_save :reindex', + ' validates :title, presence: true', // attributes, NOT methods → no edge + ' rescue_from StandardError, with: :render_500', + '', + ' def reindex; end', + ' def render_500; end', + ' def title; end', + 'end', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const auth = fnRefEdgesInto(cg, 'authenticate'); + expect(auth).toHaveLength(1); + expect(cg.getNode(auth[0]!.target)?.qualifiedName).toContain('ApplicationController'); + + expect(fnRefEdgesInto(cg, 'reindex')).toHaveLength(1); + expect(fnRefEdgesInto(cg, 'render_500')).toHaveLength(1); + // `validates :title` names an attribute — the same-named METHOD must + // get no registration edge. + expect(fnRefEdgesInto(cg, 'title')).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-')); + fs.writeFileSync( + path.join(tmpDir, 'main.c'), + [ + 'static void cb_a(int x) { (void)x; }', + 'void reg(void (*cb)(int)) { cb(1); }', + 'void wire(void) { reg(cb_a); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const stats1 = cg.getStats(); + + // No function_ref rows may linger for resolvable names — the batched + // resolver must have drained them (delete keyed on the ORIGINAL stored + // ref; the #760 runaway came from violating that). + const db = (cg as unknown as { db: { prepare(sql: string): { all(): unknown[] } } }).db; + let leftover: unknown[] = []; + try { + leftover = db + .prepare("SELECT * FROM unresolved_refs WHERE reference_kind = 'function_ref'") + .all(); + } catch { + // If internals aren't reachable this guard is covered by the edge + // assertions below. + } + expect(leftover).toHaveLength(0); + + // Re-index: identical node/edge counts (idempotent, no accumulation). + await cg.indexAll(); + const stats2 = cg.getStats(); + expect(stats2.totalNodes).toBe(stats1.totalNodes); + expect(stats2.totalEdges).toBe(stats1.totalEdges); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'cb_a'))).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); +}); diff --git a/__tests__/generated-detection.test.ts b/__tests__/generated-detection.test.ts new file mode 100644 index 0000000..90bbae7 --- /dev/null +++ b/__tests__/generated-detection.test.ts @@ -0,0 +1,47 @@ +/** + * Regression coverage for the generated-file detector that drives + * symbol-disambiguation down-ranking. Locked here because the suffix + * list is a contract: if a future edit drops `.pb.go`, the cosmos-sdk + * trace endpoint regresses to the gRPC stub (see + * `project_go_multi_module_audit` memory + the audit in #N/A). + */ + +import { describe, it, expect } from 'vitest'; +import { isGeneratedFile } from '../src/extraction/generated-detection'; + +describe('isGeneratedFile', () => { + it('classifies Go protobuf / gRPC / pulsar / mock outputs as generated', () => { + expect(isGeneratedFile('api/cosmos/bank/v1beta1/tx_grpc.pb.go')).toBe(true); + expect(isGeneratedFile('x/bank/types/tx.pb.go')).toBe(true); + expect(isGeneratedFile('api/cosmos/bank/v1beta1/tx.pulsar.go')).toBe(true); + // cosmos-sdk uses `_mocks.go`; mockgen's default is `mock_.go`; + // many projects use `_mock.go`. All three are mockgen output. + expect(isGeneratedFile('x/auth/testutil/expected_keepers_mocks.go')).toBe(true); + expect(isGeneratedFile('internal/foo_mock.go')).toBe(true); + expect(isGeneratedFile('mock_keeper.go')).toBe(true); + }); + + it('does not flag the hand-written keeper as generated', () => { + expect(isGeneratedFile('x/bank/keeper/msg_server.go')).toBe(false); + expect(isGeneratedFile('x/bank/keeper/send.go')).toBe(false); + }); + + it('catches common cross-language codegen suffixes', () => { + expect(isGeneratedFile('app/foo.generated.ts')).toBe(true); + expect(isGeneratedFile('app/foo.generated.tsx')).toBe(true); + expect(isGeneratedFile('proto/bar_pb2.py')).toBe(true); + expect(isGeneratedFile('proto/bar_pb2_grpc.py')).toBe(true); + expect(isGeneratedFile('lib/baz.pb.cc')).toBe(true); + expect(isGeneratedFile('lib/baz.pb.h')).toBe(true); + expect(isGeneratedFile('lib/quux.g.dart')).toBe(true); + expect(isGeneratedFile('lib/quux.freezed.dart')).toBe(true); + }); + + it('leaves ordinary source files alone', () => { + expect(isGeneratedFile('src/index.ts')).toBe(false); + expect(isGeneratedFile('src/components/Foo.tsx')).toBe(false); + expect(isGeneratedFile('lib/main.dart')).toBe(false); + expect(isGeneratedFile('cmd/server/main.go')).toBe(false); + expect(isGeneratedFile('app/db.py')).toBe(false); + }); +}); diff --git a/__tests__/gin-middleware-chain.test.ts b/__tests__/gin-middleware-chain.test.ts new file mode 100644 index 0000000..b3f0ae0 --- /dev/null +++ b/__tests__/gin-middleware-chain.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +/** + * End-to-end synthesizer test for the gin middleware chain. + * + * `(*Context).Next` runs the handler chain by slice index + * (`c.handlers[c.index](c)`) — a computed dispatch tree-sitter can't resolve, so + * `callees(Next)` would otherwise dead-end at the `len()` helper. Handlers are + * registered via `.Use(...)` / `.GET("/path", h)`. Verify the synthesizer links + * `Next` → each registered NAMED HandlerFunc, captures the wiring site, and + * skips inline (anonymous) closures. + */ +describe('gin middleware-chain synthesizer', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gin-chain-fixture-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('links Context.Next to handlers registered via Use/GET and skips inline closures', async () => { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module ginapp\n\ngo 1.21\n'); + + // gin-core shape: the dynamic-dispatch chain driver + registration surface. + fs.writeFileSync( + path.join(dir, 'gin.go'), + `package gin + +type HandlerFunc func(*Context) +type HandlersChain []HandlerFunc + +type Context struct { + handlers HandlersChain + index int8 +} + +func (c *Context) Next() { + c.index++ + for c.index < int8(len(c.handlers)) { + c.handlers[c.index](c) + c.index++ + } +} + +type Engine struct { + Handlers HandlersChain +} + +func (e *Engine) Use(middleware ...HandlerFunc) { + e.Handlers = append(e.Handlers, middleware...) +} + +func (e *Engine) GET(path string, handlers ...HandlerFunc) {} +` + ); + + // registration site: named middleware + named route handler + an inline closure. + fs.writeFileSync( + path.join(dir, 'app.go'), + `package gin + +func Logger(c *Context) {} +func Recovery(c *Context) {} +func getUser(c *Context) {} + +func setup() { + e := &Engine{} + e.Use(Logger, Recovery) + e.GET("/users", getUser) + e.GET("/inline", func(c *Context) {}) +} +` + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT s.name source_name, s.kind source_kind, t.name target_name, + json_extract(e.metadata,'$.via') via, + json_extract(e.metadata,'$.registeredAt') registeredAt + FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'gin-middleware-chain'` + ) + .all(); + cg.close?.(); + + // Every edge originates from the chain dispatcher Context.Next. + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r: any) => r.source_name === 'Next' && r.source_kind === 'method')).toBe(true); + + // Exactly the three NAMED handlers are linked — the inline closure (4th + // registration) is anonymous and must be skipped. + const targets = new Set(rows.map((r: any) => r.target_name)); + expect(targets).toEqual(new Set(['Logger', 'Recovery', 'getUser'])); + + // The wiring site (`.Use`/`.GET` call) is surfaced for the agent. + const logger = rows.find((r: any) => r.target_name === 'Logger'); + expect(logger.via).toBe('Logger'); + expect(logger.registeredAt).toMatch(/app\.go:\d+/); + }); +}); diff --git a/__tests__/git-hooks.test.ts b/__tests__/git-hooks.test.ts new file mode 100644 index 0000000..4dfd80e --- /dev/null +++ b/__tests__/git-hooks.test.ts @@ -0,0 +1,129 @@ +/** + * Git Sync Hooks Tests + * + * Covers installing/removing the opt-in commit/merge/checkout hooks that + * keep the index fresh when the live watcher is disabled (issue #199). + * Exercises real git repos in temp dirs — no mocking. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + installGitSyncHook, + removeGitSyncHook, + isSyncHookInstalled, + isGitRepo, + DEFAULT_SYNC_HOOKS, +} from '../src/sync/git-hooks'; + +function gitInit(dir: string): void { + execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' }); +} + +function isExecutable(file: string): boolean { + if (process.platform === 'win32') return true; // mode bits not meaningful + return (fs.statSync(file).mode & 0o111) !== 0; +} + +describe('git sync hooks', () => { + let repo: string; + + beforeEach(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-githooks-')); + }); + + afterEach(() => { + if (fs.existsSync(repo)) fs.rmSync(repo, { recursive: true, force: true }); + }); + + it('installs all default hooks, executable, invoking codegraph sync', () => { + gitInit(repo); + const result = installGitSyncHook(repo); + + expect(result.installed.sort()).toEqual([...DEFAULT_SYNC_HOOKS].sort()); + expect(result.skipped).toBeUndefined(); + + for (const hook of DEFAULT_SYNC_HOOKS) { + const file = path.join(repo, '.git', 'hooks', hook); + expect(fs.existsSync(file)).toBe(true); + const body = fs.readFileSync(file, 'utf8'); + expect(body).toContain('codegraph sync'); + expect(body).toContain('command -v codegraph'); // no-op when not on PATH + expect(isExecutable(file)).toBe(true); + } + expect(isSyncHookInstalled(repo)).toBe(true); + }); + + it('is idempotent — re-install does not duplicate the block', () => { + gitInit(repo); + installGitSyncHook(repo); + installGitSyncHook(repo); + + const body = fs.readFileSync(path.join(repo, '.git', 'hooks', 'post-commit'), 'utf8'); + const occurrences = body.split('# >>> codegraph sync hook >>>').length - 1; + expect(occurrences).toBe(1); + }); + + it('preserves a pre-existing user hook and appends our block', () => { + gitInit(repo); + const file = path.join(repo, '.git', 'hooks', 'post-commit'); + fs.writeFileSync(file, '#!/bin/sh\necho "my custom hook"\n', { mode: 0o755 }); + + installGitSyncHook(repo, ['post-commit']); + + const body = fs.readFileSync(file, 'utf8'); + expect(body).toContain('echo "my custom hook"'); + expect(body).toContain('codegraph sync'); + }); + + it('remove strips our block; deletes a hook that was only ours', () => { + gitInit(repo); + installGitSyncHook(repo, ['post-commit']); + const file = path.join(repo, '.git', 'hooks', 'post-commit'); + expect(fs.existsSync(file)).toBe(true); + + const result = removeGitSyncHook(repo, ['post-commit']); + expect(result.installed).toEqual(['post-commit']); + expect(fs.existsSync(file)).toBe(false); // was ours-only → deleted + expect(isSyncHookInstalled(repo)).toBe(false); + }); + + it('remove keeps user content when the hook is shared', () => { + gitInit(repo); + const file = path.join(repo, '.git', 'hooks', 'post-commit'); + fs.writeFileSync(file, '#!/bin/sh\necho "keep me"\n', { mode: 0o755 }); + installGitSyncHook(repo, ['post-commit']); + + removeGitSyncHook(repo, ['post-commit']); + + expect(fs.existsSync(file)).toBe(true); + const body = fs.readFileSync(file, 'utf8'); + expect(body).toContain('echo "keep me"'); + expect(body).not.toContain('codegraph sync'); + }); + + it('honors core.hooksPath', () => { + gitInit(repo); + const customHooks = path.join(repo, '.husky'); + fs.mkdirSync(customHooks); + execFileSync('git', ['config', 'core.hooksPath', '.husky'], { cwd: repo, stdio: 'ignore' }); + + const result = installGitSyncHook(repo, ['post-commit']); + expect(result.hooksDir).toBe(customHooks); + expect(fs.existsSync(path.join(customHooks, 'post-commit'))).toBe(true); + // The default .git/hooks dir should NOT have received the hook. + expect(fs.existsSync(path.join(repo, '.git', 'hooks', 'post-commit'))).toBe(false); + }); + + it('skips cleanly when not a git repository', () => { + expect(isGitRepo(repo)).toBe(false); + const result = installGitSyncHook(repo); + expect(result.installed).toEqual([]); + expect(result.hooksDir).toBeNull(); + expect(result.skipped).toMatch(/not a git repository/); + expect(isSyncHookInstalled(repo)).toBe(false); + }); +}); diff --git a/__tests__/glyphs.test.ts b/__tests__/glyphs.test.ts new file mode 100644 index 0000000..db41a10 --- /dev/null +++ b/__tests__/glyphs.test.ts @@ -0,0 +1,170 @@ +/** + * Glyph fallback / Unicode-support detection. + * + * Pinned because the matrix is small and the consequence of regression + * is highly visible: shimmer-worker output on Windows mojibakes when + * UTF-8 glyphs are written via `fs.writeSync` (see #168). The detection + * + ASCII fallback is the contract that prevents this. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + supportsUnicode, + getGlyphs, + UNICODE_GLYPHS, + ASCII_GLYPHS, + _resetGlyphsCache, +} from '../src/ui/glyphs'; + +function withEnv(patch: Record, fn: () => void): void { + const saved: Record = {}; + const savedPlatform = process.platform; + for (const key of Object.keys(patch)) { + saved[key] = process.env[key]; + if (patch[key] === undefined) delete process.env[key]; + else process.env[key] = patch[key]; + } + _resetGlyphsCache(); + try { + fn(); + } finally { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + Object.defineProperty(process, 'platform', { value: savedPlatform }); + _resetGlyphsCache(); + } +} + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value }); +} + +describe('supportsUnicode', () => { + let originalPlatform: NodeJS.Platform; + + beforeEach(() => { + originalPlatform = process.platform; + _resetGlyphsCache(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + _resetGlyphsCache(); + }); + + it('returns false on Windows by default (mojibake-prone consoles)', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => { + setPlatform('win32'); + expect(supportsUnicode()).toBe(false); + }); + }); + + it('returns true on macOS by default', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => { + setPlatform('darwin'); + expect(supportsUnicode()).toBe(true); + }); + }); + + it('returns true on Linux by default', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => { + setPlatform('linux'); + expect(supportsUnicode()).toBe(true); + }); + }); + + it('returns false on Linux kernel console (TERM=linux)', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: 'linux' }, () => { + setPlatform('linux'); + expect(supportsUnicode()).toBe(false); + }); + }); + + it('respects CODEGRAPH_UNICODE=1 on Windows (opt-in escape hatch)', () => { + withEnv({ CODEGRAPH_UNICODE: '1', CODEGRAPH_ASCII: undefined }, () => { + setPlatform('win32'); + expect(supportsUnicode()).toBe(true); + }); + }); + + it('respects CODEGRAPH_ASCII=1 on macOS (opt-out escape hatch)', () => { + withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: undefined }, () => { + setPlatform('darwin'); + expect(supportsUnicode()).toBe(false); + }); + }); + + it('CODEGRAPH_ASCII takes precedence over CODEGRAPH_UNICODE', () => { + withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: '1' }, () => { + setPlatform('darwin'); + expect(supportsUnicode()).toBe(false); + }); + }); +}); + +describe('getGlyphs', () => { + let originalPlatform: NodeJS.Platform; + + beforeEach(() => { + originalPlatform = process.platform; + _resetGlyphsCache(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + _resetGlyphsCache(); + }); + + it('returns ASCII glyphs on Windows', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => { + setPlatform('win32'); + const g = getGlyphs(); + expect(g).toBe(ASCII_GLYPHS); + expect(g.ok).toBe('[OK]'); + expect(g.rail).toBe('|'); + expect(g.phaseDone).toBe('*'); + expect(g.dash).toBe('-'); + }); + }); + + it('returns Unicode glyphs on macOS', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => { + setPlatform('darwin'); + const g = getGlyphs(); + expect(g).toBe(UNICODE_GLYPHS); + expect(g.ok).toBe('✓'); + expect(g.rail).toBe('│'); + expect(g.phaseDone).toBe('◆'); + expect(g.dash).toBe('—'); + }); + }); + + it('caches the result so repeated calls return the same object', () => { + withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => { + setPlatform('darwin'); + expect(getGlyphs()).toBe(getGlyphs()); + }); + }); +}); + +describe('Glyph sets', () => { + it('ASCII and Unicode sets cover the same keys', () => { + expect(Object.keys(ASCII_GLYPHS).sort()).toEqual(Object.keys(UNICODE_GLYPHS).sort()); + }); + + it('ASCII glyphs are all 7-bit ASCII', () => { + for (const [key, value] of Object.entries(ASCII_GLYPHS)) { + const flat = Array.isArray(value) ? value.join('') : value; + for (let i = 0; i < flat.length; i++) { + const codepoint = flat.charCodeAt(i); + expect(codepoint, `ASCII_GLYPHS.${key} contains non-ASCII char U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`).toBeLessThan(128); + } + } + }); + + it('ASCII spinner has the same frame count as the Unicode spinner', () => { + expect(ASCII_GLYPHS.spinner.length).toBe(UNICODE_GLYPHS.spinner.length); + }); +}); diff --git a/__tests__/goframe.test.ts b/__tests__/goframe.test.ts new file mode 100644 index 0000000..3d37337 --- /dev/null +++ b/__tests__/goframe.test.ts @@ -0,0 +1,181 @@ +/** + * GoFrame route → controller-method coverage (#747), end to end. + * + * GoFrame binds routes reflectively, so the route declared in a request type's + * `g.Meta` tag has no static edge to the controller method that serves it, and + * the method name is NOT derivable from the request type (`DeptSearchReq` is + * served by `List`). This indexes a fixture through the full pipeline and + * checks: the `g.Meta` tags become route nodes; each route joins to its handler + * by the request type in the method signature (the naming-mismatch case + * included); a response (`mime`-only) `g.Meta` makes no route; a route with no + * handler is left unlinked (silent beats wrong); and the response type never + * produces a spurious edge. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +describe('GoFrame route synthesizer', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'goframe-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + it('joins each g.Meta route to its controller method by the request-type signature', async () => { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/app\n\nrequire github.com/gogf/gf/v2 v2.7.0\n'); + + fs.mkdirSync(path.join(dir, 'api', 'system'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'api', 'system', 'dept.go'), + `package system + +import "github.com/gogf/gf/v2/frame/g" + +type DeptSearchReq struct { + g.Meta \`path:"/dept/list" tags:"Dept" method:"get" summary:"list"\` + DeptName string +} +type DeptSearchRes struct { + g.Meta \`mime:"application/json"\` + List []string +} + +type DeptAddReq struct { + g.Meta \`path:"/dept/add" method:"post"\` + Name string +} +type DeptAddRes struct{} + +// A declared route whose handler does not exist in this codebase. +type OrphanReq struct { + g.Meta \`path:"/orphan" method:"get"\` +} +type OrphanRes struct{} +` + ); + + fs.mkdirSync(path.join(dir, 'internal', 'controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'internal', 'controller', 'dept.go'), + `package controller + +import ( + "context" + + "example.com/app/api/system" +) + +type sysDeptController struct{} + +// NB: method name (List) differs from the request type (DeptSearchReq) — the join +// must be by signature, not name. +func (c *sysDeptController) List(ctx context.Context, req *system.DeptSearchReq) (res *system.DeptSearchRes, err error) { + return helper(ctx) +} + +func (c *sysDeptController) Add(ctx context.Context, req *system.DeptAddReq) (res *system.DeptAddRes, err error) { + return +} + +// Returns the response type but takes no request type — must NOT be linked. +func helper(ctx context.Context) (res *system.DeptSearchRes, err error) { + return +} +` + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + const routes = db.prepare(`SELECT name FROM nodes WHERE kind='route' ORDER BY name`).all(); + const edges = db + .prepare( + `SELECT json_extract(e.metadata,'$.route') route, json_extract(e.metadata,'$.requestType') reqType, + e.kind, t.name target_name, t.kind target_kind + FROM edges e JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'goframe-route' + ORDER BY route` + ) + .all(); + cg.close?.(); + + // Three routes from path-bearing g.Meta; the mime-only response g.Meta makes none. + expect(routes.map((r: any) => r.name)).toEqual(['GET /dept/list', 'GET /orphan', 'POST /dept/add']); + + // Two route→handler edges — the orphan route stays unlinked (silent beats wrong). + expect(edges).toHaveLength(2); + const byRoute = Object.fromEntries(edges.map((e: any) => [e.route, e])); + + // Naming mismatch resolved by signature: GET /dept/list → List. + expect(byRoute['GET /dept/list'].target_name).toBe('List'); + expect(byRoute['GET /dept/list'].reqType).toBe('DeptSearchReq'); + expect(byRoute['POST /dept/add'].target_name).toBe('Add'); + + // It is a dynamic-dispatch `calls` hop to a real method, never to the helper. + expect(edges.every((e: any) => e.kind === 'calls' && e.target_kind === 'method')).toBe(true); + expect(edges.some((e: any) => e.target_name === 'helper')).toBe(false); + expect(byRoute['GET /orphan']).toBeUndefined(); + }); + + it('disambiguates identical bare request types across modules by package qualifier', async () => { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/app\n\nrequire github.com/gogf/gf/v2 v2.7.0\n'); + + // Two modules that BOTH define `type ListReq struct` — the collision a large + // GoFrame app has dozens of. The package qualifier in the handler signature + // (`*cash.ListReq` vs `*order.ListReq`) is the only thing that tells them apart. + for (const mod of ['cash', 'order']) { + fs.mkdirSync(path.join(dir, 'api', mod), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'api', mod, `${mod}.go`), + `package ${mod} + +import "github.com/gogf/gf/v2/frame/g" + +type ListReq struct { + g.Meta \`path:"/${mod}/list" method:"get"\` +} +type ListRes struct{} +` + ); + fs.mkdirSync(path.join(dir, 'internal', 'controller', mod), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'internal', 'controller', mod, `${mod}.go`), + `package ${mod} + +import ( + "context" + + "example.com/app/api/${mod}" +) + +type c${mod} struct{} + +func (c *c${mod}) List(ctx context.Context, req *${mod}.ListReq) (res *${mod}.ListRes, err error) { + return +} +` + ); + } + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT json_extract(e.metadata,'$.route') route, t.file_path handler_file + FROM edges e JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'goframe-route' + ORDER BY route` + ) + .all(); + cg.close?.(); + + expect(rows).toHaveLength(2); + // Each route binds to ITS OWN module's handler, never the other's. + const byRoute = Object.fromEntries(rows.map((r: any) => [r.route, r.handler_file])); + expect(byRoute['GET /cash/list']).toContain('controller/cash/'); + expect(byRoute['GET /order/list']).toContain('controller/order/'); + }); +}); diff --git a/__tests__/grammar-wasm-bytes.test.ts b/__tests__/grammar-wasm-bytes.test.ts new file mode 100644 index 0000000..e2daa27 --- /dev/null +++ b/__tests__/grammar-wasm-bytes.test.ts @@ -0,0 +1,43 @@ +/** + * readGrammarWasmBytes + bytes-based grammar loading (#1231, Phase 2.1). + * + * The orchestrator pre-reads each needed grammar's WASM once on the main + * thread and hands the bytes to every parse worker, so a worker respawn loads + * grammars from memory instead of re-reading them from a (possibly slow) disk. + * These tests pin that the byte reader resolves the same artifacts the loader + * would, and that web-tree-sitter genuinely accepts the bytes. + */ +import { describe, it, expect } from 'vitest'; +import { Parser, Language as WasmLanguage } from 'web-tree-sitter'; +import { readGrammarWasmBytes } from '../src/extraction/grammars'; + +describe('readGrammarWasmBytes', () => { + it('reads bytes for a tree-sitter-wasms grammar and a vendored grammar', async () => { + const bytes = await readGrammarWasmBytes(['typescript', 'lua']); + expect(bytes.typescript).toBeInstanceOf(Uint8Array); // from tree-sitter-wasms + expect(bytes.typescript.byteLength).toBeGreaterThan(10_000); + expect(bytes.lua).toBeInstanceOf(Uint8Array); // vendored under src/extraction/wasm/ + expect(bytes.lua.byteLength).toBeGreaterThan(10_000); + }); + + it('expands delegating languages to the grammars they need (svelte → ts/js)', async () => { + const bytes = await readGrammarWasmBytes(['svelte']); + expect(Object.keys(bytes).sort()).toEqual(['javascript', 'typescript']); + }); + + it('omits languages without a WASM grammar instead of failing', async () => { + const bytes = await readGrammarWasmBytes(['yaml', 'unknown']); + expect(Object.keys(bytes)).toEqual([]); + }); + + it('produces bytes web-tree-sitter can load into a working parser', async () => { + await Parser.init(); + const bytes = await readGrammarWasmBytes(['javascript']); + const language = await WasmLanguage.load(bytes.javascript); + const parser = new Parser(); + parser.setLanguage(language); + const tree = parser.parse('function hello() { return 1; }'); + expect(tree!.rootNode.hasError).toBe(false); + expect(tree!.rootNode.toString()).toContain('function_declaration'); + }); +}); diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts new file mode 100644 index 0000000..5379c97 --- /dev/null +++ b/__tests__/graph.test.ts @@ -0,0 +1,615 @@ +/** + * Graph Query Tests + * + * Tests for graph traversal and query functionality. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { Node, Edge } from '../src/types'; +import { GraphTraverser } from '../src/graph/traversal'; + +describe('Graph Queries', () => { + let testDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + // Create temp directory + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-graph-test-')); + + // Create test files with relationships + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + // Create base class + fs.writeFileSync( + path.join(srcDir, 'base.ts'), + ` +export class BaseClass { + protected value: number; + + constructor(value: number) { + this.value = value; + } + + getValue(): number { + return this.value; + } +} + +export interface Printable { + print(): void; +} +` + ); + + // Create derived class + fs.writeFileSync( + path.join(srcDir, 'derived.ts'), + ` +import { BaseClass, Printable } from './base'; + +export class DerivedClass extends BaseClass implements Printable { + private name: string; + + constructor(value: number, name: string) { + super(value); + this.name = name; + } + + print(): void { + console.log(this.getName(), this.getValue()); + } + + getName(): string { + return this.name; + } +} +` + ); + + // Create utility functions + fs.writeFileSync( + path.join(srcDir, 'utils.ts'), + ` +export function formatValue(value: number): string { + return value.toFixed(2); +} + +export function processValue(value: number): number { + const formatted = formatValue(value); + return parseFloat(formatted); +} + +export function doubleValue(value: number): number { + return value * 2; +} + +// Unused function (dead code) +function unusedHelper(): void { + console.log('never called'); +} +` + ); + + // Create main file that uses everything + fs.writeFileSync( + path.join(srcDir, 'main.ts'), + ` +import { DerivedClass } from './derived'; +import { processValue, doubleValue } from './utils'; + +function main(): void { + const obj = new DerivedClass(10, 'test'); + obj.print(); + + const result = processValue(doubleValue(obj.getValue())); + console.log(result); +} + +export { main }; +` + ); + + // Initialize and index + cg = CodeGraph.initSync(testDir, { + config: { + include: ['src/**/*.ts'], + exclude: [], + }, + }); + + await cg.indexAll(); + cg.resolveReferences(); + }); + + afterEach(() => { + if (cg) { + cg.destroy(); + } + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('traverse()', () => { + it('should traverse graph from a starting node', () => { + const nodes = cg.getNodesByKind('function'); + const mainFunc = nodes.find((n) => n.name === 'main'); + + if (!mainFunc) { + console.log('main function not found, skipping test'); + return; + } + + const subgraph = cg.traverse(mainFunc.id, { + maxDepth: 2, + direction: 'outgoing', + }); + + expect(subgraph.nodes.size).toBeGreaterThan(0); + expect(subgraph.roots).toContain(mainFunc.id); + }); + + it('should respect maxDepth option', () => { + const nodes = cg.getNodesByKind('function'); + const mainFunc = nodes.find((n) => n.name === 'main'); + + if (!mainFunc) { + return; + } + + const shallow = cg.traverse(mainFunc.id, { maxDepth: 1 }); + const deep = cg.traverse(mainFunc.id, { maxDepth: 3 }); + + expect(deep.nodes.size).toBeGreaterThanOrEqual(shallow.nodes.size); + }); + + it('should support incoming direction', () => { + const nodes = cg.getNodesByKind('function'); + const formatValue = nodes.find((n) => n.name === 'formatValue'); + + if (!formatValue) { + return; + } + + const subgraph = cg.traverse(formatValue.id, { + maxDepth: 2, + direction: 'incoming', + }); + + expect(subgraph.nodes.size).toBeGreaterThan(0); + }); + }); + + describe('getContext()', () => { + it('should return context for a node', () => { + const nodes = cg.getNodesByKind('class'); + const derivedClass = nodes.find((n) => n.name === 'DerivedClass'); + + if (!derivedClass) { + console.log('DerivedClass not found, skipping test'); + return; + } + + const context = cg.getContext(derivedClass.id); + + expect(context.focal).toBeDefined(); + expect(context.focal.id).toBe(derivedClass.id); + expect(context.ancestors).toBeDefined(); + expect(context.children).toBeDefined(); + expect(context.incomingRefs).toBeDefined(); + expect(context.outgoingRefs).toBeDefined(); + }); + + it('should throw for non-existent node', () => { + expect(() => cg.getContext('non-existent-id')).toThrow('Node not found'); + }); + }); + + describe('getCallGraph()', () => { + it('should return call graph for a function', () => { + const nodes = cg.getNodesByKind('function'); + const processValue = nodes.find((n) => n.name === 'processValue'); + + if (!processValue) { + console.log('processValue not found, skipping test'); + return; + } + + const callGraph = cg.getCallGraph(processValue.id, 2); + + expect(callGraph.nodes.size).toBeGreaterThan(0); + expect(callGraph.nodes.has(processValue.id)).toBe(true); + }); + }); + + describe('getTypeHierarchy()', () => { + it('should return type hierarchy for a class', () => { + const nodes = cg.getNodesByKind('class'); + const derivedClass = nodes.find((n) => n.name === 'DerivedClass'); + + if (!derivedClass) { + return; + } + + const hierarchy = cg.getTypeHierarchy(derivedClass.id); + + expect(hierarchy.nodes.size).toBeGreaterThan(0); + expect(hierarchy.nodes.has(derivedClass.id)).toBe(true); + }); + + it('should return empty subgraph for non-existent node', () => { + const hierarchy = cg.getTypeHierarchy('non-existent-id'); + + expect(hierarchy.nodes.size).toBe(0); + expect(hierarchy.edges.length).toBe(0); + }); + }); + + describe('findUsages()', () => { + it('should find usages of a symbol', () => { + const nodes = cg.getNodesByKind('class'); + const baseClass = nodes.find((n) => n.name === 'BaseClass'); + + if (!baseClass) { + return; + } + + const usages = cg.findUsages(baseClass.id); + + // Should find at least the extends relationship + expect(usages).toBeDefined(); + expect(Array.isArray(usages)).toBe(true); + }); + }); + + describe('getCallers() and getCallees()', () => { + it('should get callers of a function', () => { + const nodes = cg.getNodesByKind('function'); + const formatValue = nodes.find((n) => n.name === 'formatValue'); + + if (!formatValue) { + return; + } + + const callers = cg.getCallers(formatValue.id); + + // processValue calls formatValue + expect(Array.isArray(callers)).toBe(true); + }); + + it('should get callees of a function', () => { + const nodes = cg.getNodesByKind('function'); + const processValue = nodes.find((n) => n.name === 'processValue'); + + if (!processValue) { + return; + } + + const callees = cg.getCallees(processValue.id); + + expect(Array.isArray(callees)).toBe(true); + }); + + it('treats class instantiation as a caller/callee of the class (#774)', () => { + // main() does `new DerivedClass(10, 'test')`. Constructing a class is + // calling its constructor, so main is a caller of DerivedClass and + // DerivedClass is a callee of main. Before #774 the `instantiates` edge + // was excluded from the caller/callee traversal, so `callers ` + // returned the importing file (or nothing) and missed every + // construction site. + const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass'); + const main = cg.getNodesByKind('function').find((n) => n.name === 'main'); + expect(derived).toBeDefined(); + expect(main).toBeDefined(); + + const callerNames = cg.getCallers(derived!.id).map((c) => c.node.name); + expect(callerNames).toContain('main'); + + const calleeNames = cg.getCallees(main!.id).map((c) => c.node.name); + expect(calleeNames).toContain('DerivedClass'); + }); + }); + + describe('getImpactRadius()', () => { + it('should calculate impact radius', () => { + const nodes = cg.getNodesByKind('function'); + const formatValue = nodes.find((n) => n.name === 'formatValue'); + + if (!formatValue) { + return; + } + + const impact = cg.getImpactRadius(formatValue.id, 3); + + expect(impact.nodes.size).toBeGreaterThan(0); + expect(impact.nodes.has(formatValue.id)).toBe(true); + }); + + it('does not drag in sibling members via the structural contains edge (#536)', () => { + const getName = cg.getNodesByKind('method').find((n) => n.name === 'getName'); + const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass'); + expect(getName).toBeDefined(); + expect(derived).toBeDefined(); + + const impact = cg.getImpactRadius(getName!.id, 3); + // The containing class must NOT be pulled into impact just because it + // *contains* getName — climbing that contains edge would re-expand every + // sibling method and explode impact for a leaf symbol. (#536) + expect(impact.nodes.has(derived!.id)).toBe(false); + }); + }); + + describe('findPath()', () => { + it('should find path between connected nodes', () => { + const stats = cg.getStats(); + + if (stats.nodeCount < 2) { + return; + } + + const functions = cg.getNodesByKind('function'); + if (functions.length < 2) { + return; + } + + // Try to find any path + const processValue = functions.find((n) => n.name === 'processValue'); + const formatValue = functions.find((n) => n.name === 'formatValue'); + + if (processValue && formatValue) { + const path = cg.findPath(processValue.id, formatValue.id); + + // Path might exist or might not depending on edge direction + expect(path === null || Array.isArray(path)).toBe(true); + } + }); + + it('should return null for disconnected nodes', () => { + // Create two nodes that definitely don't have a path + const path = cg.findPath('non-existent-1', 'non-existent-2'); + + expect(path).toBeNull(); + }); + }); + + describe('getAncestors() and getChildren()', () => { + it('should get ancestors of a node', () => { + const methods = cg.getNodesByKind('method'); + const printMethod = methods.find((n) => n.name === 'print'); + + if (!printMethod) { + return; + } + + const ancestors = cg.getAncestors(printMethod.id); + + // Should have class and file as ancestors + expect(Array.isArray(ancestors)).toBe(true); + }); + + it('should get children of a node', () => { + const classes = cg.getNodesByKind('class'); + const derivedClass = classes.find((n) => n.name === 'DerivedClass'); + + if (!derivedClass) { + return; + } + + const children = cg.getChildren(derivedClass.id); + + // Should have methods as children + expect(Array.isArray(children)).toBe(true); + }); + }); + + describe('File dependency analysis', () => { + // Regression: getFileDependents/getFileDependencies used to follow + // ONLY `imports` edges, which in this engine are same-file (a file → its + // own local import declarations). That made both return [] for EVERY file, + // so `codegraph affected` found no dependents on any language/framework. + // They must follow the cross-file symbol graph instead (calls / references + // / instantiates / extends / implements / ...). + it('reports cross-file dependencies via the symbol graph, not just imports', () => { + const deps = cg.getFileDependencies('src/main.ts'); + // main() instantiates DerivedClass (derived.ts) and calls + // processValue/doubleValue (utils.ts) — both are real dependencies. + expect(deps).toContain('src/utils.ts'); + expect(deps).toContain('src/derived.ts'); + }); + + it('reports cross-file dependents via the symbol graph, not just imports', () => { + // utils.ts is used by main.ts (processValue/doubleValue calls); the old + // imports-only implementation returned [] here. + expect(cg.getFileDependents('src/utils.ts')).toContain('src/main.ts'); + }); + + it('counts extends/implements as a dependency edge', () => { + // derived.ts extends BaseClass / implements Printable, both in base.ts. + expect(cg.getFileDependencies('src/derived.ts')).toContain('src/base.ts'); + expect(cg.getFileDependents('src/base.ts')).toContain('src/derived.ts'); + }); + + it('never lists a file as its own dependent or dependency', () => { + for (const f of ['src/main.ts', 'src/utils.ts', 'src/base.ts', 'src/derived.ts']) { + expect(cg.getFileDependents(f)).not.toContain(f); + expect(cg.getFileDependencies(f)).not.toContain(f); + } + }); + }); + + describe('findCircularDependencies()', () => { + it('should detect circular dependencies', () => { + const cycles = cg.findCircularDependencies(); + + // Our test files don't have circular deps + expect(Array.isArray(cycles)).toBe(true); + }); + }); + + describe('findDeadCode()', () => { + it('should find dead code', () => { + const deadCode = cg.findDeadCode(['function']); + + expect(Array.isArray(deadCode)).toBe(true); + + // unusedHelper should be detected + const hasUnused = deadCode.some((n) => n.name === 'unusedHelper'); + // Note: This depends on extraction properly detecting function scope + expect(deadCode.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('getNodeMetrics()', () => { + it('should return metrics for a node', () => { + const functions = cg.getNodesByKind('function'); + const func = functions[0]; + + if (!func) { + return; + } + + const metrics = cg.getNodeMetrics(func.id); + + expect(metrics).toHaveProperty('incomingEdgeCount'); + expect(metrics).toHaveProperty('outgoingEdgeCount'); + expect(metrics).toHaveProperty('callCount'); + expect(metrics).toHaveProperty('callerCount'); + expect(metrics).toHaveProperty('childCount'); + expect(metrics).toHaveProperty('depth'); + + expect(typeof metrics.incomingEdgeCount).toBe('number'); + expect(typeof metrics.outgoingEdgeCount).toBe('number'); + }); + }); +}); + +// ============================================================================= +// Traversal edge-completeness & node-limit regressions (#1086–#1090) +// +// These drive GraphTraverser directly against an in-memory graph (the same +// approach the reporter used), so the exact parallel-edge / high-degree +// topologies can be constructed deterministically without round-tripping +// through extraction. +// ============================================================================= + +/** Minimal Node stub — the traversal code only reads id/kind/name. */ +function tNode(id: string, kind: Node['kind'] = 'function'): Node { + return { + id, + kind, + name: id, + qualifiedName: id, + filePath: `src/${id}.ts`, + language: 'typescript', + startLine: 1, + endLine: 10, + startColumn: 0, + endColumn: 0, + } as unknown as Node; +} + +/** Build a GraphTraverser over a fixed node/edge set, honoring the `kinds` filter. */ +function tGraph(nodes: Node[], edges: Edge[]): GraphTraverser { + const byId = new Map(nodes.map((n) => [n.id, n])); + const q = { + getNodeById: (id: string) => byId.get(id) ?? null, + getNodesByIds: (ids: readonly string[]) => { + const m = new Map(); + for (const id of ids) { + const n = byId.get(id); + if (n) m.set(id, n); + } + return m; + }, + getOutgoingEdges: (source: string, kinds?: string[]) => + edges.filter((e) => e.source === source && (!kinds || kinds.includes(e.kind))), + getIncomingEdges: (target: string, kinds?: string[]) => + edges.filter((e) => e.target === target && (!kinds || kinds.includes(e.kind))), + }; + return new GraphTraverser(q as never); +} + +describe('Traversal edge-completeness & limits (#1086–#1090)', () => { + it('traverseBFS keeps every parallel edge to the same target (#1090)', () => { + // A reaches B via both `calls` and `references` — two distinct edges. + const edges: Edge[] = [ + { source: 'A', target: 'B', kind: 'calls', line: 1 }, + { source: 'A', target: 'B', kind: 'references', line: 2 }, + ]; + const sub = tGraph([tNode('A'), tNode('B')], edges).traverseBFS('A', { direction: 'outgoing' }); + + const ab = sub.edges.filter((e) => e.source === 'A' && e.target === 'B'); + // Pre-fix: only the higher-priority `calls` edge survived; `references` was dropped. + expect(ab.map((e) => e.kind).sort()).toEqual(['calls', 'references']); + expect(sub.nodes.has('B')).toBe(true); + }); + + it('traverseBFS keeps two same-kind edges on different lines (#1090)', () => { + const edges: Edge[] = [ + { source: 'A', target: 'B', kind: 'calls', line: 3 }, + { source: 'A', target: 'B', kind: 'calls', line: 7 }, + ]; + const sub = tGraph([tNode('A'), tNode('B')], edges).traverseBFS('A', { direction: 'outgoing' }); + expect(sub.edges.filter((e) => e.source === 'A' && e.target === 'B')).toHaveLength(2); + }); + + it('traverseBFS does not overshoot opts.limit on a high-degree node (#1087)', () => { + const neighbors = ['B', 'C', 'D', 'E', 'F']; + const nodes = [tNode('A'), ...neighbors.map((n) => tNode(n))]; + const edges: Edge[] = neighbors.map((n) => ({ source: 'A', target: n, kind: 'calls' as const })); + const sub = tGraph(nodes, edges).traverseBFS('A', { limit: 3, direction: 'outgoing' }); + // Pre-fix: all 5 neighbors were added in one pass → 6 nodes despite limit 3. + expect(sub.nodes.size).toBeLessThanOrEqual(3); + }); + + it('traverseDFS does not overshoot opts.limit on a high-degree node (#1088)', () => { + const neighbors = ['B', 'C', 'D', 'E', 'F']; + const nodes = [tNode('A'), ...neighbors.map((n) => tNode(n))]; + const edges: Edge[] = neighbors.map((n) => ({ source: 'A', target: n, kind: 'calls' as const })); + const sub = tGraph(nodes, edges).traverseDFS('A', { limit: 2, direction: 'outgoing' }); + expect(sub.nodes.size).toBeLessThanOrEqual(2); + }); + + it('getCallers returns each caller once when reached via multiple edges (#1086)', () => { + // Y calls X at two sites and also references it — three incoming edges. + const edges: Edge[] = [ + { source: 'Y', target: 'X', kind: 'calls', line: 1 }, + { source: 'Y', target: 'X', kind: 'calls', line: 2 }, + { source: 'Y', target: 'X', kind: 'references', line: 3 }, + ]; + const callers = tGraph([tNode('X'), tNode('Y')], edges).getCallers('X'); // default maxDepth = 1 + // Pre-fix: Y appeared three times (depth guard returned before visited.add). + expect(callers.map((c) => c.node.id)).toEqual(['Y']); + }); + + it('getCallees returns each callee once when reached via multiple edges (#1086)', () => { + const edges: Edge[] = [ + { source: 'X', target: 'Y', kind: 'calls', line: 1 }, + { source: 'X', target: 'Y', kind: 'calls', line: 2 }, + ]; + const callees = tGraph([tNode('X'), tNode('Y')], edges).getCallees('X'); + expect(callees.map((c) => c.node.id)).toEqual(['Y']); + }); + + it('getImpactRadius keeps a direct edge into a node already collected via another path (#1089)', () => { + // Class P contains method M. Q calls both M and P. Reaching M first collects + // Q; the pre-fix `!nodes.has()` gate then dropped the direct Q→P edge. + const nodes = [tNode('P', 'class'), tNode('M', 'method'), tNode('Q')]; + const edges: Edge[] = [ + { source: 'P', target: 'M', kind: 'contains' }, + { source: 'Q', target: 'M', kind: 'calls', line: 1 }, + { source: 'Q', target: 'P', kind: 'calls', line: 2 }, + ]; + const sub = tGraph(nodes, edges).getImpactRadius('P', 2); + + expect(sub.nodes.has('Q')).toBe(true); + expect(sub.edges.some((e) => e.source === 'Q' && e.target === 'M' && e.kind === 'calls')).toBe(true); + // The regression: this direct dependency edge used to vanish. + expect(sub.edges.some((e) => e.source === 'Q' && e.target === 'P' && e.kind === 'calls')).toBe(true); + }); +}); diff --git a/__tests__/identifier-segments.test.ts b/__tests__/identifier-segments.test.ts new file mode 100644 index 0000000..11884bf --- /dev/null +++ b/__tests__/identifier-segments.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { + splitIdentifierSegments, + extractProseCandidates, + normalizeProseWord, + segmentLookupVariants, +} from '../src/search/identifier-segments'; + +describe('splitIdentifierSegments — symbol names → prose words', () => { + it('splits camelCase / PascalCase at humps', () => { + expect(splitIdentifierSegments('OrderStateMachine')).toEqual(['order', 'state', 'machine']); + expect(splitIdentifierSegments('userId')).toEqual(['user', 'id']); + }); + + it('handles acronym runs — HTML stays one segment', () => { + expect(splitIdentifierSegments('parseHTMLDocument')).toEqual(['parse', 'html', 'document']); + expect(splitIdentifierSegments('HTMLParser')).toEqual(['html', 'parser']); + }); + + it('keeps digits glued to their word', () => { + expect(splitIdentifierSegments('base64Encode')).toEqual(['base64', 'encode']); + expect(splitIdentifierSegments('parseHTML5Doc')).toEqual(['parse', 'html5', 'doc']); + }); + + it('splits snake_case, kebab-case, and dotted file names', () => { + expect(splitIdentifierSegments('snake_case_name')).toEqual(['snake', 'case', 'name']); + expect(splitIdentifierSegments('MAX_RETRY_COUNT')).toEqual(['max', 'retry', 'count']); + expect(splitIdentifierSegments('checkout.service.ts')).toEqual(['checkout', 'service', 'ts']); + expect(splitIdentifierSegments('state-machine')).toEqual(['state', 'machine']); + }); + + it('drops sub-minimum and digit-only fragments, dedupes', () => { + expect(splitIdentifierSegments('x')).toEqual([]); + expect(splitIdentifierSegments('42')).toEqual([]); + expect(splitIdentifierSegments('getData_getData')).toEqual(['get', 'data']); + }); +}); + +describe('extractProseCandidates — prompt prose → lookup words', () => { + it('keeps content words, drops short function words, in any Latin language', () => { + expect(extractProseCandidates('comment marche la state machine des commandes ?')).toEqual([ + 'comment', 'marche', 'state', 'machine', 'commandes', + ]); + }); + + it('strips diacritics so loanwords meet ASCII identifier segments', () => { + expect(extractProseCandidates('la résolution des références')).toEqual(['resolution', 'references']); + expect(normalizeProseWord('Übersicht')).toBe('ubersicht'); + }); + + it("splits on apostrophes — l'architecture keeps the noun", () => { + expect(extractProseCandidates("explique l'architecture du module de stock")).toEqual([ + 'explique', 'architecture', 'module', 'stock', + ]); + }); + + it('caps candidates and skips unsegmented-script sentence runs', () => { + const many = Array.from({ length: 25 }, (_, i) => `distinctword${String.fromCharCode(97 + i)}`).join(' '); + expect(extractProseCandidates(many)).toHaveLength(16); + // A no-spaces CJK sentence is one giant run — over the length ceiling, skipped. + expect(extractProseCandidates('請解釋一下這個訂單狀態機的整體運作流程與架構設計方式')).toEqual([]); + // Short CJK runs pass through as candidates — no script filter; the graph + // verification tier rejects them (identifiers are almost never CJK). + expect(extractProseCandidates('修复这个拼写错误')).toEqual(['修复这个拼写错误']); + }); + + it('drops digit-only and sub-4-char words', () => { + expect(extractProseCandidates('fix the bug in v2 at 1234')).toEqual([]); + }); +}); + +describe('segmentLookupVariants — light plural folding', () => { + it('folds trailing s/es so plurals hit singular segments', () => { + expect(segmentLookupVariants('services')).toContain('service'); + expect(segmentLookupVariants('machines')).toContain('machine'); + expect(segmentLookupVariants('classes')).toContain('class'); + }); + + it('bare-s plurals no longer mint a bogus -es sibling (#1145)', () => { + expect(segmentLookupVariants('services')).toEqual(['services', 'service']); + expect(segmentLookupVariants('machines')).toEqual(['machines', 'machine']); + }); + + it('unambiguous sibilant-es plurals no longer mint a bogus -s sibling (#1145)', () => { + expect(segmentLookupVariants('classes')).toEqual(['classes', 'class']); + expect(segmentLookupVariants('hashes')).toEqual(['hashes', 'hash']); + }); + + it('a trailing -ss is a singular, not a plural — no strip (#1145)', () => { + expect(segmentLookupVariants('class')).toEqual(['class']); + expect(segmentLookupVariants('process')).toEqual(['process']); + }); + + it('ambiguous endings emit BOTH candidate keys — a wrong exclusive guess would lose the real match', () => { + expect(segmentLookupVariants('caches')).toEqual(['caches', 'cach', 'cache']); // cache + s + expect(segmentLookupVariants('databases')).toEqual(['databases', 'databas', 'database']); // database + s + }); + + it('never strips a word below the minimum', () => { + expect(segmentLookupVariants('bus')).toEqual(['bus']); + expect(segmentLookupVariants('boxes')).toEqual(['boxes']); // -es strip would go sub-minimum + }); +}); diff --git a/__tests__/include-config.test.ts b/__tests__/include-config.test.ts new file mode 100644 index 0000000..105d1d9 --- /dev/null +++ b/__tests__/include-config.test.ts @@ -0,0 +1,262 @@ +/** + * `codegraph.json` `include` — force first-party source INTO the index even when + * `.gitignore` would drop it. + * + * The whitelist `includeIgnored` never was: that one only revives *embedded git + * repos* inside ignored dirs (#622/#699), so pure source gitignored out of Git + * (the SVN+Git dual-VCS case — committed to SVN, `.gitignore`d so it never lands + * in Git) had no way in. Three layers under test: + * 1. Loader: parse/validate/cache, mirroring the `exclude` loader. + * 2. Behavior: `scanDirectory` adds included paths on BOTH the git + * (`git ls-files`) and non-git (filesystem walk) enumeration paths. + * 3. Scope: `buildScopeIgnore` (the watcher's source of truth) treats an + * included file — and the gitignored dirs leading to it — as not-ignored. + * + * Invariants: an explicit `exclude` still wins; built-in default-ignored dirs + * (`node_modules`, …) are never resurfaced; every loader failure mode degrades + * to the zero-config default (force nothing in), never a throw. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { + loadIncludePatterns, + loadExcludePatterns, + loadExtensionOverrides, + loadIncludeIgnoredPatterns, + clearProjectConfigCache, +} from '../src/project-config'; +import { scanDirectory, buildScopeIgnore } from '../src/extraction'; + +describe('include loader (codegraph.json)', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-include-')); + clearProjectConfigCache(); + }); + afterEach(() => { + clearProjectConfigCache(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + const writeConfig = (obj: unknown) => + fs.writeFileSync( + path.join(dir, 'codegraph.json'), + typeof obj === 'string' ? obj : JSON.stringify(obj) + ); + + it('returns an empty list when there is no codegraph.json (the default)', () => { + expect(loadIncludePatterns(dir)).toEqual([]); + }); + + it('loads a well-formed pattern array', () => { + writeConfig({ include: ['Tools/', 'Local/**'] }); + expect(loadIncludePatterns(dir)).toEqual(['Tools/', 'Local/**']); + }); + + it('trims whitespace and drops blank / non-string entries', () => { + writeConfig({ include: [' Tools/ ', '', ' ', 42, null, 'Local/'] }); + expect(loadIncludePatterns(dir)).toEqual(['Tools/', 'Local/']); + }); + + it('ignores a non-array include value without throwing', () => { + writeConfig({ include: 'Tools/' }); + expect(loadIncludePatterns(dir)).toEqual([]); + }); + + it('ignores malformed JSON without throwing', () => { + writeConfig('{ not: valid json '); + expect(loadIncludePatterns(dir)).toEqual([]); + }); + + it('coexists with extensions / includeIgnored / exclude in one file (shared single parse)', () => { + writeConfig({ + extensions: { '.foo': 'typescript' }, + includeIgnored: ['pkgs/'], + exclude: ['static/'], + include: ['Tools/'], + }); + expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript' }); + expect(loadIncludeIgnoredPatterns(dir)).toEqual(['pkgs/']); + expect(loadExcludePatterns(dir)).toEqual(['static/']); + expect(loadIncludePatterns(dir)).toEqual(['Tools/']); + }); + + it('picks up a changed config (mtime-invalidated cache)', () => { + writeConfig({ include: ['Tools/'] }); + expect(loadIncludePatterns(dir)).toEqual(['Tools/']); + + writeConfig({ include: ['Local/'] }); + const future = new Date(Date.now() + 2000); + fs.utimesSync(path.join(dir, 'codegraph.json'), future, future); + + expect(loadIncludePatterns(dir)).toEqual(['Local/']); + }); + + it('drops the patterns again when the config file is removed', () => { + writeConfig({ include: ['Tools/'] }); + expect(loadIncludePatterns(dir)).toEqual(['Tools/']); + fs.rmSync(path.join(dir, 'codegraph.json')); + expect(loadIncludePatterns(dir)).toEqual([]); + }); +}); + +describe('include behavior — scanDirectory force-indexes gitignored source', () => { + let dir: string; + const mk = (rel: string, content = 'export const x = 1;\n') => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content); + }; + const writeConfig = (obj: unknown) => + fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify(obj)); + const scan = () => scanDirectory(dir).map((f) => f.replace(/\\/g, '/')); + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-include-scan-')); + clearProjectConfigCache(); + }); + afterEach(() => { + clearProjectConfigCache(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const gitInit = () => { + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'x'], { cwd: dir }); + }; + + it('indexes a .gitignored source dir when include opts it in (git path) — the core fix', () => { + mk('app/main.ts'); + mk('Tools/gen.py', 'def gen():\n return 1\n'); + fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n'); // SVN-only source, kept out of Git + gitInit(); // Tools/ is gitignored → NOT tracked + + // Sanity: without include the gitignored source is invisible. + let files = scan(); + expect(files).toContain('app/main.ts'); + expect(files.some((f) => f.startsWith('Tools/'))).toBe(false); + + // With include the gitignored source is forced in, app code still there. + writeConfig({ include: ['Tools/'] }); + clearProjectConfigCache(); + files = scan(); + expect(files).toContain('app/main.ts'); + expect(files).toContain('Tools/gen.py'); + }); + + it('forces gitignored source in on the non-git filesystem-walk path too', () => { + mk('app/main.ts'); + mk('Tools/gen.py', 'def gen():\n return 1\n'); + fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n'); + // No git init → scanDirectory falls back to the filesystem walk (which still + // honours .gitignore), so Tools/ must be re-added by include. + writeConfig({ include: ['Tools/'] }); + clearProjectConfigCache(); + const files = scan(); + expect(files).toContain('app/main.ts'); + expect(files).toContain('Tools/gen.py'); + }); + + it('supports a recursive ** glob and nested dirs', () => { + mk('src/a.ts'); + mk('Local/ts/a.ts'); + mk('Local/ts/nested/b.ts'); + fs.writeFileSync(path.join(dir, '.gitignore'), 'Local/\n'); + gitInit(); + writeConfig({ include: ['Local/**'] }); + clearProjectConfigCache(); + const files = scan(); + expect(files).toContain('Local/ts/a.ts'); + expect(files).toContain('Local/ts/nested/b.ts'); + }); + + it('lets an explicit exclude win over include', () => { + mk('Tools/keep.py', 'def k():\n return 1\n'); + mk('Tools/secret/drop.py', 'def d():\n return 1\n'); + fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n'); + gitInit(); + writeConfig({ include: ['Tools/'], exclude: ['Tools/secret/'] }); + clearProjectConfigCache(); + const files = scan(); + expect(files).toContain('Tools/keep.py'); + expect(files.some((f) => f.startsWith('Tools/secret/'))).toBe(false); + }); + + it('prunes an explicitly-excluded subtree under an included dir (a frontend own deps stay out)', () => { + // The real-world case: an SVN-committed frontend is force-included, but its + // own vendored deps live in a NON-default-named dir (`third_party/`) the + // built-in ignore list does not cover, so it is excluded explicitly. The + // whole subtree - nested files and all - must stay out, while sibling source + // stays in. + mk('Local/frontend/src/app.ts'); + mk('Local/frontend/src/util.ts'); + mk('Local/frontend/third_party/lib/a.ts'); + mk('Local/frontend/third_party/lib/nested/b.ts'); + fs.writeFileSync(path.join(dir, '.gitignore'), 'Local/\n'); + gitInit(); + writeConfig({ include: ['Local/frontend/'], exclude: ['Local/frontend/third_party/'] }); + clearProjectConfigCache(); + const files = scan(); + expect(files).toContain('Local/frontend/src/app.ts'); + expect(files).toContain('Local/frontend/src/util.ts'); + expect(files.some((f) => f.startsWith('Local/frontend/third_party/'))).toBe(false); + }); + + it('never resurrects a built-in default-ignored dir (node_modules) via include', () => { + mk('src/a.ts'); + mk('node_modules/pkg/index.js'); + gitInit(); + // Even explicitly opting node_modules in must not pull it into the graph. + writeConfig({ include: ['node_modules/'] }); + clearProjectConfigCache(); + const files = scan(); + expect(files).toContain('src/a.ts'); + expect(files.some((f) => f.startsWith('node_modules/'))).toBe(false); + }); + + it('is a no-op with no include config (gitignored source stays out)', () => { + mk('app/main.ts'); + mk('Tools/gen.py', 'def gen():\n return 1\n'); + fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n'); + gitInit(); + const files = scan(); + expect(files).toContain('app/main.ts'); + expect(files.some((f) => f.startsWith('Tools/'))).toBe(false); + }); +}); + +describe('include scope — buildScopeIgnore keeps included paths watchable', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-include-scope-')); + clearProjectConfigCache(); + execFileSync('git', ['init', '-q'], { cwd: dir }); + fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\nOther/\n'); + fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({ include: ['Tools/'] })); + }); + afterEach(() => { + clearProjectConfigCache(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('does not ignore an included file, nor the gitignored dir leading to it', () => { + const scope = buildScopeIgnore(dir); + // The included file and its (gitignored) directory are watchable. + expect(scope.ignores('Tools/gen.py')).toBe(false); + expect(scope.ignores('Tools/')).toBe(false); + // A different gitignored dir that was NOT opted in stays ignored. + expect(scope.ignores('Other/')).toBe(true); + expect(scope.ignores('Other/x.py')).toBe(true); + }); + + it('still ignores everything when no include is configured', () => { + fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({})); + clearProjectConfigCache(); + const scope = buildScopeIgnore(dir); + expect(scope.ignores('Tools/gen.py')).toBe(true); + expect(scope.ignores('Tools/')).toBe(true); + }); +}); diff --git a/__tests__/include-ignored-config.test.ts b/__tests__/include-ignored-config.test.ts new file mode 100644 index 0000000..b632709 --- /dev/null +++ b/__tests__/include-ignored-config.test.ts @@ -0,0 +1,144 @@ +/** + * `codegraph.json` `includeIgnored` loader (#970, #976 / #622, #699). + * + * Parsing, validation, and mtime-caching of the opt-in patterns that re-include + * gitignored directories for embedded-repo discovery. The behavioral end of this + * feature (scanDirectory / discoverEmbeddedRepoRoots / sync honoring the patterns) + * lives in `multi-repo-workspace.test.ts`; these are the loader unit tests, + * mirroring the `extensions` loader coverage in `extension-mapping.test.ts`. + * + * Invariant under test: every failure mode degrades to the zero-config default + * (empty patterns → `.gitignore` fully respected), never a throw. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { loadIncludeIgnoredPatterns, loadExtensionOverrides, clearProjectConfigCache, addIncludeIgnoredPatterns } from '../src/project-config'; + +describe('includeIgnored loader (codegraph.json)', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-includeignored-')); + clearProjectConfigCache(); + }); + afterEach(() => { + clearProjectConfigCache(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + const writeConfig = (obj: unknown) => + fs.writeFileSync( + path.join(dir, 'codegraph.json'), + typeof obj === 'string' ? obj : JSON.stringify(obj) + ); + + it('returns an empty list when there is no codegraph.json (the default)', () => { + expect(loadIncludeIgnoredPatterns(dir)).toEqual([]); + }); + + it('loads a well-formed pattern array', () => { + writeConfig({ includeIgnored: ['packages/', 'services/'] }); + expect(loadIncludeIgnoredPatterns(dir)).toEqual(['packages/', 'services/']); + }); + + it('trims whitespace and drops blank / non-string entries', () => { + writeConfig({ includeIgnored: [' packages/ ', '', ' ', 42, null, 'services/'] }); + expect(loadIncludeIgnoredPatterns(dir)).toEqual(['packages/', 'services/']); + }); + + it('ignores a non-array includeIgnored value without throwing', () => { + writeConfig({ includeIgnored: 'packages/' }); + expect(loadIncludeIgnoredPatterns(dir)).toEqual([]); + }); + + it('ignores malformed JSON without throwing', () => { + writeConfig('{ not: valid json '); + expect(loadIncludeIgnoredPatterns(dir)).toEqual([]); + }); + + it('returns [] when the field is absent but other config is present', () => { + writeConfig({ extensions: { '.foo': 'typescript' } }); + expect(loadIncludeIgnoredPatterns(dir)).toEqual([]); + }); + + it('coexists with extensions in one file (shared single parse)', () => { + writeConfig({ extensions: { '.foo': 'typescript' }, includeIgnored: ['vendor/'] }); + expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript' }); + expect(loadIncludeIgnoredPatterns(dir)).toEqual(['vendor/']); + }); + + it('picks up a changed config (mtime-invalidated cache)', () => { + writeConfig({ includeIgnored: ['packages/'] }); + expect(loadIncludeIgnoredPatterns(dir)).toEqual(['packages/']); + + writeConfig({ includeIgnored: ['services/'] }); + // Force a distinct mtime in case the filesystem clock is coarse. + const future = new Date(Date.now() + 2000); + fs.utimesSync(path.join(dir, 'codegraph.json'), future, future); + + expect(loadIncludeIgnoredPatterns(dir)).toEqual(['services/']); + }); + + it('drops the patterns again when the config file is removed', () => { + writeConfig({ includeIgnored: ['packages/'] }); + expect(loadIncludeIgnoredPatterns(dir)).toEqual(['packages/']); + + fs.rmSync(path.join(dir, 'codegraph.json')); + expect(loadIncludeIgnoredPatterns(dir)).toEqual([]); + }); +}); + +describe('addIncludeIgnoredPatterns (codegraph.json writer, #1156)', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-addincludeignored-')); + clearProjectConfigCache(); + }); + afterEach(() => { + clearProjectConfigCache(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + const readConfig = () => JSON.parse(fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf-8')); + + it('creates codegraph.json when none exists', () => { + expect(addIncludeIgnoredPatterns(dir, ['mtc-a/', 'mtc-b/'])).toBe(2); + expect(loadIncludeIgnoredPatterns(dir)).toEqual(['mtc-a/', 'mtc-b/']); + }); + + it('merges into an existing list, preserving other keys and de-duping', () => { + fs.writeFileSync( + path.join(dir, 'codegraph.json'), + JSON.stringify({ extensions: { '.foo': 'typescript' }, includeIgnored: ['mtc-a/'] }), + ); + expect(addIncludeIgnoredPatterns(dir, ['mtc-a/', 'mtc-b/'])).toBe(1); // only mtc-b/ is new + const parsed = readConfig(); + expect(parsed.includeIgnored).toEqual(['mtc-a/', 'mtc-b/']); + expect(parsed.extensions).toEqual({ '.foo': 'typescript' }); // untouched + }); + + it('is idempotent — re-adding the same patterns adds nothing', () => { + addIncludeIgnoredPatterns(dir, ['mtc-a/']); + expect(addIncludeIgnoredPatterns(dir, ['mtc-a/'])).toBe(0); + expect(loadIncludeIgnoredPatterns(dir)).toEqual(['mtc-a/']); + }); + + it('replaces a non-array includeIgnored value rather than crashing', () => { + fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({ includeIgnored: 'oops' })); + expect(addIncludeIgnoredPatterns(dir, ['mtc-a/'])).toBe(1); + expect(loadIncludeIgnoredPatterns(dir)).toEqual(['mtc-a/']); + }); + + it('refuses to clobber a malformed existing codegraph.json (throws, leaves file intact)', () => { + const bad = '{ not: valid json '; + fs.writeFileSync(path.join(dir, 'codegraph.json'), bad); + expect(() => addIncludeIgnoredPatterns(dir, ['mtc-a/'])).toThrow(); + expect(fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf-8')).toBe(bad); + }); + + it('writes pretty-printed, newline-terminated JSON', () => { + addIncludeIgnoredPatterns(dir, ['mtc-a/']); + const raw = fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf-8'); + expect(raw.endsWith('\n')).toBe(true); + expect(raw).toContain('\n "includeIgnored"'); // 2-space indent + }); +}); diff --git a/__tests__/index-command.test.ts b/__tests__/index-command.test.ts new file mode 100644 index 0000000..47b2aec --- /dev/null +++ b/__tests__/index-command.test.ts @@ -0,0 +1,207 @@ +/** + * Regression coverage for issue #874: `codegraph index` produced 0 nodes / 0 + * edges while `codegraph init` worked, and appeared to wipe the graph. + * + * Root cause: `index` ran a full extraction against the already-populated DB + * without clearing it first. Every file's content hash still matched, so the + * orchestrator skipped re-inserting all of them, and the run reported its delta + * (after - before = 0) as "0 nodes, 0 edges". The fix makes `index` a true full + * rebuild — clear, then re-index — so it produces the same complete result as a + * fresh `init`. + * + * Exercised end-to-end against the built binary so the CLI wiring (not just the + * library) is covered. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { DatabaseConnection } from '../src/db'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +/** Normalize a PRAGMA read across return shapes (array | object | scalar). */ +function pragmaValue(raw: unknown, key: string): unknown { + const row = Array.isArray(raw) ? raw[0] : raw; + if (row !== null && typeof row === 'object') return (row as Record)[key]; + return row; +} + +function runCodegraph(args: string[], cwd: string): string { + return execFileSync(process.execPath, [BIN, ...args], { + cwd, + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function graphCounts(dir: string): { nodes: number; edges: number } { + const cg = CodeGraph.openSync(dir); + try { + const stats = cg.getStats(); + return { nodes: stats.nodeCount, edges: stats.edgeCount }; + } finally { + cg.close(); + } +} + +describe('codegraph index — full re-index keeps the graph populated (#874)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-cmd-')); + // A couple of files with a call edge so there is a non-trivial graph to + // (fail to) reproduce. + fs.writeFileSync( + path.join(tempDir, 'a.ts'), + `export function greet(name: string) { return hello(name); }\n` + + `export function hello(n: string) { return 'hi ' + n; }\n`, + ); + fs.writeFileSync( + path.join(tempDir, 'b.ts'), + `import { greet } from './a';\nexport function main() { return greet('world'); }\n`, + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('reproduces init\'s node/edge counts instead of emptying the index', () => { + runCodegraph(['init'], tempDir); + const afterInit = graphCounts(tempDir); + expect(afterInit.nodes).toBeGreaterThan(0); + expect(afterInit.edges).toBeGreaterThan(0); + + const out = runCodegraph(['index'], tempDir); + const afterIndex = graphCounts(tempDir); + + // The graph is still fully populated — `index` rebuilt it, it did not wipe it. + expect(afterIndex.nodes).toBe(afterInit.nodes); + expect(afterIndex.edges).toBe(afterInit.edges); + + // ...and the CLI reported the real counts, never the misleading "0 nodes". + expect(out).not.toMatch(/\b0 nodes, 0 edges\b/); + expect(out).toMatch(new RegExp(`\\b${afterInit.nodes} nodes\\b`)); + }); + + it('is idempotent: a second index does not grow the graph', () => { + runCodegraph(['init'], tempDir); + runCodegraph(['index'], tempDir); + const first = graphCounts(tempDir); + runCodegraph(['index'], tempDir); + const second = graphCounts(tempDir); + + // A clean rebuild each time — no duplicate (re-resolved) edges accumulating + // across runs (the C# "+18 edges" symptom in the report). + expect(second.nodes).toBe(first.nodes); + expect(second.edges).toBe(first.edges); + }); + + it('--quiet path also rebuilds a populated graph', () => { + runCodegraph(['init'], tempDir); + const afterInit = graphCounts(tempDir); + + runCodegraph(['index', '--quiet'], tempDir); + const afterIndex = graphCounts(tempDir); + + expect(afterIndex.nodes).toBe(afterInit.nodes); + expect(afterIndex.edges).toBe(afterInit.edges); + }); +}); + +/** + * Regression coverage for issue #1067: a full re-index must RECOVER an existing + * oversized/stale index from earlier versions, not wedge on it. + * + * Root cause: `index` opened the old database and DELETE-d every row to clear + * it. With FTS triggers firing per deleted node, a pre-fix poisoned graph (an + * ignored gitlink corpus scanned into ~1.6M nodes + a multi-GB WAL, #1065) took + * well over the 60s liveness-watchdog window to clear, so the process was + * SIGKILLed before scanning even began and the bad state could never be rebuilt + * away. The fix discards (unlinks) the database files and re-initializes a fresh + * one — O(1) regardless of size — so `index` recovers any prior state. + */ +describe('codegraph index — recovers a stale/oversized prior index (#1067)', () => { + let tempDir: string; + const dbPath = (dir: string) => path.join(dir, '.codegraph', 'codegraph.db'); + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-recover-')); + fs.writeFileSync( + path.join(tempDir, 'a.ts'), + `export function greet(name: string) { return hello(name); }\n` + + `export function hello(n: string) { return 'hi ' + n; }\n`, + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('rebuilds to the current disk state, discarding content for files that no longer exist', () => { + // Stand in for the "old graph indexed an ignored corpus" shape: index a tree + // that also has a junk/ directory, then delete junk/ from disk so the DB now + // carries stale nodes for paths that should no longer be indexed. + const junkDir = path.join(tempDir, 'junk'); + fs.mkdirSync(junkDir); + for (let i = 0; i < 12; i++) { + fs.writeFileSync(path.join(junkDir, `j${i}.ts`), `export function j${i}() { return ${i}; }\n`); + } + runCodegraph(['init'], tempDir); + const withJunk = graphCounts(tempDir); + + // Remove the corpus from disk. The DB still holds its nodes — the stale, + // oversized prior state #1067 is about. + fs.rmSync(junkDir, { recursive: true, force: true }); + + runCodegraph(['index'], tempDir); + const recovered = graphCounts(tempDir); + + // The rebuild reflects only what's on disk now — the junk nodes are gone… + expect(recovered.nodes).toBeLessThan(withJunk.nodes); + + // …and the result is identical to a fresh init of the same (now-smaller) tree. + const fresh = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-fresh-')); + try { + fs.copyFileSync(path.join(tempDir, 'a.ts'), path.join(fresh, 'a.ts')); + runCodegraph(['init'], fresh); + const freshCounts = graphCounts(fresh); + expect(recovered.nodes).toBe(freshCounts.nodes); + expect(recovered.edges).toBe(freshCounts.edges); + } finally { + fs.rmSync(fresh, { recursive: true, force: true }); + } + }); + + // The fix rebuilds a fresh DB rather than DELETE-ing rows in place. Prove it + // with a header sentinel: PRAGMA user_version survives an in-place clear but + // not a from-scratch recreate. (An inode check is unreliable — ext4/overlayfs + // recycle the inode number after unlink+recreate.) + it('rebuilds a fresh database rather than clearing the old one in place', () => { + runCodegraph(['init'], tempDir); + + const stamp = DatabaseConnection.open(dbPath(tempDir)); + stamp.getDb().pragma('user_version = 4242'); + stamp.close(); + + runCodegraph(['index'], tempDir); + + const check = DatabaseConnection.open(dbPath(tempDir)); + const userVersion = pragmaValue(check.getDb().pragma('user_version'), 'user_version'); + check.close(); + + // Sentinel gone → `index` discarded the old DB and rebuilt it, the path that + // avoids the per-row FTS delete wedge on a poisoned graph (#1067). + expect(Number(userVersion)).not.toBe(4242); + + // …and the graph is intact afterwards. + const counts = graphCounts(tempDir); + expect(counts.nodes).toBeGreaterThan(0); + expect(counts.edges).toBeGreaterThan(0); + }); +}); diff --git a/__tests__/index-orphan-watchdog.test.ts b/__tests__/index-orphan-watchdog.test.ts new file mode 100644 index 0000000..8646fc4 --- /dev/null +++ b/__tests__/index-orphan-watchdog.test.ts @@ -0,0 +1,120 @@ +/** + * `index` / `init` command supervision regression test (#999, secondary issues). + * + * `codegraph index` runs in a child re-exec'd with `--liftoff-only` whose parent + * blocks in `spawnSync` and so cannot forward a signal — when the parent shim is + * killed the indexer used to keep running, orphaned, pinning a CPU core. The + * `#850` liveness watchdog and `#277` ppid watchdog were also wired only into + * `serve`, never `index`/`init`. `installCommandSupervision` (src/bin/ + * command-supervision.ts) closes both gaps; this proves the orphan half end to + * end: a process running it self-terminates once its parent dies. + * + * Windows is excluded — `process.kill(pid, 'SIGKILL')` doesn't deliver SIGKILL + * there and the reparenting semantics the ppid watchdog relies on are POSIX-only + * (same exclusion as mcp-ppid-watchdog.test.ts). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const SUPERVISION = path.resolve(__dirname, '../dist/bin/command-supervision.js'); + +function isAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +function waitForExit(pid: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const start = Date.now(); + const tick = () => { + if (!isAlive(pid)) return resolve(true); + if (Date.now() - start > timeoutMs) return resolve(false); + setTimeout(tick, 100); + }; + tick(); + }); +} + +describe.skipIf(process.platform === 'win32')('index/init orphan supervision (#999)', () => { + let wrapper: ChildProcessWithoutNullStreams | null = null; + let childPid: number | null = null; + + afterEach(() => { + if (wrapper && !wrapper.killed) { + try { wrapper.kill('SIGKILL'); } catch { /* already gone */ } + } + if (childPid !== null && isAlive(childPid)) { + try { process.kill(childPid, 'SIGKILL'); } catch { /* already gone */ } + } + wrapper = null; + childPid = null; + }); + + it("self-terminates when its parent is SIGKILL'd mid-index", async () => { + const stderrLog = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'cg-index-orphan-')), + 'child.stderr.log', + ); + // The child stands in for a running indexer: it installs the SAME command + // supervision `index`/`init` install, then idles on a ref'd timer so it + // stays alive until the watchdog (not the timer) takes it down. + // CODEGRAPH_NO_WATCHDOG=1 isolates the ppid (orphan) path from the liveness + // child; CODEGRAPH_PPID_POLL_MS=200 keeps it responsive in test. + const childSrc = ` + const { installCommandSupervision } = require(${JSON.stringify(SUPERVISION)}); + installCommandSupervision('index'); + process.stdout.write('UP ' + process.pid + '\\n'); + setInterval(() => {}, 60000); + `; + // The wrapper spawns the child detached (so it's reparented to init when the + // wrapper dies, not killed with it), waits for it to report its pid + install + // the watchdog, relays the pid, then idles until SIGKILL'd. + const wrapperSrc = ` + const { spawn } = require('child_process'); + const fs = require('fs'); + const errFd = fs.openSync(${JSON.stringify(stderrLog)}, 'a'); + const child = spawn(process.execPath, ['-e', ${JSON.stringify(childSrc)}], { + stdio: ['ignore', 'pipe', errFd], + env: { ...process.env, CODEGRAPH_NO_WATCHDOG: '1', CODEGRAPH_PPID_POLL_MS: '200', CODEGRAPH_WASM_RELAUNCHED: '1' }, + detached: true, + }); + child.unref(); + child.stdout.on('data', (d) => { + const m = /UP (\\d+)/.exec(d.toString()); + if (m) process.stdout.write(JSON.stringify({ pid: Number(m[1]) }) + '\\n'); + }); + setInterval(() => {}, 60000); + `; + wrapper = spawn(process.execPath, ['-e', wrapperSrc], { + stdio: ['pipe', 'pipe', 'inherit'], + }) as ChildProcessWithoutNullStreams; + + const { pid } = await new Promise<{ pid: number }>((resolve, reject) => { + let buf = ''; + const timer = setTimeout(() => reject(new Error('child did not report its pid in time')), 10000); + wrapper!.stdout.on('data', (chunk: Buffer) => { + buf += chunk.toString('utf8'); + const m = buf.match(/\{"pid":(\d+)\}/); + if (m) { clearTimeout(timer); resolve({ pid: parseInt(m[1], 10) }); } + }); + wrapper!.on('exit', () => { clearTimeout(timer); reject(new Error('wrapper exited before reporting pid')); }); + }); + childPid = pid; + expect(isAlive(childPid)).toBe(true); + + // SIGKILL the wrapper — no cleanup runs, just like killing the parent shim. + // The child is reparented to init; only its ppid watchdog can take it down. + wrapper.kill('SIGKILL'); + + const exited = await waitForExit(childPid, 5000); + const stderr = fs.existsSync(stderrLog) ? fs.readFileSync(stderrLog, 'utf-8') : ''; + expect( + exited, + `child (pid=${childPid}) did not self-terminate within 5s after parent SIGKILL.\nstderr:\n${stderr}`, + ).toBe(true); + // Confirm it died from the parent-death path, not some other cause. + expect(stderr).toMatch(/Parent process exited.*aborting/); + }, 20000); +}); diff --git a/__tests__/install-sh-prune.test.ts b/__tests__/install-sh-prune.test.ts new file mode 100644 index 0000000..73183cb --- /dev/null +++ b/__tests__/install-sh-prune.test.ts @@ -0,0 +1,113 @@ +/** + * install.sh version-prune tests (issue #1074). + * + * The standalone installer keeps each release in its own `versions/` dir and + * — before this fix — never removed the old ones, so they piled up (~50 MB of + * vendored Node runtime each) across upgrades. `install.sh` now prunes every + * `versions/*` dir except the one it just installed. + * + * Rather than duplicate the shell (which would drift from the shipped script), + * these tests extract the REAL prune block from `install.sh` — between its + * `CODEGRAPH_PRUNE_OLD_VERSIONS` markers — and run it under `sh` against a temp + * fixture, with `$INSTALL_DIR` / `$dest` injected. No network, no download. + * + * POSIX only: the block is `/bin/sh`. Windows installs overwrite a single dir in + * place (install.ps1) and never reach this code, so there's nothing to prune. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const INSTALL_SH = path.join(__dirname, '..', 'install.sh'); +const START = '# >>> CODEGRAPH_PRUNE_OLD_VERSIONS'; +const END = '# <<< CODEGRAPH_PRUNE_OLD_VERSIONS'; + +/** Pull the exact prune block out of the shipped install.sh (no duplication). */ +function extractPruneBlock(): string { + const lines = fs.readFileSync(INSTALL_SH, 'utf8').split('\n'); + const i = lines.findIndex((l) => l.trim() === START); + const j = lines.findIndex((l) => l.trim() === END); + if (i < 0 || j < 0 || j <= i) { + throw new Error('CODEGRAPH_PRUNE_OLD_VERSIONS markers not found in install.sh'); + } + return lines.slice(i + 1, j).join('\n'); +} + +/** Single-quote a path for safe interpolation into the sh script. */ +function shq(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +/** Run the real prune block with INSTALL_DIR/dest set, return code + stdout. */ +function runPrune(installDir: string, dest: string): { code: number; stdout: string } { + const script = `set -eu\nINSTALL_DIR=${shq(installDir)}\ndest=${shq(dest)}\n${extractPruneBlock()}\n`; + const r = spawnSync('sh', ['-c', script], { encoding: 'utf8' }); + return { code: r.status ?? -1, stdout: r.stdout ?? '' }; +} + +/** Create a versions//bin dir with a dummy launcher, like a real bundle. */ +function seedVersion(installDir: string, version: string): string { + const dir = path.join(installDir, 'versions', version); + fs.mkdirSync(path.join(dir, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'bin', 'codegraph'), '#!/bin/sh\n'); + return dir; +} + +describe.skipIf(process.platform === 'win32')('install.sh version prune (#1074)', () => { + let installDir: string; + + beforeEach(() => { + installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-prune-')); + }); + afterEach(() => { + fs.rmSync(installDir, { recursive: true, force: true }); + }); + + it('removes older version dirs and keeps only the just-installed one', () => { + seedVersion(installDir, 'v1.1.2'); + seedVersion(installDir, 'v1.1.3'); + const dest = seedVersion(installDir, 'v1.1.4'); + fs.symlinkSync(dest, path.join(installDir, 'current')); + + const { code, stdout } = runPrune(installDir, dest); + + expect(code).toBe(0); + const remaining = fs.readdirSync(path.join(installDir, 'versions')).sort(); + expect(remaining).toEqual(['v1.1.4']); + expect(stdout).toContain('Removed 2 older version(s)'); + // The `current` symlink (outside versions/) is never globbed → untouched. + expect(fs.existsSync(path.join(installDir, 'current'))).toBe(true); + expect(fs.realpathSync(path.join(installDir, 'current'))).toBe(fs.realpathSync(dest)); + }); + + it('is a silent no-op when the just-installed version is the only one', () => { + const dest = seedVersion(installDir, 'v1.1.4'); + + const { code, stdout } = runPrune(installDir, dest); + + expect(code).toBe(0); + expect(fs.readdirSync(path.join(installDir, 'versions'))).toEqual(['v1.1.4']); + expect(stdout).not.toContain('Removed'); + }); + + it('does not error when there is no versions/ dir yet', () => { + const dest = path.join(installDir, 'versions', 'v1.1.4'); // never created + const { code, stdout } = runPrune(installDir, dest); + expect(code).toBe(0); + expect(stdout).not.toContain('Removed'); + }); + + it('reports the count when several older versions are present', () => { + for (const v of ['v1.0.0', 'v1.1.0', 'v1.1.1', 'v1.1.2', 'v1.1.3']) seedVersion(installDir, v); + const dest = seedVersion(installDir, 'v1.1.4'); + + const { code, stdout } = runPrune(installDir, dest); + + expect(code).toBe(0); + expect(fs.readdirSync(path.join(installDir, 'versions'))).toEqual(['v1.1.4']); + expect(stdout).toContain('Removed 5 older version(s)'); + }); +}); diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts new file mode 100644 index 0000000..e11efd3 --- /dev/null +++ b/__tests__/installer-targets.test.ts @@ -0,0 +1,1711 @@ +/** + * Multi-target installer tests. + * + * Each `AgentTarget` is exercised against the same contract: + * - `install` writes the expected files + * - re-running `install` is byte-identical (idempotent) + * - sibling MCP servers / unrelated config is preserved + * - `uninstall` reverses `install` + * - `printConfig` returns parseable, non-empty content + * + * For agent-config destinations we redirect HOME to a tmpdir via + * `os.homedir` spying, and CWD via `process.chdir` — same pattern as + * the legacy `installer.test.ts`. No real `~/.claude/` etc. ever + * touched. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry'; +import { uninstallTargets, refreshTargets } from '../src/installer'; +import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; +import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude'; + +function mkTmpDir(label: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`)); +} + +// `os.homedir` is non-configurable on Node, so we redirect it via the +// `$HOME` (POSIX) / `$USERPROFILE` (Windows) env vars that +// `os.homedir()` reads first. Same trick the rest of the suite uses +// when it needs a mock home. +function setHome(dir: string): { restore: () => void } { + const prev = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + APPDATA: process.env.APPDATA, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + HERMES_HOME: process.env.HERMES_HOME, + }; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + process.env.APPDATA = path.join(dir, '.config'); + process.env.XDG_CONFIG_HOME = path.join(dir, '.config'); + delete process.env.HERMES_HOME; + return { + restore() { + if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME; + if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prev.USERPROFILE; + if (prev.APPDATA === undefined) delete process.env.APPDATA; else process.env.APPDATA = prev.APPDATA; + if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME; + if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME; + }, + }; +} + +// A marker-delimited CodeGraph block exactly as a previous installer +// wrote it. Issue #529: the installer no longer writes an instructions +// file, but install (self-heal on upgrade) and uninstall both still +// strip a block a prior install left, so we plant this to exercise it. +const LEGACY_BLOCK = [ + '', + '## CodeGraph', + '', + 'Prefer `codegraph_search` / `codegraph_callers` over grep.', + '', +].join('\n'); + +describe('Installer targets — contract', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('home'); + tmpCwd = mkTmpDir('cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + for (const target of ALL_TARGETS) { + describe(target.id, () => { + const supportedLocations = (['global', 'local'] as const).filter((l) => + target.supportsLocation(l), + ); + + for (const location of supportedLocations) { + describe(`location=${location}`, () => { + it('install writes files; detect.alreadyConfigured becomes true', () => { + expect(target.detect(location).alreadyConfigured).toBe(false); + + const result = target.install(location, { autoAllow: true }); + expect(result.files.length).toBeGreaterThan(0); + for (const file of result.files) { + if (file.action !== 'unchanged') { + expect(fs.existsSync(file.path)).toBe(true); + } + } + + expect(target.detect(location).alreadyConfigured).toBe(true); + }); + + it('re-running install is idempotent (no actions other than unchanged)', () => { + target.install(location, { autoAllow: true }); + const second = target.install(location, { autoAllow: true }); + for (const file of second.files) { + expect(file.action).toBe('unchanged'); + } + }); + + it('install preserves a pre-existing sibling MCP server (where applicable)', () => { + // Plant a sibling entry in the same JSON config, install, + // and verify the sibling survives. Skip for Codex (TOML) + // and any target with no JSON config — they get covered + // by their own dedicated tests below. + const paths = target.describePaths(location); + // Match .json or .jsonc — opencode prefers .jsonc. + const jsonPath = paths.find((p) => /\.jsonc?$/.test(p)); + if (!jsonPath) return; + + // Seed pre-existing config. + fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); + const seed: Record = { mcpServers: { other: { command: 'x' } } }; + // opencode uses `mcp` not `mcpServers`. Match its shape too. + if (target.id === 'opencode') { + delete seed.mcpServers; + seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } }; + } + fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n'); + + target.install(location, { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + if (target.id === 'opencode') { + expect(after.mcp.other).toBeDefined(); + expect(after.mcp.codegraph).toBeDefined(); + } else { + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeDefined(); + } + }); + + it('uninstall reverses install (alreadyConfigured returns to false)', () => { + target.install(location, { autoAllow: true }); + expect(target.detect(location).alreadyConfigured).toBe(true); + + target.uninstall(location); + expect(target.detect(location).alreadyConfigured).toBe(false); + }); + + it('printConfig returns non-empty output without writing anything', () => { + const before = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd)); + const out = target.printConfig(location); + expect(out.length).toBeGreaterThan(0); + const after = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd)); + expect(after.sort()).toEqual(before.sort()); + }); + }); + } + }); + } +}); + +describe('Installer targets — partial-state idempotency', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('home'); + tmpCwd = mkTmpDir('cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + it('codex: install writes config.toml AND the AGENTS.md codegraph block (#704)', () => { + const codex = getTarget('codex')!; + const first = codex.install('global', { autoAllow: false }); + const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md'); + expect(first.files.some((f) => f.path.endsWith('config.toml'))).toBe(true); + // The short instructions block IS written (subagents / non-MCP + // harnesses read AGENTS.md but never the MCP initialize instructions). + expect(fs.existsSync(agentsMd)).toBe(true); + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('## CodeGraph'); + expect(body).toContain('codegraph explore'); + // Re-install is fully unchanged (byte-equal block → idempotent). + const second = codex.install('global', { autoAllow: false }); + for (const f of second.files) expect(f.action).toBe('unchanged'); + }); + + it('codex: install replaces a legacy AGENTS.md codegraph block with the current one, keeping user content', () => { + const codex = getTarget('codex')!; + const dir = path.join(tmpHome, '.codex'); + fs.mkdirSync(dir, { recursive: true }); + const agentsMd = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `# My codex notes\n\nBe terse.\n\n${LEGACY_BLOCK}\n`); + + const result = codex.install('global', { autoAllow: false }); + + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('# My codex notes'); + expect(body).toContain('Be terse.'); + // Self-heal: the stale pre-#529 body is gone, the current block is in. + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + const mdEntry = result.files.find((f) => f.path.endsWith('AGENTS.md')); + expect(mdEntry?.action).toBe('updated'); + }); + + it('opencode: prefers .jsonc when both .json and .jsonc exist', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); + fs.writeFileSync(path.join(dir, 'opencode.jsonc'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); + + const result = opencode.install('global', { autoAllow: true }); + const written = result.files.find((f) => /\.jsonc$/.test(f.path))!; + expect(written).toBeDefined(); + expect(written.action).not.toBe('not-found'); + // The .json file is left alone. + const jsonText = fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8'); + expect(jsonText).not.toContain('codegraph'); + }); + + it('opencode: uses .json when only .json exists (no .jsonc)', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); + + const result = opencode.install('global', { autoAllow: true }); + expect(result.files[0].path).toMatch(/opencode\.json$/); + expect(fs.existsSync(path.join(dir, 'opencode.jsonc'))).toBe(false); + }); + + it('opencode: defaults to .jsonc for fresh installs (no existing file)', () => { + const opencode = getTarget('opencode')!; + const result = opencode.install('global', { autoAllow: true }); + expect(result.files[0].path).toMatch(/opencode\.jsonc$/); + expect(result.files[0].action).toBe('created'); + }); + + it('opencode: preserves line and block comments through install + idempotent re-run', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'opencode.jsonc'); + const original = [ + '{', + ' // top-level note about my opencode setup', + ' "$schema": "https://opencode.ai/config.json",', + ' /* multi-line block comment', + ' describing the providers section */', + ' "providers": {', + ' "anthropic": { "model": "claude-opus-4-7" } // pinned', + ' }', + '}', + '', + ].join('\n'); + fs.writeFileSync(file, original); + + opencode.install('global', { autoAllow: true }); + const afterInstall = fs.readFileSync(file, 'utf-8'); + expect(afterInstall).toContain('// top-level note about my opencode setup'); + expect(afterInstall).toContain('/* multi-line block comment'); + expect(afterInstall).toContain('// pinned'); + expect(afterInstall).toContain('"codegraph"'); + expect(afterInstall).toContain('"providers"'); + + // Idempotent re-run reports unchanged, file is byte-identical. + const second = opencode.install('global', { autoAllow: true }); + expect(second.files[0].action).toBe('unchanged'); + expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall); + }); + + it('opencode: install writes the AGENTS.md codegraph block (#704)', () => { + const opencode = getTarget('opencode')!; + const result = opencode.install('global', { autoAllow: true }); + const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md'); + expect(fs.existsSync(agentsMd)).toBe(true); + expect(fs.readFileSync(agentsMd, 'utf-8')).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('created'); + }); + + it('opencode: install replaces a legacy AGENTS.md codegraph block, preserving user content', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + const agentsMd = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `# My personal opencode instructions\n\nAlways respond in pirate.\n\n${LEGACY_BLOCK}\n`); + + const result = opencode.install('global', { autoAllow: true }); + + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('# My personal opencode instructions'); + expect(body).toContain('Always respond in pirate.'); + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('updated'); + }); + + it('opencode: uninstall strips a leftover codegraph block from AGENTS.md, keeping user content', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + const agentsMd = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `# My personal opencode instructions\n\nAlways respond in pirate.\n\n${LEGACY_BLOCK}\n`); + + opencode.uninstall('global'); + + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('# My personal opencode instructions'); + expect(body).toContain('Always respond in pirate.'); + expect(body).not.toContain('CODEGRAPH_START'); + }); + + it('opencode: local install writes ./opencode.jsonc and the ./AGENTS.md block (#704)', () => { + const opencode = getTarget('opencode')!; + const result = opencode.install('local', { autoAllow: true }); + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + // macOS realpath shenanigans (/var vs /private/var) — suffix match. + expect(paths.some((p) => p.endsWith('/opencode.jsonc'))).toBe(true); + expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), 'AGENTS.md'))).toBe(true); + }); + + it('gemini: install writes settings.json (mcpServers.codegraph) and the GEMINI.md block (#704)', () => { + const gemini = getTarget('gemini')!; + const result = gemini.install('global', { autoAllow: true }); + const settings = path.join(tmpHome, '.gemini', 'settings.json'); + const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md'); + expect(result.files.some((f) => f.path === settings)).toBe(true); + expect(result.files.some((f) => f.path === geminiMd)).toBe(true); + expect(fs.existsSync(geminiMd)).toBe(true); + expect(fs.readFileSync(geminiMd, 'utf-8')).toContain('codegraph explore'); + + const cfg = JSON.parse(fs.readFileSync(settings, 'utf-8')); + expect(cfg.mcpServers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); + }); + + it('gemini: install preserves pre-existing settings (security.auth survives)', () => { + const gemini = getTarget('gemini')!; + const settings = path.join(tmpHome, '.gemini', 'settings.json'); + fs.mkdirSync(path.dirname(settings), { recursive: true }); + fs.writeFileSync(settings, JSON.stringify({ + security: { auth: { selectedType: 'oauth-personal' } }, + }, null, 2) + '\n'); + + gemini.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(settings, 'utf-8')); + expect(after.security?.auth?.selectedType).toBe('oauth-personal'); + expect(after.mcpServers?.codegraph).toBeDefined(); + }); + + it('gemini: uninstall strips codegraph but leaves pre-existing settings (security.auth) intact', () => { + const gemini = getTarget('gemini')!; + const settings = path.join(tmpHome, '.gemini', 'settings.json'); + fs.mkdirSync(path.dirname(settings), { recursive: true }); + fs.writeFileSync(settings, JSON.stringify({ + security: { auth: { selectedType: 'oauth-personal' } }, + }, null, 2) + '\n'); + + gemini.install('global', { autoAllow: true }); + gemini.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(settings, 'utf-8')); + expect(after.security?.auth?.selectedType).toBe('oauth-personal'); + expect(after.mcpServers).toBeUndefined(); + }); + + it('gemini: local install writes ./.gemini/settings.json and the project-root ./GEMINI.md block (#704)', () => { + const gemini = getTarget('gemini')!; + const result = gemini.install('local', { autoAllow: true }); + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + expect(paths.some((p) => p.endsWith('/.gemini/settings.json'))).toBe(true); + expect(paths.some((p) => p.endsWith('/GEMINI.md'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), 'GEMINI.md'))).toBe(true); + }); + + it('gemini: uninstall strips a leftover GEMINI.md codegraph block, keeping user content', () => { + const gemini = getTarget('gemini')!; + const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md'); + fs.mkdirSync(path.dirname(geminiMd), { recursive: true }); + fs.writeFileSync(geminiMd, `# My personal Gemini context\n\nAlways respond concisely.\n\n${LEGACY_BLOCK}\n`); + + gemini.uninstall('global'); + + const body = fs.readFileSync(geminiMd, 'utf-8'); + expect(body).toContain('# My personal Gemini context'); + expect(body).toContain('Always respond concisely.'); + expect(body).not.toContain('CODEGRAPH_START'); + }); + + it('kiro: install writes settings/mcp.json (mcpServers.codegraph) and no steering doc (#529)', () => { + const kiro = getTarget('kiro')!; + const result = kiro.install('global', { autoAllow: true }); + const mcp = path.join(tmpHome, '.kiro', 'settings', 'mcp.json'); + const steering = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md'); + expect(result.files.some((f) => f.path === mcp)).toBe(true); + expect(result.files.some((f) => f.path === steering)).toBe(false); + expect(fs.existsSync(steering)).toBe(false); + + const cfg = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(cfg.mcpServers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); + }); + + it('kiro: install deletes a leftover steering codegraph.md (self-heal) (#529)', () => { + const kiro = getTarget('kiro')!; + const steering = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md'); + fs.mkdirSync(path.dirname(steering), { recursive: true }); + fs.writeFileSync(steering, `${LEGACY_BLOCK}\n`); + + const result = kiro.install('global', { autoAllow: true }); + expect(fs.existsSync(steering)).toBe(false); + expect(result.files.find((f) => f.path === steering)?.action).toBe('removed'); + }); + + it('kiro: install preserves a pre-existing sibling MCP server in mcp.json', () => { + const kiro = getTarget('kiro')!; + const mcp = path.join(tmpHome, '.kiro', 'settings', 'mcp.json'); + fs.mkdirSync(path.dirname(mcp), { recursive: true }); + fs.writeFileSync(mcp, JSON.stringify({ + mcpServers: { other: { command: 'uvx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + kiro.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeDefined(); + }); + + it('kiro: uninstall strips codegraph but leaves sibling MCP servers intact', () => { + const kiro = getTarget('kiro')!; + const mcp = path.join(tmpHome, '.kiro', 'settings', 'mcp.json'); + fs.mkdirSync(path.dirname(mcp), { recursive: true }); + fs.writeFileSync(mcp, JSON.stringify({ + mcpServers: { other: { command: 'uvx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + kiro.install('global', { autoAllow: true }); + kiro.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeUndefined(); + }); + + it('kiro: uninstall removes a leftover steering codegraph.md file outright', () => { + const kiro = getTarget('kiro')!; + const steering = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md'); + fs.mkdirSync(path.dirname(steering), { recursive: true }); + fs.writeFileSync(steering, `${LEGACY_BLOCK}\n`); + + kiro.uninstall('global'); + expect(fs.existsSync(steering)).toBe(false); + }); + + it('kiro: uninstall removes our steering doc but leaves a sibling (product.md) untouched', () => { + const kiro = getTarget('kiro')!; + const sibling = path.join(tmpHome, '.kiro', 'steering', 'product.md'); + const ours = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md'); + fs.mkdirSync(path.dirname(sibling), { recursive: true }); + fs.writeFileSync(sibling, '# Product\n\nMy team practices.\n'); + fs.writeFileSync(ours, `${LEGACY_BLOCK}\n`); + + kiro.uninstall('global'); + + expect(fs.existsSync(ours)).toBe(false); + expect(fs.existsSync(sibling)).toBe(true); + expect(fs.readFileSync(sibling, 'utf-8')).toContain('My team practices.'); + }); + + it('kiro: local install writes ./.kiro/settings/mcp.json and no steering doc (#529)', () => { + const kiro = getTarget('kiro')!; + const result = kiro.install('local', { autoAllow: true }); + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + expect(paths.some((p) => p.endsWith('/.kiro/settings/mcp.json'))).toBe(true); + expect(paths.some((p) => p.endsWith('/.kiro/steering/codegraph.md'))).toBe(false); + }); + + it('antigravity: install writes to LEGACY ~/.gemini/antigravity/mcp_config.json when no migration marker', () => { + const antigravity = getTarget('antigravity')!; + antigravity.install('global', { autoAllow: true }); + + const legacyFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); + expect(fs.existsSync(legacyFile)).toBe(true); + const cfg = JSON.parse(fs.readFileSync(legacyFile, 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + // Crucially: does NOT touch the Gemini CLI's settings.json. + expect(fs.existsSync(path.join(tmpHome, '.gemini', 'settings.json'))).toBe(false); + }); + + it('antigravity: install writes to UNIFIED ~/.gemini/config/mcp_config.json when .migrated marker present', () => { + const antigravity = getTarget('antigravity')!; + // Plant the migration marker — same signal Antigravity itself drops + // when it migrates a user's config. + const unifiedDir = path.join(tmpHome, '.gemini', 'config'); + fs.mkdirSync(unifiedDir, { recursive: true }); + fs.writeFileSync(path.join(unifiedDir, '.migrated'), ''); + + antigravity.install('global', { autoAllow: true }); + + const unifiedFile = path.join(unifiedDir, 'mcp_config.json'); + expect(fs.existsSync(unifiedFile)).toBe(true); + const cfg = JSON.parse(fs.readFileSync(unifiedFile, 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + // Legacy path is NOT touched when the marker tells us migration happened. + expect(fs.existsSync(path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'))).toBe(false); + }); + + it('antigravity: install writes to UNIFIED path when ~/.gemini/config/mcp_config.json already exists (even without marker)', () => { + const antigravity = getTarget('antigravity')!; + // Antigravity creates this file on first launch post-migration — its + // presence is the second signal we accept, in case the .migrated + // marker semantics change across Antigravity versions. + const unifiedFile = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'); + fs.mkdirSync(path.dirname(unifiedFile), { recursive: true }); + fs.writeFileSync(unifiedFile, JSON.stringify({ mcpServers: {} }, null, 2) + '\n'); + + antigravity.install('global', { autoAllow: true }); + + const cfg = JSON.parse(fs.readFileSync(unifiedFile, 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + }); + + it('antigravity: entry has NO `type` field (Antigravity rejects entries with it)', () => { + const antigravity = getTarget('antigravity')!; + // Marker → unified path; doesn't matter which path, just inspect the entry shape. + fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), ''); + + antigravity.install('global', { autoAllow: true }); + + const cfg = JSON.parse(fs.readFileSync( + path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'), 'utf-8' + )); + expect(cfg.mcpServers.codegraph.type).toBeUndefined(); + expect(cfg.mcpServers.codegraph.command).toBeDefined(); + expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']); + }); + + it('antigravity: install migrates a legacy codegraph entry to the unified path when marker appears', () => { + const antigravity = getTarget('antigravity')!; + // Simulate: user installed on the legacy path, then Antigravity + // migrated their config (dropped the `.migrated` marker + created + // the unified file). Re-running codegraph install should land + // codegraph in the new file AND strip the stale legacy entry. + const legacyFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); + fs.mkdirSync(path.dirname(legacyFile), { recursive: true }); + fs.writeFileSync(legacyFile, JSON.stringify({ + mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } }, + }, null, 2) + '\n'); + fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), ''); + + antigravity.install('global', { autoAllow: true }); + + const unified = JSON.parse(fs.readFileSync( + path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'), 'utf-8' + )); + expect(unified.mcpServers.codegraph).toBeDefined(); + // Legacy file's codegraph entry got stripped. + const legacy = JSON.parse(fs.readFileSync(legacyFile, 'utf-8')); + expect(legacy.mcpServers).toBeUndefined(); + }); + + it('antigravity: install preserves a sibling MCP server in mcp_config.json (legacy path)', () => { + const antigravity = getTarget('antigravity')!; + const mcpFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); + fs.mkdirSync(path.dirname(mcpFile), { recursive: true }); + fs.writeFileSync(mcpFile, JSON.stringify({ + mcpServers: { other: { command: 'uvx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + antigravity.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeDefined(); + }); + + it('antigravity: install preserves Antigravity-managed fields on sibling servers (e.g. disabled flag)', () => { + const antigravity = getTarget('antigravity')!; + // Antigravity adds `"disabled": true` to entries the user disables via + // the IDE. Install must not clobber that on sibling entries. + fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), ''); + const unified = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'); + fs.writeFileSync(unified, JSON.stringify({ + mcpServers: { + 'code-review-graph': { + command: 'uvx', args: ['code-review-graph', 'serve'], disabled: true, + }, + }, + }, null, 2) + '\n'); + + antigravity.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(unified, 'utf-8')); + expect(after.mcpServers['code-review-graph'].disabled).toBe(true); + expect(after.mcpServers.codegraph).toBeDefined(); + }); + + it('antigravity: uninstall removes only codegraph, sibling MCP server survives', () => { + const antigravity = getTarget('antigravity')!; + const mcpFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); + fs.mkdirSync(path.dirname(mcpFile), { recursive: true }); + fs.writeFileSync(mcpFile, JSON.stringify({ + mcpServers: { other: { command: 'uvx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + antigravity.install('global', { autoAllow: true }); + antigravity.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeUndefined(); + }); + + it('antigravity: uninstall sweeps BOTH legacy and unified paths (handles migration half-state)', () => { + const antigravity = getTarget('antigravity')!; + // User had codegraph in BOTH files (e.g. legacy install + post-migration + // re-install before our migration cleanup landed). Uninstall must clean + // both so a "fresh slate" really is fresh. + const legacy = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); + const unified = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + fs.mkdirSync(path.dirname(unified), { recursive: true }); + fs.writeFileSync(legacy, JSON.stringify({ + mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } }, + }, null, 2) + '\n'); + fs.writeFileSync(unified, JSON.stringify({ + mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } }, + }, null, 2) + '\n'); + fs.writeFileSync(path.join(path.dirname(unified), '.migrated'), ''); + + antigravity.uninstall('global'); + + const legacyAfter = JSON.parse(fs.readFileSync(legacy, 'utf-8')); + const unifiedAfter = JSON.parse(fs.readFileSync(unified, 'utf-8')); + expect(legacyAfter.mcpServers).toBeUndefined(); + expect(unifiedAfter.mcpServers).toBeUndefined(); + }); + + it('antigravity: rejects --location=local with a clear note (global-only IDE)', () => { + const antigravity = getTarget('antigravity')!; + expect(antigravity.supportsLocation('local')).toBe(false); + const result = antigravity.install('local', { autoAllow: true }); + expect(result.files).toEqual([]); + expect(result.notes?.join(' ')).toMatch(/no project-local config/); + }); + + it('antigravity: does not write GEMINI.md (only gemini target owns instructions)', () => { + const antigravity = getTarget('antigravity')!; + antigravity.install('global', { autoAllow: true }); + const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md'); + expect(fs.existsSync(geminiMd)).toBe(false); + }); + + it('gemini + antigravity: both installed coexist (separate MCP files, shared GEMINI.md)', () => { + const gemini = getTarget('gemini')!; + const antigravity = getTarget('antigravity')!; + gemini.install('global', { autoAllow: true }); + antigravity.install('global', { autoAllow: true }); + + const cliCfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'settings.json'), 'utf-8')); + // Antigravity lands on the LEGACY path here since no .migrated marker + // was planted — same end-to-end check either way. + const ideCfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'), 'utf-8')); + expect(cliCfg.mcpServers.codegraph).toBeDefined(); + expect(ideCfg.mcpServers.codegraph).toBeDefined(); + + // Uninstall one — the other's MCP entry must survive. + antigravity.uninstall('global'); + const cliAfter = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'settings.json'), 'utf-8')); + expect(cliAfter.mcpServers.codegraph).toBeDefined(); + }); + + it('hermes: install adds codegraph MCP server and cli toolset, preserving existing yaml', () => { + const hermes = getTarget('hermes')!; + const config = path.join(tmpHome, '.hermes', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + fs.writeFileSync(config, [ + 'model:', + ' default: qwen-3.7', + 'mcp_servers:', + ' other:', + ' command: other', + 'platform_toolsets:', + ' cli:', + ' - hermes-cli', + ' discord:', + ' - hermes-discord', + '', + ].join('\n')); + + const result = hermes.install('global', { autoAllow: true }); + expect(result.files[0].action).toBe('updated'); + const body = fs.readFileSync(config, 'utf-8'); + expect(body).toContain('model:\n default: qwen-3.7'); + expect(body).toContain('mcp_servers:\n other:\n command: other'); + expect(body).toContain(' codegraph:\n command: codegraph'); + expect(body).toContain(' - hermes-cli'); + expect(body).toContain(' - mcp-codegraph'); + expect(body).toContain(' discord:\n - hermes-discord'); + + const second = hermes.install('global', { autoAllow: true }); + expect(second.files[0].action).toBe('unchanged'); + }); + + it('hermes: uninstall removes only codegraph MCP server and toolset entry', () => { + const hermes = getTarget('hermes')!; + const config = path.join(tmpHome, '.hermes', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + + hermes.install('global', { autoAllow: true }); + fs.appendFileSync(config, 'custom:\n keep: true\n'); + + hermes.uninstall('global'); + const body = fs.readFileSync(config, 'utf-8'); + expect(body).not.toContain('codegraph:'); + expect(body).not.toContain('mcp-codegraph'); + expect(body).toContain('custom:\n keep: true'); + }); + + // Regression for #456: PyYAML's default block style writes list items at the + // SAME indent as the parent key (`cli:` and its `- hermes-cli` are both at + // indent 2). The pre-fix line-based patcher mistook that first list item for + // the next sibling key, truncated the cli block, and spliced `- mcp-codegraph` + // at indent 4 BEFORE the existing items — producing unparseable YAML. + it('hermes: install preserves PyYAML-default list-at-same-indent style (issue #456)', () => { + const hermes = getTarget('hermes')!; + const config = path.join(tmpHome, '.hermes', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + const original = [ + 'model:', + ' default: gpt-4o', + 'platform_toolsets:', + ' cli:', + ' - hermes-cli', + ' - browser', + ' - clarify', + ' - terminal', + ' - web', + ' telegram:', + ' - hermes-telegram', + ' discord:', + ' - hermes-discord', + '', + ].join('\n'); + fs.writeFileSync(config, original); + + hermes.install('global', { autoAllow: true }); + const body = fs.readFileSync(config, 'utf-8'); + + // mcp-codegraph appended at the same 2-space indent as existing items + expect(body).toContain('\n - mcp-codegraph\n'); + // hermes-cli preserved + expect(body).toContain('\n - hermes-cli\n'); + // Sibling sections kept their indent — `telegram:` is still a key under + // platform_toolsets, not promoted up. + expect(body).toContain('\n telegram:\n - hermes-telegram\n'); + expect(body).toContain('\n discord:\n - hermes-discord\n'); + // No list items leaked to the platform_toolsets level (indent 0). + expect(body).not.toMatch(/^- browser/m); + expect(body).not.toMatch(/^- hermes-telegram/m); + + // The whole platform_toolsets block extracted by line search should + // start with `cli:` and not contain a stray 4-space `mcp-codegraph` + // appearing before the rest of the existing items. + expect(body).toContain(' cli:\n - hermes-cli\n - browser'); + + // Idempotent + const second = hermes.install('global', { autoAllow: true }); + expect(second.files[0]?.action).toBe('unchanged'); + }); + + it('hermes: uninstall reverses the install on a PyYAML-default config', () => { + const hermes = getTarget('hermes')!; + const config = path.join(tmpHome, '.hermes', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + const original = [ + 'platform_toolsets:', + ' cli:', + ' - hermes-cli', + ' - browser', + ' telegram:', + ' - hermes-telegram', + '', + ].join('\n'); + fs.writeFileSync(config, original); + + hermes.install('global', { autoAllow: true }); + const installed = fs.readFileSync(config, 'utf-8'); + expect(installed).toContain('- mcp-codegraph'); + expect(installed).toContain('codegraph:'); + + hermes.uninstall('global'); + const body = fs.readFileSync(config, 'utf-8'); + expect(body).not.toContain('mcp-codegraph'); + expect(body).not.toContain('command: codegraph'); + expect(body).toContain(' cli:\n - hermes-cli\n - browser'); + expect(body).toContain(' telegram:\n - hermes-telegram'); + }); + + it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => { + const opencode = getTarget('opencode')!; + const dir = path.join(tmpHome, '.config', 'opencode'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'opencode.jsonc'); + fs.writeFileSync(file, [ + '{', + ' // important comment', + ' "$schema": "https://opencode.ai/config.json",', + ' "mcp": {', + ' "other": { "type": "local", "command": ["x"], "enabled": true }', + ' }', + '}', + '', + ].join('\n')); + + opencode.install('global', { autoAllow: true }); + const afterInstall = fs.readFileSync(file, 'utf-8'); + expect(afterInstall).toContain('"codegraph"'); + expect(afterInstall).toContain('"other"'); + + opencode.uninstall('global'); + const afterUninstall = fs.readFileSync(file, 'utf-8'); + expect(afterUninstall).not.toContain('codegraph'); + expect(afterUninstall).toContain('// important comment'); + expect(afterUninstall).toContain('"other"'); + }); + + it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => { + const codex = getTarget('codex')!; + codex.install('global', { autoAllow: false }); + const tomlPath = path.join(tmpHome, '.codex', 'config.toml'); + const original = fs.readFileSync(tomlPath, 'utf-8'); + // User edits the block to add a custom key. + const edited = original.replace( + 'args = ["serve", "--mcp"]', + 'args = ["serve", "--mcp"]\nenabled = true', + ); + fs.writeFileSync(tomlPath, edited); + // Re-install: our serializer doesn't know `enabled = true`, so + // the block no longer matches the canonical form — we'll + // overwrite it. This is the documented contract: we own the + // codegraph block exclusively. + const second = codex.install('global', { autoAllow: false }); + const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!; + expect(tomlEntry.action).toBe('updated'); + const after = fs.readFileSync(tomlPath, 'utf-8'); + expect(after).not.toContain('enabled = true'); + }); + + it('claude: local install writes ./.mcp.json (project scope), not ./.claude.json', () => { + const claude = getTarget('claude')!; + const result = claude.install('local', { autoAllow: false }); + // The MCP entry lands in ./.mcp.json — the file Claude Code reads. + expect(result.files.some((f) => f.path.replace(/\\/g, '/').endsWith('/.mcp.json'))).toBe(true); + expect(fs.existsSync(path.join(tmpCwd, '.mcp.json'))).toBe(true); + expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false); + const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + }); + + it('claude: install creates the CLAUDE.md codegraph block (#704)', () => { + const claude = getTarget('claude')!; + const result = claude.install('local', { autoAllow: false }); + const claudeMd = path.join(tmpCwd, '.claude', 'CLAUDE.md'); + expect(fs.existsSync(claudeMd)).toBe(true); + const body = fs.readFileSync(claudeMd, 'utf-8'); + expect(body).toContain('## CodeGraph'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('created'); + }); + + it('claude: install replaces a legacy CLAUDE.md codegraph block, keeping user content', () => { + const claude = getTarget('claude')!; + const claudeMd = path.join(tmpCwd, '.claude', 'CLAUDE.md'); + fs.mkdirSync(path.dirname(claudeMd), { recursive: true }); + fs.writeFileSync(claudeMd, `# My project rules\n\nUse tabs.\n\n${LEGACY_BLOCK}\n`); + + const result = claude.install('local', { autoAllow: false }); + + const body = fs.readFileSync(claudeMd, 'utf-8'); + expect(body).toContain('# My project rules'); + expect(body).toContain('Use tabs.'); + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('updated'); + }); + + it('claude: global install targets ~/.claude.json (user scope)', () => { + const claude = getTarget('claude')!; + claude.install('global', { autoAllow: false }); + const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + }); + + it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => { + const claude = getTarget('claude')!; + const legacy = path.join(tmpCwd, '.claude.json'); + fs.writeFileSync( + legacy, + JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2), + ); + + claude.install('local', { autoAllow: false }); + + // codegraph now lives in .mcp.json; the legacy file (which held only + // codegraph) is gone. + const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); + expect(mcp.mcpServers.codegraph).toBeDefined(); + expect(fs.existsSync(legacy)).toBe(false); + }); + + it('claude: legacy ./.claude.json migration preserves sibling servers and unrelated keys', () => { + const claude = getTarget('claude')!; + const legacy = path.join(tmpCwd, '.claude.json'); + fs.writeFileSync( + legacy, + JSON.stringify({ + mcpServers: { + codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }, + other: { command: 'x' }, + }, + somethingElse: true, + }, null, 2), + ); + + claude.install('local', { autoAllow: false }); + + // Only codegraph is stripped from the legacy file; siblings survive. + const after = JSON.parse(fs.readFileSync(legacy, 'utf-8')); + expect(after.mcpServers.codegraph).toBeUndefined(); + expect(after.mcpServers.other).toBeDefined(); + expect(after.somethingElse).toBe(true); + const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); + expect(mcp.mcpServers.codegraph).toBeDefined(); + }); + + it('claude: uninstall strips codegraph from ./.mcp.json and a legacy ./.claude.json', () => { + const claude = getTarget('claude')!; + // A user left with both the working .mcp.json and a stale .claude.json. + fs.writeFileSync( + path.join(tmpCwd, '.mcp.json'), + JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph' } } }, null, 2), + ); + fs.writeFileSync( + path.join(tmpCwd, '.claude.json'), + JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph' }, other: { command: 'x' } } }, null, 2), + ); + + claude.uninstall('local'); + + const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); + expect(mcp.mcpServers).toBeUndefined(); + const legacy = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.claude.json'), 'utf-8')); + expect(legacy.mcpServers.codegraph).toBeUndefined(); + expect(legacy.mcpServers.other).toBeDefined(); + }); + + // ---- Legacy auto-sync hook cleanup ---- + // Pre-0.8 installs wrote `codegraph mark-dirty` / `sync-if-dirty` + // hooks to settings.json. Both subcommands were removed from the CLI, + // so the Stop hook fails every turn ("unknown command + // 'sync-if-dirty'"). The installer must strip them on upgrade and + // uninstall — without touching the user's unrelated hooks. + + function seedSettings(loc: 'global' | 'local', settings: Record): string { + const dir = path.join(loc === 'global' ? tmpHome : tmpCwd, '.claude'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'settings.json'); + fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n'); + return file; + } + + // Realistic pre-0.8 settings.json: our two auto-sync hooks plus an + // unrelated GitKraken Stop hook the user added (matches the report). + function legacyHookSettings(): Record { + return { + hooks: { + PostToolUse: [ + { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'codegraph mark-dirty', async: true }] }, + ], + Stop: [ + { hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }, + { hooks: [{ type: 'command', command: '"/Users/me/gk" ai hook run --host claude-code' }] }, + ], + }, + }; + } + + it('claude: install strips stale codegraph auto-sync hooks but keeps the user\'s GitKraken hook', () => { + const claude = getTarget('claude')!; + const file = seedSettings('global', legacyHookSettings()); + + claude.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + // The only PostToolUse group held mark-dirty → the event is gone. + expect(after.hooks?.PostToolUse).toBeUndefined(); + const stopCommands = (after.hooks?.Stop ?? []).flatMap((g: any) => + (g.hooks ?? []).map((h: any) => h.command), + ); + expect(stopCommands).not.toContain('codegraph sync-if-dirty'); + // The unrelated GitKraken hook survives untouched. + expect(stopCommands.some((c: string) => c.includes('gk') && c.includes('ai hook run'))).toBe(true); + // Permissions still written as normal alongside the cleanup. + expect(after.permissions?.allow).toContain('mcp__codegraph__*'); + }); + + it('claude: cleanupLegacyHooks preserves a sibling hook sharing our matcher group', () => { + const file = seedSettings('global', { + hooks: { + Stop: [ + { + hooks: [ + { type: 'command', command: 'codegraph sync-if-dirty' }, + { type: 'command', command: 'gk ai hook run --host claude-code' }, + ], + }, + ], + }, + }); + + expect(cleanupLegacyHooks('global').action).toBe('removed'); + + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(after.hooks.Stop[0].hooks.map((h: any) => h.command)).toEqual([ + 'gk ai hook run --host claude-code', + ]); + }); + + it('claude: cleanupLegacyHooks is a byte-for-byte no-op without codegraph hooks', () => { + const original = + JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'gk ai hook run' }] }] } }, null, 2) + '\n'; + const file = seedSettings('global', JSON.parse(original)); + + expect(cleanupLegacyHooks('global').action).toBe('unchanged'); + expect(fs.readFileSync(file, 'utf-8')).toBe(original); + }); + + it('claude: cleanupLegacyHooks reports not-found when settings.json is absent', () => { + expect(cleanupLegacyHooks('global').action).toBe('not-found'); + }); + + it('claude: re-running install after a legacy cleanup leaves settings.json unchanged', () => { + const claude = getTarget('claude')!; + const file = seedSettings('global', legacyHookSettings()); + claude.install('global', { autoAllow: true }); + const firstPass = fs.readFileSync(file, 'utf-8'); + claude.install('global', { autoAllow: true }); + expect(fs.readFileSync(file, 'utf-8')).toBe(firstPass); + }); + + it('claude: uninstall strips stale hooks written in the npx form (local)', () => { + const claude = getTarget('claude')!; + const file = seedSettings('local', { + hooks: { + PostToolUse: [ + { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph mark-dirty', async: true }] }, + ], + Stop: [ + { hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph sync-if-dirty' }] }, + ], + }, + }); + + claude.uninstall('local'); + + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + // Both events emptied → the whole `hooks` object is removed. + expect(after.hooks).toBeUndefined(); + }); + + // ---- Front-load prompt hook (UserPromptSubmit) — #841 follow-up ---- + // Opt-in (default-yes in the installer) UserPromptSubmit hook that runs + // `codegraph prompt-hook`. Must write/remove surgically, be idempotent, and + // round-trip an opt-out — without disturbing the user's own hooks. + const promptCommands = (s: any): string[] => + (s.hooks?.UserPromptSubmit ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command)); + + it('claude: install with promptHook:true writes the UserPromptSubmit hook (alongside permissions)', () => { + const claude = getTarget('claude')!; + claude.install('global', { autoAllow: true, promptHook: true }); + const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8')); + expect(promptCommands(s)).toContain('codegraph prompt-hook'); + expect(s.permissions?.allow).toContain('mcp__codegraph__*'); + }); + + it('claude: install without promptHook does NOT add the hook', () => { + const claude = getTarget('claude')!; + claude.install('global', { autoAllow: true }); + const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8')); + expect(promptCommands(s)).not.toContain('codegraph prompt-hook'); + }); + + it('claude: install with promptHook:true is idempotent (no duplicate, byte-identical re-run)', () => { + const claude = getTarget('claude')!; + const file = path.join(tmpHome, '.claude', 'settings.json'); + claude.install('global', { autoAllow: true, promptHook: true }); + const first = fs.readFileSync(file, 'utf-8'); + claude.install('global', { autoAllow: true, promptHook: true }); + expect(fs.readFileSync(file, 'utf-8')).toBe(first); + const s = JSON.parse(first); + expect(promptCommands(s).filter((c: string) => c === 'codegraph prompt-hook')).toHaveLength(1); + }); + + it('claude: install with promptHook:false strips a hook a prior install wrote (opt-out round-trips)', () => { + const claude = getTarget('claude')!; + claude.install('global', { autoAllow: true, promptHook: true }); + claude.install('global', { autoAllow: true, promptHook: false }); + const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8')); + expect(promptCommands(s)).not.toContain('codegraph prompt-hook'); + }); + + it('claude: writePromptHookEntry preserves a sibling UserPromptSubmit hook', () => { + const file = seedSettings('global', { + hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'my-own-hook' }] }] }, + }); + expect(writePromptHookEntry('global').action).toBe('updated'); + const s = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(promptCommands(s)).toEqual(['my-own-hook', 'codegraph prompt-hook']); + }); + + it('claude: uninstall removes the prompt hook but keeps the user\'s sibling', () => { + const file = seedSettings('global', { + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: 'codegraph prompt-hook' }] }, + { hooks: [{ type: 'command', command: 'my-own-hook' }] }, + ], + }, + }); + getTarget('claude')!.uninstall('global'); + const s = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(promptCommands(s)).toEqual(['my-own-hook']); + }); + + it('claude: removePromptHookEntry leaves the legacy auto-sync hook untouched', () => { + const file = seedSettings('global', { + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'codegraph prompt-hook' }] }], + Stop: [{ hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }], + }, + }); + expect(removePromptHookEntry('global').action).toBe('removed'); + const s = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(promptCommands(s)).not.toContain('codegraph prompt-hook'); + const stopCmds = (s.hooks?.Stop ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command)); + expect(stopCmds).toContain('codegraph sync-if-dirty'); + }); +}); + +describe('Installer targets — registry', () => { + it('getTarget returns the right target for each id', () => { + expect(getTarget('claude')?.id).toBe('claude'); + expect(getTarget('cursor')?.id).toBe('cursor'); + expect(getTarget('codex')?.id).toBe('codex'); + expect(getTarget('opencode')?.id).toBe('opencode'); + expect(getTarget('hermes')?.id).toBe('hermes'); + expect(getTarget('gemini')?.id).toBe('gemini'); + expect(getTarget('antigravity')?.id).toBe('antigravity'); + expect(getTarget('kiro')?.id).toBe('kiro'); + expect(getTarget('not-a-real-target')).toBeUndefined(); + }); + + it('resolveTargetFlag handles auto/all/none/csv', () => { + expect(resolveTargetFlag('none', 'global')).toEqual([]); + expect(resolveTargetFlag('all', 'global').length).toBe(ALL_TARGETS.length); + const csv = resolveTargetFlag('claude,cursor', 'global'); + expect(csv.map((t) => t.id)).toEqual(['claude', 'cursor']); + }); + + it('resolveTargetFlag throws on unknown id', () => { + expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/); + }); +}); + +describe('Installer targets — TOML serializer (Codex backbone)', () => { + it('builds a [mcp_servers.codegraph] block with command + args', () => { + const block = buildTomlTable('mcp_servers.codegraph', { + command: 'codegraph', + args: ['serve', '--mcp'], + }); + expect(block).toContain('[mcp_servers.codegraph]'); + expect(block).toContain('command = "codegraph"'); + expect(block).toContain('args = ["serve", "--mcp"]'); + }); + + it('upsert inserts into empty content', () => { + const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] }); + const { content, action } = upsertTomlTable('', 'mcp_servers.codegraph', block); + expect(action).toBe('inserted'); + expect(content.startsWith('[mcp_servers.codegraph]')).toBe(true); + }); + + it('upsert is idempotent — second call returns unchanged', () => { + const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] }); + const first = upsertTomlTable('', 'mcp_servers.codegraph', block); + const second = upsertTomlTable(first.content, 'mcp_servers.codegraph', block); + expect(second.action).toBe('unchanged'); + expect(second.content).toBe(first.content); + }); + + it('upsert replaces an existing block in place, preserving sibling tables', () => { + const existing = [ + '[other_table]', + 'foo = "bar"', + '', + '[mcp_servers.codegraph]', + 'command = "old-codegraph"', + 'args = ["old"]', + '', + '[zzz]', + 'baz = "qux"', + '', + ].join('\n'); + const newBlock = buildTomlTable('mcp_servers.codegraph', { + command: 'codegraph', + args: ['serve', '--mcp'], + }); + const { content, action } = upsertTomlTable(existing, 'mcp_servers.codegraph', newBlock); + expect(action).toBe('replaced'); + expect(content).toContain('[other_table]'); + expect(content).toContain('foo = "bar"'); + expect(content).toContain('[zzz]'); + expect(content).toContain('baz = "qux"'); + expect(content).toContain('command = "codegraph"'); + expect(content).not.toContain('old-codegraph'); + }); + + it('removeTomlTable strips the block and preserves siblings', () => { + const existing = [ + '[other_table]', + 'foo = "bar"', + '', + '[mcp_servers.codegraph]', + 'command = "codegraph"', + 'args = ["serve"]', + ].join('\n'); + const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph'); + expect(action).toBe('removed'); + expect(content).toContain('[other_table]'); + expect(content).toContain('foo = "bar"'); + expect(content).not.toContain('mcp_servers.codegraph'); + }); + + it('removeTomlTable on missing table returns not-found, no content change', () => { + const existing = '[other]\nfoo = "bar"\n'; + const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph'); + expect(action).toBe('not-found'); + expect(content).toBe(existing); + }); + + it('upsert preserves an array-of-tables sibling [[foo]]', () => { + const existing = [ + '[[foo]]', + 'name = "a"', + '', + '[[foo]]', + 'name = "b"', + '', + ].join('\n'); + const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] }); + const { content } = upsertTomlTable(existing, 'mcp_servers.codegraph', block); + expect(content.match(/\[\[foo\]\]/g)?.length).toBe(2); + expect(content).toContain('[mcp_servers.codegraph]'); + }); +}); + +describe('Installer — uninstallTargets sweep (codegraph uninstall)', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('un-home'); + tmpCwd = mkTmpDir('un-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + it('sweeps every agent it was installed on and reports removed for each (global)', () => { + for (const t of ALL_TARGETS) { + if (t.supportsLocation('global')) t.install('global', { autoAllow: true }); + } + + const reports = uninstallTargets(ALL_TARGETS, 'global'); + + for (const t of ALL_TARGETS) { + const r = reports.find((x) => x.id === t.id)!; + expect(r.status).toBe('removed'); + expect(r.removedPaths.length).toBeGreaterThan(0); + // The actual config is gone afterward. + expect(t.detect('global').alreadyConfigured).toBe(false); + } + }); + + it('is safe on a clean slate — every agent reports not-configured, nothing removed', () => { + const reports = uninstallTargets(ALL_TARGETS, 'global'); + for (const r of reports) { + expect(r.status).toBe('not-configured'); + expect(r.removedPaths).toEqual([]); + } + }); + + it('reports removed only for agents that were actually configured', () => { + // Install on Claude only; the rest stay untouched. + getTarget('claude')!.install('global', { autoAllow: true }); + + const reports = uninstallTargets(ALL_TARGETS, 'global'); + + const claude = reports.find((r) => r.id === 'claude')!; + expect(claude.status).toBe('removed'); + expect(claude.displayName).toBe(getTarget('claude')!.displayName); + + for (const r of reports.filter((x) => x.id !== 'claude')) { + expect(r.status).toBe('not-configured'); + } + }); + + it('marks global-only agents as unsupported for a local sweep (and never touches them)', () => { + const reports = uninstallTargets(ALL_TARGETS, 'local'); + for (const t of ALL_TARGETS) { + const r = reports.find((x) => x.id === t.id)!; + if (t.supportsLocation('local')) { + expect(r.status).toBe('not-configured'); + } else { + expect(r.status).toBe('unsupported'); + expect(r.removedPaths).toEqual([]); + expect(r.notes[0]).toMatch(/global-only/); + } + } + }); + + it('is idempotent — a second sweep finds nothing left to remove', () => { + for (const t of ALL_TARGETS) { + if (t.supportsLocation('global')) t.install('global', { autoAllow: true }); + } + const first = uninstallTargets(ALL_TARGETS, 'global'); + expect(first.some((r) => r.status === 'removed')).toBe(true); + + const second = uninstallTargets(ALL_TARGETS, 'global'); + for (const r of second) { + expect(r.status).toBe('not-configured'); + expect(r.removedPaths).toEqual([]); + } + }); + + it('a --target subset removes only the chosen agents, leaving siblings configured', () => { + getTarget('claude')!.install('global', { autoAllow: true }); + getTarget('cursor')!.install('global', { autoAllow: true }); + + const reports = uninstallTargets(resolveTargetFlag('claude', 'global'), 'global'); + + expect(reports.map((r) => r.id)).toEqual(['claude']); + expect(reports[0].status).toBe('removed'); + // Cursor was not in the subset — still configured. + expect(getTarget('cursor')!.detect('global').alreadyConfigured).toBe(true); + expect(getTarget('claude')!.detect('global').alreadyConfigured).toBe(false); + }); +}); + +describe('Installer — refreshTargets sweep (codegraph install --refresh)', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('rf-home'); + tmpCwd = mkTmpDir('rf-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + it('rewrites a stale instructions block a previous version left, and reports refreshed', () => { + const claude = getTarget('claude')!; + claude.install('global', { autoAllow: true }); + + // Simulate the file as an old install left it: same markers, the old + // multi-tool wording. + const claudeMd = path.join(tmpHome, '.claude', 'CLAUDE.md'); + fs.writeFileSync(claudeMd, LEGACY_BLOCK + '\n'); + + const reports = refreshTargets([claude], 'global'); + expect(reports[0].status).toBe('refreshed'); + expect(reports[0].changedPaths).toContain(claudeMd); + + const md = fs.readFileSync(claudeMd, 'utf-8'); + expect(md).not.toContain('codegraph_search'); + expect(md).toContain('codegraph_explore'); + }); + + it('never performs a first install — unconfigured agents stay untouched', () => { + const reports = refreshTargets(ALL_TARGETS, 'global'); + for (const t of ALL_TARGETS) { + const r = reports.find((x) => x.id === t.id)!; + expect(r.status).toBe(t.supportsLocation('global') ? 'not-configured' : 'unsupported'); + expect(r.changedPaths).toEqual([]); + expect(t.detect('global').alreadyConfigured).toBe(false); + } + }); + + it('preserves the user\'s permission choices (refresh never writes permissions)', () => { + const claude = getTarget('claude')!; + claude.install('global', { autoAllow: true }); + + // The user has since trimmed the allowlist by hand. + const settingsPath = path.join(tmpHome, '.claude', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + settings.permissions.allow = []; + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + refreshTargets([claude], 'global'); + + const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + expect(after.permissions.allow).toEqual([]); + }); + + it('is idempotent — a second sweep on a current machine reports unchanged everywhere', () => { + for (const t of ALL_TARGETS) { + if (t.supportsLocation('global')) t.install('global', { autoAllow: true }); + } + const first = refreshTargets(ALL_TARGETS, 'global'); + // Fresh installs are already current, so even the first sweep may be + // all-unchanged; what matters is the second definitely is. + const second = refreshTargets(ALL_TARGETS, 'global'); + for (const r of [...first, ...second]) { + expect(['unchanged', 'refreshed']).toContain(r.status); + } + for (const r of second) { + expect(r.status).toBe('unchanged'); + expect(r.changedPaths).toEqual([]); + } + }); +}); + +describe('Installer — Cursor rules file cleanup on uninstall', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + const cursor = getTarget('cursor')!; + + beforeEach(() => { + tmpHome = mkTmpDir('cur-home'); + tmpCwd = mkTmpDir('cur-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + const rulesFile = () => path.join(process.cwd(), '.cursor', 'rules', 'codegraph.mdc'); + + // The frontmatter a previous install wrote ahead of the marked block. + // `removeRulesEntry` recognizes it to decide whether the leftover .mdc + // is ours-to-delete or carries user content worth keeping. + const MDC_FRONTMATTER = [ + '---', + 'description: CodeGraph MCP usage guide — when to use which tool', + 'alwaysApply: true', + '---', + '', + ].join('\n'); + + function plantLegacyRulesFile(extra = ''): void { + fs.mkdirSync(path.dirname(rulesFile()), { recursive: true }); + fs.writeFileSync(rulesFile(), MDC_FRONTMATTER + LEGACY_BLOCK + '\n' + extra); + } + + it('uninstall deletes a leftover codegraph.mdc entirely (no orphaned frontmatter left behind)', () => { + plantLegacyRulesFile(); + expect(fs.existsSync(rulesFile())).toBe(true); + + cursor.uninstall('local'); + + // The whole file — frontmatter included — is gone, not just the block. + expect(fs.existsSync(rulesFile())).toBe(false); + }); + + it('install self-heals a leftover codegraph.mdc (#529)', () => { + plantLegacyRulesFile(); + const result = cursor.install('local', { autoAllow: true }); + expect(fs.existsSync(rulesFile())).toBe(false); + expect(result.files.some((f) => f.path.endsWith('codegraph.mdc') && f.action === 'removed')).toBe(true); + }); + + it('uninstall preserves user content added outside the codegraph markers (strips only our block)', () => { + plantLegacyRulesFile('## My own rule\nkeep me\n'); + + cursor.uninstall('local'); + + expect(fs.existsSync(rulesFile())).toBe(true); + const after = fs.readFileSync(rulesFile(), 'utf-8'); + expect(after).toContain('keep me'); + // Our tool-usage block is gone. + expect(after).not.toContain('codegraph_search'); + expect(after).not.toContain('CODEGRAPH_START'); + }); +}); + +function listAllFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...listAllFiles(full)); + else out.push(full); + } + return out; +} + +// --------------------------------------------------------------------------- +// opencode global config path — XDG on every platform (#535) +// +// opencode resolves its config dir with `xdg-basedir`: XDG_CONFIG_HOME if +// set, else ~/.config — on ALL platforms, Windows included. It never reads +// %APPDATA%; we used to write there on Windows, so opencode never saw the +// entry. The suite-wide setHome() points APPDATA and XDG_CONFIG_HOME at the +// SAME directory (which is exactly how this bug stayed invisible), so these +// tests deliberately split them. +// --------------------------------------------------------------------------- +describe('Installer targets — opencode XDG config path (#535)', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + let appDataDir: string; // distinct from ~/.config, like real Windows + + beforeEach(() => { + tmpHome = mkTmpDir('home'); + tmpCwd = mkTmpDir('cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + appDataDir = path.join(tmpHome, 'AppData', 'Roaming'); + process.env.APPDATA = appDataDir; // realistic split: APPDATA ≠ ~/.config + delete process.env.XDG_CONFIG_HOME; // default resolution: ~/.config + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + const xdgConfigFile = () => path.join(tmpHome, '.config', 'opencode', 'opencode.jsonc'); + const legacyDir = () => path.join(appDataDir, 'opencode'); + // NOTE: never match on an 'AppData' substring — on Windows os.tmpdir() + // itself lives under AppData\Local\Temp, so EVERY harness path contains + // it. Match on the legacy dir prefix instead. + const inLegacyDir = (p: string) => path.resolve(p).startsWith(path.resolve(legacyDir()) + path.sep); + + it('global install writes to ~/.config/opencode, never %APPDATA% (#535)', () => { + const opencode = getTarget('opencode')!; + const result = opencode.install('global', { autoAllow: true }); + + const written = result.files.find((f) => f.path.endsWith('opencode.jsonc'))!; + expect(written.action).toBe('created'); + expect(path.resolve(written.path)).toBe(path.resolve(xdgConfigFile())); + expect(fs.existsSync(xdgConfigFile())).toBe(true); + // Nothing of ours may land in the legacy location. + expect(fs.existsSync(legacyDir())).toBe(false); + }); + + it('greenfield: targets ~/.config/opencode even when the dir does not exist yet (#535)', () => { + // The rejected fallback design (#670) would send this install to + // %APPDATA% — where opencode would never find it. opencode creates + // ~/.config/opencode itself on first run; installing codegraph FIRST + // must land where opencode will look. + expect(fs.existsSync(path.join(tmpHome, '.config', 'opencode'))).toBe(false); + const opencode = getTarget('opencode')!; + const result = opencode.install('global', { autoAllow: true }); + expect(path.resolve(result.files[0]!.path)).toBe(path.resolve(xdgConfigFile())); + expect(fs.existsSync(xdgConfigFile())).toBe(true); + expect(fs.existsSync(legacyDir())).toBe(false); + }); + + it('honors XDG_CONFIG_HOME for the global path, like opencode does', () => { + const custom = path.join(tmpHome, 'xdg-custom'); + process.env.XDG_CONFIG_HOME = custom; + const opencode = getTarget('opencode')!; + const result = opencode.install('global', { autoAllow: true }); + expect(path.resolve(result.files[0]!.path)) + .toBe(path.resolve(path.join(custom, 'opencode', 'opencode.jsonc'))); + }); + + it('install self-heals a pre-#535 %APPDATA% entry, preserving siblings and comments', () => { + // A previous codegraph version wrote into %APPDATA%/opencode. The user + // also has another MCP server and a comment there — those must survive. + fs.mkdirSync(legacyDir(), { recursive: true }); + fs.writeFileSync(path.join(legacyDir(), 'opencode.jsonc'), [ + '{', + ' // my servers', + ' "$schema": "https://opencode.ai/config.json",', + ' "mcp": {', + ' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },', + ' "other": { "type": "local", "command": ["other"], "enabled": true }', + ' }', + '}', + '', + ].join('\n')); + fs.writeFileSync(path.join(legacyDir(), 'AGENTS.md'), LEGACY_BLOCK + '\n'); + + const opencode = getTarget('opencode')!; + const result = opencode.install('global', { autoAllow: true }); + + // New entry in the right place… + expect(fs.existsSync(xdgConfigFile())).toBe(true); + // …stale entry swept out of the legacy file, siblings + comment intact. + const legacyText = fs.readFileSync(path.join(legacyDir(), 'opencode.jsonc'), 'utf-8'); + expect(legacyText).not.toContain('codegraph'); + expect(legacyText).toContain('"other"'); + expect(legacyText).toContain('// my servers'); + // …and the legacy AGENTS.md — block-only, so emptied — removed outright + // (removeMarkedSection unlinks a file it leaves empty). + expect(fs.existsSync(path.join(legacyDir(), 'AGENTS.md'))).toBe(false); + // Both cleanups are reported. + const removed = result.files.filter((f) => f.action === 'removed').map((f) => f.path); + expect(removed.some((p) => inLegacyDir(p) && p.endsWith('opencode.jsonc'))).toBe(true); + expect(removed.some((p) => inLegacyDir(p) && p.endsWith('AGENTS.md'))).toBe(true); + }); + + it('uninstall sweeps the legacy %APPDATA% entry too (no prior re-install needed)', () => { + // A user on the broken version goes straight to `codegraph uninstall`: + // the only entry that exists is the stale %APPDATA% one. + fs.mkdirSync(legacyDir(), { recursive: true }); + fs.writeFileSync(path.join(legacyDir(), 'opencode.json'), + '{\n "mcp": {\n "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true }\n }\n}\n'); + + const opencode = getTarget('opencode')!; + const result = opencode.uninstall('global'); + + expect(fs.readFileSync(path.join(legacyDir(), 'opencode.json'), 'utf-8')).not.toContain('codegraph'); + expect(result.files.some((f) => f.action === 'removed' && inLegacyDir(f.path))).toBe(true); + }); + + it('install after install sweeps only once — second run reports no legacy changes', () => { + fs.mkdirSync(legacyDir(), { recursive: true }); + fs.writeFileSync(path.join(legacyDir(), 'opencode.json'), + '{\n "mcp": {\n "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true }\n }\n}\n'); + + const opencode = getTarget('opencode')!; + const first = opencode.install('global', { autoAllow: true }); + expect(first.files.some((f) => f.action === 'removed' && inLegacyDir(f.path))).toBe(true); + + const second = opencode.install('global', { autoAllow: true }); + expect(second.files.some((f) => inLegacyDir(f.path))).toBe(false); + expect(second.files.find((f) => f.path.endsWith('opencode.jsonc'))!.action).toBe('unchanged'); + }); + + it('detects opencode as installed from a legacy-only %APPDATA% dir (so install can heal it)', () => { + fs.mkdirSync(legacyDir(), { recursive: true }); + const opencode = getTarget('opencode')!; + expect(opencode.detect('global').installed).toBe(true); + // But configuration state is read from the REAL path only. + expect(opencode.detect('global').alreadyConfigured).toBe(false); + }); +}); diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts new file mode 100644 index 0000000..6f174f6 --- /dev/null +++ b/__tests__/installer.test.ts @@ -0,0 +1,104 @@ +/** + * Installer Tests + * + * Tests for installer config-writer fixes: + * - readJsonFile error handling + * + * (The CLAUDE.md instructions block is no longer written — see issue + * #529. The marker-based install/uninstall self-heal is covered in + * `installer-targets.test.ts`.) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// We test the exported functions from config-writer +import { + writeMcpConfig, +} from '../src/installer/config-writer'; + +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-installer-test-')); +} + +function cleanupTempDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe('Installer Config Writer', () => { + let origCwd: string; + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + origCwd = process.cwd(); + process.chdir(tempDir); + }); + + afterEach(() => { + process.chdir(origCwd); + cleanupTempDir(tempDir); + }); + + describe('readJsonFile error handling', () => { + it('should return empty object for non-existent file', () => { + // writeMcpConfig reads .mcp.json - if it doesn't exist, it should create it + writeMcpConfig('local'); + + const mcpJson = path.join(tempDir, '.mcp.json'); + expect(fs.existsSync(mcpJson)).toBe(true); + + const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8')); + expect(content.mcpServers).toBeDefined(); + expect(content.mcpServers.codegraph).toBeDefined(); + }); + + it('should handle corrupted JSON by creating backup', () => { + // Create a corrupted .mcp.json + const mcpJson = path.join(tempDir, '.mcp.json'); + fs.writeFileSync(mcpJson, '{ this is not valid json !!!'); + + // Suppress console.warn during test + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Should not throw - gracefully handles corruption + writeMcpConfig('local'); + + // Should have warned + expect(warnSpy).toHaveBeenCalled(); + const warnMsg = warnSpy.mock.calls[0][0]; + expect(warnMsg).toContain('Warning'); + + // Backup should exist + expect(fs.existsSync(mcpJson + '.backup')).toBe(true); + // Original backup content should be the corrupted content + const backup = fs.readFileSync(mcpJson + '.backup', 'utf-8'); + expect(backup).toContain('this is not valid json'); + + // New file should be valid JSON with codegraph config + const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8')); + expect(content.mcpServers.codegraph).toBeDefined(); + + warnSpy.mockRestore(); + }); + + it('should preserve existing valid config when adding codegraph', () => { + const mcpJson = path.join(tempDir, '.mcp.json'); + fs.writeFileSync(mcpJson, JSON.stringify({ + mcpServers: { other: { command: 'other-tool' } }, + customField: 'preserved', + }, null, 2)); + + writeMcpConfig('local'); + + const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8')); + expect(content.mcpServers.codegraph).toBeDefined(); + expect(content.mcpServers.other).toBeDefined(); + expect(content.customField).toBe('preserved'); + }); + }); +}); diff --git a/__tests__/integration/full-pipeline.test.ts b/__tests__/integration/full-pipeline.test.ts new file mode 100644 index 0000000..5b551c1 --- /dev/null +++ b/__tests__/integration/full-pipeline.test.ts @@ -0,0 +1,272 @@ +/** + * End-to-end pipeline integration tests + * + * Exercises the full happy path that unit tests cover in isolation: + * init → indexAll → resolveReferences → searchNodes/getCallers/buildContext → sync + * + * Also covers two error paths that were previously uncovered: + * - Indexing a file that contains a syntactically invalid snippet + * (parse errors must not abort the batch). + * - Sync correctly applies adds + modifies + removes in a single pass. + * + * A synthetic ~120-file project is generated per test (5k files would + * dwarf the test runner; 120 files of varied TS shape is enough to + * stress the resolver and graph layers without slowing the suite to a + * crawl). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../../src/index'; + +function createTempDir(prefix = 'codegraph-int-'): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanupTempDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Generate a synthetic TypeScript project with the given module count. + * Each module exports a function that calls the previous module's + * function so that the resolver has real import edges + call edges to + * resolve. The first module is a leaf; the last is the root. + */ +function generateSyntheticProject(root: string, moduleCount: number): void { + const srcDir = path.join(root, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + // Leaf module — no imports. + fs.writeFileSync( + path.join(srcDir, `mod0.ts`), + `export function fn0(x: number): number { return x + 1; }\n` + + `export class Mod0 { ping(): string { return 'mod0'; } }\n` + ); + + for (let i = 1; i < moduleCount; i++) { + const prev = i - 1; + fs.writeFileSync( + path.join(srcDir, `mod${i}.ts`), + `import { fn${prev}, Mod${prev} } from './mod${prev}';\n` + + `export function fn${i}(x: number): number { return fn${prev}(x) + 1; }\n` + + `export class Mod${i} extends Mod${prev} {\n` + + ` call${i}(): number { return fn${i}(${i}); }\n` + + `}\n` + ); + } + + // Entry point file. + fs.writeFileSync( + path.join(srcDir, 'index.ts'), + `import { fn${moduleCount - 1}, Mod${moduleCount - 1} } from './mod${moduleCount - 1}';\n` + + `export function entry(): number {\n` + + ` const m = new Mod${moduleCount - 1}();\n` + + ` return fn${moduleCount - 1}(0) + m.call${moduleCount - 1}();\n` + + `}\n` + ); +} + +describe('Integration: full pipeline', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('runs init → index → resolve → search → callers → context → sync', async () => { + const MODULE_COUNT = 120; + generateSyntheticProject(tempDir, MODULE_COUNT); + + // ── init ────────────────────────────────────────────────────── + const cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + + try { + // ── indexAll ──────────────────────────────────────────────── + const indexResult = await cg.indexAll(); + // Synthetic project: MODULE_COUNT mod files + 1 index file. + expect(indexResult.filesIndexed).toBeGreaterThanOrEqual(MODULE_COUNT); + + const statsAfterIndex = cg.getStats(); + expect(statsAfterIndex.fileCount).toBeGreaterThanOrEqual(MODULE_COUNT); + expect(statsAfterIndex.nodeCount).toBeGreaterThan(MODULE_COUNT * 2); + + // ── resolveReferences ──────────────────────────────────────── + // Many call-site edges are wired up during extraction itself, so + // the unresolved-reference queue may already be drained by the + // time we get here. We assert that resolve completes cleanly and + // returns a well-formed result; downstream callers/callees + // assertions verify the graph is actually populated. + cg.reinitializeResolver(); + const resolution = cg.resolveReferences(); + expect(resolution).toBeDefined(); + expect(resolution.stats).toBeDefined(); + expect(typeof resolution.stats.total).toBe('number'); + expect(typeof resolution.stats.resolved).toBe('number'); + + // ── searchNodes ────────────────────────────────────────────── + const entryResults = cg.searchNodes('entry', { limit: 10 }); + expect(entryResults.length).toBeGreaterThan(0); + const entryNode = entryResults.find((r) => r.node.name === 'entry'); + expect(entryNode).toBeDefined(); + + const midResults = cg.searchNodes(`fn50`, { limit: 10 }); + expect(midResults.find((r) => r.node.name === 'fn50')).toBeDefined(); + + // ── getCallers / getCallees ────────────────────────────────── + const fn0Results = cg.searchNodes('fn0', { limit: 5 }); + const fn0Node = fn0Results.find((r) => r.node.name === 'fn0'); + expect(fn0Node).toBeDefined(); + const callers = cg.getCallers(fn0Node!.node.id); + // fn0 is called by fn1 (at least). After resolution this should + // be wired up. + expect(Array.isArray(callers)).toBe(true); + + // ── buildContext ───────────────────────────────────────────── + const context = await cg.buildContext('entry function chain', { + maxNodes: 10, + format: 'markdown', + }); + expect(typeof context).toBe('string'); + expect((context as string).length).toBeGreaterThan(0); + + // ── sync (add + modify + remove in one pass) ───────────────── + // Add: a new file referencing entry(). + fs.writeFileSync( + path.join(tempDir, 'src', 'consumer.ts'), + `import { entry } from './index';\nexport const result = entry();\n` + ); + // Modify: change mod0. + fs.writeFileSync( + path.join(tempDir, 'src', 'mod0.ts'), + `export function fn0(x: number): number { return x + 2; }\n` + + `export function newHelper(): string { return 'new'; }\n` + + `export class Mod0 { ping(): string { return 'mod0v2'; } }\n` + ); + // Remove: drop mod1 — note this will leave dangling imports in + // mod2, which the resolver should tolerate. + fs.unlinkSync(path.join(tempDir, 'src', 'mod1.ts')); + + const syncResult = await cg.sync(); + expect(syncResult.filesAdded).toBeGreaterThanOrEqual(1); + expect(syncResult.filesModified).toBeGreaterThanOrEqual(1); + expect(syncResult.filesRemoved).toBeGreaterThanOrEqual(1); + + // New symbol must now be findable; removed file's symbols gone. + expect(cg.searchNodes('newHelper').length).toBeGreaterThan(0); + + // Removed file should no longer appear in the indexed file list. + // (FTS prefix matching makes name-based assertions unreliable here — + // Mod10/Mod11/… all start with "Mod1" — so we check the file set + // instead.) + const filesAfterSync = cg.getNodesInFile('src/mod1.ts'); + expect(filesAfterSync).toHaveLength(0); + } finally { + cg.destroy(); + } + }, 60_000); + + it('keeps indexing files when one file has a parse error', async () => { + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + // Valid files + fs.writeFileSync( + path.join(srcDir, 'good1.ts'), + `export function good1(): number { return 1; }\n` + ); + fs.writeFileSync( + path.join(srcDir, 'good2.ts'), + `export function good2(): number { return 2; }\n` + ); + // Intentionally broken file — unclosed brace, stray tokens. + fs.writeFileSync( + path.join(srcDir, 'broken.ts'), + `export function broken(\n this is { not valid typescript at all\n` + ); + + const cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + + try { + const result = await cg.indexAll(); + // The two good files must still be indexed regardless of the + // broken one. Tree-sitter is error-tolerant so it may still + // extract a partial AST from broken.ts — but the test only + // requires that the batch completes and finds the good symbols. + expect(result.filesIndexed).toBeGreaterThanOrEqual(2); + + const good1 = cg.searchNodes('good1'); + const good2 = cg.searchNodes('good2'); + expect(good1.find((r) => r.node.name === 'good1')).toBeDefined(); + expect(good2.find((r) => r.node.name === 'good2')).toBeDefined(); + } finally { + cg.destroy(); + } + }, 30_000); + + it('handles repeated sync calls when nothing has changed', async () => { + generateSyntheticProject(tempDir, 10); + + const cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + + try { + await cg.indexAll(); + const statsBefore = cg.getStats(); + + const first = await cg.sync(); + const second = await cg.sync(); + + // Subsequent sync with no changes should be a no-op. + expect(first.filesAdded + first.filesModified + first.filesRemoved).toBe(0); + expect(second.filesAdded + second.filesModified + second.filesRemoved).toBe(0); + + const statsAfter = cg.getStats(); + expect(statsAfter.fileCount).toBe(statsBefore.fileCount); + expect(statsAfter.nodeCount).toBe(statsBefore.nodeCount); + } finally { + cg.destroy(); + } + }, 30_000); + + it('reports edgesCreated including resolution + synthesizer phases', async () => { + // The synthetic project has cross-file imports, calls, and extends — + // all wired up in the resolution phase, AFTER the orchestrator's + // per-file extraction counter is done. The CLI summary used to read + // only the extraction-phase counter and undercount the graph; this + // test pins the counter to the true DB totals across all phases. + generateSyntheticProject(tempDir, 30); + + const cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + + try { + const result = await cg.indexAll(); + const stats = cg.getStats(); + + expect(result.success).toBe(true); + expect(result.nodesCreated).toBe(stats.nodeCount); + expect(result.edgesCreated).toBe(stats.edgeCount); + // Sanity: cross-file resolution had something to do — calls/extends + // edges should exist beyond the bare extraction-time contains edges. + const containsOnly = stats.edgesByKind.contains ?? 0; + expect(stats.edgeCount).toBeGreaterThan(containsOnly); + } finally { + cg.destroy(); + } + }, 30_000); +}); diff --git a/__tests__/integration/lru-cache.test.ts b/__tests__/integration/lru-cache.test.ts new file mode 100644 index 0000000..8156760 --- /dev/null +++ b/__tests__/integration/lru-cache.test.ts @@ -0,0 +1,96 @@ +/** + * LRUCache unit tests + * + * Covers the eviction guarantees that the resolver relies on: + * - capacity is enforced (never exceeds max) + * - LRU ordering: hot keys survive eviction passes + * - has()/get()/set()/clear() behave like the original Map shape + * - null values are storable (the fileCache uses null for "failed read") + */ + +import { describe, it, expect } from 'vitest'; +import { LRUCache } from '../../src/resolution/lru-cache'; + +describe('LRUCache', () => { + it('enforces capacity by evicting the oldest entry on overflow', () => { + const cache = new LRUCache(3); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + cache.set('d', 4); // evicts 'a' + + expect(cache.size).toBe(3); + expect(cache.has('a')).toBe(false); + expect(cache.get('a')).toBeUndefined(); + expect(cache.get('b')).toBe(2); + expect(cache.get('c')).toBe(3); + expect(cache.get('d')).toBe(4); + }); + + it('promotes touched keys to most-recent so they survive eviction', () => { + const cache = new LRUCache(3); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + + // Touch 'a' — it should now be most-recent. + expect(cache.get('a')).toBe(1); + + cache.set('d', 4); // evicts the LRU, which is now 'b' (not 'a') + + expect(cache.has('a')).toBe(true); + expect(cache.has('b')).toBe(false); + expect(cache.has('c')).toBe(true); + expect(cache.has('d')).toBe(true); + }); + + it('overwriting an existing key refreshes its recency but does not grow size', () => { + const cache = new LRUCache(2); + cache.set('a', 1); + cache.set('b', 2); + cache.set('a', 99); // 'a' is now most-recent + + expect(cache.size).toBe(2); + expect(cache.get('a')).toBe(99); + + cache.set('c', 3); // should evict 'b', not 'a' + + expect(cache.has('a')).toBe(true); + expect(cache.has('b')).toBe(false); + expect(cache.has('c')).toBe(true); + }); + + it('stores null values (used by the file content cache)', () => { + const cache = new LRUCache(2); + cache.set('missing.ts', null); + expect(cache.has('missing.ts')).toBe(true); + expect(cache.get('missing.ts')).toBeNull(); + }); + + it('clear() resets the cache', () => { + const cache = new LRUCache(3); + cache.set('a', 1); + cache.set('b', 2); + cache.clear(); + expect(cache.size).toBe(0); + expect(cache.has('a')).toBe(false); + }); + + it('rejects non-positive capacity', () => { + expect(() => new LRUCache(0)).toThrow(); + expect(() => new LRUCache(-1)).toThrow(); + expect(() => new LRUCache(NaN)).toThrow(); + }); + + it('stays bounded under heavy churn (regression for OOM scenario)', () => { + const cache = new LRUCache(100); + for (let i = 0; i < 10_000; i++) { + cache.set(`key${i}`, i); + } + expect(cache.size).toBe(100); + // The last 100 keys should still be present, the rest evicted. + expect(cache.has('key9999')).toBe(true); + expect(cache.has('key9900')).toBe(true); + expect(cache.has('key0')).toBe(false); + }); +}); diff --git a/__tests__/integration/mcp-input-limits.test.ts b/__tests__/integration/mcp-input-limits.test.ts new file mode 100644 index 0000000..7471f82 --- /dev/null +++ b/__tests__/integration/mcp-input-limits.test.ts @@ -0,0 +1,109 @@ +/** + * MCP tool input-size limits + * + * Regression coverage for the DoS vector: MCP clients can ship + * unbounded payloads (`query`, `task`, `symbol`, `projectPath`, + * `path`, `pattern`). Before the cap, a 100MB string would hit + * the FTS5 layer and pin the server. These tests assert that the + * tool layer rejects oversize inputs early. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../../src/index'; +import { ToolHandler } from '../../src/mcp/tools'; + +describe('MCP input size limits', () => { + let tempDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-limits-')); + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src', 'a.ts'), + `export function alpha(): number { return 1; }\n` + ); + cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterEach(() => { + if (cg) cg.destroy(); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('accepts a normal-sized query', async () => { + const result = await handler.execute('codegraph_search', { query: 'alpha' }); + expect(result.isError).toBeFalsy(); + }); + + it('rejects an oversize query on codegraph_search', async () => { + const huge = 'a'.repeat(20_000); + const result = await handler.execute('codegraph_search', { query: huge }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/maximum length/i); + }); + + it('rejects an oversize query on codegraph_explore', async () => { + const huge = 'b'.repeat(50_000); + const result = await handler.execute('codegraph_explore', { query: huge }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/maximum length/i); + }); + + it('rejects an oversize symbol on codegraph_callers', async () => { + const huge = 'c'.repeat(15_000); + const result = await handler.execute('codegraph_callers', { symbol: huge }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/maximum length/i); + }); + + it('rejects an oversize symbol on codegraph_impact', async () => { + const huge = 'd'.repeat(11_000); + const result = await handler.execute('codegraph_impact', { symbol: huge }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/maximum length/i); + }); + + it('rejects an oversize projectPath', async () => { + const hugePath = '/tmp/' + 'x'.repeat(5_000); + const result = await handler.execute('codegraph_search', { + query: 'alpha', + projectPath: hugePath, + }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/projectPath/); + }); + + it('rejects an oversize path filter on codegraph_files', async () => { + const hugePath = 'src/' + 'y'.repeat(5_000); + const result = await handler.execute('codegraph_files', { path: hugePath }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/path/); + }); + + it('rejects an oversize glob pattern on codegraph_files', async () => { + const hugePattern = '*'.repeat(5_000); + const result = await handler.execute('codegraph_files', { pattern: hugePattern }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/pattern/); + }); + + it('rejects a non-string projectPath', async () => { + const result = await handler.execute('codegraph_search', { + query: 'alpha', + projectPath: 12345 as unknown as string, + }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/projectPath/); + }); +}); diff --git a/__tests__/is-test-file.test.ts b/__tests__/is-test-file.test.ts new file mode 100644 index 0000000..e3fc6d0 --- /dev/null +++ b/__tests__/is-test-file.test.ts @@ -0,0 +1,53 @@ +/** + * isTestFile heuristic — test-file detection used to deprioritize test code in + * search/explore ranking. + * + * Regression coverage for the cold-query fix: the heuristic previously only + * knew Java/JS/Python conventions, so Kotlin (`*Test.kt`, `jvmTest/`), Swift + * (`*Tests.swift`), and camelCase test source-set dirs slipped through — which + * let OkHttp's tests flood `codegraph_explore` results on a plain-language + * query. The false-positive guards matter just as much: `latest.kt` / + * `manifest.kt` / a `RealCall.kt` production file must NOT be flagged. + */ +import { describe, it, expect } from 'vitest'; +import { isTestFile } from '../src/search/query-utils'; + +describe('isTestFile', () => { + it('flags Kotlin test files and source sets', () => { + expect(isTestFile('okhttp/src/jvmTest/kotlin/okhttp3/CallTest.kt')).toBe(true); + expect(isTestFile('okhttp/src/commonTest/kotlin/okhttp3/CompressionInterceptorTest.kt')).toBe(true); + expect(isTestFile('app/src/androidTest/java/com/example/FooTest.kt')).toBe(true); + expect(isTestFile('module/src/integrationTest/kotlin/BarSpec.kt')).toBe(true); + }); + + it('flags Swift test files', () => { + expect(isTestFile('Tests/SessionTests.swift')).toBe(true); + expect(isTestFile('Sources/FooTest.swift')).toBe(true); + }); + + it('still flags the previously-supported conventions', () => { + expect(isTestFile('foo/test_bar.py')).toBe(true); + expect(isTestFile('pkg/bar_test.go')).toBe(true); + expect(isTestFile('src/foo.test.ts')).toBe(true); + expect(isTestFile('src/foo.spec.ts')).toBe(true); + expect(isTestFile('com/example/FooTest.java')).toBe(true); + expect(isTestFile('com/example/FooTestCase.java')).toBe(true); + expect(isTestFile('project/__tests__/foo.ts')).toBe(true); + expect(isTestFile('project/tests/foo.rb')).toBe(true); + }); + + it('does NOT flag production files that merely contain "test" lowercase', () => { + // The fix is capital-led so camelCase boundaries distinguish these. + expect(isTestFile('src/latest/loader.kt')).toBe(false); + expect(isTestFile('lib/manifest.kt')).toBe(false); + expect(isTestFile('okhttp/src/jvmMain/kotlin/okhttp3/internal/connection/RealCall.kt')).toBe(false); + expect(isTestFile('src/contestEntry.ts')).toBe(false); + expect(isTestFile('pkg/greatest.go')).toBe(false); + }); + + it('does NOT flag ordinary production source', () => { + expect(isTestFile('src/flask/app.py')).toBe(false); + expect(isTestFile('src/vs/workbench/api/common/extensionHostMain.ts')).toBe(false); + expect(isTestFile('okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt')).toBe(false); + }); +}); diff --git a/__tests__/iterate-nodes-by-kind.test.ts b/__tests__/iterate-nodes-by-kind.test.ts new file mode 100644 index 0000000..19ee05f --- /dev/null +++ b/__tests__/iterate-nodes-by-kind.test.ts @@ -0,0 +1,62 @@ +/** + * `QueryBuilder.iterateNodesByKind` — the streaming scan that fixes the #610 + * OOM. The dynamic-edge synthesizers used to `getNodesByKind('function')` / + * `('method')`, materializing every symbol into one array (gigabytes on a + * symbol-dense project → JS-heap OOM). They now iterate. These tests pin the + * two properties that refactor relies on: the streamed set equals the eager + * set, and an open iterator cursor coexists with other queries on the same + * connection. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; + +describe('iterateNodesByKind (#610 streaming)', () => { + let dir: string; + let cg: CodeGraph; + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-iter-')); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync( + path.join(dir, 'src', 'a.ts'), + 'export function foo() { return 1; }\n' + + 'export function bar() { return 2; }\n' + + 'export class C { m() { return 3; } n() { return 4; } }\n' + ); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + }); + + afterEach(() => { + try { cg.close(); } catch { /* ignore */ } + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('yields exactly the same nodes as the eager getNodesByKind', () => { + const q = (cg as unknown as { queries: any }).queries; + for (const kind of ['function', 'method', 'class'] as const) { + const eager = q.getNodesByKind(kind).map((n: any) => n.id).sort(); + const streamed = [...q.iterateNodesByKind(kind)].map((n: any) => n.id).sort(); + expect(streamed).toEqual(eager); + } + // sanity: the fixture actually produced functions + methods to stream + expect([...q.iterateNodesByKind('function')].length).toBeGreaterThan(0); + expect([...q.iterateNodesByKind('method')].length).toBeGreaterThan(0); + }); + + it('keeps the cursor valid while other queries run mid-iteration', () => { + const q = (cg as unknown as { queries: any }).queries; + let seen = 0; + for (const n of q.iterateNodesByKind('function')) { + // A different prepared statement stepped on the same connection while the + // iterator's cursor is open must not corrupt it. + const again = q.getNodeById(n.id); + expect(again?.id).toBe(n.id); + seen++; + } + expect(seen).toBe(q.getNodesByKind('function').length); + }); +}); diff --git a/__tests__/laravel-event-synthesizer.test.ts b/__tests__/laravel-event-synthesizer.test.ts new file mode 100644 index 0000000..220af85 --- /dev/null +++ b/__tests__/laravel-event-synthesizer.test.ts @@ -0,0 +1,169 @@ +/** + * Laravel event-dispatch bridge (PHP). + * + * Laravel decouples an event dispatch from its listener(s), linked by the event class: + * `event(new SongLiked($id))` has no static edge to the `handle(SongLiked $e)` that runs it + * (usually a separate `app/Listeners/` file). This bridges each `event(new X(...))` site to every + * listener's `handle` for X, via TWO registration mechanisms: (A) a typed `handle(EventType $e)` + * (auto-discovery, union-split for `A|B`) and (B) the `protected $listen` map in an + * EventServiceProvider (which also covers a listener whose `handle()` is untyped). Queued JOBS + * dispatch via `::dispatch()`/`dispatch()` and their `handle()` takes a service — so only + * `event(new X)` is matched and jobs are excluded. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +describe('laravel-event synthesizer', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'laravel-event-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + it('bridges event(new X) to listener handles via typed handles, the $listen map, unions, and fan-out; excludes jobs', async () => { + for (const [name, body] of [ + ['SongLiked', 'public int $id; public function __construct(int $id) { $this->id = $id; }'], + ['LibraryChanged', ''], + ['ScanDone', ''], + ['OwnerTest', ''], + ['UserTest', ''], + ] as const) { + write(`app/Events/${name}.php`, ` [ + PruneLibrary::class, + ], + ]; +} +`); + write('app/Services/SongService.php', ` edges.filter((r: any) => r.source === s); + const file = (r: any) => /(\w+)\.php$/.exec(r.tf)![1]; + + expect(edges.length).toBe(6); + expect(edges.every((r: any) => r.target === 'handle')).toBe(true); + // (A) typed handle. + expect(bySrc('like').map((r: any) => [r.via, file(r)])).toEqual([['SongLiked', 'LoveTrack']]); + // (B) untyped handle via the $listen map. + expect(bySrc('deleteSongs').map((r: any) => [r.via, file(r)])).toEqual([['LibraryChanged', 'PruneLibrary']]); + // Fan-out: ScanDone → both listeners. + expect(new Set(bySrc('scan').map(file))).toEqual(new Set(['WriteScanLog', 'DeleteStale'])); + // Union split: OwnerTest and UserTest each reach the one listener (separate dispatchers, + // so they aren't deduped to a single source→target edge). + expect(bySrc('ownerTest').map((r: any) => [r.via, file(r)])).toEqual([['OwnerTest', 'SendsTestNotification']]); + expect(bySrc('userTest').map((r: any) => [r.via, file(r)])).toEqual([['UserTest', 'SendsTestNotification']]); + // PRECISION: a queued job (::dispatch / dispatch()) produces nothing. + expect(edges.some((r: any) => r.source === 'process')).toBe(false); + + cg.close?.(); + }); + + it('produces no edges in a PHP project with no Laravel events (clean control)', async () => { + write('src/Client.php', ` { + it('parseWatchdogTimeoutMs falls back for missing/invalid input', () => { + expect(parseWatchdogTimeoutMs(undefined)).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS); + expect(parseWatchdogTimeoutMs('not-a-number')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS); + expect(parseWatchdogTimeoutMs('0')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS); + expect(parseWatchdogTimeoutMs('-5')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS); + expect(parseWatchdogTimeoutMs('1500')).toBe(1500); + }); + + it('deriveCheckIntervalMs stays within [50, 2000] and scales with the timeout', () => { + expect(deriveCheckIntervalMs(60_000)).toBe(2000); // clamped high + expect(deriveCheckIntervalMs(500)).toBe(100); // 500/5 + expect(deriveCheckIntervalMs(10)).toBe(50); // clamped low + }); +}); + +describe('installMainThreadWatchdog opt-out', () => { + it('returns null (spawns nothing) when CODEGRAPH_NO_WATCHDOG is set', () => { + const prev = process.env.CODEGRAPH_NO_WATCHDOG; + process.env.CODEGRAPH_NO_WATCHDOG = '1'; + try { + expect(installMainThreadWatchdog()).toBeNull(); + } finally { + if (prev === undefined) delete process.env.CODEGRAPH_NO_WATCHDOG; + else process.env.CODEGRAPH_NO_WATCHDOG = prev; + } + }); +}); + +/** + * End-to-end: spawn a real process, install the real watchdog (which spawns a + * separate watchdog child), and prove it kills a wedged main thread — including + * the case a worker thread could NOT (a non-allocating loop under heap pressure, + * which strands a same-process worker on V8's global safepoint, #850). Drives + * the built module the way mcp-ppid-watchdog.test.ts drives the built CLI. + */ +describe('liveness watchdog (spawned, real watchdog process)', () => { + const MODULE = path.resolve(__dirname, '../dist/mcp/liveness-watchdog.js'); + + beforeAll(() => { + if (!fs.existsSync(MODULE)) { + throw new Error(`Build the project first: ${MODULE} is missing (run npm run build).`); + } + }); + + function runChild( + env: Record, + body: string, + hardTimeoutMs: number, + progressPaths?: string[] + ): Promise<{ code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null }> { + const src = ` + const { installMainThreadWatchdog } = require(${JSON.stringify(MODULE)}); + installMainThreadWatchdog(${progressPaths ? JSON.stringify({ progressPaths }) : ''}); + ${body} + `; + const child = spawn(process.execPath, ['-e', src], { + env: { ...process.env, ...env }, + stdio: ['ignore', 'ignore', 'ignore'], + }); + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + resolve({ code: null, signal: 'TIMEOUT' }); + }, hardTimeoutMs); + child.on('exit', (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }); + } + + // Assert the watchdog terminated the process. POSIX surfaces the external + // SIGKILL as signal 'SIGKILL'; Windows has no real signals, so the watchdog's + // `process.kill(pid, 'SIGKILL')` maps to TerminateProcess and an observer sees + // signal=null with a non-zero exit code. Either is a kill; the synthetic + // 'TIMEOUT' (the watchdog never fired) is the failure we're guarding against. + function expectKilled(r: { code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null }): void { + expect(r.signal === 'SIGKILL' || (r.signal === null && r.code !== 0 && r.code !== null)).toBe(true); + } + + it('SIGKILLs a process whose main thread wedges in a sync loop', async () => { + const r = await runChild( + { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' }, + 'setTimeout(() => { while (true) {} }, 150);', + 8000 + ); + expectKilled(r); + }, 12000); + + it('SIGKILLs a non-allocating wedge under heap pressure (the case worker threads stalled on)', async () => { + const r = await runChild( + { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' }, + // ~40MB retained so a GC is likely, then a tight NON-allocating loop — the + // exact shape that deadlocks a same-process worker on the global safepoint. + 'const k=[]; for (let i=0;i<40;i++) k.push(Buffer.alloc(1024*1024,i)); global.__k=k; setTimeout(() => { while (true) {} }, 150);', + 8000 + ); + expectKilled(r); + }, 12000); + + it('does NOT kill a healthy process that keeps its event loop turning', async () => { + const { code, signal } = await runChild( + { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' }, + 'const iv = setInterval(() => {}, 50); setTimeout(() => { clearInterval(iv); process.exit(7); }, 1500);', + 8000 + ); + expect(signal).toBeNull(); // never signalled + expect(code).toBe(7); // exited on its own terms + }, 12000); + + // --- disk-progress deferral (#1231): a blocked event loop is NOT a wedge + // when the watched DB files keep advancing (a slow synchronous SQLite + // statement on degraded storage). --- + + /** Grow `file` every 150ms for `forMs`; resolves when done. */ + function growFile(file: string, forMs: number): Promise { + return new Promise((resolve) => { + const iv = setInterval(() => { fs.appendFileSync(file, 'x'.repeat(64)); }, 150); + setTimeout(() => { clearInterval(iv); resolve(); }, forMs); + }); + } + + it('does NOT kill a blocked loop while the watched files advance (slow store, not a wedge)', async () => { + const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal'); + fs.writeFileSync(tmp, 'seed'); + // Base timeout 500ms; the loop blocks for 2.5s (5 timeouts, under the 10× + // cap) while the test process grows the watched file. Old behavior: killed + // at ~500ms. New: deferred, exits on its own with code 5. + const [r] = await Promise.all([ + runChild( + { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' }, + 'setTimeout(() => { const end = Date.now() + 2500; while (Date.now() < end) {} process.exit(5); }, 200);', + 10_000, + [tmp] + ), + growFile(tmp, 3200), + ]); + expect(r.signal).toBeNull(); + expect(r.code).toBe(5); + }, 15000); + + it('still kills a blocked loop when the watched files do NOT advance (a true wedge)', async () => { + const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal'); + fs.writeFileSync(tmp, 'seed'); + // Same blocked loop, nobody grows the file: the base timeout kills it long + // before its own exit(5) at 2.5s. + const r = await runChild( + { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' }, + 'setTimeout(() => { const end = Date.now() + 2500; while (Date.now() < end) {} process.exit(5); }, 200);', + 10_000, + [tmp] + ); + expectKilled(r); + }, 15000); + + it('kills at the hard cap even with ongoing file activity (bounded deferral)', async () => { + const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal'); + fs.writeFileSync(tmp, 'seed'); + // Base timeout 300ms ⇒ cap 3s. The loop blocks for 8s with continuous file + // growth: deferral carries it past 300ms but the cap kills it around ~3s, + // well before its own exit(5). + const [r] = await Promise.all([ + runChild( + { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '300' }, + 'setTimeout(() => { const end = Date.now() + 8000; while (Date.now() < end) {} process.exit(5); }, 200);', + 15_000, + [tmp] + ), + growFile(tmp, 9000), + ]); + expectKilled(r); + }, 20000); + + it('does NOT kill a wedged process when CODEGRAPH_NO_WATCHDOG=1', async () => { + const { code, signal } = await runChild( + { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500', CODEGRAPH_NO_WATCHDOG: '1' }, + 'setTimeout(() => { const end = Date.now() + 1500; while (Date.now() < end) {} process.exit(3); }, 150);', + 8000 + ); + // It exits with its OWN code 3 — proving nothing killed it. (Checking only + // signal=null is insufficient on Windows, where a kill also reports null.) + expect(signal).toBeNull(); + expect(code).toBe(3); + }, 12000); +}); diff --git a/__tests__/lombok.test.ts b/__tests__/lombok.test.ts new file mode 100644 index 0000000..5027607 --- /dev/null +++ b/__tests__/lombok.test.ts @@ -0,0 +1,156 @@ +/** + * Lombok-generated member synthesis (Java, #912). + * + * Lombok generates getters/setters/builder/equals/hashCode/toString and the + * `log` field at compile time, so they never appear in the source AST. Without + * synthesis they're absent from the index and any `bean.getX()` / `Bean.builder()` + * / `log.info()` call resolves to nothing — call chains break silently. We + * synthesize the mechanical ones from the annotations + fields, mark them + * (`lombok` decorator + a docstring naming the source annotation), and they then + * resolve as ordinary call targets. These tests prove the synthesis, the call + * resolution that motivated it, and the precision boundaries (static fields + * skipped, hand-written members never overridden, a non-Lombok class is clean). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +describe('lombok synthesis', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lombok-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + type Row = { name: string; kind: string; decorators: string | null; docstring: string | null; signature: string | null }; + const load = async () => { + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + const nodes: Row[] = db.prepare(`SELECT name, kind, decorators, docstring, signature FROM nodes`).all(); + const calls: { src: string; tgt: string }[] = db + .prepare( + `SELECT s.name src, t.name tgt FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind = 'calls'` + ) + .all(); + cg.close?.(); + return { nodes, calls }; + }; + + const isLombok = (n: Row | undefined) => !!n && (n.decorators ?? '').includes('lombok'); + + it('synthesizes accessors that resolve as call targets, and the @Slf4j log field', async () => { + write('model/User.java', `package model; +import lombok.Data; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; + +@Data +@Builder +@Slf4j +public class User { + private String name; + private boolean active; + private static final int MAX = 10; +} +`); + write('svc/UserService.java', `package svc; +import model.User; + +class UserService { + String describe(User user) { + user.setActive(true); + return user.getName(); + } + User make() { + return User.builder(); + } +} +`); + + const { nodes, calls } = await load(); + const byName = (name: string) => nodes.find((n) => n.name === name && isLombok(n)); + + // Accessors + Data contract + builder are synthesized and marked. + for (const m of ['getName', 'setName', 'isActive', 'setActive', 'builder', 'equals', 'hashCode', 'toString']) { + expect(isLombok(byName(m)), `expected synthesized ${m}`).toBe(true); + } + expect(byName('getName')!.docstring).toMatch(/Lombok-generated/); + expect(byName('getName')!.signature).toBe('String getName()'); + expect(byName('isActive')!.signature).toBe('boolean isActive()'); // boolean → is-prefix + expect(byName('builder')!.signature).toContain('static '); + + // @Slf4j → a `log` field. + expect(isLombok(nodes.find((n) => n.name === 'log' && n.kind === 'field'))).toBe(true); + + // PRECISION: a static field gets no accessor. + expect(nodes.some((n) => n.name === 'getMAX' || n.name === 'getMax')).toBe(false); + + // THE FIX: calls to Lombok-generated methods resolve to their synthesized target. + const resolved = (src: string, tgt: string) => calls.some((c) => c.src === src && c.tgt === tgt); + expect(resolved('describe', 'getName')).toBe(true); + expect(resolved('describe', 'setActive')).toBe(true); + expect(resolved('make', 'builder')).toBe(true); + }); + + it('never overrides a hand-written accessor', async () => { + write('model/Account.java', `package model; +import lombok.Getter; + +@Getter +public class Account { + private int balance; + private String owner; + + // explicit getter — Lombok skips it, so must we + public int getBalance() { return balance < 0 ? 0 : balance; } +} +`); + const { nodes } = await load(); + const getBalance = nodes.filter((n) => n.name === 'getBalance'); + expect(getBalance.length).toBe(1); // exactly one, not duplicated + expect(isLombok(getBalance[0])).toBe(false); // the hand-written one survives + // the un-shadowed field still gets its synthesized getter + expect(isLombok(nodes.find((n) => n.name === 'getOwner'))).toBe(true); + }); + + it('field-level @Getter/@Setter and final-field rules', async () => { + write('model/Box.java', `package model; +import lombok.Getter; +import lombok.Setter; + +public class Box { + @Getter @Setter private String label; + @Getter private final long id; // final → getter only, no setter + private int hidden; // no annotation → nothing +} +`); + const { nodes } = await load(); + expect(isLombok(nodes.find((n) => n.name === 'getLabel'))).toBe(true); + expect(isLombok(nodes.find((n) => n.name === 'setLabel'))).toBe(true); + expect(isLombok(nodes.find((n) => n.name === 'getId'))).toBe(true); + expect(nodes.some((n) => n.name === 'setId')).toBe(false); // final → no setter + expect(nodes.some((n) => n.name === 'getHidden')).toBe(false); // un-annotated → nothing + }); + + it('produces no synthesized members for a plain Java class (clean control)', async () => { + write('model/Plain.java', `package model; + +public class Plain { + private int value; + public int getValue() { return value; } + public void setValue(int v) { this.value = v; } +} +`); + const { nodes } = await load(); + expect(nodes.some((n) => isLombok(n))).toBe(false); + }); +}); diff --git a/__tests__/mcp-catchup-gate.test.ts b/__tests__/mcp-catchup-gate.test.ts new file mode 100644 index 0000000..15f42ec --- /dev/null +++ b/__tests__/mcp-catchup-gate.test.ts @@ -0,0 +1,173 @@ +/** + * MCP catch-up gate — first tool call blocks on the engine's post-open + * filesystem reconcile so it never serves rows for files that were + * deleted (or edited) while no MCP server was running. + * + * Background: `MCPEngine.catchUpSync()` fires `cg.sync()` in the background. + * Before this fix it was fire-and-forget — a tool call could race past it + * and return rows for files that no longer exist on disk. The per-file + * staleness banner (`withStalenessNotice`) couldn't help, because + * `getPendingFiles()` is populated by the watcher, not by catch-up. + * + * The fix: `catchUpSync()` pushes its promise into the `ToolHandler` via + * `setCatchUpGate(p)`; the first `execute()` call awaits the gate and then + * clears it. These tests exercise the gate directly (deterministic) and + * the engine-driven path (proves the engine actually pokes the gate). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; + +describe('MCP catch-up gate', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-catchup-gate-')); + fs.mkdirSync(path.join(testDir, 'src')); + fs.writeFileSync( + path.join(testDir, 'src', 'survivor.ts'), + 'export function survivor() { return 1; }\n', + ); + fs.writeFileSync( + path.join(testDir, 'src', 'deleted-later.ts'), + 'export function deletedLater() { return 2; }\n', + ); + + cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterEach(() => { + try { cg.unwatch(); } catch { /* ignore */ } + try { cg.close(); } catch { /* ignore */ } + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('awaits the gate before serving the first tool call', async () => { + let gateResolved = false; + const gate = new Promise((resolve) => { + setTimeout(() => { gateResolved = true; resolve(); }, 80); + }); + handler.setCatchUpGate(gate); + + const res = await handler.execute('codegraph_search', { query: 'survivor' }); + expect(gateResolved).toBe(true); + expect(res.isError).toBeFalsy(); + expect(res.content[0].text).toMatch(/survivor/); + }); + + it('drops the gate after first await — second call does not re-wait', async () => { + let awaitCount = 0; + const gate = new Promise((resolve) => { + awaitCount++; + setTimeout(resolve, 20); + }); + handler.setCatchUpGate(gate); + + await handler.execute('codegraph_search', { query: 'survivor' }); + const before = awaitCount; + await handler.execute('codegraph_search', { query: 'survivor' }); + // The promise body runs once when constructed; second execute never + // resubscribes to a fresh promise because the gate field was nulled. + expect(awaitCount).toBe(before); + }); + + it('catch-up reconciles a deleted file before the first tool call sees it', async () => { + // Simulate the empty-project / deleted-files startup case: file is in + // the DB (we indexed it above) but vanishes from disk before the MCP + // server's first query. The catch-up sync, awaited via the gate, + // must remove the row so the first tool call returns no hit. + fs.unlinkSync(path.join(testDir, 'src', 'deleted-later.ts')); + + // Push the actual catch-up sync as the gate — same flow the MCP engine + // uses (`cg.sync()` returns a Promise, the wrapper voids it). + handler.setCatchUpGate(cg.sync().then(() => undefined)); + + const res = await handler.execute('codegraph_search', { query: 'deletedLater' }); + expect(res.isError).toBeFalsy(); + const text = res.content[0].text; + expect(text).not.toMatch(/src\/deleted-later\.ts/); + }); + + it('catch-up that converges the project to 0 files clears all rows', async () => { + // Worst case: every source file is gone between sessions. Without the + // gate, the first tool call serves whatever was in the DB. With the + // gate + the orchestrator's filesystem reconcile, the DB drains. + fs.unlinkSync(path.join(testDir, 'src', 'survivor.ts')); + fs.unlinkSync(path.join(testDir, 'src', 'deleted-later.ts')); + + handler.setCatchUpGate(cg.sync().then(() => undefined)); + + const res = await handler.execute('codegraph_search', { query: 'survivor' }); + expect(res.isError).toBeFalsy(); + expect(cg.getStats().fileCount).toBe(0); + }); + + it('does not hang the first call when catch-up runs past the timeout (#905)', async () => { + // The issue #905 hang: on a huge repo the post-open reconcile takes minutes, + // and gating the first tool call on all of it reads as a multi-minute hang. + // With the time-box, the call is served promptly and the reconcile finishes + // in the background. + const prev = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS; + process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '50'; + let timer: NodeJS.Timeout | undefined; + try { + let gateResolved = false; + const gate = new Promise((resolve) => { + timer = setTimeout(() => { gateResolved = true; resolve(); }, 5000); + }); + handler.setCatchUpGate(gate); + + const started = Date.now(); + const res = await handler.execute('codegraph_search', { query: 'survivor' }); + const elapsed = Date.now() - started; + + expect(res.isError).toBeFalsy(); + expect(res.content[0].text).toMatch(/survivor/); + // Served on the timeout (~50ms), NOT after the 5s reconcile. + expect(gateResolved).toBe(false); + expect(elapsed).toBeLessThan(2000); + } finally { + if (timer) clearTimeout(timer); + if (prev === undefined) delete process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS; + else process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = prev; + } + }); + + it('CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS=0 restores the unbounded wait', async () => { + const prev = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS; + process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '0'; + try { + let gateResolved = false; + const gate = new Promise((resolve) => { + setTimeout(() => { gateResolved = true; resolve(); }, 80); + }); + handler.setCatchUpGate(gate); + + const res = await handler.execute('codegraph_search', { query: 'survivor' }); + // With the time-box disabled, the call waits for the full reconcile. + expect(gateResolved).toBe(true); + expect(res.isError).toBeFalsy(); + } finally { + if (prev === undefined) delete process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS; + else process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = prev; + } + }); + + it('gate that rejects does not break the tool call', async () => { + // A catch-up sync failure (lock contention, transient FS error) must + // not poison tool dispatch — the engine logs it, the handler proceeds. + handler.setCatchUpGate(Promise.reject(new Error('simulated sync failure'))); + + const res = await handler.execute('codegraph_search', { query: 'survivor' }); + expect(res.isError).toBeFalsy(); + expect(res.content[0].text).toMatch(/survivor/); + }); +}); diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts new file mode 100644 index 0000000..ab76136 --- /dev/null +++ b/__tests__/mcp-daemon.test.ts @@ -0,0 +1,460 @@ +/** + * Shared MCP daemon — issue #411. + * + * Validates the daemon architecture in `src/mcp/{daemon,proxy,session,index}.ts` + * AFTER the review fixes: + * + * - The daemon is a *detached* background process; every `serve --mcp` + * invocation is a thin proxy to it. Two invocations against one project + * share ONE daemon. + * - Concurrent launchers converge on a single daemon (the must-fix-1 + * lockfile-race: an empty-pidfile window used to let a racing candidate + * delete the winner's lock → two daemons). + * - Killing the launcher that spawned the daemon does NOT take the daemon + * down — other attached clients keep working (the must-fix-2 detach: the + * in-process daemon used to die with its launcher's process group and + * orphan on host SIGKILL, regressing #277). + * - A stale lockfile (dead pid) is cleared; `CODEGRAPH_NO_DAEMON=1` opts out; + * the proxy refuses to attach across a version mismatch; the daemon + * idle-times-out after the last client leaves (so a single session can't + * leak a daemon forever). + * + * These tests intentionally spawn real `node dist/bin/codegraph.js` processes + * over real sockets/pipes — the same surface a Claude Code / Cursor / Codex + * install exercises. The daemon logs to `.codegraph/daemon.log` (it has no + * client stderr of its own), so daemon-side assertions read that file. + * + * `realRoot` vs `tempDir`: processes are spawned with the (possibly symlinked) + * `tempDir` as cwd/rootUri — on macOS `os.tmpdir()` lives under `/var`, a + * symlink to `/private/var`, and a spawned child's `process.cwd()` is already + * realpath'd. The daemon canonicalizes the root with `realpathSync`, so all + * path assertions use `realRoot` (the canonical form). That this matches end to + * end is itself the proof the canonicalization works. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { getDaemonSocketPath } from '../src/mcp/daemon-paths'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +interface SpawnedServer { + child: ChildProcessWithoutNullStreams; + stdout: string[]; + stderr: string[]; +} + +function spawnServer(cwd: string, env: NodeJS.ProcessEnv = {}): SpawnedServer { + const child = spawn(process.execPath, [BIN, 'serve', '--mcp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + // #618: the daemon-attach log line is now off by default; opt the test + // harness into it (CODEGRAPH_MCP_LOG_ATTACH=1) so the attach assertions + // below can still observe a successful attach. A per-test env still wins. + env: { CODEGRAPH_MCP_LOG_ATTACH: '1', ...process.env, ...env }, + }) as ChildProcessWithoutNullStreams; + // Swallow spawn/EPIPE errors so killing a child mid-write can't surface as an + // unhandled error that crashes the vitest worker. + child.on('error', () => { /* ignore */ }); + child.stdin.on('error', () => { /* ignore */ }); + const stdout: string[] = []; + const stderr: string[] = []; + let stdoutBuf = ''; + let stderrBuf = ''; + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuf += chunk.toString('utf8'); + let idx: number; + while ((idx = stdoutBuf.indexOf('\n')) !== -1) { + stdout.push(stdoutBuf.slice(0, idx)); + stdoutBuf = stdoutBuf.slice(idx + 1); + } + }); + child.stderr.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString('utf8'); + let idx: number; + while ((idx = stderrBuf.indexOf('\n')) !== -1) { + stderr.push(stderrBuf.slice(0, idx)); + stderrBuf = stderrBuf.slice(idx + 1); + } + }); + return { child, stdout, stderr }; +} + +function sendMessage(child: ChildProcessWithoutNullStreams, msg: unknown): void { + try { child.stdin.write(JSON.stringify(msg) + '\n'); } catch { /* child may be gone */ } +} + +function sendInitialize(child: ChildProcessWithoutNullStreams, rootUri: string, id: number): void { + sendMessage(child, { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri, + }, + }); +} + +/** Find a JSON-RPC response with the given id (result OR error) on stdout. */ +function findResponse(stdout: string[], id: number): any | null { + for (const line of stdout) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + if (parsed && parsed.id === id && (parsed.result !== undefined || parsed.error !== undefined)) { + return parsed; + } + } catch { /* not JSON */ } + } + return null; +} + +function waitFor( + predicate: () => T | undefined | null | false, + timeoutMs: number, + pollMs = 25, + label = '', +): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + let v: T | undefined | null | false; + try { v = predicate(); } catch (e) { return reject(e); } + if (v) return resolve(v as T); + if (Date.now() - started > timeoutMs) { + // Name the wait: an async stack loses the await site, so an unlabeled + // timeout can't tell WHICH step flaked (the #662 test's recurring + // timeout was undiagnosable for exactly this reason). + return reject(new Error(`Timed out after ${timeoutMs}ms${label ? ` waiting for: ${label}` : ''}`)); + } + setTimeout(tick, pollMs); + }; + tick(); + }); +} + +function isAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +function readLockPid(root: string): number | null { + try { + const raw = fs.readFileSync(path.join(root, '.codegraph', 'daemon.pid'), 'utf8'); + const info = JSON.parse(raw); + return typeof info.pid === 'number' ? info.pid : null; + } catch { return null; } +} + +function readDaemonLog(root: string): string { + try { return fs.readFileSync(path.join(root, '.codegraph', 'daemon.log'), 'utf8'); } + catch { return ''; } +} + +function countListeningLines(root: string): number { + return readDaemonLog(root).split('\n').filter((l) => l.includes('[CodeGraph daemon] Listening on')).length; +} + +function killTree(...procs: ChildProcessWithoutNullStreams[]): void { + for (const p of procs) { + if (!p.killed) { try { p.kill('SIGKILL'); } catch { /* gone */ } } + } +} + +async function waitProcessExit(pid: number, timeoutMs: number): Promise { + return waitFor(() => !isAlive(pid), timeoutMs).then(() => true).catch(() => false); +} + +describe('Shared MCP daemon (issue #411)', () => { + let tempDir: string; // the (possibly symlinked) path processes are spawned with + let realRoot: string; // its canonical form — what the daemon keys paths on + const servers: SpawnedServer[] = []; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-daemon-')); + const cg = await CodeGraph.init(tempDir); + cg.close(); + realRoot = fs.realpathSync(tempDir); + }); + + afterEach(async () => { + killTree(...servers.map((s) => s.child)); + // The daemon is detached (not a tracked child) — reap it explicitly via the + // pid it recorded, so a test can't leak a background daemon. Guard against + // our own pid: the version-mismatch test plants `pid: process.pid` in the + // lockfile, and we must never SIGKILL the vitest worker. + const daemonPid = readLockPid(realRoot); + if (daemonPid && daemonPid !== process.pid && isAlive(daemonPid)) { + try { process.kill(daemonPid, 'SIGKILL'); } catch { /* race */ } + } + await new Promise((r) => setTimeout(r, 50)); + servers.length = 0; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('two invocations share ONE detached daemon; both attach as proxies', async () => { + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; + + const first = spawnServer(tempDir, env); + servers.push(first); + sendInitialize(first.child, `file://${tempDir}`, 1); + const firstResp = await waitFor(() => findResponse(first.stdout, 1), 10000); + expect(firstResp.result.serverInfo.name).toBe('codegraph'); + + // The launcher is a PROXY (not the daemon itself) — that's the detach fix. + await waitFor(() => first.stderr.some((l) => l.includes('Attached to shared daemon')), 8000); + + // A detached daemon came up and recorded itself. + await waitFor(() => fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid')), 8000); + await waitFor(() => countListeningLines(realRoot) >= 1, 8000); + const daemonPid = readLockPid(realRoot); + expect(daemonPid).toBeTruthy(); + expect(isAlive(daemonPid!)).toBe(true); + // The socket exists at the path the code computes from the canonical root. + // On Windows the daemon listens on a named pipe (\\.\pipe\...), which isn't + // a filesystem entry — existsSync doesn't apply there, and the "Attached to + // shared daemon" proof above already confirms the proxy reached it. + if (process.platform !== 'win32') { + expect(fs.existsSync(getDaemonSocketPath(realRoot))).toBe(true); + } + + // Second invocation attaches as a proxy to the SAME daemon. + const second = spawnServer(tempDir, env); + servers.push(second); + sendInitialize(second.child, `file://${tempDir}`, 2); + const secondResp = await waitFor(() => findResponse(second.stdout, 2), 10000); + expect(secondResp.result.serverInfo.name).toBe('codegraph'); + await waitFor(() => second.stderr.some((l) => l.includes('Attached to shared daemon')), 8000); + + // Exactly one daemon ever bound, and it's the same pid both attached to. + expect(countListeningLines(realRoot)).toBe(1); + expect(readLockPid(realRoot)).toBe(daemonPid); + }, 40000); + + it('concurrent launchers converge on a single daemon (lockfile race — must-fix 1)', async () => { + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; + + // Fire three launchers as close to simultaneously as possible — this is the + // race window where the old code could end up with two daemons. + const procs = [spawnServer(tempDir, env), spawnServer(tempDir, env), spawnServer(tempDir, env)]; + procs.forEach((p, i) => { servers.push(p); sendInitialize(p.child, `file://${tempDir}`, i + 1); }); + + // All three get a valid initialize response... + for (let i = 0; i < procs.length; i++) { + const resp = await waitFor(() => findResponse(procs[i].stdout, i + 1), 12000); + expect(resp.result.serverInfo.name).toBe('codegraph'); + } + // ...and all three attached as proxies (none fell back / wedged). + for (const p of procs) { + await waitFor(() => p.stderr.some((l) => l.includes('Attached to shared daemon')), 10000); + } + + // The decisive assertion: exactly ONE daemon bound the socket. Losing + // candidates log "already holds the lock; exiting" and never listen. + expect(countListeningLines(realRoot)).toBe(1); + const daemonPid = readLockPid(realRoot); + expect(daemonPid).toBeTruthy(); + expect(isAlive(daemonPid!)).toBe(true); + }, 45000); + + it('daemon survives the first client dying; a second client keeps working (must-fix 2 / #277)', async () => { + // Idle high so the daemon doesn't reap mid-test; poll fast so proxy 1 + // notices its dead parent quickly. + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_PPID_POLL_MS: '200' }; + + const first = spawnServer(tempDir, env); + servers.push(first); + sendInitialize(first.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(first.stdout, 1), 10000); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); + const daemonPid = readLockPid(realRoot)!; + expect(isAlive(daemonPid)).toBe(true); + + const second = spawnServer(tempDir, env); + servers.push(second); + sendInitialize(second.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(second.stdout, 1), 10000); + await waitFor(() => second.stderr.some((l) => l.includes('Attached to shared daemon')), 8000); + + // Kill the launcher that spawned the daemon. With the old in-process design + // this would take the daemon (and thus the second client) down. + killTree(first.child); + + // The daemon is detached — it must still be alive a beat later. + await new Promise((r) => setTimeout(r, 1500)); + expect(isAlive(daemonPid)).toBe(true); + + // And the second client can still drive a real tool call through it. + sendMessage(second.child, { jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const toolsResp = await waitFor(() => findResponse(second.stdout, 2), 10000); + expect(Array.isArray(toolsResp.result.tools)).toBe(true); + expect(toolsResp.result.tools.length).toBeGreaterThan(0); + }, 45000); + + it('CODEGRAPH_NO_DAEMON=1 keeps each process independent (no socket/pidfile)', async () => { + const env = { CODEGRAPH_NO_DAEMON: '1' }; + const first = spawnServer(tempDir, env); + servers.push(first); + sendInitialize(first.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(first.stdout, 1), 10000); + // Direct mode — no daemon machinery touched. + expect(first.stderr.some((l) => l.includes('Attached to shared daemon'))).toBe(false); + expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid'))).toBe(false); + expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.log'))).toBe(false); + }, 20000); + + it('clears a stale (dead-pid) lockfile and a fresh daemon takes over', async () => { + // Plant a lockfile pointing at a definitely-dead pid + the real socket path. + fs.writeFileSync( + path.join(realRoot, '.codegraph', 'daemon.pid'), + JSON.stringify({ + pid: 999_999, + version: '0.0.0-fake', + socketPath: getDaemonSocketPath(realRoot), + startedAt: Date.now() - 1000, + }), + ); + + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + const resp = await waitFor(() => findResponse(server.stdout, 1), 10000).catch((e) => { + throw new Error(`${(e as Error).message}\nstderr:\n${server.stderr.join('\n')}\ndaemon.log:\n${readDaemonLog(realRoot)}`); + }); + expect(resp.result.serverInfo.name).toBe('codegraph'); + await waitFor(() => countListeningLines(realRoot) >= 1, 10000); + // The pidfile now names a live daemon, not the planted-dead 999999. + const livePid = readLockPid(realRoot); + expect(livePid).not.toBe(999_999); + expect(isAlive(livePid!)).toBe(true); + }, 40000); + + it('proxy falls back to direct mode on a daemon version mismatch', async () => { + const net = await import('net'); + const sockPath = getDaemonSocketPath(realRoot); + // Plant a live-pid lockfile so the launcher treats the lock as held, and a + // mini-server that answers with a mismatched-version hello. + fs.writeFileSync( + path.join(realRoot, '.codegraph', 'daemon.pid'), + JSON.stringify({ pid: process.pid, version: '0.0.0-mismatch', socketPath: sockPath, startedAt: Date.now() }), + ); + const miniServer = net.createServer((sock) => { + sock.write(JSON.stringify({ codegraph: '0.0.0-mismatch', pid: 1, socketPath: sockPath, protocol: 1 }) + '\n'); + }); + await new Promise((resolve) => miniServer.listen(sockPath, () => resolve())); + + try { + const server = spawnServer(tempDir); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + // Despite the mismatched daemon, the client still gets an initialize + // response — the proxy answers the handshake locally and, refusing to + // attach across the version mismatch, serves the session in-process. + const resp = await waitFor(() => findResponse(server.stdout, 1), 10000); + expect(resp.result.serverInfo.name).toBe('codegraph'); + await waitFor( + () => server.stderr.some((l) => l.includes('serving this session in-process')), + 6000, + ); + } finally { + await new Promise((resolve) => miniServer.close(() => resolve())); + } + }, 30000); + + // The over-the-wire client-hello → record → sweep path, and the inactivity + // backstop's liveness gate, are covered by the deterministic unit tests in + // daemon-client-liveness (`reapDeadClients`, `backstopShouldExit`) — a + // raw-socket variant here was flaky under heavy parallel load. What stays + // here is the lifecycle behavior that needs real procs: a live-but-quiet + // client must SURVIVE the inactivity backstop. Reaping it used to silently + // degrade the session (and any others sharing the daemon) to an in-process + // engine; on a real machine the backstop fired on live sessions far more + // often than on the phantoms it exists for. The phantom case it still covers + // (an unknown-pid connection) is the `backstopShouldExit` unit test. + it('does NOT reap a live-but-quiet client on the inactivity backstop (#692)', async () => { + // Backstop short, idle timeout long: with a client connected the idle timer + // never arms, so the inactivity backstop is the only thing that could take + // the daemon down — and it must not, because the client's peer is alive. + const env = { CODEGRAPH_DAEMON_MAX_IDLE_MS: '1200', CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '60000' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 10000); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); + const daemonPid = readLockPid(realRoot)!; + expect(isAlive(daemonPid)).toBe(true); + + // Stay silent well past several backstop windows. The live session's peer is + // provably alive, so the daemon must keep running (and never log a backstop + // shutdown), with its lockfile intact. + await new Promise((r) => setTimeout(r, 4000)); // > 3× maxIdle + expect(isAlive(daemonPid)).toBe(true); + expect(readDaemonLog(realRoot)).not.toContain('inactivity backstop'); + expect(readLockPid(realRoot)).toBe(daemonPid); + }, 30000); + + it('daemon idle-times-out after the last client disconnects', async () => { + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '800', CODEGRAPH_PPID_POLL_MS: '200' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 10000); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); + const daemonPid = readLockPid(realRoot)!; + + // Close the only client's stdin → proxy exits → daemon refcount hits 0 → + // idle timer fires → daemon exits and cleans up its lockfile. + server.child.stdin.end(); + + expect(await waitProcessExit(daemonPid, 10000)).toBe(true); + expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid'))).toBe(false); + }, 30000); + + it('proxy survives the daemon dying mid-session and keeps serving (#662)', async () => { + // The #662 scenario: an MCP host SIGTERM's the shared daemon while a session + // is live. The proxy must NOT exit (losing CodeGraph for that session) — it + // falls back to an in-process engine and keeps answering. + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_PPID_POLL_MS: '5000' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 20000, 25, 'initialize response'); + await waitFor(() => server.stderr.some((l) => l.includes('Attached to shared daemon')), 8000, 25, 'daemon attach log'); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000, 25, 'daemon pidfile'); + const daemonPid = readLockPid(realRoot)!; + + // A warm call goes through the daemon. + sendMessage(server.child, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); + try { + await waitFor(() => findResponse(server.stdout, 2), 30000, 25, 'warm tools/call via daemon'); + } catch (e) { + // This is the wait that historically flaked — surface WHERE the request + // died: proxy side (stderr) or daemon side (daemon.log). + let daemonLog = ''; + try { daemonLog = fs.readFileSync(path.join(realRoot, '.codegraph', 'daemon.log'), 'utf8').split('\n').slice(-25).join('\n'); } catch { /* absent */ } + throw new Error( + `${(e as Error).message}\ndaemonAlive=${isAlive(daemonPid)} proxyAlive=${isAlive(server.child.pid!)}\n` + + `--- proxy stderr tail ---\n${server.stderr.slice(-15).join('')}\n--- daemon.log tail ---\n${daemonLog}` + ); + } + + // Kill the daemon out from under the live proxy. + process.kill(daemonPid, 'SIGTERM'); + expect(await waitProcessExit(daemonPid, 8000)).toBe(true); + + // The proxy must still be alive and still answer — served in-process now. + expect(isAlive(server.child.pid!)).toBe(true); + await waitFor(() => server.stderr.some((l) => l.includes('serving this session in-process')), 8000, 25, 'in-process failover log'); + sendMessage(server.child, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); + const resp = await waitFor(() => findResponse(server.stdout, 3), 15000); + expect(resp.result !== undefined || resp.error !== undefined).toBe(true); + expect(isAlive(server.child.pid!)).toBe(true); + }, 45000); +}); diff --git a/__tests__/mcp-debounce-env.test.ts b/__tests__/mcp-debounce-env.test.ts new file mode 100644 index 0000000..8261997 --- /dev/null +++ b/__tests__/mcp-debounce-env.test.ts @@ -0,0 +1,47 @@ +/** + * CODEGRAPH_WATCH_DEBOUNCE_MS env override (issue #403). + * + * Lets users tune the watcher quiet window from MCP-launched configs without + * editing the agent's command line — formatter-on-save chains and large + * generated outputs benefit from a longer window. Clamped to [100ms, 60s]; + * out-of-range / non-numeric values fall back to the FileWatcher default + * (2000ms) rather than throwing or silently capping a likely typo. + */ +import { describe, it, expect } from 'vitest'; +import { parseDebounceEnv } from '../src/mcp/engine'; + +describe('parseDebounceEnv', () => { + it('returns undefined for unset / empty values', () => { + expect(parseDebounceEnv(undefined)).toBeUndefined(); + expect(parseDebounceEnv('')).toBeUndefined(); + expect(parseDebounceEnv(' ')).toBeUndefined(); + }); + + it('accepts integer values inside [100, 60000]', () => { + expect(parseDebounceEnv('100')).toBe(100); + expect(parseDebounceEnv('2000')).toBe(2000); + expect(parseDebounceEnv('5000')).toBe(5000); + expect(parseDebounceEnv('60000')).toBe(60000); + }); + + it('rejects out-of-range values (returns undefined, lets default win)', () => { + expect(parseDebounceEnv('0')).toBeUndefined(); + expect(parseDebounceEnv('50')).toBeUndefined(); // below 100 + expect(parseDebounceEnv('99')).toBeUndefined(); + expect(parseDebounceEnv('60001')).toBeUndefined(); // above 60s + expect(parseDebounceEnv('-500')).toBeUndefined(); + }); + + it('rejects non-integer / non-numeric values', () => { + expect(parseDebounceEnv('abc')).toBeUndefined(); + expect(parseDebounceEnv('500.5')).toBeUndefined(); + expect(parseDebounceEnv('NaN')).toBeUndefined(); + expect(parseDebounceEnv('Infinity')).toBeUndefined(); + }); + + it('accepts scientific notation that resolves to an in-range integer', () => { + // Number('1e3') === 1000, Number.isInteger(1000) === true. Power users + // who write debounce as 1e3 should not be surprised; the clamp still applies. + expect(parseDebounceEnv('1e3')).toBe(1000); + }); +}); diff --git a/__tests__/mcp-files-path-normalization.test.ts b/__tests__/mcp-files-path-normalization.test.ts new file mode 100644 index 0000000..94d8701 --- /dev/null +++ b/__tests__/mcp-files-path-normalization.test.ts @@ -0,0 +1,113 @@ +/** + * codegraph_files path-filter normalization (#426) + * + * Stored file paths are project-relative POSIX (e.g. "src/foo.ts"). Some + * agents pass project-root variants like "/", ".", "./" or "" when they want + * "the whole project", and Windows-style backslashes or leading "/" / "./" + * prefixes when they want a subtree. The old filter used a plain + * `startsWith(pathFilter)`, so any of those buried the agent at "no files + * found" and pushed it back to Read/Glob — the exact opencode regression in + * #426. These tests pin every branch of the normalization. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; + +describe('codegraph_files path normalization', () => { + let tempDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-files-paths-')); + fs.mkdirSync(path.join(tempDir, 'src', 'components'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'tests'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'src', 'index.ts'), `export const x = 1;\n`); + fs.writeFileSync( + path.join(tempDir, 'src', 'components', 'Button.ts'), + `export const Button = () => 1;\n` + ); + fs.writeFileSync(path.join(tempDir, 'tests', 'a.test.ts'), `export const t = 1;\n`); + cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterEach(() => { + if (cg) cg.destroy(); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + async function listed(pathFilter: string | undefined): Promise { + const result = await handler.execute('codegraph_files', { + ...(pathFilter !== undefined ? { path: pathFilter } : {}), + format: 'flat', + includeMetadata: false, + }); + expect(result.isError).toBeFalsy(); + return result.content[0]!.text as string; + } + + // Root-ish filters: every shape an agent might guess for "whole project" + // must list the same files as no filter at all. + for (const rootish of ['/', '.', './', '', '\\', '//', './/']) { + it(`treats path=${JSON.stringify(rootish)} as project root`, async () => { + const output = await listed(rootish); + expect(output).toContain('src/index.ts'); + expect(output).toContain('src/components/Button.ts'); + expect(output).toContain('tests/a.test.ts'); + }); + } + + it('matches a real subdirectory prefix', async () => { + const output = await listed('src'); + expect(output).toContain('src/index.ts'); + expect(output).toContain('src/components/Button.ts'); + expect(output).not.toContain('tests/a.test.ts'); + }); + + it('tolerates a leading slash on a real subdirectory', async () => { + const output = await listed('/src'); + expect(output).toContain('src/index.ts'); + expect(output).not.toContain('tests/a.test.ts'); + }); + + it('tolerates a leading "./" on a real subdirectory', async () => { + const output = await listed('./src'); + expect(output).toContain('src/index.ts'); + expect(output).not.toContain('tests/a.test.ts'); + }); + + it('tolerates a trailing slash on a real subdirectory', async () => { + const output = await listed('src/'); + expect(output).toContain('src/index.ts'); + expect(output).not.toContain('tests/a.test.ts'); + }); + + it('normalizes Windows backslashes', async () => { + const output = await listed('src\\components'); + expect(output).toContain('src/components/Button.ts'); + expect(output).not.toContain('src/index.ts'); + }); + + // Old code matched on raw `startsWith`, so a filter "src" would also + // return a sibling like "src-utils/...". The new code requires either an + // exact match or a "/" boundary, so prefixes don't bleed. + it('does not match sibling directories that share a prefix', async () => { + fs.mkdirSync(path.join(tempDir, 'src-utils'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'src-utils', 'helper.ts'), `export const h = 1;\n`); + await cg.indexAll(); + + const output = await listed('src'); + expect(output).toContain('src/index.ts'); + expect(output).not.toContain('src-utils/helper.ts'); + }); +}); diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts new file mode 100644 index 0000000..0a32077 --- /dev/null +++ b/__tests__/mcp-initialize.test.ts @@ -0,0 +1,183 @@ +/** + * MCP `initialize` handshake regression tests. + * + * Issue #172: on slow filesystems (Docker Desktop VirtioFS on macOS, WSL2), + * the MCP server was blocking the initialize response on CodeGraph.open() and + * Parser.init() (web-tree-sitter WASM bootstrap), which could take longer than + * Claude Code's ~30s handshake timeout. The child process stayed alive and + * had received the request, but never sent a response, so tools never + * appeared in the client. The fix sends the initialize response before + * kicking off the heavy init in the background. These tests guard the + * contract that initialize is fast regardless of how much work init does. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnServer(cwd: string): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + // Pin to direct (in-process) mode. #172 is a contract about the in-process + // server's init ordering — the "File watcher active" log this test observes + // is emitted in-process. In daemon mode the watcher runs in the detached + // daemon (logging to .codegraph/daemon.log, not the child's stderr); the + // same response-before-init guarantee lives in the shared session code and + // is covered by mcp-daemon.test.ts. Direct mode also avoids leaking a + // detached daemon from this suite. + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' }, + }) as ChildProcessWithoutNullStreams; +} + +function sendInitialize(child: ChildProcessWithoutNullStreams, projectPath: string) { + const msg = JSON.stringify({ + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${projectPath}`, + }, + }); + child.stdin.write(msg + '\n'); +} + +/** + * Collect stdout lines and stderr text from the child, tagging each piece + * with a monotonic sequence number. Lets us assert ordering between the + * JSON-RPC response (stdout) and side-effect logs (stderr). + */ +function tagStreams(child: ChildProcessWithoutNullStreams) { + const events: Array<{ seq: number; stream: 'stdout' | 'stderr'; text: string }> = []; + let seq = 0; + let stdoutBuf = ''; + let stderrBuf = ''; + child.stdout.on('data', (chunk) => { + stdoutBuf += chunk.toString('utf8'); + let idx; + while ((idx = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, idx); + stdoutBuf = stdoutBuf.slice(idx + 1); + events.push({ seq: seq++, stream: 'stdout', text: line }); + } + }); + child.stderr.on('data', (chunk) => { + stderrBuf += chunk.toString('utf8'); + let idx; + while ((idx = stderrBuf.indexOf('\n')) !== -1) { + const line = stderrBuf.slice(0, idx); + stderrBuf = stderrBuf.slice(idx + 1); + events.push({ seq: seq++, stream: 'stderr', text: line }); + } + }); + return events; +} + +function waitFor( + events: ReadonlyArray<{ seq: number; stream: string; text: string }>, + predicate: (e: { seq: number; stream: string; text: string }) => boolean, + timeoutMs: number, +): Promise<{ seq: number; stream: string; text: string }> { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + const hit = events.find(predicate); + if (hit) return resolve(hit); + if (Date.now() - started > timeoutMs) { + return reject(new Error(`Timed out waiting for predicate. Events: ${JSON.stringify(events)}`)); + } + setTimeout(tick, 20); + }; + tick(); + }); +} + +describe('MCP initialize handshake (issue #172)', () => { + let tempDir: string; + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-')); + }); + + afterEach(() => { + if (child && !child.killed) { + child.kill('SIGKILL'); + child = null; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('responds to initialize quickly when no .codegraph exists in cwd', async () => { + child = spawnServer(tempDir); + const events = tagStreams(child); + sendInitialize(child, tempDir); + const response = await waitFor(events, (e) => e.stream === 'stdout', 5000); + const json = JSON.parse(response.text); + expect(json.jsonrpc).toBe('2.0'); + expect(json.id).toBe(0); + expect(json.result.protocolVersion).toBeDefined(); + expect(json.result.capabilities.tools).toBeDefined(); + }, 10000); + + it('sends initialize response BEFORE tryInitializeDefault finishes', async () => { + // Seed a real .codegraph so the server's tryInitializeDefault path runs + // its full body: CodeGraph.open() (which awaits initGrammars()) and then + // startWatching() (which logs "File watcher active" to stderr). On any + // platform, that stderr log is observable evidence that tryInitializeDefault + // has completed. The contract we're protecting: the JSON-RPC response on + // stdout must arrive BEFORE that stderr log. If a future change re-awaits + // tryInitializeDefault before sendResult, this ordering inverts and the + // test fails — regardless of how fast the local filesystem is. + const cg = await CodeGraph.init(tempDir); + cg.close(); + + child = spawnServer(tempDir); + const events = tagStreams(child); + sendInitialize(child, tempDir); + + const response = await waitFor(events, (e) => e.stream === 'stdout', 10000); + const watcherLog = await waitFor( + events, + (e) => e.stream === 'stderr' && e.text.includes('File watcher active'), + 10000, + ); + expect(response.seq).toBeLessThan(watcherLog.seq); + const json = JSON.parse(response.text); + expect(json.id).toBe(0); + expect(json.result.serverInfo.name).toBe('codegraph'); + }, 20000); + + it('answers resources/list and prompts/list with empty lists, not -32601 (issue #621)', async () => { + child = spawnServer(tempDir); + const events = tagStreams(child); + sendInitialize(child, tempDir); + await waitFor(events, (e) => e.stream === 'stdout', 5000); // initialize reply + + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'resources/list', params: {} }) + '\n'); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'prompts/list', params: {} }) + '\n'); + + const replyFor = async (id: number) => { + const ev = await waitFor(events, (e) => { + if (e.stream !== 'stdout') return false; + try { return JSON.parse(e.text).id === id; } catch { return false; } + }, 5000); + return JSON.parse(ev.text); + }; + + const resources = await replyFor(1); + expect(resources.error).toBeUndefined(); + expect(resources.result.resources).toEqual([]); + + const prompts = await replyFor(2); + expect(prompts.error).toBeUndefined(); + expect(prompts.result.prompts).toEqual([]); + }, 15000); +}); diff --git a/__tests__/mcp-ppid-watchdog.test.ts b/__tests__/mcp-ppid-watchdog.test.ts new file mode 100644 index 0000000..781e0be --- /dev/null +++ b/__tests__/mcp-ppid-watchdog.test.ts @@ -0,0 +1,173 @@ +/** + * PPID watchdog regression test (#277). + * + * On Linux, when an MCP host (Claude Code, opencode, …) is SIGKILL'd by the + * OOM killer / a force-quit / a container teardown, the kernel does NOT + * propagate the death to its `codegraph serve --mcp` child. The child gets + * reparented to init/systemd, its stdin stays half-open in some + * configurations, and the existing `stdin.on('end' | 'close')` handlers + * never fire — the server lingers indefinitely, holding inotify watches, + * file descriptors, and the SQLite WAL. + * + * `src/mcp/index.ts` polls `process.ppid` and shuts down the moment it + * diverges from the value observed at startup. This test stands up a + * four-tier process tree (vitest → wrapper → {stdin-holder, codegraph}) and + * SIGKILL's the wrapper. The stdin-holder is a long-lived sibling whose + * `stdout` pipe is dup'd into codegraph's `stdin`. After the wrapper dies + * the pipe stays open (stdin-holder still owns the write-end), so the + * existing stdin close handlers do **not** fire — the only thing that can + * terminate codegraph then is the PPID watchdog. + * + * Windows is excluded — `process.kill(pid, 'SIGKILL')` does not actually + * deliver SIGKILL there, and the per-OS reparenting semantics the watchdog + * relies on are POSIX-specific. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function waitForExit(pid: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const start = Date.now(); + const tick = () => { + if (!isAlive(pid)) return resolve(true); + if (Date.now() - start > timeoutMs) return resolve(false); + setTimeout(tick, 100); + }; + tick(); + }); +} + +describe.skipIf(process.platform === 'win32')('MCP PPID watchdog (#277)', () => { + let wrapper: ChildProcessWithoutNullStreams | null = null; + let childPid: number | null = null; + let stdinHolderPid: number | null = null; + + afterEach(() => { + if (wrapper && !wrapper.killed) { + try { wrapper.kill('SIGKILL'); } catch { /* already gone */ } + } + // Belt and suspenders — don't leak processes if an assertion failed. + for (const pid of [childPid, stdinHolderPid]) { + if (pid !== null && isAlive(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } + } + } + wrapper = null; + childPid = null; + stdinHolderPid = null; + }); + + it("shuts down when its parent is SIGKILL'd and stdin stays open", async () => { + // The wrapper: + // 1. Spawns a "stdin-holder" — a tiny long-lived node process whose + // `stdout` pipe is dup'd into codegraph's `stdin`. As long as the + // stdin-holder is alive (it is — it's an orphan after the wrapper + // dies), codegraph's stdin never sees EOF. + // 2. Spawns codegraph with that pipe as fd 0 and its stderr redirected + // to a tmp file that survives the wrapper, then reports both PIDs. + // 3. Idles until SIGKILL'd from the test. + // + // CODEGRAPH_PPID_POLL_MS=200 keeps the watchdog responsive in test; the + // production default is 5000ms. + const stderrLog = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ppid-watchdog-')), + 'codegraph.stderr.log', + ); + // The wrapper waits 800ms before reporting the PIDs so the codegraph + // child has time to finish its async start() (dynamic import + transport + // setup + watchdog registration). Otherwise the test races: it + // SIGKILL's the wrapper before the watchdog interval is installed, and + // nothing terminates codegraph. + const wrapperSrc = ` + const { spawn } = require('child_process'); + const fs = require('fs'); + const stderrFd = fs.openSync(${JSON.stringify(stderrLog)}, 'a'); + const stdinHolder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], { + stdio: ['ignore', 'pipe', 'ignore'], + detached: true, + }); + stdinHolder.unref(); + const child = spawn(process.execPath, [${JSON.stringify(BIN)}, 'serve', '--mcp'], { + stdio: [stdinHolder.stdout, 'ignore', stderrFd], + // Pin to direct (in-process) mode: this test targets the in-process + // server's PPID watchdog (#277). The detached-daemon/proxy watchdog is + // covered separately in mcp-daemon.test.ts ("daemon survives the first + // client dying"). Without this the spawned process becomes a proxy and + // also spawns a detached daemon that would outlive the test. + env: { ...process.env, CODEGRAPH_PPID_POLL_MS: '200', CODEGRAPH_NO_DAEMON: '1' }, + detached: true, + }); + child.unref(); + setTimeout(() => { + process.stdout.write(JSON.stringify({ pid: child.pid, stdinHolderPid: stdinHolder.pid }) + '\\n'); + }, 800); + setInterval(() => {}, 60000); + `; + wrapper = spawn(process.execPath, ['-e', wrapperSrc], { + stdio: ['pipe', 'pipe', 'pipe'], + }) as ChildProcessWithoutNullStreams; + + const pids = await new Promise<{ pid: number; stdinHolderPid: number }>((resolve, reject) => { + let buf = ''; + const timer = setTimeout( + () => reject(new Error('wrapper did not report PIDs in time')), + 10000, + ); + wrapper!.stdout.on('data', (chunk: Buffer) => { + buf += chunk.toString('utf8'); + const m = buf.match(/\{"pid":(\d+),"stdinHolderPid":(\d+)\}/); + if (m) { + clearTimeout(timer); + resolve({ pid: parseInt(m[1], 10), stdinHolderPid: parseInt(m[2], 10) }); + } + }); + wrapper!.on('exit', () => { + clearTimeout(timer); + reject(new Error('wrapper exited before reporting PIDs')); + }); + }); + childPid = pids.pid; + stdinHolderPid = pids.stdinHolderPid; + + expect(isAlive(childPid)).toBe(true); + expect(isAlive(stdinHolderPid)).toBe(true); + + // SIGKILL the wrapper — no cleanup runs, just like a real OOM kill. + // codegraph and the stdin-holder both get reparented to init/systemd. + // Crucially, the pipe between them stays open, so codegraph's stdin + // doesn't close: only the watchdog can take it down. + wrapper.kill('SIGKILL'); + + // Watchdog runs every 200ms in this test → 5s gives ~25 polls of headroom. + const exited = await waitForExit(childPid, 5000); + const stderrContent = fs.existsSync(stderrLog) ? fs.readFileSync(stderrLog, 'utf-8') : ''; + expect( + exited, + `codegraph child (pid=${childPid}) did not exit within 5s after wrapper was SIGKILL'd.\nstderr:\n${stderrContent}`, + ).toBe(true); + // The watchdog announces itself before tearing down — assert that the + // shutdown came from the parent-death path, not from any other signal. + expect(stderrContent).toMatch(/Parent process exited.*shutting down/); + + // The stdin-holder is now an orphan — kill it explicitly so it doesn't + // outlive the test. It's still tracked in `stdinHolderPid` for the + // afterEach safety net, but we tidy up proactively here too. + if (isAlive(stdinHolderPid)) { + try { process.kill(stdinHolderPid, 'SIGKILL'); } catch { /* race */ } + } + }, 20000); +}); diff --git a/__tests__/mcp-require-project-path.test.ts b/__tests__/mcp-require-project-path.test.ts new file mode 100644 index 0000000..4e26a35 --- /dev/null +++ b/__tests__/mcp-require-project-path.test.ts @@ -0,0 +1,104 @@ +/** + * No-default-project → projectPath is `required` in the tool schema (issue #993). + * + * When the MCP server has no default project to fall back to — a gateway server + * started outside any repo, or a monorepo root whose `.codegraph/` indexes live + * only in sub-projects — every tool call MUST carry an explicit `projectPath`. + * `ToolHandler.getTools()` reflects that by marking `projectPath` required in the + * exposed schemas, a high-salience nudge that gets the agent to pass it on the + * first call instead of omitting it (the reported behavior). When a default + * project IS open, projectPath stays optional: a bare call falls back to it. + * + * The change is schema-only — the runtime stays exactly as before: a missing + * projectPath with no default still returns SUCCESS-shaped guidance (never + * `isError`), and a missing projectPath WITH a default still falls back to it. + */ +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ToolHandler, tools } from '../src/mcp/tools'; +import { CodeGraph } from '../src'; + +const ENV = 'CODEGRAPH_MCP_TOOLS'; + +const exploreOf = (defs: { name: string; inputSchema: { required?: string[] } }[]) => + defs.find((t) => t.name === 'codegraph_explore')!; + +describe('No-default-project requires projectPath in the schema (#993)', () => { + const originalAllowlist = process.env[ENV]; + afterEach(() => { + if (originalAllowlist === undefined) delete process.env[ENV]; + else process.env[ENV] = originalAllowlist; + }); + + it('marks projectPath required on codegraph_explore when no default project is loaded', () => { + const explore = exploreOf(new ToolHandler(null).getTools()); + expect(explore.inputSchema.required).toContain('projectPath'); + // The tool's own required arg is preserved, not replaced. + expect(explore.inputSchema.required).toContain('query'); + }); + + it('requires projectPath on EVERY exposed tool, incl. ones with no prior required list', () => { + // status has no `required` array of its own → it should gain ['projectPath']. + process.env[ENV] = 'explore,node,status'; + const got = new ToolHandler(null).getTools(); + expect(got.map((t) => t.name).sort()).toEqual([ + 'codegraph_explore', + 'codegraph_node', + 'codegraph_status', + ]); + for (const t of got) { + expect(t.inputSchema.required ?? []).toContain('projectPath'); + } + }); + + it('does NOT mutate the shared module-level tools array (purity)', () => { + // Marking required must clone — otherwise a no-default session would corrupt + // the schema every later default-project session reuses. + new ToolHandler(null).getTools(); + expect(exploreOf(tools).inputSchema.required).toEqual(['query']); + }); + + it('a missing projectPath with no default is still SUCCESS-shaped guidance, not isError', async () => { + // Schema-only change: the runtime backstop is unchanged. A client that + // ignores `required` still gets the nudge, never a session-souring isError. + const res = await new ToolHandler(null).execute('codegraph_explore', { query: 'anything' }); + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/No CodeGraph project is loaded/); + expect(res.content[0]!.text).toMatch(/projectPath/); + }); +}); + +describe('A default project keeps projectPath OPTIONAL (#993)', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-reqpath-')); + fs.writeFileSync( + path.join(tempDir, 'pay.ts'), + 'export function processPayment(amount: number): boolean { return amount > 0; }\n' + ); + cg = await CodeGraph.init(tempDir, { index: true }); + }); + + afterEach(() => { + cg.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('leaves projectPath optional when a default project is loaded', () => { + const explore = exploreOf(new ToolHandler(cg).getTools()); + expect(explore.inputSchema.required).toEqual(['query']); + expect(explore.inputSchema.required).not.toContain('projectPath'); + }); + + it('a bare call (no projectPath) still falls back to the default project', async () => { + const res = await new ToolHandler(cg).execute('codegraph_explore', { query: 'processPayment' }); + expect(res.isError).toBeUndefined(); + // Resolved against the default project — not the no-default guidance. + expect(res.content[0]!.text).not.toMatch(/No CodeGraph project is loaded/); + expect(res.content[0]!.text).toMatch(/processPayment/); + }); +}); diff --git a/__tests__/mcp-roots.test.ts b/__tests__/mcp-roots.test.ts new file mode 100644 index 0000000..8e1d452 --- /dev/null +++ b/__tests__/mcp-roots.test.ts @@ -0,0 +1,180 @@ +/** + * MCP project-resolution regression tests (issue #196). + * + * When an MCP client launches the server outside the project directory AND + * doesn't pass a `rootUri`/`workspaceFolders` in `initialize`, the server used + * to fall straight back to `process.cwd()` — which for many IDE clients is the + * wrong directory. Every tool call without an explicit `projectPath` then + * failed with a misleading "CodeGraph not initialized. Run 'codegraph init'." + * + * The fix: when no explicit path is provided, the server asks the client for + * its workspace root via the spec-blessed `roots/list` request (if the client + * advertised the `roots` capability), and only falls back to cwd otherwise. + * When it still can't resolve, the error now says exactly how to fix it. + * + * These tests drive the real stdio transport via a spawned subprocess — no + * mocking — so they also exercise the new bidirectional request/response path. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnServer(cwd: string): ChildProcessWithoutNullStreams { + // --no-watch keeps the test deterministic and avoids watcher startup noise. + return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }) as ChildProcessWithoutNullStreams; +} + +/** Parse every JSON-RPC message the server writes to stdout into an array. */ +function collectMessages(child: ChildProcessWithoutNullStreams): Array> { + const messages: Array> = []; + let buf = ''; + child.stdout.on('data', (chunk) => { + buf += chunk.toString('utf8'); + let idx; + while ((idx = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + try { messages.push(JSON.parse(line)); } catch { /* ignore non-JSON */ } + } + }); + return messages; +} + +function waitForMessage( + messages: ReadonlyArray>, + predicate: (m: Record) => boolean, + timeoutMs: number, +): Promise> { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + const hit = messages.find(predicate); + if (hit) return resolve(hit); + if (Date.now() - started > timeoutMs) { + return reject(new Error(`Timed out. Messages so far: ${JSON.stringify(messages)}`)); + } + setTimeout(tick, 20); + }; + tick(); + }); +} + +function send(child: ChildProcessWithoutNullStreams, msg: object): void { + child.stdin.write(JSON.stringify(msg) + '\n'); +} + +const CLIENT_INFO = { name: 'test', version: '0.0.0' }; + +describe('MCP project resolution via roots/list (issue #196)', () => { + let cwdDir: string; // where the server is launched — has NO .codegraph + let projectDir: string; // the real indexed project the client reports + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + cwdDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-cwd-')); + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-proj-')); + }); + + afterEach(() => { + if (child && !child.killed) { + child.kill('SIGKILL'); + child = null; + } + fs.rmSync(cwdDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + it('resolves the project from the client roots/list when no rootUri is sent', async () => { + const cg = await CodeGraph.init(projectDir); + cg.close(); + + child = spawnServer(cwdDir); + const messages = collectMessages(child); + + // Advertise the roots capability but pass NO rootUri/workspaceFolders. + send(child, { + jsonrpc: '2.0', id: 0, method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: { roots: {} }, clientInfo: CLIENT_INFO }, + }); + await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000); + send(child, { jsonrpc: '2.0', method: 'notifications/initialized' }); + + // First tool call (no projectPath) drives the server to ask us for roots. + send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); + + const rootsReq = await waitForMessage(messages, (m) => m.method === 'roots/list', 5000); + expect(typeof rootsReq.id).toBe('string'); // server-initiated id + send(child, { + jsonrpc: '2.0', id: rootsReq.id, + result: { roots: [{ uri: `file://${projectDir}`, name: 'proj' }] }, + }); + + // The status call now succeeds against the resolved project. + const resp = await waitForMessage(messages, (m) => m.id === 1, 8000); + const text = resp.result.content[0].text as string; + expect(text).toContain('CodeGraph Status'); + expect(text).not.toContain('No CodeGraph project is loaded'); + }, 20000); + + it('returns an actionable error when there is no rootUri and no roots capability', async () => { + child = spawnServer(cwdDir); + const messages = collectMessages(child); + + send(child, { + jsonrpc: '2.0', id: 0, method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: CLIENT_INFO }, + }); + await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000); + send(child, { jsonrpc: '2.0', method: 'notifications/initialized' }); + + send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); + const resp = await waitForMessage(messages, (m) => m.id === 1, 8000); + const text = resp.result.content[0].text as string; + + expect(text).toContain('No CodeGraph project is loaded'); + expect(text).toContain('projectPath'); + expect(text).toContain('--path'); + // Names the directory it actually searched (the wrong cwd) so the user can + // see why detection missed. basename survives any symlink realpath-ing. + expect(text).toContain(path.basename(cwdDir)); + // It must not have hung waiting on roots/list — the client never offered it. + expect(messages.some((m) => m.method === 'roots/list')).toBe(false); + }, 20000); + + it('honors an explicit rootUri without asking the client for roots', async () => { + const cg = await CodeGraph.init(projectDir); + cg.close(); + + child = spawnServer(cwdDir); + const messages = collectMessages(child); + + send(child, { + jsonrpc: '2.0', id: 0, method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: { roots: {} }, + clientInfo: CLIENT_INFO, + rootUri: `file://${projectDir}`, + }, + }); + await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000); + send(child, { jsonrpc: '2.0', method: 'notifications/initialized' }); + + send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); + const resp = await waitForMessage(messages, (m) => m.id === 1, 8000); + const text = resp.result.content[0].text as string; + + expect(text).toContain('CodeGraph Status'); + // rootUri is a stronger signal than roots — we never needed to ask. + expect(messages.some((m) => m.method === 'roots/list')).toBe(false); + }, 20000); +}); diff --git a/__tests__/mcp-staleness-banner.test.ts b/__tests__/mcp-staleness-banner.test.ts new file mode 100644 index 0000000..4b51e12 --- /dev/null +++ b/__tests__/mcp-staleness-banner.test.ts @@ -0,0 +1,212 @@ +/** + * Per-file staleness banner on MCP tool responses (issue #403). + * + * The watcher tracks every file event since the last successful sync; the + * tool dispatcher intersects "files referenced in this response" with that + * pending set and prepends a banner ("⚠️ Some files referenced below were + * edited since the last index sync…") plus an optional footer ("(Note: N + * file(s) elsewhere in this project are pending index sync…)"). + * + * No auto-flush, no static wait — the response is instant and the agent + * decides whether to Read the specific stale file. These tests exercise + * the full real path: real CodeGraph index + real ToolHandler.execute(). + * + * **Event delivery uses a synthetic seam** (`__emitWatchEventForTests`): the + * real native fs.watch (FSEvents/inotify) delivery is non-deterministic under + * parallel vitest execution and produced a consistent ~30% failure rate on + * these tests when run inside the full suite. The seam drives the watcher's + * pending-set pipeline directly so the tests synthesize file events + * deterministically. The watcher's actual debounce timer (real setTimeout) is + * left untouched. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; +import { __emitWatchEventForTests, __setFsWatchForTests } from '../src/sync/watcher'; + +function waitFor(condition: () => boolean, timeoutMs = 2000, intervalMs = 25): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + if (condition()) return resolve(); + if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out')); + setTimeout(tick, intervalMs); + }; + tick(); + }); +} + +describe('MCP staleness banner', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-banner-')); + fs.mkdirSync(path.join(testDir, 'src')); + // Three isolated files with no cross-references — keeps each test's + // "which path does the response mention?" assertion unambiguous. If the + // files shared imports/calls, codegraph_search responses would surface + // multiple file paths and the banner-vs-footer split would be racy. + fs.writeFileSync( + path.join(testDir, 'src', 'alpha-only.ts'), + 'export function alphaOnly() { return 1; }\n', + ); + fs.writeFileSync( + path.join(testDir, 'src', 'bravo-only.ts'), + 'export function bravoOnly() { return 2; }\n', + ); + fs.writeFileSync( + path.join(testDir, 'src', 'charlie-only.ts'), + 'export function charlieOnly() { return 3; }\n', + ); + + cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterEach(() => { + __setFsWatchForTests(null); // reset the injected fs.watch seam + try { cg.unwatch(); } catch { /* ignore */ } + try { cg.close(); } catch { /* ignore */ } + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + // Force watch-resource exhaustion at startup so the real watcher degrades + // deterministically on any platform (recursive or per-directory strategy). + const degradeWatcher = () => { + __setFsWatchForTests(() => { + const err = new Error('too many open files') as NodeJS.ErrnoException; + err.code = 'EMFILE'; + throw err; + }); + const started = cg.watch({ debounceMs: 1000 }); // real (non-inert) watcher + expect(started).toBe(false); + expect(cg.isWatcherDegraded()).toBe(true); + }; + + it('prepends a stale banner when the response references a pending file', async () => { + // Long debounce so the edit lingers in pendingFiles while we query. + cg.watch({ debounceMs: 4000, inertForTests: true }); + await cg.waitUntilWatcherReady(); + + // Real disk write so a later sync (if it fires) sees the new content, + // plus a synthesized chokidar event so the watcher's pendingFiles set + // updates immediately without waiting on OS-level event delivery. + fs.writeFileSync( + path.join(testDir, 'src', 'alpha-only.ts'), + 'export function alphaOnly() { return 99; }\n', + ); + __emitWatchEventForTests(testDir, 'src/alpha-only.ts'); + + // With mocked chokidar this is synchronous — keep the wait just to + // exercise the realistic shape (the watcher's `chokidarReady` gate + // and the small window before the pending-file Map is populated). + await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/alpha-only.ts')); + + const res = await handler.execute('codegraph_search', { query: 'alphaOnly' }); + expect(res.isError).toBeFalsy(); + const text = res.content[0].text; + + // Banner shape: warning glyph + filename + actionable instruction. + expect(text.startsWith('⚠️')).toBe(true); + expect(text).toContain('src/alpha-only.ts'); + expect(text).toMatch(/edited \d+ms ago/); + expect(text).toMatch(/Read them directly/); + // The actual result must still follow the banner. + expect(text).toMatch(/alphaOnly/); + }); + + it('uses the footer (not the banner) when pending files are not referenced', async () => { + cg.watch({ debounceMs: 4000, inertForTests: true }); + await cg.waitUntilWatcherReady(); + + // Edit bravo-only.ts but search for the alphaOnly symbol, whose hit is + // only in alpha-only.ts. The two files share no imports/calls so the + // response text won't mention bravo-only.ts. + fs.writeFileSync( + path.join(testDir, 'src', 'bravo-only.ts'), + 'export function bravoOnly() { return 22; }\n', + ); + __emitWatchEventForTests(testDir, 'src/bravo-only.ts'); + await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/bravo-only.ts')); + + const res = await handler.execute('codegraph_search', { query: 'alphaOnly' }); + const text = res.content[0].text; + + expect(text.startsWith('⚠️')).toBe(false); + expect(text).toMatch(/elsewhere in this project are pending index sync/); + expect(text).toContain('src/bravo-only.ts'); + }); + + it('drops the banner once the sync completes and clears the pending entry', async () => { + cg.watch({ debounceMs: 200, inertForTests: true }); + await cg.waitUntilWatcherReady(); + + fs.writeFileSync( + path.join(testDir, 'src', 'alpha-only.ts'), + 'export function alphaOnly() { return 7; }\n', + ); + __emitWatchEventForTests(testDir, 'src/alpha-only.ts'); + // Wait through debounce (200ms) + sync; pendingFiles drains back to empty. + await waitFor(() => cg.getPendingFiles().length === 0, 3000); + + const res = await handler.execute('codegraph_search', { query: 'alphaOnly' }); + const text = res.content[0].text; + expect(text.startsWith('⚠️')).toBe(false); + expect(text).not.toMatch(/elsewhere in this project are pending index sync/); + }); + + it('lists pending files under "Pending sync" in codegraph_status', async () => { + cg.watch({ debounceMs: 4000, inertForTests: true }); + await cg.waitUntilWatcherReady(); + + fs.writeFileSync( + path.join(testDir, 'src', 'charlie-only.ts'), + 'export function charlieOnly() { return 33; }\n', + ); + __emitWatchEventForTests(testDir, 'src/charlie-only.ts'); + await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/charlie-only.ts')); + + const res = await handler.execute('codegraph_status', {}); + const text = res.content[0].text; + expect(text).toContain('**Pending sync:'); + expect(text).toContain('src/charlie-only.ts'); + // Status embeds the info first-class, so the auto-banner is suppressed. + expect(text.startsWith('⚠️')).toBe(false); + }); + + it('returns zero pending files when no watcher is active', () => { + expect(cg.getPendingFiles()).toEqual([]); + }); + + it('prepends a whole-index degraded banner once live watching has permanently stopped (#876)', async () => { + degradeWatcher(); + + const res = await handler.execute('codegraph_search', { query: 'alphaOnly' }); + expect(res.isError).toBeFalsy(); + const text = res.content[0].text; + + expect(text.startsWith('⚠️')).toBe(true); + expect(text).toMatch(/auto-sync is DISABLED/i); + expect(text).toMatch(/Read files directly/i); + expect(text).toContain('OS watch/file limit exhausted'); // the degrade reason + expect(text).toMatch(/alphaOnly/); // the real result still follows the banner + }); + + it('surfaces the degraded state as its own section in codegraph_status (#876)', async () => { + degradeWatcher(); + + const res = await handler.execute('codegraph_status', {}); + const text = res.content[0].text; + expect(text).toContain('**Auto-sync disabled:'); + expect(text).toContain('OS watch/file limit exhausted'); + // status renders the notice inline, so the auto-banner is not also prepended. + expect(text.startsWith('⚠️')).toBe(false); + }); +}); diff --git a/__tests__/mcp-startup-orphan.test.ts b/__tests__/mcp-startup-orphan.test.ts new file mode 100644 index 0000000..caac71d --- /dev/null +++ b/__tests__/mcp-startup-orphan.test.ts @@ -0,0 +1,106 @@ +/** + * Startup-orphan regression tests (#1185) — spawn-level. + * + * Reproduced bug: an MCP host kills the launcher chain within the server's + * first ~100ms while keeping the stdio pipes open (config probe, instant + * cancel, initialize-timeout teardown; Rust hosts that kill a child without + * dropping its stdio handles hold pipes exactly like this). The server booted + * already reparented, so its PPID-watchdog baseline read 1 (blind forever), + * stdin never EOF'd, and the process lived until the HOST exited — one ~30MB + * node process leaked per occurrence. + * + * These tests exercise the last-resort defense end-to-end on the real built + * binary: a server that receives no MCP traffic shuts itself down when the + * startup-handshake timeout lapses, and a server that got even one message + * is never touched by it. + * + * POSIX-only: the blind spot is a POSIX reparenting artifact (Windows never + * reparents, so its liveness-based check keeps working with a late baseline), + * and the suite avoids the known Windows EPERM teardown quirk of spawned + * `serve --mcp` children holding the temp cwd open. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnServer(cwd: string, handshakeTimeoutMs: number): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + // Direct mode: hermetic (no detached daemon to leak from the suite). + // The backstop is armed identically on the proxy path. + CODEGRAPH_NO_DAEMON: '1', + // Single process (skip the --liftoff-only re-exec) so exit-code and + // liveness assertions observe the server itself. + CODEGRAPH_WASM_RELAUNCHED: '1', + // One less helper child; the liveness watchdog is not under test. + CODEGRAPH_NO_WATCHDOG: '1', + CODEGRAPH_TELEMETRY: '0', + DO_NOT_TRACK: '1', + CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS: String(handshakeTimeoutMs), + }, + }) as ChildProcessWithoutNullStreams; +} + +function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (child.exitCode !== null) { resolve(child.exitCode); return; } + const timer = setTimeout( + () => reject(new Error(`server did not exit within ${timeoutMs}ms`)), + timeoutMs + ); + child.on('exit', (code) => { clearTimeout(timer); resolve(code); }); + }); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe.skipIf(process.platform === 'win32')('startup-orphan backstop (#1185)', () => { + let dir: string; + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-orphan-')); + }); + + afterEach(() => { + if (child && child.exitCode === null) child.kill('SIGKILL'); + child = null; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('a server that never receives MCP traffic shuts itself down', async () => { + child = spawnServer(dir, 1000); + let stderr = ''; + child.stderr.on('data', (c) => { stderr += c.toString(); }); + + // Keep our pipe ends open the whole time — the abandoned-launch shape: + // no stdin EOF ever arrives; only the backstop can reap the server. + const code = await waitForExit(child, 15_000); + expect(code).toBe(0); + expect(stderr).toContain('No MCP traffic since startup'); + }, 20_000); + + it('a server that got an initialize is never reaped by the backstop', async () => { + child = spawnServer(dir, 1000); + child.stdin.write(JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 't', version: '0' } }, + }) + '\n'); + + // Well past the 1s backstop window: the first byte disarmed it for good. + await sleep(3000); + expect(child.exitCode).toBeNull(); + + // Normal lifecycle still intact: closing stdin ends the session. + child.stdin.end(); + const code = await waitForExit(child, 10_000); + expect(code).toBe(0); + }, 20_000); +}); diff --git a/__tests__/mcp-tool-allowlist.test.ts b/__tests__/mcp-tool-allowlist.test.ts new file mode 100644 index 0000000..8d34213 --- /dev/null +++ b/__tests__/mcp-tool-allowlist.test.ts @@ -0,0 +1,63 @@ +/** + * CODEGRAPH_MCP_TOOLS allowlist — lets an operator (or an A/B harness) trim the + * exposed MCP tool surface without touching the client config. Inert when unset. + * Filtering happens in ListTools (getTools) and is enforced again on execute(). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { ToolHandler } from '../src/mcp/tools'; + +const ENV = 'CODEGRAPH_MCP_TOOLS'; + +describe('CODEGRAPH_MCP_TOOLS allowlist', () => { + const original = process.env[ENV]; + afterEach(() => { + if (original === undefined) delete process.env[ENV]; + else process.env[ENV] = original; + }); + + const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort(); + + it('exposes ONLY codegraph_explore by default when unset', () => { + delete process.env[ENV]; + // The default set (see DEFAULT_MCP_TOOLS) is pared to explore alone — the one + // tool that earns its place (verbatim source grouped by file). + // node/search/callers/callees/impact/files/status stay defined and executable + // but unlisted; CODEGRAPH_MCP_TOOLS re-enables them. + expect(listed()).toEqual(['codegraph_explore']); + }); + + it('re-enables an unlisted tool via the allowlist (impact)', () => { + process.env[ENV] = 'explore,impact'; + expect(listed()).toEqual(['codegraph_explore', 'codegraph_impact']); + }); + + it('filters ListTools to the allowlisted short names', () => { + process.env[ENV] = 'explore,search,node'; + expect(listed()).toEqual(['codegraph_explore', 'codegraph_node', 'codegraph_search']); + }); + + it('accepts fully-qualified codegraph_ names and ignores whitespace', () => { + process.env[ENV] = ' codegraph_explore , search '; + expect(listed()).toEqual(['codegraph_explore', 'codegraph_search']); + }); + + it('treats an empty/whitespace value as unset (default surface)', () => { + process.env[ENV] = ' '; + expect(listed()).toEqual(['codegraph_explore']); + }); + + it('rejects a disabled tool on execute (defense in depth)', async () => { + process.env[ENV] = 'node'; + const res = await new ToolHandler(null).execute('codegraph_explore', {}); + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/disabled via CODEGRAPH_MCP_TOOLS/); + }); + + it('lets an allowlisted tool past the guard', async () => { + process.env[ENV] = 'search'; + // No CodeGraph attached, so it fails *after* the allowlist guard — the + // "disabled" message must NOT appear, proving the guard passed it through. + const res = await new ToolHandler(null).execute('codegraph_search', { query: 'x' }); + expect(res.content[0].text).not.toMatch(/disabled via CODEGRAPH_MCP_TOOLS/); + }); +}); diff --git a/__tests__/mcp-tool-annotations.test.ts b/__tests__/mcp-tool-annotations.test.ts new file mode 100644 index 0000000..28dbe25 --- /dev/null +++ b/__tests__/mcp-tool-annotations.test.ts @@ -0,0 +1,105 @@ +/** + * Read-only MCP ToolAnnotations on every codegraph tool (issue #1018). + * + * Every codegraph tool is query-only — it reads the pre-built index and never + * mutates the workspace. Clients gate on this: Cursor's Ask mode refuses any MCP + * tool that doesn't advertise `readOnlyHint: true`, so without annotations the + * codegraph tools were blocked there even though they only read. + * + * These tests pin that the read-only contract is present on the master tool + * array AND survives every transform that builds a `tools/list` response — the + * static proxy surface (`getStaticTools`), the live surface (`getTools`, which + * rewrites codegraph_explore's description via spread), and the no-default- + * project surface (`withRequiredProjectPath`, which clones the schema). A drop in + * any of those would silently re-block the tools in Ask mode. + */ +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ToolHandler, getStaticTools, tools, type ToolDefinition } from '../src/mcp/tools'; +import { CodeGraph } from '../src'; + +const ENV = 'CODEGRAPH_MCP_TOOLS'; +const ALL_TOOLS = tools.map((t) => t.name).join(','); + +/** Assert a single tool advertises the full read-only contract from #1018. */ +function expectReadOnly(tool: ToolDefinition): void { + expect(tool.annotations, `${tool.name} is missing annotations`).toBeDefined(); + // The hint Cursor Ask mode (and other clients) gate on. + expect(tool.annotations!.readOnlyHint).toBe(true); + // The exact triplet the issue asks for, plus the honest closed-world hint. + expect(tool.annotations!.destructiveHint).toBe(false); + expect(tool.annotations!.idempotentHint).toBe(true); + expect(tool.annotations!.openWorldHint).toBe(false); +} + +describe('Read-only annotations on the codegraph MCP tools (#1018)', () => { + const original = process.env[ENV]; + afterEach(() => { + if (original === undefined) delete process.env[ENV]; + else process.env[ENV] = original; + }); + + it('every tool in the master array is annotated read-only', () => { + expect(tools.length).toBeGreaterThan(0); + for (const tool of tools) expectReadOnly(tool); + }); + + it('the static proxy surface carries annotations on every exposed tool', () => { + // getStaticTools() answers tools/list before any project opens (proxy path). + process.env[ENV] = ALL_TOOLS; + const got = getStaticTools(); + expect(got.map((t) => t.name).sort()).toEqual(tools.map((t) => t.name).sort()); + for (const tool of got) expectReadOnly(tool); + }); + + it('the no-default-project surface keeps annotations through the schema clone', () => { + // withRequiredProjectPath (null cg) clones each tool's inputSchema — the + // top-level annotations field must ride along on the spread. + process.env[ENV] = ALL_TOOLS; + const got = new ToolHandler(null).getTools(); + expect(got.length).toBe(tools.length); + for (const tool of got) { + expectReadOnly(tool); + // Sanity: this IS the clone path (projectPath got marked required). + expect(tool.inputSchema.required ?? []).toContain('projectPath'); + } + }); +}); + +describe('Live tool surface keeps annotations with a project open (#1018)', () => { + let tempDir: string; + let cg: CodeGraph; + const original = process.env[ENV]; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-annot-')); + fs.writeFileSync( + path.join(tempDir, 'pay.ts'), + 'export function processPayment(amount: number): boolean { return amount > 0; }\n' + ); + cg = await CodeGraph.init(tempDir, { index: true }); + }); + + afterEach(() => { + cg.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + if (original === undefined) delete process.env[ENV]; + else process.env[ENV] = original; + }); + + it('getTools() keeps annotations, incl. codegraph_explore whose description is rebuilt', () => { + process.env[ENV] = ALL_TOOLS; + const got = new ToolHandler(cg).getTools(); + expect(got.length).toBeGreaterThan(0); + for (const tool of got) expectReadOnly(tool); + + // explore's description is regenerated with a per-repo budget suffix via + // object spread; the annotation must survive that rewrite. + const explore = got.find((t) => t.name === 'codegraph_explore'); + expect(explore).toBeDefined(); + expect(explore!.description).toMatch(/Budget: make at most/); + expectReadOnly(explore!); + }); +}); diff --git a/__tests__/mcp-unindexed.test.ts b/__tests__/mcp-unindexed.test.ts new file mode 100644 index 0000000..efc4e67 --- /dev/null +++ b/__tests__/mcp-unindexed.test.ts @@ -0,0 +1,265 @@ +/** + * No-root-index session policy tests (#964). + * + * A server whose own root has no .codegraph/ still exposes its tools — gating + * tool AVAILABILITY on whether `./` is indexed broke monorepos (only + * sub-projects indexed) and hid the tools from a session that started before + * `codegraph init`. So `initialize` returns the per-project instructions + * variant (not the full single-project playbook, and NOT an "inactive" note), + * `tools/list` exposes the tool surface, and a query against an indexed project + * by `projectPath` works even with no default project. Safety is preserved by + * the response SHAPE, not by hiding tools: a call against an un-indexed path + * returns SUCCESS-shaped guidance ("pass projectPath / run codegraph init"), + * never `isError: true` — one or two early isError responses teach an agent to + * abandon codegraph for the whole session, and that failure mode is still + * guarded below. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { ToolHandler } from '../src/mcp/tools'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnServer(cwd: string): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + // Direct (in-process) mode — the unindexed path never has a daemon + // anyway (the daemon socket lives in .codegraph/), and this keeps the + // suite from leaking a detached daemon in the indexed test. + // CODEGRAPH_WASM_RELAUNCHED skips the --liftoff-only re-exec: without + // it the server runs as a GRANDCHILD that survives child.kill() on + // Windows and holds the temp cwd/SQLite handles, failing teardown with + // EPERM no matter how long rmSync retries (the class documented for + // the mcp-initialize/mcp-roots suites). + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + }) as ChildProcessWithoutNullStreams; +} + +/** Send a JSON-RPC request and resolve with the response matching its id. */ +function request( + child: ChildProcessWithoutNullStreams, + msg: { id: number; method: string; params?: unknown }, + timeoutMs = 15000 +): Promise> { + return new Promise((resolve, reject) => { + let buf = ''; + const timer = setTimeout(() => { + child.stdout.off('data', onData); + reject(new Error(`timeout waiting for response id=${msg.id}`)); + }, timeoutMs); + const onData = (chunk: Buffer) => { + buf += chunk.toString(); + let idx: number; + while ((idx = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + try { + const parsed = JSON.parse(line) as Record; + if (parsed.id === msg.id) { + clearTimeout(timer); + child.stdout.off('data', onData); + resolve(parsed); + return; + } + } catch { + // non-JSON noise on stdout — ignore + } + } + }; + child.stdout.on('data', onData); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n'); + }); +} + +function initializeParams(projectPath: string) { + return { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${projectPath}`, + }; +} + +describe('No-root-index session policy', () => { + let tempDir: string; + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unindexed-')); + }); + + afterEach(async () => { + if (child) { + // Wait for the child to actually exit before removing its cwd — on + // Windows a just-killed process briefly holds the directory/SQLite + // handles, and an immediate rmSync fails the teardown with EPERM + // (the documented file-locking class that fails the sibling + // mcp-initialize/mcp-roots suites). kill + await exit + retried + // removal keeps this suite green on Windows. + const exited = new Promise((resolve) => child!.once('exit', () => resolve())); + child.kill('SIGKILL'); + await Promise.race([exited, new Promise((r) => setTimeout(r, 3000))]); + child = null; + } + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + }); + + it('initialize returns the per-project instructions (not "inactive", not the full playbook)', async () => { + fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const x = 1;\n'); + child = spawnServer(tempDir); + + const res = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + const instructions = (res.result as { instructions: string }).instructions; + + // No longer an "inactive, do nothing" note — the tools are available. + expect(instructions).not.toMatch(/inactive/i); + // It steers the agent to target a project explicitly via projectPath... + expect(instructions).toMatch(/projectPath/); + expect(instructions).toMatch(/codegraph_explore/); + expect(instructions).toMatch(/codegraph init/); + // ...but it is NOT the full single-project playbook (that's sent only when + // the root itself is indexed — keeps the common case tight). + expect(instructions).not.toMatch(/## How to query/); + }); + + it('tools/list exposes the tools even when the server root has no index (#964)', async () => { + child = spawnServer(tempDir); + await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + + const res = await request(child, { id: 1, method: 'tools/list' }); + const tools = (res.result as { tools: Array<{ name: string }> }).tools; + expect(tools.length).toBeGreaterThanOrEqual(1); + expect(tools.map((t) => t.name)).toContain('codegraph_explore'); + }); + + it('a query by projectPath reaches an INDEXED sub-project of an unindexed root (monorepo) (#964)', async () => { + // The server root (tempDir) has no index; an indexed sub-project lives + // under it — exactly the monorepo shape. The query must resolve to the + // sub-project's .codegraph/ and return real results. Run through the real + // spawned server (a second-project open can't be exercised in-process under + // vitest — see mcp-toolhandler cache notes — but a child process can). + const svc = path.join(tempDir, 'service_a'); + fs.mkdirSync(svc); + fs.writeFileSync( + path.join(svc, 'auth.ts'), + 'export function validateToken(t: string): boolean { return !!t; }\n' + ); + const cg = await CodeGraph.init(svc, { index: true }); + cg.close(); + + child = spawnServer(tempDir); + await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + + const res = await request(child, { + id: 1, + method: 'tools/call', + params: { name: 'codegraph_search', arguments: { query: 'validateToken', projectPath: svc } }, + }); + const result = res.result as { content: Array<{ text: string }>; isError?: boolean }; + expect(result.isError).toBeUndefined(); + expect(result.content[0]!.text).toMatch(/validateToken/); + expect(result.content[0]!.text).not.toMatch(/isn't indexed/); + }); + + it('an INDEXED workspace still gets the full playbook and the explore tool', async () => { + fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export function hello(): string { return "hi"; }\n'); + const cg = await CodeGraph.init(tempDir, { index: true }); + cg.close(); + + child = spawnServer(tempDir); + const init = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + const instructions = (init.result as { instructions: string }).instructions; + expect(instructions).toMatch(/How to query/); + expect(instructions).not.toMatch(/inactive/i); + + const list = await request(child, { id: 1, method: 'tools/list' }); + const tools = (list.result as { tools: Array<{ name: string }> }).tools; + // The default surface is pared to explore alone (see DEFAULT_MCP_TOOLS) — the + // contract under test is "indexed → tools are PRESENT", in contrast to the + // unindexed empty list above. + expect(tools.length).toBeGreaterThanOrEqual(1); + expect(tools.map((t) => t.name)).toContain('codegraph_explore'); + }); +}); + +describe('No-error policy on expected conditions', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-noerror-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('cross-project query to an unindexed path is SUCCESS-shaped guidance, not isError', async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { + query: 'anything', + projectPath: tempDir, + }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/isn't indexed/); + expect(res.content[0]!.text).toMatch(/codegraph init/); + expect(res.content[0]!.text).toMatch(/built-in tools/); + }); + + it('no-default-project (working-directory detection miss) is SUCCESS-shaped guidance', async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { query: 'anything' }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/No CodeGraph project is loaded/); + expect(res.content[0]!.text).toMatch(/projectPath/); + }); + + + it.runIf(process.platform !== 'win32')( + 'sensitive-path refusal stays a hard error (no retry encouragement)', + async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { + query: 'anything', + projectPath: '/etc', + }); + + expect(res.isError).toBe(true); + expect(res.content[0]!.text).not.toMatch(/retry the call once/); + } + ); +}); + +describe('search kind filter', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-kind-')); + fs.writeFileSync( + path.join(tempDir, 'types.ts'), + 'export type PaymentMethod = { id: string };\nexport function pay(): void {}\n' + ); + cg = await CodeGraph.init(tempDir, { index: true }); + }); + + afterEach(() => { + cg.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("kind: 'type' (the advertised enum value) finds type aliases", async () => { + const res = await new ToolHandler(cg).execute('codegraph_search', { + query: 'PaymentMethod', + kind: 'type', + }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/PaymentMethod/); + expect(res.content[0]!.text).not.toMatch(/No results found/); + }); +}); diff --git a/__tests__/mediatr-dispatch-synthesizer.test.ts b/__tests__/mediatr-dispatch-synthesizer.test.ts new file mode 100644 index 0000000..221b759 --- /dev/null +++ b/__tests__/mediatr-dispatch-synthesizer.test.ts @@ -0,0 +1,128 @@ +/** + * MediatR request/notification dispatch bridge (C#/.NET). + * + * MediatR decouples a `_mediator.Send(x)` / `_mediator.Publish(x)` call from the `Handle` + * method that runs it, linked by the request/notification TYPE (the `IRequestHandler` + * generic). This bridges each mediator dispatch → the `Handle` of the matching handler. + * The sent type is resolved from the argument three ways — inline `new X(...)`, a local + * `var v = new X(...)`, and a parameter/local declared `X v` — and precision rests on two + * gates proven here: the receiver must be mediator-ish (a `MessagingCenter.Send` is ignored), + * and the type must have a handler (an `IRequest` with no handler is never bridged). Covers + * `IRequest`, void `IRequest` (single-arg `IRequestHandler`), and `INotification`. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +describe('mediatr-dispatch synthesizer', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mediatr-dispatch-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + it('bridges Send/Publish to the matching Handle across inline, local, and param arg forms', async () => { + write('Requests.cs', `namespace Shop; +using MediatR; +public record GetThingsQuery : IRequest; +public record CreateThingCommand(string Name) : IRequest; +public record DeleteThingCommand(int Id) : IRequest; +public record ThingDeletedNotification(int Id) : INotification; +public class UnhandledCommand : IRequest { } +`); + write('Handlers.cs', `namespace Shop; +using MediatR; +using System.Threading; +using System.Threading.Tasks; +public class GetThingsQueryHandler : IRequestHandler { + public Task Handle(GetThingsQuery request, CancellationToken ct) => Task.FromResult(new ThingsVm()); +} +public class CreateThingCommandHandler : IRequestHandler { + public Task Handle(CreateThingCommand request, CancellationToken ct) => Task.FromResult(1); +} +public class DeleteThingCommandHandler : IRequestHandler { + public Task Handle(DeleteThingCommand request, CancellationToken ct) => Task.CompletedTask; +} +public class ThingDeletedNotificationHandler : INotificationHandler { + public Task Handle(ThingDeletedNotification notification, CancellationToken ct) => Task.CompletedTask; +} +`); + write('ThingsController.cs', `namespace Shop; +using MediatR; +using System.Threading.Tasks; +public class ThingsController { + private readonly ISender _mediator; + public ThingsController(ISender mediator) { _mediator = mediator; } + + public async Task GetThings() { + var vm = await _mediator.Send(new GetThingsQuery()); + } + public async Task Create(CreateThingCommand command) { + var id = await _mediator.Send(command); + } + public async Task Delete(int id) { + var command = new DeleteThingCommand(id); + await _mediator.Send(command); + } + public async Task Notify(int id) { + await _mediator.Publish(new ThingDeletedNotification(id)); + } + public async Task Bogus() { + await _mediator.Send(new UnhandledCommand()); + } + public void ViaMessagingCenter() { + MessagingCenter.Send(this, "evt", new CreateThingCommand("x")); + } +} +`); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + const edges = db + .prepare( + `SELECT s.name source, t.name target, json_extract(e.metadata,'$.via') via + FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'mediatr-dispatch'` + ) + .all(); + + // Four bridged dispatches: inline (GetThings, Notify), param-typed (Create), local var (Delete). + expect(edges.map((r: any) => r.source).sort()).toEqual(['Create', 'Delete', 'GetThings', 'Notify']); + expect([...new Set(edges.map((r: any) => r.via))].sort()).toEqual([ + 'CreateThingCommand', 'DeleteThingCommand', 'GetThingsQuery', 'ThingDeletedNotification', + ]); + // Every target is a Handle method. + expect(edges.every((r: any) => r.target === 'Handle')).toBe(true); + // PRECISION: an IRequest with no handler is never bridged; a non-mediator .Send is ignored. + expect(edges.some((r: any) => r.via === 'UnhandledCommand')).toBe(false); + expect(edges.some((r: any) => r.source === 'ViaMessagingCenter')).toBe(false); + + cg.close?.(); + }); + + it('produces no edges in a C# project with no MediatR (clean control)', async () => { + write('Service.cs', `namespace Shop; +public class Service { + private readonly IRepo _repo; + public Service(IRepo repo) { _repo = repo; } + public string Find(string id) => _repo.Get(id); +} +`); + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + const count = db + .prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'mediatr-dispatch'`) + .get(); + expect(count.c).toBe(0); + cg.close?.(); + }); +}); diff --git a/__tests__/multi-repo-workspace.test.ts b/__tests__/multi-repo-workspace.test.ts new file mode 100644 index 0000000..2e6b3c6 --- /dev/null +++ b/__tests__/multi-repo-workspace.test.ts @@ -0,0 +1,441 @@ +/** + * Multi-repo workspaces (#514) — and the `.gitignore`-respect default (#970, #976). + * + * A directory holding several independent git repositories can be indexed as a + * whole, but ONLY when the project opts the gitignored directories in. The + * default is the universal one: `.gitignore` excludes. Walking into a gitignored + * directory to index embedded repos there is OPT-IN via `codegraph.json` + * `includeIgnored` (#622, #699) — without it a gitignored `node_modules`-style + * reference/data dir full of nested clones is left untouched, instead of blowing + * the graph up or stalling the scan (#970, #976). + * + * Two enumeration paths are exercised under opt-in: + * - git path: the workspace root is itself a git repo (a "super-repo") whose + * `.gitignore` hides the child repos. They are discovered via the ignored- + * directories listing and enumerated by their own `git ls-files`. (#193 + * covered the *untracked* embedded case, which stays on by default.) + * - sync path: `git status` in the parent says nothing about embedded repos; + * change detection recurses into the opted-in ones. + * + * The non-git-parent case (plain folder of repos) works via the filesystem walk + * regardless — locked in here so it stays that way. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execFileSync } from 'child_process'; +import CodeGraph from '../src/index'; +import { scanDirectory, buildScopeIgnore, discoverEmbeddedRepoRoots, findUnindexedIgnoredRepos } from '../src/extraction'; +import { clearProjectConfigCache } from '../src/project-config'; + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'ignore'] }); +} + +/** git init + commit everything currently in `dir` as one repo. */ +function makeRepo(dir: string): void { + git(dir, 'init', '-q'); + git(dir, 'add', '-A'); + git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init', '--allow-empty'); +} + +function write(file: string, content: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +describe('multi-repo workspaces (#514) + .gitignore-respect default (#970, #976)', () => { + let ws: string; + + beforeEach(() => { + ws = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-multirepo-')); + clearProjectConfigCache(); + }); + + afterEach(() => { + clearProjectConfigCache(); + fs.rmSync(ws, { recursive: true, force: true }); + }); + + /** Drop a `codegraph.json` at the workspace root. */ + const writeConfig = (obj: unknown) => + fs.writeFileSync(path.join(ws, 'codegraph.json'), + typeof obj === 'string' ? obj : JSON.stringify(obj)); + + describe('default: .gitignore is respected (#970, #976)', () => { + it('does NOT index embedded repos inside a gitignored dir without opt-in', () => { + // The exact #976 layout: nested clones under a directory the user + // explicitly gitignored. They must stay out of the index — no graph blowup. + write(path.join(ws, '.repos/lib-a/src/a.ts'), 'export function fromLibA() { return 1; }\n'); + write(path.join(ws, '.repos/lib-b/src/b.ts'), 'export function fromLibB() { return 2; }\n'); + makeRepo(path.join(ws, '.repos/lib-a')); + makeRepo(path.join(ws, '.repos/lib-b')); + write(path.join(ws, '.gitignore'), '/.repos/\n'); + write(path.join(ws, 'app.ts'), 'export function app() { return 0; }\n'); + makeRepo(ws); + + const files = scanDirectory(ws); + expect(files).toContain('app.ts'); // the project's own code still indexes + expect(files.some((f) => f.startsWith('.repos/'))).toBe(false); + }); + + it('does NOT discover gitignored embedded roots without opt-in', () => { + write(path.join(ws, 'resource/ref/src/x.ts'), 'export const x = 1;\n'); + makeRepo(path.join(ws, 'resource/ref')); + write(path.join(ws, '.gitignore'), '/resource/\n'); + makeRepo(ws); + + // The #970 perf fix: a gitignored dir of reference repos is never walked. + expect(discoverEmbeddedRepoRoots(ws)).toEqual([]); + }); + + it('ScopeIgnore: a gitignored dir is fully pruned without opt-in', () => { + write(path.join(ws, 'resource/ref/src/x.ts'), 'export const x = 1;\n'); + makeRepo(path.join(ws, 'resource/ref')); + write(path.join(ws, '.gitignore'), '/resource/\n'); + makeRepo(ws); + + const scope = buildScopeIgnore(ws); + // Both the dir and its contents are ignored — the watcher won't descend. + expect(scope.ignores('resource/')).toBe(true); + expect(scope.ignores('resource/ref/src/x.ts')).toBe(true); + }); + }); + + describe('opt-in: codegraph.json includeIgnored re-includes a gitignored dir (#622, #699)', () => { + it('indexes embedded repos hidden by the super-repo .gitignore', () => { + write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() { return 1; }\n'); + write(path.join(ws, 'packages/proj-b/src/billing.ts'), 'export function charge() { return 2; }\n'); + makeRepo(path.join(ws, 'packages/proj-a')); + makeRepo(path.join(ws, 'packages/proj-b')); + write(path.join(ws, '.gitignore'), '/packages/\n'); + write(path.join(ws, 'tools.ts'), 'export function tool() { return 0; }\n'); + writeConfig({ includeIgnored: ['packages/'] }); + makeRepo(ws); + + const files = scanDirectory(ws); + expect(files).toContain('packages/proj-a/src/auth.ts'); + expect(files).toContain('packages/proj-b/src/billing.ts'); + expect(files).toContain('tools.ts'); // the parent's own tracked code still indexes + }); + + it('only re-includes the opted-in dir, not every gitignored dir', () => { + // `packages/` is opted in; `scratch/` (also holding a repo) is NOT. + write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() {}\n'); + makeRepo(path.join(ws, 'packages/proj-a')); + write(path.join(ws, 'scratch/throwaway/src/junk.ts'), 'export function junk() {}\n'); + makeRepo(path.join(ws, 'scratch/throwaway')); + write(path.join(ws, '.gitignore'), '/packages/\n/scratch/\n'); + writeConfig({ includeIgnored: ['packages/'] }); + makeRepo(ws); + + const files = scanDirectory(ws); + expect(files).toContain('packages/proj-a/src/auth.ts'); + expect(files.some((f) => f.startsWith('scratch/'))).toBe(false); + }); + + it('discovers the opted-in ignored root alongside untracked roots', () => { + write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() {}\n'); + makeRepo(path.join(ws, 'packages/proj-a')); + write(path.join(ws, 'vendor-src/lib/util.ts'), 'export function util() {}\n'); + makeRepo(path.join(ws, 'vendor-src/lib')); + write(path.join(ws, '.gitignore'), '/packages/\n'); // vendor-src stays untracked + writeConfig({ includeIgnored: ['packages/'] }); + makeRepo(ws); + git(ws, 'rm', '-r', '--cached', '-q', 'vendor-src'); + git(ws, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'untrack'); + + const roots = discoverEmbeddedRepoRoots(ws); + expect(roots).toContain('packages/proj-a/'); // opted-in ignored kind + expect(roots).toContain('vendor-src/lib/'); // untracked kind (always on) + }); + + it('ScopeIgnore: opted-in embedded files use the child rules; the watcher can descend', () => { + write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() {}\n'); + write(path.join(ws, 'packages/proj-a/.gitignore'), 'build/\n'); + makeRepo(path.join(ws, 'packages/proj-a')); + write(path.join(ws, '.gitignore'), '/packages/\n'); + writeConfig({ includeIgnored: ['packages/'] }); + makeRepo(ws); + + const scope = buildScopeIgnore(ws); + // Inside the opted-in embedded repo: the CHILD's rules decide. + expect(scope.ignores('packages/proj-a/src/auth.ts')).toBe(false); + expect(scope.ignores('packages/proj-a/build/out.ts')).toBe(true); + // Under the ignored dir but NOT in any embedded repo: parent rules apply. + expect(scope.ignores('packages/stray.ts')).toBe(true); + // Directory form: ancestors of an embedded root are never pruned — + // the Linux per-directory watcher must descend through `packages/`. + expect(scope.ignores('packages/')).toBe(false); + // Ordinary paths: unchanged semantics. + expect(scope.ignores('node_modules/dep/index.ts')).toBe(true); + expect(scope.ignores('src/app.ts')).toBe(false); + }); + + it('sync picks up a change inside an opted-in gitignored embedded repo', async () => { + write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() { return 1; }\n'); + makeRepo(path.join(ws, 'packages/proj-a')); + write(path.join(ws, '.gitignore'), '/packages/\n'); + writeConfig({ includeIgnored: ['packages/'] }); + makeRepo(ws); + + const cg = CodeGraph.initSync(ws, { config: { include: ['**/*.ts'], exclude: [] } }); + try { + await cg.indexAll(); + expect(cg.searchNodes('login', { limit: 5 }).length).toBeGreaterThan(0); + + // Change inside the embedded repo — invisible to the parent's `git status`. + write(path.join(ws, 'packages/proj-a/src/auth.ts'), + 'export function login() { return 1; }\nexport function logout() { return 0; }\n'); + await cg.sync(); + + expect(cg.searchNodes('logout', { limit: 5 }).length).toBeGreaterThan(0); + } finally { + cg.destroy(); + } + }); + }); + + describe('discovery/classifier machinery (exercised under opt-in)', () => { + it('keeps respecting the parent .gitignore for the parent own (non-repo) dirs', () => { + write(path.join(ws, 'scratch/junk.ts'), 'export function junk() { return 9; }\n'); + write(path.join(ws, 'src/app.ts'), 'export function app() { return 1; }\n'); + write(path.join(ws, '.gitignore'), '/scratch/\n'); + makeRepo(ws); + + const files = scanDirectory(ws); + expect(files).toContain('src/app.ts'); + // scratch/ is gitignored and contains NO embedded repo — stays excluded. + expect(files.some((f) => f.startsWith('scratch/'))).toBe(false); + }); + + it('never descends into git repos inside node_modules (npm git-dependencies)', () => { + // Embedded repo first (clean), node_modules dropped in afterwards — + // matching reality, where node_modules is never committed. + write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() {}\n'); + makeRepo(path.join(ws, 'packages/proj-a')); + write(path.join(ws, 'packages/proj-a/node_modules/inner/src/evil2.ts'), 'export function evil2() {}\n'); + makeRepo(path.join(ws, 'packages/proj-a/node_modules/inner')); // npm git-dep: has commits + // Workspace-level git-dep too. + write(path.join(ws, 'node_modules/git-dep/src/evil.ts'), 'export function evil() {}\n'); + makeRepo(path.join(ws, 'node_modules/git-dep')); + write(path.join(ws, '.gitignore'), '/packages/\nnode_modules\n'); + writeConfig({ includeIgnored: ['packages/'] }); + makeRepo(ws); + + const files = scanDirectory(ws); + expect(files).toContain('packages/proj-a/src/auth.ts'); + // node_modules is a built-in default exclude — never re-included, even though + // `packages/` is opted in and node_modules is gitignored. + expect(files.some((f) => f.includes('node_modules'))).toBe(false); + }); + + it('still indexes UNTRACKED embedded repos by default (#193 regression)', () => { + write(path.join(ws, 'vendor-src/lib/src/util.ts'), 'export function util() {}\n'); + makeRepo(path.join(ws, 'vendor-src/lib')); + write(path.join(ws, 'main.ts'), 'export function main() {}\n'); + makeRepo(ws); // vendor-src/ is untracked (not ignored) — committed ws has only main.ts + nothing else + // NOTE: makeRepo committed vendor-src too via add -A… recreate untracked state: + git(ws, 'rm', '-r', '--cached', '-q', 'vendor-src'); + git(ws, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'untrack'); + + // No codegraph.json: the untracked path is unaffected by the opt-in gate. + const files = scanDirectory(ws); + expect(files).toContain('vendor-src/lib/src/util.ts'); + expect(files).toContain('main.ts'); + }); + + it('skips nested git worktrees instead of indexing them as duplicate embedded repos (#848)', () => { + // Claude Code (and others) create worktrees under a gitignored path like + // `.claude/worktrees//`. A worktree's `.git` is a FILE pointing into + // the host repo's own `.git/worktrees/`, so it is the SAME repo already + // indexed — sweeping it in as an embedded repo multiplies the whole graph. + // A genuine embedded clone (a `.git` *directory*) must still be indexed. + // Both dirs are opted in so the classifier (not the gitignore gate) is what + // decides: the worktree is skipped, the genuine clone is kept. + write(path.join(ws, 'src/app.ts'), 'export function app() { return 1; }\n'); + write(path.join(ws, '.gitignore'), '.claude/\nvendored/\n'); + writeConfig({ includeIgnored: ['.claude/', 'vendored/'] }); + makeRepo(ws); + // A real linked worktree under the gitignored .claude/worktrees/. + git(ws, 'worktree', 'add', '-q', '.claude/worktrees/feature', '-b', 'feature'); + // A genuine embedded clone, also gitignored — must STAY indexed under opt-in. + write(path.join(ws, 'vendored/lib.ts'), 'export function vendoredFn() { return 9; }\n'); + makeRepo(path.join(ws, 'vendored')); + + const files = scanDirectory(ws); + expect(files).toContain('src/app.ts'); + // The worktree is a duplicate working view — never indexed (#848). + expect(files.some((f) => f.includes('.claude/worktrees'))).toBe(false); + // The genuine embedded clone is still indexed under opt-in (#514/#622). + expect(files).toContain('vendored/lib.ts'); + }); + + it('skips a submodule worktree instead of indexing it as a duplicate (#945)', () => { + // A worktree OF A SUBMODULE points its `.git` into + // `.git/modules//worktrees/` — not the top-level repo's + // `.git/worktrees/`. The detector used to miss that extra `modules/` + // segment, so the worktree fell through to "embedded" and every symbol it + // shared with the real submodule checkout got indexed twice. The submodule's + // own checkout (`.git/modules/`, no `worktrees/`) is distinct code + // and must stay indexed. The worktree dir is opted in so the classifier is + // what skips it (not the gitignore gate). + const upstream = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-945-up-')); + try { + // The repo that becomes the submodule's origin. + write(path.join(upstream, 'lib.ts'), 'export function libFn() { return 1; }\n'); + makeRepo(upstream); + + write(path.join(ws, 'src/app.ts'), 'export function app() { return 1; }\n'); + write(path.join(ws, '.gitignore'), '.worktrees/\n'); + writeConfig({ includeIgnored: ['.worktrees/'] }); + git(ws, 'init', '-q'); + // protocol.file.allow=always: modern git refuses a local-path submodule otherwise. + git(ws, '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', upstream, 'common'); + git(ws, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'add submodule'); + + // A worktree of the submodule, under the gitignored .worktrees/ — its `.git` + // points into `.git/modules/common/worktrees/`. + git(path.join(ws, 'common'), 'worktree', 'add', '-q', '../.worktrees/common-feature', '-b', 'feature'); + + const files = scanDirectory(ws); + expect(files).toContain('src/app.ts'); + // The real submodule checkout is distinct code — still indexed (#514). + expect(files).toContain('common/lib.ts'); + // The submodule worktree is a duplicate working view — never indexed (#945). + expect(files.some((f) => f.includes('.worktrees'))).toBe(false); + } finally { + fs.rmSync(upstream, { recursive: true, force: true }); + } + }); + + it('non-git workspace: walks children and respects each child own .gitignore', () => { + write(path.join(ws, 'proj-a/src/auth.ts'), 'export function login() {}\n'); + write(path.join(ws, 'proj-a/build/out.ts'), 'export function generated() {}\n'); + write(path.join(ws, 'proj-a/.gitignore'), 'build/\n'); + write(path.join(ws, 'proj-b/src/billing.ts'), 'export function charge() {}\n'); + makeRepo(path.join(ws, 'proj-a')); + makeRepo(path.join(ws, 'proj-b')); + // ws itself is NOT a git repo. + + const files = scanDirectory(ws); + expect(files).toContain('proj-a/src/auth.ts'); + expect(files).toContain('proj-b/src/billing.ts'); + expect(files.some((f) => f.includes('build/'))).toBe(false); + }); + + it('does not search beyond the embedded-repo depth cap (opted-in dir)', () => { + // Repo buried 5 levels under the ignored dir — past EMBEDDED_REPO_SEARCH_DEPTH (4). + const deep = path.join(ws, 'pkgs/a/b/c/d/e'); + write(path.join(deep, 'src/deep.ts'), 'export function deep() {}\n'); + makeRepo(deep); + write(path.join(ws, 'main.ts'), 'export function main() {}\n'); + write(path.join(ws, '.gitignore'), '/pkgs/\n'); + writeConfig({ includeIgnored: ['pkgs/'] }); + makeRepo(ws); + + const files = scanDirectory(ws); + expect(files).toContain('main.ts'); + expect(files.some((f) => f.includes('deep.ts'))).toBe(false); + }); + + it('buildScopeIgnore: indexed root is itself a gitignored subdir of an enclosing repo (#936)', () => { + // `child/` is NOT its own repo, so `git` resolves the ENCLOSING repo from + // inside it — and `git ls-files --directory`, whose cwd is then a wholly + // ignored directory, emits the literal `./` ("this entire directory"). + // That sentinel used to reach the `ignore` matcher and throw + // ("path should be a `path.relative()`d string, but got "./""), aborting + // buildScopeIgnore → the MCP daemon's watcher never started and auto-sync + // silently stalled until a manual `codegraph sync`. + write(path.join(ws, 'child/src/a.ts'), 'export const x = 1;\n'); + write(path.join(ws, '.gitignore'), '/child/\n'); + makeRepo(ws); + + const child = path.join(ws, 'child'); + // The crux: building scope for the ignored subdir must not throw. + const scope = buildScopeIgnore(child); + // The subdir's own source is watchable/indexable, not ignored. + expect(scope.ignores('src/a.ts')).toBe(false); + // And the `./` self entry must not be mistaken for a nested embedded repo. + expect(discoverEmbeddedRepoRoots(child)).toEqual([]); + }); + }); + + describe('findUnindexedIgnoredRepos: the skipped-child-repos hint (#1156)', () => { + // The reported layout: a super-repo whose `.gitignore` excludes its child + // repos, so `init` at the parent correctly indexes ~nothing. This detector + // is the inverse of `discoverEmbeddedRepoRoots` — it names exactly the repos + // the default scan skipped so the CLI can offer to opt them in. + it('names the gitignored child repos a default index skipped', () => { + write(path.join(ws, 'mtc-activity/src/a.ts'), 'export const a = 1;\n'); + write(path.join(ws, 'mtc-admin/src/b.ts'), 'export const b = 2;\n'); + makeRepo(path.join(ws, 'mtc-activity')); + makeRepo(path.join(ws, 'mtc-admin')); + write(path.join(ws, '.gitignore'), 'mtc-*/\n'); + write(path.join(ws, 'AGENTS.md'), '# docs\n'); + makeRepo(ws); + + // Nothing of the child repos indexes by default — the symptom being fixed. + expect(scanDirectory(ws).some((f) => f.startsWith('mtc-'))).toBe(false); + // ...but the detector names them (trailing-slashed, valid includeIgnored patterns). + expect(findUnindexedIgnoredRepos(ws).sort()).toEqual(['mtc-activity/', 'mtc-admin/']); + }); + + it('excludes repos already opted in via includeIgnored (only the rest remain)', () => { + write(path.join(ws, 'mtc-activity/src/a.ts'), 'export const a = 1;\n'); + write(path.join(ws, 'mtc-admin/src/b.ts'), 'export const b = 2;\n'); + makeRepo(path.join(ws, 'mtc-activity')); + makeRepo(path.join(ws, 'mtc-admin')); + write(path.join(ws, '.gitignore'), 'mtc-*/\n'); + writeConfig({ includeIgnored: ['mtc-activity/'] }); + makeRepo(ws); + + expect(findUnindexedIgnoredRepos(ws)).toEqual(['mtc-admin/']); + }); + + it('returns [] when every gitignored repo is already opted in (nothing to nag)', () => { + write(path.join(ws, 'pkgs/a/src/a.ts'), 'export const a = 1;\n'); + makeRepo(path.join(ws, 'pkgs/a')); + write(path.join(ws, '.gitignore'), '/pkgs/\n'); + writeConfig({ includeIgnored: ['pkgs/'] }); + makeRepo(ws); + + expect(findUnindexedIgnoredRepos(ws)).toEqual([]); + }); + + it('does NOT report nested repos that are NOT gitignored (they already index)', () => { + // Scenario A: an untracked, non-ignored nested repo is indexed via the + // untracked-embedded path, so there is nothing to hint about. + write(path.join(ws, 'sub/src/a.ts'), 'export const a = 1;\n'); + makeRepo(path.join(ws, 'sub')); + write(path.join(ws, 'app.ts'), 'export const app = 0;\n'); + makeRepo(ws); // sub/ stays untracked, not ignored + + expect(findUnindexedIgnoredRepos(ws)).toEqual([]); + }); + + it('skips a gitignored node_modules even when it holds a git repo', () => { + write(path.join(ws, 'node_modules/dep/index.js'), 'module.exports = 1;\n'); + makeRepo(path.join(ws, 'node_modules/dep')); + write(path.join(ws, '.gitignore'), 'node_modules/\n'); + makeRepo(ws); + + expect(findUnindexedIgnoredRepos(ws)).toEqual([]); + }); + + it('finds repos nested inside a gitignored data dir, not just top-level ones', () => { + write(path.join(ws, 'refs/lib-a/x.ts'), 'export const x = 1;\n'); + makeRepo(path.join(ws, 'refs/lib-a')); + write(path.join(ws, '.gitignore'), '/refs/\n'); + makeRepo(ws); + + expect(findUnindexedIgnoredRepos(ws)).toEqual(['refs/lib-a/']); + }); + + it('returns [] for a non-git directory', () => { + write(path.join(ws, 'a.ts'), 'export const a = 1;\n'); // no git init at all + expect(findUnindexedIgnoredRepos(ws)).toEqual([]); + }); + }); +}); diff --git a/__tests__/mybatis-extractor-robustness.test.ts b/__tests__/mybatis-extractor-robustness.test.ts new file mode 100644 index 0000000..9598075 --- /dev/null +++ b/__tests__/mybatis-extractor-robustness.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from 'vitest'; +import { extractFromSource } from '../src/extraction/tree-sitter'; + +// Robustness of the MyBatis / iBatis mapper extractor. Four shapes the regex +// scanner previously mishandled, all reported and diagnosed by @ESPINS in #1182: +// 1. single-quoted attribute values, +// 2. tags that live inside XML comments, +// 3. iBatis 2 `` files (zero statement coverage before), +// 4. two statements that share a qualifiedName *and* a start line colliding +// on the node id (silent statement loss at the DB layer). +// The quoting and comment suites below follow @ESPINS's fix-mybatis-quotes-comments +// branch; the iBatis and collision suites cover the regex-only path taken here +// (no parser dependency). + +const methodNodes = (xml: string, file = 'FooMapper.xml') => + extractFromSource(file, xml).nodes.filter((n) => n.kind === 'method'); + +const methodNames = (xml: string, file = 'FooMapper.xml') => + methodNodes(xml, file).map((n) => n.qualifiedName); + +describe('MyBatis extractor — attribute quoting', () => { + it('accepts a single-quoted namespace', () => { + const xml = + "" + + ''; + expect(methodNames(xml)).toContain('com.example.FooMapper::getById'); + }); + + it('accepts a single-quoted statement id', () => { + const xml = + '' + + ""; + expect(methodNames(xml)).toContain('com.example.FooMapper::getById'); + }); + + it('accepts a single-quoted ', () => { + const xml = + '' + + 'id, name' + + "" + + ''; + const refs = extractFromSource('FooMapper.xml', xml).unresolvedReferences.map( + (r) => r.referenceName + ); + expect(refs).toContain('com.example.FooMapper::cols'); + }); + + it('reads single-quoted resultType / parameterType into the signature', () => { + const xml = + "" + + "" + + ''; + const sig = methodNodes(xml).find((n) => n.name === 'getById')?.signature; + expect(sig).toContain('result=User'); + expect(sig).toContain('param=int'); + }); + + it('handles mixed single- and double-quoted attributes in one file', () => { + const xml = + "" + + "" + + 'UPDATE t SET x=1' + + ''; + expect(methodNames(xml)).toEqual([ + 'com.example.FooMapper::getById', + 'com.example.FooMapper::touch', + ]); + }); + + it('still accepts double-quoted attributes (regression guard)', () => { + const xml = + '' + + ''; + expect(methodNames(xml)).toContain('com.example.FooMapper::getById'); + }); +}); + +describe('MyBatis extractor — XML comments', () => { + const result = (xml: string) => extractFromSource('FooMapper.xml', xml); + + it('does not emit a node for a statement inside a comment', () => { + const xml = + '' + + '' + + ''; + const names = result(xml) + .nodes.filter((n) => n.kind === 'method') + .map((n) => n.name); + expect(names).toContain('live'); + expect(names).not.toContain('dead'); + }); + + it('does not follow an inside a comment', () => { + const xml = + '' + + '' + + ''; + const refs = result(xml).unresolvedReferences.map((r) => r.referenceName); + expect(refs).not.toContain('com.example.FooMapper::cols'); + }); + + it('keeps the correct startLine for a statement after a multi-line comment', () => { + const xml = + '\n' + + '\n' + + '\n' + + '\n'; + const stmt = result(xml).nodes.find((n) => n.name === 'getById'); + expect(stmt).toBeDefined(); + // The SELECT 1' + + ']]>' + + ''; + const names = result(xml) + .nodes.filter((n) => n.kind === 'method') + .map((n) => n.name); + expect(names).toContain('live'); + }); + + it('does not crash on an unterminated comment (blanks to end of file)', () => { + const xml = + '' + + '' + + ' + + + + + + + + + + + + + + + + + + diff --git a/docs/SEARCH_QUALITY_LOOP.md b/docs/SEARCH_QUALITY_LOOP.md new file mode 100644 index 0000000..97d57de --- /dev/null +++ b/docs/SEARCH_QUALITY_LOOP.md @@ -0,0 +1,558 @@ +# CodeGraph Language Verification Guide + +You are verifying that CodeGraph fully supports a specific programming language. The user will give you a path to a real-world, popular open-source codebase cloned locally. Your job is to run a battery of realistic prompts against it using CodeGraph's API and verify the results are good enough to say that language is **covered and supported**. + +A language is NOT verified until an LLM can reliably use CodeGraph's MCP tools to navigate that codebase — finding the right symbols, understanding call chains, exploring subsystems, and getting useful context for real tasks. + +## Setup + +### 1. Build and index + +```bash +npm run build +rm -rf /.codegraph +node dist/bin/codegraph.js init -iv +``` + +The `-iv` flag gives verbose output showing extraction progress, node/edge counts, and timing. + +### 2. Quick sanity check + +```bash +# Verify nodes were extracted with proper qualified names +sqlite3 /.codegraph/codegraph.db \ + "SELECT name, kind, qualified_name FROM nodes WHERE kind = 'method' LIMIT 10;" + +# GOOD: file.go::StructName::method_name (owner type present) +# BAD: file.go::file.go::method_name (owner type missing — needs getReceiverType) + +# Check edge counts +sqlite3 /.codegraph/codegraph.db \ + "SELECT kind, COUNT(*) FROM edges GROUP BY kind ORDER BY COUNT(*) DESC;" + +# Check node kind distribution +sqlite3 /.codegraph/codegraph.db \ + "SELECT kind, COUNT(*) FROM nodes GROUP BY kind ORDER BY COUNT(*) DESC;" +``` + +If methods are missing their owner type in `qualified_name`, fix that first (see [Adding getReceiverType](#adding-getreceivertype)) before proceeding with the full test battery. + +## The Test Battery + +Run **all** of the following test categories against the codebase. Use the Node.js API directly — the test scripts below are templates. Adapt the queries to match real types, methods, and subsystems in the codebase you're testing. + +**Pass criteria for each test:** Does the result give an LLM enough correct information to answer the question or complete the task? Would you trust these results if you were the LLM? + +--- + +### Test 1: `codegraph_explore` — Deep Exploration (MOST IMPORTANT) + +This is the primary tool LLMs use. It must return relevant source code grouped by file, with correct relationships, for a natural language query. Test it with **at least 5 different query types**: + +```bash +node -e " +const { CodeGraph } = require('./dist/index.js'); +async function test() { + const cg = await CodeGraph.open(''); + + const queries = [ + // A. Subsystem exploration — broad topic, should find the right files and key classes + 'How does the caching system work?', + + // B. Specific class/type deep dive — should return that class, its methods, and related types + 'CacheBuilder configuration and build process', + + // C. Cross-cutting concern — should find implementations across multiple files + 'How are errors handled and propagated?', + + // D. Data flow question — should trace through multiple layers + 'How does data flow from input to storage?', + + // E. Implementation detail — specific method behavior + 'How does eviction decide which entries to remove?', + ]; + + for (const query of queries) { + console.log(\`\n========================================\`); + console.log(\`QUERY: \${query}\`); + console.log(\`========================================\`); + + const subgraph = await cg.findRelevantContext(query, { + searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2, + }); + + // Show entry points — these are what the LLM sees first + console.log(\`\nEntry points (\${subgraph.roots.length}):\`); + for (const rootId of subgraph.roots.slice(0, 8)) { + const node = subgraph.nodes.get(rootId); + if (node) console.log(\` \${node.name} (\${node.kind}) — \${node.filePath}:\${node.startLine}\`); + } + + // Show file distribution — are the right files surfacing? + const fileGroups = new Map(); + for (const node of subgraph.nodes.values()) { + if (!fileGroups.has(node.filePath)) fileGroups.set(node.filePath, []); + fileGroups.get(node.filePath).push(node.name); + } + console.log(\`\nFiles (\${fileGroups.size}):\`); + for (const [file, nodes] of [...fileGroups.entries()].sort((a,b) => b[1].length - a[1].length).slice(0, 8)) { + console.log(\` \${file} (\${nodes.length} symbols): \${nodes.slice(0, 6).join(', ')}\`); + } + + // Show edge distribution — are relationships being captured? + const edgeKinds = new Map(); + for (const edge of subgraph.edges) { + edgeKinds.set(edge.kind, (edgeKinds.get(edge.kind) || 0) + 1); + } + console.log(\`\nEdges (\${subgraph.edges.length}):\`); + for (const [kind, count] of [...edgeKinds.entries()].sort((a,b) => b - a)) { + console.log(\` \${kind}: \${count}\`); + } + + console.log(\`\nTotal: \${subgraph.nodes.size} nodes, \${subgraph.edges.length} edges, \${fileGroups.size} files\`); + } + + await cg.close(); +} +test().catch(console.error); +" +``` + +**What to check for each query:** +- Do the entry points make sense for the question? +- Are the right files surfacing (not just test files or unrelated code)? +- Is there a mix of edge types (calls, contains, extends, implements) — not just `contains`? +- Does the node count feel right? Too few (<5) means search failed. Too many irrelevant ones means noise. + +--- + +### Test 2: `codegraph_search` — Symbol Lookup + +Test that searching for specific symbols returns the right results ranked correctly. + +```bash +node -e " +const { CodeGraph } = require('./dist/index.js'); +async function test() { + const cg = await CodeGraph.open(''); + + const searches = [ + // A. Class by name + { query: 'CacheBuilder', kinds: ['class'], desc: 'Find a specific class' }, + + // B. Method on a specific type (the classic disambiguation test) + { query: 'CacheBuilder build', kinds: ['method'], desc: 'Method on specific class' }, + + // C. Common method name — should still find relevant ones + { query: 'get', kinds: ['method'], desc: 'Common method name' }, + + // D. Interface/trait + { query: 'Cache', kinds: ['interface'], desc: 'Find an interface' }, + + // E. Enum + { query: 'Strength', kinds: ['enum'], desc: 'Find an enum' }, + ]; + + for (const s of searches) { + console.log(\`\n--- \${s.desc}: \"\${s.query}\" (kinds: \${s.kinds}) ---\`); + const results = cg.searchNodes(s.query, { limit: 10, kinds: s.kinds }); + for (const r of results) { + console.log(\` \${r.score.toFixed(1)} | \${r.node.name} (\${r.node.kind}) | \${r.node.qualifiedName}\`); + } + if (results.length === 0) console.log(' *** NO RESULTS ***'); + } + + await cg.close(); +} +test().catch(console.error); +" +``` + +**What to check:** +- Does the target symbol rank in the top 3? +- For common names like `get`, do the results include qualified names that help disambiguate? +- Are there zero-result queries? That's a bug. + +--- + +### Test 3: `codegraph_callers` / `codegraph_callees` — Call Chain Tracing + +Test that call relationships were extracted correctly. + +```bash +node -e " +const { CodeGraph } = require('./dist/index.js'); +async function test() { + const cg = await CodeGraph.open(''); + + // Pick 3-4 important methods and check their call graphs + const symbols = ['build', 'get', 'put', 'invalidate']; + + for (const sym of symbols) { + // Find the symbol + const results = cg.searchNodes(sym, { limit: 5, kinds: ['method'] }); + if (results.length === 0) { console.log(\`\${sym}: not found\`); continue; } + + const node = results[0].node; + console.log(\`\n--- \${node.name} (\${node.qualifiedName}) ---\`); + + // Check callees (what does it call?) + const callees = cg.getCallees(node.id); + console.log(\` Callees (\${callees.length}): \${callees.slice(0, 10).map(c => c.node.name).join(', ')}\`); + + // Check callers (what calls it?) + const callers = cg.getCallers(node.id); + console.log(\` Callers (\${callers.length}): \${callers.slice(0, 10).map(c => c.node.name).join(', ')}\`); + } + + await cg.close(); +} +test().catch(console.error); +" +``` + +**What to check:** +- Do methods have callers AND callees? If a method has 0 of both, edge extraction may be broken. +- Do the callers/callees make sense? A `build()` method should call constructor-like things, and be called by setup/initialization code. +- Are the counts reasonable? A core method in a popular codebase should have multiple callers. + +--- + +### Test 4: `codegraph_impact` — Change Impact Analysis + +Test that the impact radius correctly identifies affected code. + +```bash +node -e " +const { CodeGraph } = require('./dist/index.js'); +async function test() { + const cg = await CodeGraph.open(''); + + // Pick a core class or interface that many things depend on + const results = cg.searchNodes('', { limit: 1, kinds: ['class', 'interface'] }); + if (results.length === 0) { console.log('Not found'); return; } + + const node = results[0].node; + console.log(\`Impact analysis for: \${node.name} (\${node.kind}) — \${node.filePath}\`); + + const impact = cg.getImpactRadius(node.id, 2); + console.log(\`\nAffected nodes: \${impact.nodes.size}\`); + console.log(\`Affected edges: \${impact.edges.length}\`); + + // Group by file + const files = new Map(); + for (const n of impact.nodes.values()) { + if (!files.has(n.filePath)) files.set(n.filePath, []); + files.get(n.filePath).push(n.name); + } + console.log(\`Affected files: \${files.size}\`); + for (const [file, nodes] of [...files.entries()].sort((a,b) => b[1].length - a[1].length).slice(0, 10)) { + console.log(\` \${file}: \${nodes.slice(0, 5).join(', ')}\`); + } + + await cg.close(); +} +test().catch(console.error); +" +``` + +**What to check:** +- Does changing a core interface/class show a wide impact radius? +- Are the affected files reasonable (things that import/extend/use it)? +- Is the impact radius non-empty? Zero impact on a core type means edges are missing. + +--- + +### Test 5: Edge Extraction Quality + +Directly verify that the major edge types are being extracted for this language. + +```bash +node -e " +const { CodeGraph } = require('./dist/index.js'); +async function test() { + const cg = await CodeGraph.open(''); + + // Check overall edge distribution + console.log('=== Edge distribution ==='); + // (Use sqlite3 query from sanity check above) + + // Find a class that extends another + const classes = cg.searchNodes('', { limit: 100, kinds: ['class'] }); + let foundExtends = false, foundImplements = false; + for (const r of classes) { + const callees = cg.getCallees(r.node.id); + // getCallees returns all outgoing edges, check for extends/implements + // Better: use graph traversal + } + + // Verify specific relationship types exist + const checks = [ + { desc: 'contains edges (class → method)', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"contains\"' }, + { desc: 'calls edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"calls\"' }, + { desc: 'imports edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"imports\"' }, + { desc: 'extends edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"extends\"' }, + { desc: 'implements edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"implements\"' }, + ]; + // Run these via sqlite3 (shown in sanity check section) + + await cg.close(); +} +test().catch(console.error); +" +``` + +```bash +sqlite3 /.codegraph/codegraph.db " + SELECT kind, COUNT(*) as cnt FROM edges GROUP BY kind ORDER BY cnt DESC; +" +``` + +**What to check:** +- `contains` should be the most common (structural hierarchy). +- `calls` should be plentiful — if near zero, call extraction is broken for this language. +- `imports` should exist — if zero, import parsing is broken. +- `extends` and `implements` should exist if the language has inheritance — if zero, `extractInheritance()` may not handle this language's AST. + +--- + +### Test 6: Node Extraction Completeness + +Verify all expected node kinds are being extracted. + +```bash +sqlite3 /.codegraph/codegraph.db " + SELECT kind, COUNT(*) as cnt FROM nodes GROUP BY kind ORDER BY cnt DESC; +" +``` + +**What to check for each language:** + +| Node Kind | Expected? | Notes | +|-----------|-----------|-------| +| `file` | Always | One per source file | +| `class` | If language has classes | | +| `method` | If language has methods | Should include owner type in `qualified_name` | +| `function` | If language has top-level functions | | +| `interface` | If language has interfaces/protocols | | +| `enum` | If language has enums | | +| `enum_member` | If language has enums | Values inside enums | +| `import` | Always | One per import statement | +| `variable` / `field` | Usually | Fields, constants, top-level vars | +| `struct` | If language has structs | Go, Rust, C, Swift | +| `trait` | If language has traits | Rust | + +If an expected node kind has 0 count, the language extractor is missing that AST type. + +--- + +### Test 7: Real-World LLM Prompts + +This is the final and most important test. Simulate the kinds of questions a developer would actually ask an LLM that's using CodeGraph. For each prompt, run `findRelevantContext` (which powers `codegraph_explore`) and evaluate whether the returned context would let an LLM give a correct, complete answer. + +**Run at least 5 of these prompt styles, adapted to the actual codebase:** + +```bash +node -e " +const { CodeGraph } = require('./dist/index.js'); +async function test() { + const cg = await CodeGraph.open(''); + + const prompts = [ + // 1. \"How does X work?\" — subsystem understanding + 'How does the cache eviction policy work?', + + // 2. \"Where is X implemented?\" — symbol location + 'Where is the LRU eviction logic implemented?', + + // 3. \"What calls X?\" — usage discovery + 'What code triggers cache invalidation?', + + // 4. \"I want to change X, what breaks?\" — impact assessment + 'If I change the Cache interface, what else is affected?', + + // 5. \"How do X and Y interact?\" — cross-component relationships + 'How does CacheBuilder connect to LocalCache?', + + // 6. \"Show me the flow from A to B\" — data/control flow + 'What happens when a cache entry expires?', + + // 7. \"What are all the implementations of X?\" — polymorphism + 'What classes implement the Cache interface?', + + // 8. Bug investigation prompt + 'Cache entries are not being evicted when they should be — where should I look?', + ]; + + for (const prompt of prompts) { + console.log(\`\n========================================\`); + console.log(\`PROMPT: \${prompt}\`); + console.log(\`========================================\`); + + const subgraph = await cg.findRelevantContext(prompt, { + searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2, + }); + + console.log(\`Result: \${subgraph.nodes.size} nodes, \${subgraph.edges.length} edges, \${subgraph.roots.length} entry points\`); + + console.log('Entry points:'); + for (const rootId of subgraph.roots.slice(0, 5)) { + const node = subgraph.nodes.get(rootId); + if (node) console.log(\` \${node.name} (\${node.kind}) — \${node.filePath}:\${node.startLine}\`); + } + + const fileGroups = new Map(); + for (const node of subgraph.nodes.values()) { + if (!fileGroups.has(node.filePath)) fileGroups.set(node.filePath, []); + fileGroups.get(node.filePath).push(node.name); + } + console.log('Top files:'); + for (const [file, nodes] of [...fileGroups.entries()].sort((a,b) => b[1].length - a[1].length).slice(0, 5)) { + console.log(\` \${file} (\${nodes.length}): \${nodes.slice(0, 5).join(', ')}\`); + } + + // PASS/FAIL judgment + const hasEntryPoints = subgraph.roots.length > 0; + const hasEdges = subgraph.edges.length > 0; + const hasMultipleFiles = fileGroups.size > 1; + console.log(\`\\nVERDICT: \${hasEntryPoints && hasEdges && hasMultipleFiles ? 'PASS' : 'FAIL — needs investigation'}\`); + } + + await cg.close(); +} +test().catch(console.error); +" +``` + +**What to check for each prompt:** +- Does it return entry points? Zero entry points = total failure. +- Are the entry points **relevant** to the question? (Not just random symbols that happen to share a word.) +- Does it span multiple files? Most real questions involve cross-file understanding. +- Are relationships present? An LLM needs to understand how symbols connect, not just a list of names. +- Would **you** be able to answer the question from this context? + +--- + +## Diagnosing Failures + +| Symptom | Likely Cause | Where to Fix | +|---------|-------------|--------------| +| Method missing owner type in `qualified_name` | Language needs `getReceiverType` | `src/extraction/languages/.ts` | +| `codegraph_explore` returns irrelevant files | Common names flooding FTS; co-location boost not helping | `src/db/queries.ts: findNodesByExactName`, `src/context/index.ts` | +| Zero `calls` edges | `callTypes` missing or wrong AST node type | `src/extraction/languages/.ts: callTypes` | +| Zero `extends`/`implements` edges | `extractInheritance()` doesn't handle this language's AST | `src/extraction/tree-sitter.ts: extractInheritance()` | +| Missing node kinds (no enums, no interfaces) | AST type not listed in extractor | `src/extraction/languages/.ts: enumTypes`, `interfaceTypes`, etc. | +| Search term dropped from query | Term is in the stop words list | `src/search/query-utils.ts: STOP_WORDS` | +| `qualified_name` missing class for nested methods | Extraction not walking parent stack correctly | `src/extraction/tree-sitter.ts: visitNode()` | +| Import edges missing | `extractImport` returns null for this syntax | `src/extraction/languages/.ts: extractImport` | +| C++ classes/structs/enums missing from macro namespaces | Macros like `NLOHMANN_JSON_NAMESPACE_BEGIN` cause tree-sitter to misparse namespace blocks as `function_definition` | `src/extraction/languages/c-cpp.ts: isMisparsedFunction` filters bad names; `src/extraction/tree-sitter.ts: visitFunctionBody` extracts structural nodes | +| C++ classes missing from `.h` headers | `.h` files default to `c` language which has `classTypes: []` | `src/extraction/grammars.ts: looksLikeCpp()` — content-based heuristic promotes `.h` files to `cpp` when C++ patterns detected | +| Ruby methods inside modules missing owner in `qualified_name` | Ruby `module` AST nodes not being extracted | `src/extraction/languages/ruby.ts: visitNode` hook extracts modules; `src/extraction/tree-sitter.ts: isInsideClassLikeNode` includes `module` kind | +| TypeScript abstract classes missing | `abstract_class_declaration` not in `classTypes` | `src/extraction/languages/typescript.ts: classTypes` — add `abstract_class_declaration` | +| Single-expression arrow functions silently dropped | `extractName` finds identifier in expression body instead of returning `` | `src/extraction/tree-sitter.ts: extractName` — skip identifier search for `arrow_function`/`function_expression` nodes | +| Kotlin interfaces/enums extracted as classes | `class_declaration` matches `classTypes` first; `interfaceTypes`/`enumTypes` never fire | `src/extraction/languages/kotlin.ts: classifyClassNode` detects `interface`/`enum` keywords in AST children | +| Kotlin functions have zero calls extracted | Tree-sitter grammar doesn't use field names, so `getChildByField(node, 'function_body')` returns null | `src/extraction/languages/kotlin.ts: resolveBody` finds body by type (`function_body`, `class_body`, `enum_class_body`) | +| Kotlin `navigation_expression` calls not resolved cleanly | `navigation_expression` fell through to `getNodeText` producing messy names with parentheses | `src/extraction/tree-sitter.ts: extractCall` — handle `navigation_expression` by extracting method name from `navigation_suffix > simple_identifier` | +| Kotlin `fun interface` declarations invisible | Tree-sitter-kotlin doesn't support `fun interface` syntax (Kotlin 1.4+), producing ERROR or misparse as `function_declaration` | `src/extraction/languages/kotlin.ts: visitNode` detects three misparse patterns: (1) ERROR node + lambda body, (2) function_declaration with `user_type("interface")` direct child + name in ERROR child, (3) function_declaration with ERROR child containing `user_type("interface")` + name. `isFunInterfaceNode` checks both direct and ERROR-nested `user_type` children | +| Kotlin class/interface methods missing when nested `fun interface` present | Tree-sitter misparsed parent body as ERROR (starting with `{`) + class_body (nested interface body); `resolveBody` found wrong body | `src/extraction/languages/kotlin.ts: resolveBody` prefers ERROR bodies starting with `{`; `visitNode` excludes body-like ERROR from `fun interface` detection | +| Svelte `$props()` destructuring produces ugly variable names | `let { x, y } = $props()` has `object_pattern` as variable name node; `getNodeText` returns full pattern | `src/extraction/tree-sitter.ts: extractVariable` skips `object_pattern`/`array_pattern` named declarators | +| Svelte template function calls invisible (e.g. `class={cn(...)}`) | SvelteExtractor only parsed ` + + + + diff --git a/site/src/styles/theme.css b/site/src/styles/theme.css new file mode 100644 index 0000000..4a406fa --- /dev/null +++ b/site/src/styles/theme.css @@ -0,0 +1,226 @@ +/* ===================================================================== + codegraph — flat / paper editorial theme + Monochrome ink-on-paper, hairline rules, square corners. Shared by the + custom landing page (src/pages/index.astro) and the Starlight docs. + ===================================================================== */ + +/* ---- Fonts ---- */ +:root { + --sl-font: 'Archivo Variable', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; + --sl-font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; +} + +/* ---- Starlight colour mapping: light / paper (default) ---- */ +:root, +:root[data-theme='light'] { + --sl-color-accent-low: #e2dfd5; + --sl-color-accent: #16150f; + --sl-color-accent-high: #16150f; + --sl-color-white: #16150f; + --sl-color-gray-1: #2a281f; + --sl-color-gray-2: #56544a; + --sl-color-gray-3: #6f6c61; + --sl-color-gray-4: #87847a; + --sl-color-gray-5: #b4b1a5; + --sl-color-gray-6: #d6d3c8; + --sl-color-gray-7: #e8e6dd; + --sl-color-black: #f7f6f2; + + --sl-color-bg: #f7f6f2; + --sl-color-bg-nav: #f7f6f2; + --sl-color-bg-sidebar: #f7f6f2; + --sl-color-bg-inline-code: #e8e6dd; + --sl-color-bg-accent: #16150f; + + --sl-color-text: #16150f; + --sl-color-text-accent: #16150f; + --sl-color-text-invert: #f7f6f2; + + --sl-color-hairline: #16150f; + --sl-color-hairline-light: #d6d3c8; + --sl-color-hairline-shade: #d6d3c8; + + /* shared tokens */ + --cg-paper: #f7f6f2; + --cg-paper-2: #f1efe8; + --cg-paper-press: #e8e6dd; + --cg-ink: #16150f; + --cg-ink-2: #56544a; + --cg-ink-3: #87847a; + --cg-rule: #16150f; + --cg-rule-soft: #d6d3c8; +} + +/* ---- Starlight colour mapping: dark / ink ---- */ +:root[data-theme='dark'] { + --sl-color-accent-low: #34322a; + --sl-color-accent: #f3f1ea; + --sl-color-accent-high: #f3f1ea; + --sl-color-white: #f3f1ea; + --sl-color-gray-1: #e7e5dc; + --sl-color-gray-2: #c9c6ba; + --sl-color-gray-3: #a7a499; + --sl-color-gray-4: #7c7a70; + --sl-color-gray-5: #57554c; + --sl-color-gray-6: #2c2a23; + --sl-color-gray-7: #1e1c16; + --sl-color-black: #16150f; + + --sl-color-bg: #16150f; + --sl-color-bg-nav: #16150f; + --sl-color-bg-sidebar: #16150f; + --sl-color-bg-inline-code: #23211a; + --sl-color-bg-accent: #f3f1ea; + + --sl-color-text: #f3f1ea; + --sl-color-text-accent: #f3f1ea; + --sl-color-text-invert: #16150f; + + --sl-color-hairline: #f3f1ea; + --sl-color-hairline-light: #34322a; + --sl-color-hairline-shade: #34322a; + + --cg-paper: #16150f; + --cg-paper-2: #1e1c16; + --cg-paper-press: #23211a; + --cg-ink: #f3f1ea; + --cg-ink-2: #b8b5a8; + --cg-ink-3: #87847a; + --cg-rule: #f3f1ea; + --cg-rule-soft: #34322a; +} + +/* ---- Global flat resets ---- */ +*, +*::before, +*::after { + border-radius: 0 !important; /* this design has no rounded corners, anywhere */ +} + +:root { + --sl-shadow-sm: none; + --sl-shadow-md: none; + --sl-shadow-lg: none; +} + +body { + background: var(--cg-paper); + color: var(--cg-ink); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +:where(h1, h2, h3, h4, h5) { + letter-spacing: -0.02em; +} + +/* ---- Docs chrome ---- */ + +/* Header: one crisp bottom rule. Starlight nests
inside +
, so a bare `.header { border-bottom }` draws two + lines — put the rule on the outer
only and clear the inner div. */ +.header { + background: var(--cg-paper); + -webkit-backdrop-filter: none; + backdrop-filter: none; +} +header.header { + border-bottom: 1px solid var(--cg-rule); +} +.header .header { + border-bottom: 0; +} + +/* Sidebar: crisp right rule */ +#starlight__sidebar, +.sidebar-pane { + border-inline-end: 1px solid var(--cg-rule); + background: var(--cg-paper); +} + +/* Sidebar group labels — small caps, committed editorial direction */ +.sidebar-content details > summary, +.sidebar-content > ul > li > span, +.sidebar-content .large { + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + font-size: 0.72rem; + color: var(--cg-ink-2); +} + +/* Sidebar links */ +.sidebar-content a { + color: var(--cg-ink-2); +} +.sidebar-content a:hover { + background: var(--cg-paper-press); + color: var(--cg-ink); +} +.sidebar-content a[aria-current='page'], +.sidebar-content a[aria-current='page']:hover { + background: transparent; + color: var(--cg-ink); + font-weight: 700; + border-inline-start: 2px solid var(--cg-ink); +} + +/* Right "On this page" rail */ +starlight-toc a { + color: var(--cg-ink-3); +} +starlight-toc a[aria-current='true'] { + color: var(--cg-ink); + font-weight: 600; +} + +/* Prev / next pagination: flat bordered boxes */ +.pagination-links a { + border: 1px solid var(--cg-rule); + box-shadow: none; + background: var(--cg-paper); +} +.pagination-links a:hover { + background: var(--cg-paper-press); +} + +/* Inline code */ +.sl-markdown-content :not(pre) > code { + border: 1px solid var(--cg-rule-soft); + background: var(--cg-paper-2); + font-size: 0.875em; +} + +/* Cards / asides: square, hairline */ +.card, +.starlight-aside { + border: 1px solid var(--cg-rule); + box-shadow: none; +} + +/* Search trigger */ +button[data-open-modal] { + border: 1px solid var(--cg-rule); + background: var(--cg-paper); +} + +/* Content horizontal rules */ +.sl-markdown-content hr { + border: 0; + border-top: 1px solid var(--cg-rule); +} + +/* Links in prose */ +.sl-markdown-content a { + color: var(--cg-ink); + text-underline-offset: 3px; +} + +/* On wide screens Starlight right-aligns the content against the TOC + (margin-inline: auto 0), piling all the empty space on the left. Center the + content within its pane so it sits balanced between the sidebar and the TOC. */ +@media (min-width: 72rem) { + .main-pane { + --sl-content-margin-inline: auto; + } +} diff --git a/site/tsconfig.json b/site/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/site/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts new file mode 100644 index 0000000..4d85e20 --- /dev/null +++ b/src/bin/codegraph.ts @@ -0,0 +1,2420 @@ +#!/usr/bin/env node +/** + * CodeGraph CLI + * + * Command-line interface for CodeGraph code intelligence. + * + * Usage: + * codegraph Run interactive installer (when no args) + * codegraph install Run interactive installer + * codegraph uninstall Remove CodeGraph from your agents + * codegraph init [path] Initialize CodeGraph in a project + * codegraph uninit [path] Remove CodeGraph from a project + * codegraph index [path] Index all files in the project + * codegraph sync [path] Sync changes since last index + * codegraph status [path] Show index status + * codegraph query Search for symbols + * codegraph files [options] Show project file structure + * codegraph context Build context for a task + * codegraph callers Find what calls a function/method + * codegraph callees Find what a function/method calls + * codegraph impact Analyze what code is affected by changing a symbol + * codegraph affected [files] Find test files affected by changes + * codegraph upgrade [version] Update CodeGraph to the latest release + */ + +// FIRST import, before anything else loads: capture process.ppid while our +// launcher is (almost certainly) still alive. A launcher killed mid-startup +// otherwise blinds the PPID watchdog forever (#1185) — see early-ppid.ts. +import '../mcp/early-ppid'; + +import { Command } from 'commander'; +import * as path from 'path'; +import * as fs from 'fs'; +import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, hasStructuralKeyword, extractCodeTokens } from '../directory'; +import { extractProseCandidates } from '../search/identifier-segments'; +import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree'; +import { createShimmerProgress } from '../ui/shimmer-progress'; +import { getGlyphs } from '../ui/glyphs'; + +import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check'; +import { installFatalHandlers } from './fatal-handler'; +import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags'; +import { installCommandSupervision } from './command-supervision'; +import { EXTRACTION_VERSION } from '../extraction/extraction-version'; +import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry'; + +// Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast. +async function loadCodeGraph(): Promise { + try { + return await import('../index'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`\x1b[31m${getGlyphs().err}\x1b[0m Failed to load CodeGraph modules.`); + console.error(`\n Node: ${process.version} Platform: ${process.platform} ${process.arch}`); + console.error(`\n Error: ${msg}`); + console.error('\n Try reinstalling with: npm install -g @colbymchenry/codegraph\n'); + process.exit(1); + } +} + +// Dynamic import helper — tsc compiles import() to require() in CJS mode, +// which fails for ESM-only packages. This bypasses the transformation. +// eslint-disable-next-line @typescript-eslint/no-implied-eval +const importESM = new Function('specifier', 'return import(specifier)') as + (specifier: string) => Promise; + +// Block CodeGraph on Node.js 25.x — V8's turboshaft WASM JIT has a Zone +// allocator bug that reliably crashes when compiling tree-sitter +// grammars (see #54, #81, #140). The previous behaviour was a soft +// console.warn that scrolls off-screen before the OOM crash 30 seconds +// later, leading to a steady stream of "what is this OOM" reports. +// Hard-exit before any WASM work; allow override via env var for users +// who patched V8 themselves or want to test a future fix. +const nodeVersion = process.versions.node; +const nodeMajor = parseInt(nodeVersion.split('.')[0] ?? '0', 10); +if (nodeMajor >= 25) { + process.stderr.write(buildNode25BlockBanner(nodeVersion) + '\n'); + if (!process.env.CODEGRAPH_ALLOW_UNSAFE_NODE) { + process.exit(1); + } + // Override active — banner shown for visibility, continuing. +} +// Enforce the supported Node floor. `engines` in package.json only *warns* on +// install (unless engine-strict), so hard-block here to actually keep users off +// unsupported versions. Mirrors the 25+ block above. See package.json `engines`. +if (nodeMajor < MIN_NODE_MAJOR) { + process.stderr.write(buildNodeTooOldBanner(nodeVersion) + '\n'); + if (!process.env.CODEGRAPH_ALLOW_UNSAFE_NODE) { + process.exit(1); + } + // Override active — banner shown for visibility, continuing. +} + +// Re-exec with V8's `--liftoff-only` if it isn't already set, so tree-sitter's +// large WASM grammars never hit the turboshaft Zone OOM (`Fatal process out of +// memory: Zone`) on Node >= 22. No-op under the bundled launcher, which already +// passes the flag. Must run before any grammar (in the parse worker, which +// inherits this process's flags) is compiled. See ../extraction/wasm-runtime-flags. +relaunchWithWasmRuntimeFlagsIfNeeded(__filename); + +// Last-resort fatal handlers: log a bounded line and exit non-zero. A fault +// that reaches here escaped every boundary, so the process is in an undefined +// state — keeping it alive is what let the detached MCP daemon orphan and pin a +// CPU core with no recovery (#799, #850). Installed before the command branch +// so it also covers a synchronous throw during startup. See ./fatal-handler. +installFatalHandlers(); + +// Check if running with no arguments - run installer +if (process.argv.length === 2) { + import('../installer').then(({ runInstaller }) => + runInstaller() + ).catch((err) => { + console.error('Installation failed:', err instanceof Error ? err.message : String(err)); + process.exit(1); + }); +} else { + // Normal CLI flow + main(); +} + +function main() { + +const program = new Command(); + +// Version from package.json +const packageJson = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8') +); + +// Make the version trivial to reach. commander's `.version()` (below) wires up +// `--version` and `-V`; intercept the spellings it can't — lowercase `-v` and +// single-dash `-version` — before any parsing. (commander's version short flag +// is the capital `-V`, and its parser rejects a multi-character single-dash +// flag.) The bare `codegraph version` subcommand is registered further down so +// the affordance also shows up in `codegraph --help`. +const firstArg = process.argv[2]; +if (firstArg === '-v' || firstArg === '-version') { + console.log(packageJson.version); + return; +} + +// ============================================================================= +// ANSI Color Helpers (avoid chalk ESM issues) +// ============================================================================= + +const colors = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + white: '\x1b[37m', + gray: '\x1b[90m', +}; + +const chalk = { + bold: (s: string) => `${colors.bold}${s}${colors.reset}`, + dim: (s: string) => `${colors.dim}${s}${colors.reset}`, + red: (s: string) => `${colors.red}${s}${colors.reset}`, + green: (s: string) => `${colors.green}${s}${colors.reset}`, + yellow: (s: string) => `${colors.yellow}${s}${colors.reset}`, + blue: (s: string) => `${colors.blue}${s}${colors.reset}`, + cyan: (s: string) => `${colors.cyan}${s}${colors.reset}`, + white: (s: string) => `${colors.white}${s}${colors.reset}`, + gray: (s: string) => `${colors.gray}${s}${colors.reset}`, +}; + +program + .name('codegraph') + .description('Code intelligence and knowledge graph for any codebase') + .version(packageJson.version); + +// Anonymous usage telemetry (see TELEMETRY.md): record the invoked subcommand +// NAME only — never arguments or paths. Counts buffer locally; network sends +// piggyback on commands that run long anyway (quick commands only append to +// the local buffer at exit, costing nothing). +// install/uninstall are absent on purpose: the installer flushes at its own +// end, AFTER its consent prompt — a flush here would fire the first-run +// notice before the user ever sees the toggle. +const TELEMETRY_FLUSH_COMMANDS = new Set(['init', 'uninit', 'index', 'sync', 'upgrade']); +program.hook('preAction', (_thisCommand, actionCommand) => { + try { + // The detached daemon re-invokes `serve --mcp` internally — not a user action. + if (process.env.CODEGRAPH_DAEMON_INTERNAL) return; + const name = actionCommand.name(); + if (name === 'telemetry') return; // managing telemetry is not usage + getTelemetry().recordUsage('cli_command', name, true); + if (TELEMETRY_FLUSH_COMMANDS.has(name)) getTelemetry().maybeFlush(); + } catch { + /* telemetry must never break the CLI */ + } +}); + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Resolve project path from argument or current directory + * Walks up parent directories to find nearest initialized CodeGraph project + * (must have .codegraph/codegraph.db, not just .codegraph/lessons.db) + */ +function resolveProjectPath(pathArg?: string): string { + const absolutePath = path.resolve(pathArg || process.cwd()); + + // If exact path is initialized (has codegraph.db), use it + if (isInitialized(absolutePath)) { + return absolutePath; + } + + // Walk up to find nearest parent with CodeGraph initialized + // Note: findNearestCodeGraphRoot finds any .codegraph folder, but we need one with codegraph.db + let current = absolutePath; + const root = path.parse(current).root; + + while (current !== root) { + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + + if (isInitialized(current)) { + return current; + } + } + + // Not found - return original path (will fail later with helpful error) + return absolutePath; +} + +/** + * Format a number with commas + */ +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +/** + * Format duration in milliseconds to human readable + */ +function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms}ms`; + } + const seconds = ms / 1000; + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds.toFixed(0)}s`; +} + +// Shimmer progress renderer (runs in a worker thread for smooth animation) +// Imported at top of file from '../ui/shimmer-progress' + +/** + * Create a plain-text progress callback for --verbose mode. + * No animations, no ANSI tricks — just timestamped lines to stdout. + */ +function createVerboseProgress(): (progress: { phase: string; current: number; total: number; currentFile?: string }) => void { + let lastPhase = ''; + let lastPct = -1; + const startTime = Date.now(); + + return (progress) => { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + if (progress.phase !== lastPhase) { + lastPhase = progress.phase; + lastPct = -1; + console.log(`[${elapsed}s] Phase: ${progress.phase}`); + } + + if (progress.total > 0) { + const pct = Math.floor((progress.current / progress.total) * 100); + // Log every 5% to keep output manageable + if (pct >= lastPct + 5 || progress.current === progress.total) { + lastPct = pct; + console.log(`[${elapsed}s] ${progress.current}/${progress.total} (${pct}%)${progress.currentFile ? ` ${getGlyphs().dash} ${progress.currentFile}` : ''}`); + } + } else if (progress.current > 0) { + // Scanning phase (no total yet) — log periodically + if (progress.current % 1000 === 0 || progress.current === 1) { + console.log(`[${elapsed}s] ${formatNumber(progress.current)} files found`); + } + } + }; +} + +/** + * Print success message + */ +function success(message: string): void { + console.log(chalk.green(getGlyphs().ok) + ' ' + message); +} + +/** + * Print error message + */ +function error(message: string): void { + console.error(chalk.red(getGlyphs().err) + ' ' + message); +} + +/** + * Print info message + */ +function info(message: string): void { + console.log(chalk.blue(getGlyphs().info) + ' ' + message); +} + +/** + * Print warning message + */ +function warn(message: string): void { + console.log(chalk.yellow(getGlyphs().warn) + ' ' + message); +} + +type IndexResult = { + success: boolean; + filesIndexed: number; + filesSkipped: number; + filesErrored: number; + nodesCreated: number; + edgesCreated: number; + errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>; + durationMs: number; +}; + +/** + * Print indexing results using clack log methods + */ +function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexResult, projectPath?: string): void { + const hasErrors = result.filesErrored > 0; + + // Surface non-file-level failures (e.g. lock-acquisition failure + // when another indexer is running) before the file-count branches. + // Without this the CLI falls through to "No files found to index", + // which is actively misleading — the index DID run, it just couldn't + // get the lock. + // + // If success is false but no severity:'error' entry exists in + // `result.errors` (degenerate case — shouldn't happen in practice + // but worth guarding because the result shape is plumbed through + // multiple call sites), fall back to a generic message rather than + // continuing to the misleading "No files found" branch or throwing. + if (!result.success && !hasErrors && result.filesIndexed === 0) { + const generic = result.errors.find((e) => e.severity === 'error'); + clack.log.error(generic?.message ?? `Indexing failed ${getGlyphs().dash} no further details available`); + return; + } + + if (result.filesIndexed > 0) { + if (hasErrors) { + clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} could not be parsed)`); + } else { + clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`); + } + clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`); + // A PARTIAL index (files silently dropped mid-pipeline) must not pass + // as a clean run — it's the difference between "indexed the repo" and + // "indexed most of the repo, quietly". Only the completeness + // reconciliation warning; per-file extractor warnings stay in the + // error-code summary below. + for (const w of result.errors.filter((e) => e.code === 'index_partial')) { + clack.log.warn(w.message); + } + } else if (hasErrors) { + clack.log.error(`Indexing failed ${getGlyphs().dash} all ${formatNumber(result.filesErrored)} files had errors`); + } else { + clack.log.warn('No files found to index'); + } + + if (hasErrors) { + const errorsByCode = new Map(); + for (const err of result.errors) { + if (err.severity === 'error') { + const code = err.code || 'unknown'; + errorsByCode.set(code, (errorsByCode.get(code) || 0) + 1); + } + } + + const codeLabels: Record = { + parse_error: 'files failed to parse', + read_error: 'files could not be read', + size_exceeded: 'files exceeded size limit', + path_traversal: 'blocked paths', + unsupported_language: 'unsupported language', + parser_error: 'parser initialization failures', + }; + + const breakdown = Array.from(errorsByCode) + .map(([code, count]) => `${formatNumber(count)} ${codeLabels[code] || code}`) + .join('\n'); + clack.note(breakdown, 'Error breakdown'); + + if (projectPath) { + writeErrorLog(projectPath, result.errors); + clack.log.info('See .codegraph/errors.log for details'); + } + + if (result.filesIndexed > 0) { + clack.log.info(`The index is fully usable ${getGlyphs().dash} only the failed files are missing.`); + } + } else if (projectPath) { + const logPath = path.join(getCodeGraphDir(projectPath), 'errors.log'); + if (fs.existsSync(logPath)) { + fs.unlinkSync(logPath); + } + } +} + +/** + * When an `init`/`index` produced an EMPTY graph and the reason is that the + * project's own `.gitignore` excludes nested git repositories — the "super-repo + * gitignores its child repos" layout (#1156), where `init` at the parent + * correctly indexes ~nothing while `init` inside each child works — name those + * repos and offer to index them. An interactive terminal gets a yes/no prompt + * that writes `includeIgnored` to codegraph.json and re-indexes; a + * non-interactive run just prints the one-line opt-in snippet. The caller gates + * this on `nodesCreated === 0`, so a project that DID index real content is + * never nagged about the gitignored reference clones it deliberately keeps out + * (#970, #1065). Best-effort throughout: detection never breaks the command. + */ +async function offerIndexIgnoredRepos( + clack: typeof import('@clack/prompts'), + projectPath: string, + reindex: () => Promise, + opts: { interactive: boolean }, +): Promise { + let repos: string[]; + try { + const { findUnindexedIgnoredRepos } = await import('../extraction'); + repos = findUnindexedIgnoredRepos(projectPath); + } catch { + return; // detection is advisory — never let it break the command + } + if (repos.length === 0) return; + + const { PROJECT_CONFIG_FILENAME } = await import('../project-config'); + const isOne = repos.length === 1; + const SHOWN = 6; + const names = repos.slice(0, SHOWN).map((r) => r.replace(/\/$/, '')); + const extra = repos.length > SHOWN ? ` (+${formatNumber(repos.length - SHOWN)} more)` : ''; + const snippet = `{ "includeIgnored": [${repos.map((p) => JSON.stringify(p)).join(', ')}] }`; + + clack.log.warn( + `Your .gitignore excludes ${isOne ? 'a nested git repository' : `${formatNumber(repos.length)} nested git repositories`} here, ` + + `so ${isOne ? 'it was' : 'they were'} not indexed: ${names.join(', ')}${extra}.`, + ); + + const manualHint = () => { + clack.log.info( + `If ${isOne ? "it's" : "they're"} your code, add ${isOne ? 'it' : 'them'} to ${PROJECT_CONFIG_FILENAME} and re-index:`, + ); + clack.log.info(` ${snippet}`); + }; + + if (!opts.interactive || !process.stdin.isTTY) { + manualHint(); + return; + } + + const yes = await clack.confirm({ + message: `Index ${isOne ? 'it' : `these ${formatNumber(repos.length)}`} now? Adds ${isOne ? 'it' : 'them'} to ${PROJECT_CONFIG_FILENAME}.`, + initialValue: true, + }); + if (clack.isCancel(yes) || !yes) { + manualHint(); + return; + } + + let added: number; + try { + const { addIncludeIgnoredPatterns } = await import('../project-config'); + added = addIncludeIgnoredPatterns(projectPath, repos); + } catch (err) { + clack.log.error(`Could not update ${PROJECT_CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`); + manualHint(); + return; + } + clack.log.success(`Added ${formatNumber(added)} ${added === 1 ? 'entry' : 'entries'} to ${PROJECT_CONFIG_FILENAME} ${getGlyphs().dash} re-indexing…`); + + const result = await reindex(); + printIndexResult(clack, result, projectPath); + return result; +} + +/** + * Write detailed error log to .codegraph/errors.log + */ +function writeErrorLog(projectPath: string, errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>): void { + const cgDir = getCodeGraphDir(projectPath); + if (!fs.existsSync(cgDir)) return; + + const logPath = path.join(cgDir, 'errors.log'); + + // Group errors by file path + const errorsByFile = new Map>(); + const noFileErrors: Array<{ message: string; code?: string }> = []; + + for (const err of errors) { + if (err.severity !== 'error') continue; + if (err.filePath) { + let list = errorsByFile.get(err.filePath); + if (!list) { + list = []; + errorsByFile.set(err.filePath, list); + } + list.push({ message: err.message, code: err.code }); + } else { + noFileErrors.push({ message: err.message, code: err.code }); + } + } + + const lines: string[] = [ + `CodeGraph Error Log - ${new Date().toISOString()}`, + `${errorsByFile.size} files with errors`, + '', + ]; + + for (const [filePath, fileErrors] of errorsByFile) { + for (const err of fileErrors) { + lines.push(`${filePath}: ${err.message}`); + } + } + + for (const err of noFileErrors) { + lines.push(err.message); + } + + fs.writeFileSync(logPath, lines.join('\n') + '\n'); +} + +/** + * Telemetry for a completed full index (see TELEMETRY.md). The bounded flush + * keeps init/index responsive (these commands just ran for seconds anyway) + * while delivering the event promptly. + */ +async function recordIndexTelemetry( + cg: { getStats(): { filesByLanguage: Record }; getBackend(): string }, + result: IndexResult, +): Promise { + recordIndexEvent(cg, result); + await getTelemetry().flushNow(); +} + +// ============================================================================= +// Commands +// ============================================================================= + +/** + * codegraph init [path] + */ +program + .command('init [path]') + .description('Initialize CodeGraph in a project directory and build the initial index') + .option('-i, --index', 'Deprecated: indexing now runs by default; flag accepted for backward compatibility') + .option('-f, --force', 'Initialize even if the path looks like your home directory or a filesystem root') + .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') + .action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean }) => { + const projectPath = path.resolve(pathArg || process.cwd()); + const clack = await importESM('@clack/prompts'); + + clack.intro('Initializing CodeGraph'); + + try { + // Refuse to index your home directory / a filesystem root — it pulls in + // caches, other projects, and your whole tree (a multi-GB index + watcher + // churn, and on pre-1.0 macOS a machine-crashing fd blowup, #845). + const unsafe = unsafeIndexRootReason(projectPath); + if (unsafe && !options.force) { + clack.log.error(`Refusing to initialize in ${projectPath} — it looks like ${unsafe}.`); + clack.log.info('Run this inside a specific project directory, or pass --force if you really mean to index everything under it.'); + clack.outro(''); + process.exitCode = 1; + return; + } + + if (isInitialized(projectPath)) { + clack.log.warn(`Already initialized in ${projectPath}`); + clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update'); + try { + const { offerWatchFallback } = await import('../installer'); + await offerWatchFallback(clack, projectPath); + } catch { /* non-fatal */ } + clack.outro(''); + return; + } + + const { default: CodeGraph, getDatabasePath } = await loadCodeGraph(); + const cg = await CodeGraph.init(projectPath, { index: false }); + clack.log.success(`Initialized in ${projectPath}`); + + // Indexing runs by default now. The legacy -i/--index flag is still + // accepted (so existing muscle memory and scripts don't break) but is a + // no-op — initializing always builds the initial index. + // Supervise the index: self-terminate if orphaned or wedged (#999). + // The DB + WAL paths let the liveness watchdog tell a slow store on + // degraded storage from a true wedge (#1231). + // A closure so we can re-run the exact same supervised, progress-rendered + // index if the user opts gitignored child repos in below (#1156). + const dbPath = getDatabasePath(projectPath); + const runIndex = async (): Promise => { + const supervision = installCommandSupervision('init', { progressPaths: [dbPath, `${dbPath}-wal`] }); + try { + if (options.verbose) { + return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true }); + } + process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); + const progress = createShimmerProgress(); + const r = await cg.indexAll({ onProgress: progress.onProgress }); + await progress.stop(); + return r; + } finally { + supervision.stop(); + } + }; + const result = await runIndex(); + printIndexResult(clack, result, projectPath); + await recordIndexTelemetry(cg, result); + + // An empty graph at a git super-repo usually means `.gitignore` excludes + // the child repos that hold the code — surface them and offer to opt in + // rather than leaving the user with a silent 0-node "Done". (#1156) + if (result.nodesCreated === 0) { + await offerIndexIgnoredRepos(clack, projectPath, runIndex, { interactive: true }); + } + + try { + const { offerWatchFallback } = await import('../installer'); + await offerWatchFallback(clack, projectPath); + } catch { /* non-fatal */ } + + clack.outro('Done'); + cg.destroy(); + } catch (err) { + clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph uninit [path] + */ +program + .command('uninit [path]') + .description('Remove CodeGraph from a project (deletes .codegraph/ directory)') + .option('-f, --force', 'Skip confirmation prompt') + .action(async (pathArg: string | undefined, options: { force?: boolean }) => { + const projectPath = resolveProjectPath(pathArg); + + try { + if (!isInitialized(projectPath)) { + warn(`CodeGraph is not initialized in ${projectPath}`); + return; + } + + if (!options.force) { + // Confirm with user + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question( + chalk.yellow(`${getGlyphs().warn} This will permanently delete all CodeGraph data. Continue? (y/N) `), + resolve + ); + }); + rl.close(); + + if (answer.toLowerCase() !== 'y') { + info('Cancelled'); + return; + } + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = CodeGraph.openSync(projectPath); + cg.uninitialize(); + + // Clean up any git sync hooks we installed (no-op if none / not a repo). + try { + const { removeGitSyncHook } = await import('../sync/git-hooks'); + const removed = removeGitSyncHook(projectPath); + if (removed.installed.length > 0) { + info(`Removed git ${removed.installed.join(', ')} sync hook${removed.installed.length > 1 ? 's' : ''}`); + } + } catch { /* non-fatal */ } + + success(`Removed CodeGraph from ${projectPath}`); + + // Churn signal — and flush now, since after an uninit there may be no + // "next run" to deliver it. + try { + getTelemetry().recordLifecycle('uninstall', {}); + await getTelemetry().flushNow(); + } catch { /* non-fatal */ } + } catch (err) { + error(`Failed to uninitialize: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph index [path] + */ +program + .command('index [path]') + .description('Rebuild the full index from scratch (same result as a fresh init)') + .option('-f, --force', 'Index even if the path looks like your home directory or a filesystem root') + .option('-q, --quiet', 'Suppress progress output') + .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') + .action(async (pathArg: string | undefined, options: { force?: boolean; quiet?: boolean; verbose?: boolean }) => { + const projectPath = resolveProjectPath(pathArg); + + try { + // Don't (re)index your home directory / a filesystem root (#845). --force + // doubles as the override. + const unsafe = unsafeIndexRootReason(projectPath); + if (unsafe && !options.force) { + error(`Refusing to index ${projectPath} — it looks like ${unsafe}. Pass --force to override.`); + process.exit(1); + } + + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + info('Run "codegraph init" first'); + process.exit(1); + } + + const { default: CodeGraph, getDatabasePath } = await loadCodeGraph(); + // `index` is a FULL re-index — identical to a fresh `init`. RECREATE the + // database from scratch (discard .codegraph/codegraph.db + its WAL) rather + // than opening the old graph and DELETE-ing every row. The clear-then-index + // approach reported "0 nodes" without the clear (#874); the recreate keeps + // that fixed AND avoids the failure mode where, on a large or pre-fix + // poisoned index, the per-row FTS delete churn wedged the main thread long + // enough to trip the liveness watchdog before scanning even began (#1067). + // recreate() hands back a fresh, empty instance — no clear() needed. For + // fast incremental updates use `sync`. + const cg = await CodeGraph.recreate(projectPath); + + // Supervise the indexer: self-terminate if orphaned (parent shim killed) + // or if the main thread wedges — neither was guarded on this path (#999). + // The DB + WAL paths let the liveness watchdog tell a slow store on + // degraded storage from a true wedge (#1231). + const dbPath = getDatabasePath(projectPath); + const supervision = installCommandSupervision('index', { progressPaths: [dbPath, `${dbPath}-wal`] }); + try { + if (options.quiet) { + // Quiet mode: no UI, just run against the freshly-recreated graph. + const result = await cg.indexAll(); + if (!result.success) process.exit(1); + cg.destroy(); + return; + } + + const clack = await importESM('@clack/prompts'); + clack.intro('Indexing project'); + + // A closure so a re-index (after opting gitignored child repos in, #1156) + // renders identically. Supervision already wraps the whole command. + const renderIndex = async (): Promise => { + if (options.verbose) { + return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true }); + } + process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); + const progress = createShimmerProgress(); + const r = await cg.indexAll({ onProgress: progress.onProgress }); + await progress.stop(); + return r; + }; + + const result = await renderIndex(); + + printIndexResult(clack, result, projectPath); + await recordIndexTelemetry(cg, result); + + // Empty graph at a git super-repo → likely `.gitignore`d child repos; + // name them and offer to opt in instead of a silent 0-node result (#1156). + let finalResult = result; + if (result.nodesCreated === 0) { + finalResult = (await offerIndexIgnoredRepos(clack, projectPath, renderIndex, { interactive: true })) ?? result; + } + + if (!finalResult.success) { + process.exit(1); + } + + clack.outro('Done'); + cg.destroy(); + } finally { + supervision.stop(); + } + } catch (err) { + error(`Failed to index: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph sync [path] + */ +program + .command('sync [path]') + .description('Sync changes since last index') + .option('-q, --quiet', 'Suppress output (for git hooks)') + .action(async (pathArg: string | undefined, options: { quiet?: boolean }) => { + const projectPath = resolveProjectPath(pathArg); + + try { + if (!isInitialized(projectPath)) { + if (!options.quiet) { + error(`CodeGraph not initialized in ${projectPath}`); + } + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + + if (options.quiet) { + await cg.sync(); + cg.destroy(); + return; + } + + const clack = await importESM('@clack/prompts'); + clack.intro('Syncing CodeGraph'); + + process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); + const progress = createShimmerProgress(); + + const result = await cg.sync({ + onProgress: progress.onProgress, + }); + + await progress.stop(); + + const totalChanges = result.filesAdded + result.filesModified + result.filesRemoved; + + if (totalChanges === 0) { + clack.log.info('Already up to date'); + } else { + clack.log.success(`Synced ${formatNumber(totalChanges)} changed files`); + const details: string[] = []; + if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`); + if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`); + if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`); + clack.log.info(`${details.join(', ')} ${getGlyphs().dash} ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`); + } + + clack.outro('Done'); + cg.destroy(); + } catch (err) { + if (!options.quiet) { + error(`Failed to sync: ${err instanceof Error ? err.message : String(err)}`); + } + process.exit(1); + } + }); + +/** + * codegraph status [path] + */ +program + .command('status [path]') + .description('Show index status and statistics') + .option('-j, --json', 'Output as JSON') + .action(async (pathArg: string | undefined, options: { json?: boolean }) => { + const projectPath = resolveProjectPath(pathArg); + // The directory the user actually ran from, before walking up to the index + // root. Used to detect when the resolved index lives in a different git + // working tree (e.g. a nested worktree borrowing the main checkout's index). + const startPath = path.resolve(pathArg || process.cwd()); + const worktreeMismatch = detectWorktreeIndexMismatch(startPath, projectPath); + + try { + if (!isInitialized(projectPath)) { + if (options.json) { + console.log(JSON.stringify({ + initialized: false, + version: packageJson.version, + projectPath, + indexPath: getCodeGraphDir(projectPath), + lastIndexed: null, + })); + return; + } + console.log(chalk.bold('\nCodeGraph Status\n')); + info(`Project: ${projectPath}`); + warn('Not initialized'); + info('Run "codegraph init" to initialize'); + return; + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const stats = cg.getStats(); + const changes = cg.getChangedFiles(); + const backend = cg.getBackend(); + const journalMode = cg.getJournalMode(); + + const buildInfo = cg.getIndexBuildInfo(); + const reindexRecommended = cg.isIndexStale(); + const indexState = cg.getIndexState(); + // Zero on a healthy index; non-zero at rest means a resolution pass was + // interrupted, so some files' call edges are missing (#1187). + const pendingRefs = cg.getPendingReferenceCount(); + + // JSON output mode + if (options.json) { + const lastIndexedMs = cg.getLastIndexedAt(); + console.log(JSON.stringify({ + initialized: true, + version: packageJson.version, + projectPath, + indexPath: getCodeGraphDir(projectPath), + lastIndexed: lastIndexedMs != null ? new Date(lastIndexedMs).toISOString() : null, + fileCount: stats.fileCount, + nodeCount: stats.nodeCount, + edgeCount: stats.edgeCount, + dbSizeBytes: stats.dbSizeBytes, + backend, + journalMode, + nodesByKind: stats.nodesByKind, + languages: Object.entries(stats.filesByLanguage).filter(([, count]) => count > 0).map(([lang]) => lang), + pendingChanges: { + added: changes.added.length, + modified: changes.modified.length, + removed: changes.removed.length, + }, + worktreeMismatch: worktreeMismatch + ? { worktreeRoot: worktreeMismatch.worktreeRoot, indexRoot: worktreeMismatch.indexRoot } + : null, + index: { + builtWithVersion: buildInfo.version, + builtWithExtractionVersion: buildInfo.extractionVersion, + currentExtractionVersion: EXTRACTION_VERSION, + reindexRecommended, + // 'complete' | 'partial' (files silently dropped) | 'indexing' + // (a run was killed mid-index — the index is truncated) | + // 'failed' | null (predates the marker). + state: indexState, + // References awaiting resolution. Non-zero at rest means an + // interrupted resolution pass left edges missing; the next + // sync sweeps them (#1187). + pendingRefs, + }, + })); + cg.destroy(); + return; + } + + console.log(chalk.bold('\nCodeGraph Status\n')); + + // Project info + console.log(chalk.cyan('Project:'), projectPath); + if (worktreeMismatch) { + warn(worktreeMismatchWarning(worktreeMismatch)); + } + if (indexState === 'indexing') { + warn('The last index run never finished (killed mid-index?) — the index is truncated. Re-run "codegraph index".'); + } else if (indexState === 'partial') { + warn('The last index run silently dropped files — the index is partial. Re-run "codegraph index".'); + } else if (indexState === 'failed') { + warn('The last index run failed — results may be incomplete. Re-run "codegraph index".'); + } + if (pendingRefs > 0) { + warn(`${formatNumber(pendingRefs)} references from an interrupted run are awaiting resolution — some callers/impact edges are missing. Run "codegraph sync" to resolve them.`); + } + console.log(); + + // Index stats + console.log(chalk.bold('Index Statistics:')); + console.log(` Files: ${formatNumber(stats.fileCount)}`); + console.log(` Nodes: ${formatNumber(stats.nodeCount)}`); + console.log(` Edges: ${formatNumber(stats.edgeCount)}`); + console.log(` DB Size: ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`); + // Surface the active SQLite backend (node:sqlite — Node's built-in real + // SQLite, full WAL + FTS5, no native build). + const backendLabel = chalk.green(`node:sqlite ${getGlyphs().dash} built-in (full WAL)`); + console.log(` Backend: ${backendLabel}`); + // Effective journal mode: 'wal' means concurrent reads never block on a + // writer; anything else means they can ("database is locked"). node:sqlite + // supports WAL everywhere, so a non-wal mode means the filesystem can't + // (network mounts, WSL2 /mnt). See issue #238. + const journalLabel = journalMode === 'wal' + ? chalk.green('wal') + : chalk.yellow(`${journalMode || 'unknown'} ${getGlyphs().dash} WAL inactive; reads can block on writes`); + console.log(` Journal: ${journalLabel}`); + console.log(); + + // Node breakdown + console.log(chalk.bold('Nodes by Kind:')); + const nodesByKind = Object.entries(stats.nodesByKind) + .filter(([, count]) => count > 0) + .sort((a, b) => b[1] - a[1]); + for (const [kind, count] of nodesByKind) { + console.log(` ${kind.padEnd(15)} ${formatNumber(count)}`); + } + console.log(); + + // Language breakdown + console.log(chalk.bold('Files by Language:')); + const filesByLang = Object.entries(stats.filesByLanguage) + .filter(([, count]) => count > 0) + .sort((a, b) => b[1] - a[1]); + for (const [lang, count] of filesByLang) { + console.log(` ${lang.padEnd(15)} ${formatNumber(count)}`); + } + console.log(); + + // Pending changes + const totalChanges = changes.added.length + changes.modified.length + changes.removed.length; + if (totalChanges > 0) { + console.log(chalk.bold('Pending Changes:')); + if (changes.added.length > 0) { + console.log(` Added: ${changes.added.length} files`); + } + if (changes.modified.length > 0) { + console.log(` Modified: ${changes.modified.length} files`); + } + if (changes.removed.length > 0) { + console.log(` Removed: ${changes.removed.length} files`); + } + info('Run "codegraph sync" to update the index'); + } else { + success('Index is up to date'); + } + console.log(); + + // Re-index hint: the index was built by an older engine than the one now + // running, so a rebuild would add data a migration can't backfill. + if (reindexRecommended) { + const builtWith = buildInfo.version ? `v${buildInfo.version.replace(/^v/, '')}` : 'an earlier version'; + warn(`Index was built by ${builtWith}; re-index to pick up this engine's improvements.`); + info('Run "codegraph index" (full rebuild) or "codegraph sync"'); + console.log(); + } + + cg.destroy(); + } catch (err) { + error(`Failed to get status: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph query + */ +program + .command('query ') + .description('Search for symbols in the codebase') + .option('-p, --path ', 'Project path') + .option('-l, --limit ', 'Maximum results', '10') + .option('-k, --kind ', 'Filter by node kind (function, class, etc.)') + .option('-j, --json', 'Output as JSON') + .action(async (search: string, options: { path?: string; limit?: string; kind?: string; json?: boolean }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + + const limit = parseInt(options.limit || '10', 10); + const rawResults = cg.searchNodes(search, { + limit, + kinds: options.kind ? [options.kind as any] : undefined, + }); + + // Mirror the MCP search down-rank so the CLI also surfaces the + // hand-written implementation before protobuf/gRPC scaffolding + // when both share a name. See extraction/generated-detection.ts. + const { isGeneratedFile } = await import('../extraction/generated-detection'); + const results = [...rawResults].sort((a, b) => { + const aGen = isGeneratedFile(a.node.filePath) ? 1 : 0; + const bGen = isGeneratedFile(b.node.filePath) ? 1 : 0; + return aGen - bGen; + }); + + if (options.json) { + console.log(JSON.stringify(results, null, 2)); + } else { + if (results.length === 0) { + info(`No results found for "${search}"`); + } else { + console.log(chalk.bold(`\nSearch Results for "${search}":\n`)); + + // Results arrive already ranked by relevance, so the order conveys + // it. We don't print the raw score: it's an unbounded BM25/FTS value + // (relative-ranking only), and the old `(score * 100)%` rendered it + // as nonsensical percentages like "12042%" (#1045). The MCP search + // tool likewise shows no score. Raw `score` stays in --json output. + for (const result of results) { + const node = result.node; + const location = `${node.filePath}:${node.startLine}`; + + console.log( + chalk.cyan(node.kind.padEnd(12)) + + chalk.white(node.name) + ); + console.log(chalk.dim(` ${location}`)); + if (node.signature) { + console.log(chalk.dim(` ${node.signature}`)); + } + console.log(); + } + } + } + + cg.destroy(); + } catch (err) { + error(`Search failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph explore + * + * The CLI face of the MCP codegraph_explore tool — same handler, same + * output (source of the relevant symbols grouped by file + the call path + * among them). Exists so agents WITHOUT the MCP tools — Task-tool + * subagents (which don't inherit MCP tools, #704) and non-MCP harnesses — + * can reach the graph through a plain shell command. + */ +program + .command('explore ') + .description('Explore an area: relevant symbols\' source + call paths in one shot (same output as the codegraph_explore MCP tool)') + .option('-p, --path ', 'Project path') + .option('--max-files ', 'Maximum number of files to include source from') + .action(async (queryParts: string[], options: { path?: string; maxFiles?: string }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph isn't available here — no .codegraph/ index exists in ${projectPath}. If you are an AI agent: continue with your usual tools; indexing is the user's decision, do not run it yourself. (The project owner can enable CodeGraph with 'codegraph init'.)`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const { ToolHandler } = await import('../mcp/tools'); + const handler = new ToolHandler(cg); + + const args: Record = { query: queryParts.join(' ') }; + if (options.maxFiles) args.maxFiles = parseInt(options.maxFiles, 10); + const result = await handler.execute('codegraph_explore', args); + + console.log(result.content[0]?.text ?? ''); + cg.destroy(); + if (result.isError) process.exit(1); + } catch (err) { + error(`Explore failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph prompt-hook (hidden) + * + * A Claude Code `UserPromptSubmit` hook entry point. Reads `{prompt, cwd}` JSON + * on stdin; for a structural/flow/impact prompt it runs `codegraph_explore` on + * the indexed project and prints the result to stdout, which Claude injects into + * the agent's context — so the agent's reflex grep/read has nothing left to find + * and reliably uses CodeGraph (the adoption problem). Installed by the installer + * into Claude's settings.json (opt-in, default-yes). + * + * LOAD-BEARING: this must NEVER break the user's prompt. Every failure path — + * kill-switch, non-structural prompt, no index, engine error — exits 0 with no + * output. The only effect is additive context when it can confidently provide it. + */ +program + .command('prompt-hook', { hidden: true }) + .description('Claude UserPromptSubmit hook: inject CodeGraph context for structural prompts (reads {prompt,cwd} JSON on stdin)') + .action(async () => { + try { + // Kill-switch: lets a user disable the nudge without uninstalling / + // editing settings.json (CI, low-power machines, personal preference). + if (process.env.CODEGRAPH_NO_PROMPT_HOOK === '1' || process.env.CODEGRAPH_PROMPT_HOOK === '0') return; + if (process.stdin.isTTY) return; // invoked by hand, no piped payload + + const raw = await new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (c) => { data += c; }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', () => resolve(data)); + }); + + let input: { prompt?: string; cwd?: string } = {}; + try { input = JSON.parse(raw); } catch { return; } + const prompt = String(input.prompt || ''); + + // Gate telemetry: how often each tier fires vs. no-ops — counter names + // only, NEVER prompt content (see TELEMETRY.md). This is the data that + // turns "is the gate any good" from vibes into a measured recall rate. + const gate = (outcome: string): void => { + try { getTelemetry().recordUsage('cli_command', `prompt-hook-gate-${outcome}`, true); } catch { /* never break the hook */ } + }; + + // Gate, tiered by confidence (#994, #1126): + // HIGH — a structural keyword (any covered language), or a code-shaped + // token verified in the index → full explore injection. + // MEDIUM — no keyword/token, but prose words match indexed symbol-name + // SEGMENTS ("state machine" → OrderStateMachine, in any + // language): inject a short list of the matching symbols and + // let the AGENT write the explore query — the graph-derived + // tier, no vocabulary involved. + // silent — nothing verified. Every other prompt ("fix this typo") + // stays a zero-cost no-op. + // Keywords fire on their own; a token or prose word is only a CANDIDATE + // verified against the graph below, so a tech brand ("JavaScript") that + // merely looks like code doesn't inject spurious context. + const keyworded = hasStructuralKeyword(prompt); + const codeTokens = keyworded ? [] : extractCodeTokens(prompt); + const proseWords = keyworded ? [] : extractProseCandidates(prompt); + if (!keyworded && codeTokens.length === 0 && proseWords.length === 0) { gate('noop-shape'); return; } + + // Decide what to inject, shaped by WHERE the index(es) are: the nearest + // indexed ancestor of cwd, or — when cwd is an un-indexed workspace root + // whose indexed project(s) live in sub-dirs (the monorepo case, #964) — + // the sub-project the prompt points at, plus a `projectPath` nudge for any + // others. Without the down-scan the hook injected nothing at a monorepo + // root (it only walked up), so the validated adoption lever never fired + // exactly where the agent most needs it. + const plan = planFrontload(String(input.cwd || process.cwd()), prompt); + if (!plan.exploreRoot && plan.nudgeProjects.length === 0) { gate('noop-no-index'); return; } // nothing reachable — the agent's normal tools apply + + // A "pass projectPath" line for indexed sub-projects we did NOT front-load. + // Follow-up codegraph_explore calls against a sub-project (cwd isn't its + // index root) need an explicit projectPath, so spell it out. + const nudge = (projects: string[], lead: string): string => + `${lead}\n${projects.map((p) => ` - projectPath: "${p}"`).join('\n')}\n`; + + if (plan.exploreRoot) { + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(plan.exploreRoot); + try { + const others = plan.nudgeProjects.length + ? `\n${nudge(plan.nudgeProjects, 'Other indexed projects in this workspace — pass projectPath to query them:')}` + : ''; + + // Tier decision against THIS index (issue #994 follow-up: candidates + // must be real here — a brand name or prose about another domain + // must not inject). Keyword-bearing prompts skip verification — the + // keyword is signal enough. + const tokenVerified = !keyworded && codeTokens.some((t) => cg.getNodesByName(t).length > 0); + if (keyworded || tokenVerified) { + const { ToolHandler } = await import('../mcp/tools'); + const handler = new ToolHandler(cg); + const result = await handler.execute('codegraph_explore', { query: prompt }); + const text = result.content[0]?.text ?? ''; + if (!result.isError && text.trim()) { + // Cap the injection so a large-repo explore can't flood the prompt. + const MAX = 16000; + const body = text.length > MAX ? `${text.slice(0, MAX)}\n…(truncated; call codegraph_explore for the rest)` : text; + // For a front-loaded SUB-project, a follow-up explore needs its path. + const more = plan.viaSubScan + ? `call codegraph_explore with projectPath: "${plan.exploreRoot}" for more` + : 'call codegraph_explore for more'; + process.stdout.write( + `\n${body}${others}\n\n`, + ); + gate(keyworded ? 'high-keyword' : 'high-token'); + } else { + // A high-* outcome must mean context was actually delivered — + // the funnel's noop-vs-high split is how gate recall is + // measured (#1143). An explore error or empty result is a + // delivery failure, not a gate success. + gate(keyworded ? 'noop-explore-keyword' : 'noop-explore-token'); + } + return; + } + + // MEDIUM: prose words → symbol-name segments, co-occurrence/rarity + // scored, each hit re-verified to exist (see getSegmentMatches). The + // payload names the symbols but does NOT run explore — the agent owns + // the query where the hook's confidence is only "these are related". + // + // A database indexed before the vocab table existed starts with it + // EMPTY, and only sync() backfills it — which this hook never runs + // (#1142). Heal it here: on a populated vocab this is one SELECT; + // the actual backfill is a one-time batched pass whose cost the MCP + // server's own catch-up sync usually pays first (it runs at every + // session start). A distinct noop outcome keeps a dormant vocab + // from polluting the noop-unverified recall signal. + const vocabReady = await cg.healSegmentVocabIfEmpty().catch(() => false); + if (!vocabReady) { gate('noop-vocab-empty'); return; } + const related = cg.getSegmentMatches(proseWords); + if (related.length === 0) { gate('noop-unverified'); return; } + const lines = related + .map((m) => ` - ${m.name} (${m.kind} — ${m.filePath}:${m.startLine})`) + .join('\n'); + const exampleQuery = related.slice(0, 3).map((m) => m.name).join(' '); + const projectHint = plan.viaSubScan ? ` with projectPath: "${plan.exploreRoot}"` : ''; + process.stdout.write( + `\n` + + `This project's CodeGraph index contains symbols matching this request:\n${lines}\n` + + `Call codegraph_explore ONCE${projectHint} with the relevant names in one query (e.g. "${exampleQuery}") ` + + `to get their source, call paths, and blast radius — cheaper and more complete than Read/Grep.\n${others}` + + `\n`, + ); + gate('medium-segment'); + } finally { + cg.destroy(); + } + } else { + // Several indexed sub-projects, none a clear match — don't guess; tell + // the agent they exist and how to query one. + process.stdout.write( + `\n` + + nudge(plan.nudgeProjects, "This workspace's CodeGraph indexes live in sub-projects. To use CodeGraph, call codegraph_explore with the projectPath of the relevant one:") + + `\n`, + ); + gate('nudge-projects'); + } + } catch { + // Degradable by contract: never surface an error to the prompt pipeline. + } + }); + +/** + * codegraph node [name] + * + * The CLI face of the MCP codegraph_node tool: one symbol's source + + * caller/callee trail, or a whole file with line numbers + dependents + * (Read-parity). Same subagent/non-MCP rationale as `explore`. + * + * `name` is OPTIONAL because `--file` (file-read mode) carries no symbol — + * a required `` made `codegraph node -f ` unreachable (#1044). + */ +program + .command('node [name]') + .description('One symbol\'s source + caller/callee trail, or read a file with line numbers + dependents (same output as the codegraph_node MCP tool)') + .option('-p, --path ', 'Project path') + .option('-f, --file ', 'Treat as file mode (or disambiguate a symbol to this file)') + .option('--offset ', 'File mode: 1-based start line') + .option('--limit ', 'File mode: maximum lines') + .option('--symbols-only', 'File mode: just the symbol map + dependents') + .action(async (name: string | undefined, options: { path?: string; file?: string; offset?: string; limit?: string; symbolsOnly?: boolean }) => { + // Need a symbol (positional) OR a file (--file / a path-like positional). + // With [name] optional, a bare `codegraph node` reaches here with neither + // and must be told what to pass, rather than crashing downstream. + if (!name && !options.file) { + error("Pass a symbol name (e.g. 'codegraph node parseToken') or a file (e.g. 'codegraph node -f src/auth.ts', or 'codegraph node src/auth.ts')."); + process.exit(1); + } + + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph isn't available here — no .codegraph/ index exists in ${projectPath}. If you are an AI agent: continue with your usual tools; indexing is the user's decision, do not run it yourself. (The project owner can enable CodeGraph with 'codegraph init'.)`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const { ToolHandler } = await import('../mcp/tools'); + const handler = new ToolHandler(cg); + + // A name with a path separator is a file read; otherwise a symbol + // (use --file for basename-only file reads or to pin an overload). + // Both separators: Windows users type src\auth\session.ts. Symbols + // never contain either ('/' isn't an identifier char anywhere we + // index; C++ scope is '::', JS members '.'). + const args: Record = {}; + if (options.file) { + args.file = options.file; + if (name && name !== options.file) args.symbol = name; + } else if (name && (name.includes('/') || name.includes('\\'))) { + args.file = name.replace(/\\/g, '/'); + } else if (name) { + args.symbol = name; + args.includeCode = true; + } + if (options.offset) args.offset = parseInt(options.offset, 10); + if (options.limit) args.limit = parseInt(options.limit, 10); + if (options.symbolsOnly) args.symbolsOnly = true; + + const result = await handler.execute('codegraph_node', args); + + console.log(result.content[0]?.text ?? ''); + cg.destroy(); + if (result.isError) process.exit(1); + } catch (err) { + error(`Node lookup failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph files [path] + */ +program + .command('files') + .description('Show project file structure from the index') + .option('-p, --path ', 'Project path') + .option('--filter ', 'Filter to files under this directory') + .option('--pattern ', 'Filter files matching this glob pattern') + .option('--format ', 'Output format (tree, flat, grouped)', 'tree') + .option('--max-depth ', 'Maximum directory depth for tree format') + .option('--no-metadata', 'Hide file metadata (language, symbol count)') + .option('-j, --json', 'Output as JSON') + .action(async (options: { + path?: string; + filter?: string; + pattern?: string; + format?: string; + maxDepth?: string; + metadata?: boolean; + json?: boolean; + }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + let files = cg.getFiles(); + + if (files.length === 0) { + info('No files indexed. Run "codegraph index" first.'); + cg.destroy(); + return; + } + + // Filter by path prefix + if (options.filter) { + const filter = options.filter; + files = files.filter(f => f.path.startsWith(filter) || f.path.startsWith('./' + filter)); + } + + // Filter by glob pattern + if (options.pattern) { + const regex = globToRegex(options.pattern); + files = files.filter(f => regex.test(f.path)); + } + + if (files.length === 0) { + info('No files found matching the criteria.'); + cg.destroy(); + return; + } + + // JSON output + if (options.json) { + const output = files.map(f => ({ + path: f.path, + language: f.language, + nodeCount: f.nodeCount, + size: f.size, + })); + console.log(JSON.stringify(output, null, 2)); + cg.destroy(); + return; + } + + const includeMetadata = options.metadata !== false; + const format = options.format || 'tree'; + const maxDepth = options.maxDepth ? parseInt(options.maxDepth, 10) : undefined; + + // Format output + switch (format) { + case 'flat': + console.log(chalk.bold(`\nFiles (${files.length}):\n`)); + for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) { + if (includeMetadata) { + console.log(` ${file.path} ${chalk.dim(`(${file.language}, ${file.nodeCount} symbols)`)}`); + } else { + console.log(` ${file.path}`); + } + } + break; + + case 'grouped': + console.log(chalk.bold(`\nFiles by Language (${files.length} total):\n`)); + const byLang = new Map(); + for (const file of files) { + const existing = byLang.get(file.language) || []; + existing.push(file); + byLang.set(file.language, existing); + } + const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length); + for (const [lang, langFiles] of sortedLangs) { + console.log(chalk.cyan(`${lang} (${langFiles.length}):`)); + for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) { + if (includeMetadata) { + console.log(` ${file.path} ${chalk.dim(`(${file.nodeCount} symbols)`)}`); + } else { + console.log(` ${file.path}`); + } + } + console.log(); + } + break; + + case 'tree': + default: + console.log(chalk.bold(`\nProject Structure (${files.length} files):\n`)); + printFileTree(files, includeMetadata, maxDepth, chalk); + break; + } + + console.log(); + cg.destroy(); + } catch (err) { + error(`Failed to list files: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * Normalize a user-supplied file path to the project-relative, forward-slash + * form CodeGraph stores in the index. Accepts an absolute path, a `./`-prefixed + * path, or Windows back-slashes; an empty string when the input is blank. Used + * by `codegraph affected` so `./src/x.ts`, `/abs/repo/src/x.ts`, and + * `src/x.ts` all match the same indexed file. (#825) + */ +function normalizeIndexPath(filePath: string, projectPath: string): string { + let f = filePath.trim(); + if (!f) return ''; + if (path.isAbsolute(f)) f = path.relative(projectPath, f); + // Collapse `.`/`..` segments, then force forward slashes and drop a leading + // `./` (path.normalize already strips it on POSIX; explicit for Windows). + f = path.normalize(f).replace(/\\/g, '/').replace(/^\.\//, ''); + return f; +} + +/** + * Convert glob pattern to regex + */ +function globToRegex(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '{{GLOBSTAR}}') + .replace(/\*/g, '[^/]*') + .replace(/\?/g, '[^/]') + .replace(/\{\{GLOBSTAR\}\}/g, '.*'); + return new RegExp(escaped); +} + +/** + * Print files as a tree + */ +function printFileTree( + files: { path: string; language: string; nodeCount: number }[], + includeMetadata: boolean, + maxDepth: number | undefined, + chalk: { dim: (s: string) => string; cyan: (s: string) => string } +): void { + interface TreeNode { + name: string; + children: Map; + file?: { language: string; nodeCount: number }; + } + + const root: TreeNode = { name: '', children: new Map() }; + + for (const file of files) { + const parts = file.path.split('/'); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!part) continue; + + if (!current.children.has(part)) { + current.children.set(part, { name: part, children: new Map() }); + } + current = current.children.get(part)!; + + if (i === parts.length - 1) { + current.file = { language: file.language, nodeCount: file.nodeCount }; + } + } + } + + const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => { + if (maxDepth !== undefined && depth > maxDepth) return; + + const glyphs = getGlyphs(); + const connector = isLast ? glyphs.treeLast : glyphs.treeBranch; + const childPrefix = isLast ? ' ' : glyphs.treePipe; + + if (node.name) { + let line = prefix + connector + node.name; + if (node.file && includeMetadata) { + line += chalk.dim(` (${node.file.language}, ${node.file.nodeCount} symbols)`); + } + console.log(line); + } + + const children = [...node.children.values()]; + children.sort((a, b) => { + const aIsDir = a.children.size > 0 && !a.file; + const bIsDir = b.children.size > 0 && !b.file; + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + const nextPrefix = node.name ? prefix + childPrefix : prefix; + renderNode(child, nextPrefix, i === children.length - 1, depth + 1); + } + }; + + renderNode(root, '', true, 0); +} + +/** + * codegraph daemon — interactive manager for the background daemons. Arrow keys + * to pick one (the current project's daemon floats to the top, auto-selected), + * enter to stop it. Falls back to a plain list when output isn't a TTY. + */ +program + .command('daemon') + .aliases(['daemons']) + .description('Manage running CodeGraph background daemons — pick one and press enter to stop it') + .action(async () => { + const { listDaemons, stopDaemonAt, stopAllDaemons } = await import('../mcp/daemon-registry'); + const { runDaemonPicker } = await import('../mcp/daemon-manager'); + + const daemons = listDaemons(); + if (daemons.length === 0) { + info('No CodeGraph daemons running.'); + return; + } + + // No TTY (piped / CI / non-interactive) — can't do arrow-key selection, so + // just print what's running instead of crashing on a prompt with no input. + if (!process.stdout.isTTY || !process.stdin.isTTY) { + for (const d of daemons) { + console.log(`pid ${d.pid} v${d.version} up ${formatDuration(Date.now() - d.startedAt)} ${d.root}`); + } + return; + } + + // The current project's daemon floats to the top and is pre-selected. + let cwdRoot: string | null = null; + const found = findNearestCodeGraphRoot(process.cwd()); + if (found) { try { cwdRoot = fs.realpathSync(found); } catch { cwdRoot = found; } } + + const clack = await importESM('@clack/prompts'); + clack.intro('CodeGraph daemons'); + await runDaemonPicker({ + list: listDaemons, + stop: stopDaemonAt, + stopAll: stopAllDaemons, + cwdRoot, + now: () => Date.now(), + select: (opts) => clack.select(opts), + isCancel: (v) => clack.isCancel(v), + note: (m) => clack.log.success(m), + done: (m) => clack.outro(m), + }); + }); + +/** + * codegraph serve + */ +program + // Hidden from `--help`: this is the stdio entry point an AI agent launches + // for itself (the installer wires `args: ['serve','--mcp']` into every + // agent's MCP config), not a command a human runs. It still works when + // invoked — hiding only removes it from the listing. See the interactive-TTY + // guard below, which explains this to anyone who runs it by hand. + .command('serve', { hidden: true }) + .description('Start CodeGraph as an MCP server for AI assistants') + .option('-p, --path ', 'Project path (optional for MCP mode, uses rootUri from client)') + .option('--mcp', 'Run as MCP server (stdio transport)') + .option('--no-watch', 'Disable the file watcher (no auto-sync; useful on slow filesystems like WSL2 /mnt drives)') + .action(async (options: { path?: string; mcp?: boolean; watch?: boolean }) => { + const projectPath = options.path ? resolveProjectPath(options.path) : undefined; + + // Commander sets watch=false when --no-watch is passed. Route it through + // the same env-var chokepoint the watcher and MCP server already honor. + if (options.watch === false) { + process.env.CODEGRAPH_NO_WATCH = '1'; + } + + try { + if (options.mcp) { + // `serve --mcp` is the stdio MCP server an AI agent launches for itself, + // not a command to run by hand. A human in a terminal would otherwise + // see it hang waiting for JSON-RPC on stdin, which reads as broken. If + // stdin is an interactive TTY, explain instead of hanging. The agent's + // pipe and the detached daemon both have a non-TTY stdin, so this only + // ever fires for a person who typed it. + if (process.stdin.isTTY && !process.env.CODEGRAPH_DAEMON_INTERNAL) { + console.error(chalk.bold('\nCodeGraph MCP server\n')); + console.error("This is the MCP server your AI agent (Claude Code, Cursor, Codex, opencode, …)"); + console.error("starts automatically — you don't run it yourself."); + console.error(`\nIt's already wired up by ${chalk.cyan('codegraph install')}. To check on things:`); + console.error(` ${chalk.cyan('codegraph status')} ${chalk.dim('— is this project indexed and healthy?')}`); + console.error(` ${chalk.cyan('codegraph daemon')} ${chalk.dim('— list or stop background MCP servers')}`); + console.error(chalk.dim('\n(Running it directly only does something when an MCP client drives it over stdin.)')); + return; + } + // Start MCP server - it handles initialization lazily based on rootUri from client + const { MCPServer } = await import('../mcp/index'); + const server = new MCPServer(projectPath); + await server.start(); + // Server will run until terminated + } else { + // Default: show info about MCP mode. + // Use stderr so stdout stays clean for any piped/stdio usage. + console.error(chalk.bold('\nCodeGraph MCP Server\n')); + console.error(chalk.blue(getGlyphs().info) + ' Use --mcp flag to start the MCP server'); + console.error('\nTo use with Claude Code, add to your MCP configuration:'); + console.error(chalk.dim(` +{ + "mcpServers": { + "codegraph": { + "command": "codegraph", + "args": ["serve", "--mcp"] + } + } +} +`)); + console.error('Available tools:'); + console.error(chalk.cyan(' codegraph_explore') + ' - Primary: source of the relevant symbols for any question'); + console.error(chalk.cyan(' codegraph_search') + ' - Search for code symbols'); + console.error(chalk.cyan(' codegraph_callers') + ' - Find callers of a symbol'); + console.error(chalk.cyan(' codegraph_callees') + ' - Find what a symbol calls'); + console.error(chalk.cyan(' codegraph_impact') + ' - Analyze impact of changes'); + console.error(chalk.cyan(' codegraph_node') + ' - Get symbol details'); + console.error(chalk.cyan(' codegraph_files') + ' - Get project file structure'); + console.error(chalk.cyan(' codegraph_status') + ' - Get index status'); + } + } catch (err) { + error(`Failed to start server: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph unlock [path] + */ +program + .command('unlock [path]') + .description('Remove a stale lock file that is blocking indexing') + .action(async (pathArg: string | undefined) => { + const projectPath = resolveProjectPath(pathArg); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + return; + } + + const lockPath = path.join(getCodeGraphDir(projectPath), 'codegraph.lock'); + + if (!fs.existsSync(lockPath)) { + info(`No lock file found ${getGlyphs().dash} nothing to do`); + return; + } + + fs.unlinkSync(lockPath); + success('Removed lock file. You can now run indexing again.'); + } catch (err) { + error(`Failed to remove lock: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph callers + * + * CLI parity with the MCP graph tools (codegraph_callers/callees/impact) so the + * traversal queries work in scripts, CI, and git hooks without a running MCP + * server. + */ +program + .command('callers ') + .description('Find all functions/methods that call a specific symbol') + .option('-p, --path ', 'Project path') + .option('-l, --limit ', 'Maximum results', '20') + .option('-j, --json', 'Output as JSON') + .action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const limit = parseInt(options.limit || '20', 10); + + const matches = cg.searchNodes(symbol, { limit: 50 }); + if (matches.length === 0) { + info(`Symbol "${symbol}" not found`); + cg.destroy(); + return; + } + + const seen = new Set(); + const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = []; + + for (const match of matches) { + const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); + if (!exactMatch && matches.length > 1) continue; + for (const c of cg.getCallers(match.node.id)) { + if (!seen.has(c.node.id)) { + seen.add(c.node.id); + allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + } + } + } + + // Fallback: if exact filter removed everything, use the top match + if (allCallers.length === 0 && matches[0]) { + for (const c of cg.getCallers(matches[0].node.id)) { + if (!seen.has(c.node.id)) { + seen.add(c.node.id); + allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + } + } + } + + const limited = allCallers.slice(0, limit); + + if (options.json) { + console.log(JSON.stringify({ symbol, callers: limited }, null, 2)); + } else if (limited.length === 0) { + info(`No callers found for "${symbol}"`); + } else { + console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\n`)); + for (const node of limited) { + const loc = node.startLine ? `:${node.startLine}` : ''; + console.log( + chalk.cyan(node.kind.padEnd(12)) + + chalk.white(node.name) + ); + console.log(chalk.dim(` ${node.filePath}${loc}`)); + console.log(); + } + } + + cg.destroy(); + } catch (err) { + error(`callers failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph callees + */ +program + .command('callees ') + .description('Find all functions/methods that a specific symbol calls') + .option('-p, --path ', 'Project path') + .option('-l, --limit ', 'Maximum results', '20') + .option('-j, --json', 'Output as JSON') + .action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const limit = parseInt(options.limit || '20', 10); + + const matches = cg.searchNodes(symbol, { limit: 50 }); + if (matches.length === 0) { + info(`Symbol "${symbol}" not found`); + cg.destroy(); + return; + } + + const seen = new Set(); + const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = []; + + for (const match of matches) { + const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); + if (!exactMatch && matches.length > 1) continue; + for (const c of cg.getCallees(match.node.id)) { + if (!seen.has(c.node.id)) { + seen.add(c.node.id); + allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + } + } + } + + if (allCallees.length === 0 && matches[0]) { + for (const c of cg.getCallees(matches[0].node.id)) { + if (!seen.has(c.node.id)) { + seen.add(c.node.id); + allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + } + } + } + + const limited = allCallees.slice(0, limit); + + if (options.json) { + console.log(JSON.stringify({ symbol, callees: limited }, null, 2)); + } else if (limited.length === 0) { + info(`No callees found for "${symbol}"`); + } else { + console.log(chalk.bold(`\nCallees of "${symbol}" (${limited.length}):\n`)); + for (const node of limited) { + const loc = node.startLine ? `:${node.startLine}` : ''; + console.log( + chalk.cyan(node.kind.padEnd(12)) + + chalk.white(node.name) + ); + console.log(chalk.dim(` ${node.filePath}${loc}`)); + console.log(); + } + } + + cg.destroy(); + } catch (err) { + error(`callees failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph impact + */ +program + .command('impact ') + .description('Analyze what code is affected by changing a symbol') + .option('-p, --path ', 'Project path') + .option('-d, --depth ', 'Traversal depth', '2') + .option('-j, --json', 'Output as JSON') + .action(async (symbol: string, options: { path?: string; depth?: string; json?: boolean }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10); + + const matches = cg.searchNodes(symbol, { limit: 50 }); + if (matches.length === 0) { + info(`Symbol "${symbol}" not found`); + cg.destroy(); + return; + } + + // Merge impact subgraphs across all exact-matching symbols + const mergedNodes = new Map(); + const seenEdges = new Set(); + let edgeCount = 0; + + for (const match of matches) { + const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); + if (!exactMatch && matches.length > 1) continue; + const impact = cg.getImpactRadius(match.node.id, depth); + for (const [id, n] of impact.nodes) { + mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine }); + } + for (const e of impact.edges) { + const key = `${e.source}->${e.target}:${e.kind}`; + if (!seenEdges.has(key)) { + seenEdges.add(key); + edgeCount++; + } + } + } + + // Fallback to top match if exact filter removed everything + if (mergedNodes.size === 0 && matches[0]) { + const impact = cg.getImpactRadius(matches[0].node.id, depth); + for (const [id, n] of impact.nodes) { + mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine }); + } + edgeCount = impact.edges.length; + } + + if (options.json) { + console.log(JSON.stringify({ + symbol, + depth, + nodeCount: mergedNodes.size, + edgeCount, + affected: Array.from(mergedNodes.values()), + }, null, 2)); + } else if (mergedNodes.size === 0) { + info(`No affected symbols found for "${symbol}"`); + } else { + console.log(chalk.bold(`\nImpact of changing "${symbol}" — ${mergedNodes.size} affected symbols:\n`)); + + // Group by file + const byFile = new Map>(); + for (const node of mergedNodes.values()) { + const list = byFile.get(node.filePath) || []; + list.push({ name: node.name, kind: node.kind, startLine: node.startLine }); + byFile.set(node.filePath, list); + } + + for (const [file, nodes] of byFile) { + console.log(chalk.cyan(file)); + for (const node of nodes) { + const loc = node.startLine ? `:${node.startLine}` : ''; + console.log(` ${chalk.dim(node.kind.padEnd(12))}${node.name}${chalk.dim(loc)}`); + } + console.log(); + } + } + + cg.destroy(); + } catch (err) { + error(`impact failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph affected [files...] + * + * Find test files affected by the given source files. + * Traces dependency edges transitively to find test files that depend on changed code. + * + * Usage: + * git diff --name-only | codegraph affected --stdin + * codegraph affected src/lib/components/Editor.svelte src/routes/+page.svelte + */ +program + .command('affected [files...]') + .description('Find test files affected by changed source files') + .option('-p, --path ', 'Project path') + .option('--stdin', 'Read file list from stdin (one per line)') + .option('-d, --depth ', 'Max dependency traversal depth', '5') + .option('-f, --filter ', 'Custom glob filter for test files (e.g. "e2e/*.spec.ts")') + .option('-j, --json', 'Output as JSON') + .option('-q, --quiet', 'Only output file paths, no decoration') + .action(async (fileArgs: string[], options: { path?: string; stdin?: boolean; depth?: string; filter?: string; json?: boolean; quiet?: boolean }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + // Collect changed files from args or stdin + let changedFiles: string[] = [...(fileArgs || [])]; + + if (options.stdin) { + const stdinData = fs.readFileSync(0, 'utf-8'); + const stdinFiles = stdinData.split('\n').map(f => f.trim()).filter(Boolean); + changedFiles.push(...stdinFiles); + } + + // Normalize inputs to the project-relative, forward-slash form the index + // stores. Without this, `affected ./src/x.ts`, an absolute path (what a + // wrapping script often passes), or a Windows back-slash path silently + // matches nothing and reports 0 affected tests. (#825) + changedFiles = changedFiles + .map((f) => normalizeIndexPath(f, projectPath)) + .filter(Boolean); + + if (changedFiles.length === 0) { + if (!options.quiet) info('No files provided. Use file arguments or --stdin.'); + process.exit(0); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const maxDepth = parseInt(options.depth || '5', 10); + + // Common test file patterns + const defaultTestPatterns = [ + /\.spec\./, + /\.test\./, + /\/__tests__\//, + /\/tests?\//, + /\/e2e\//, + /\/spec\//, + ]; + + // Custom filter pattern + let customFilter: RegExp | null = null; + if (options.filter) { + // Convert glob to regex: ** → .+, * → [^/]*, . → \. + const regex = options.filter + .replace(/[+[\]{}()^$|\\]/g, '\\$&') + .replace(/\./g, '\\.') + .replace(/\*\*/g, '.+') + .replace(/\*/g, '[^/]*'); + customFilter = new RegExp(regex); + } + + function isTestFile(filePath: string): boolean { + if (customFilter) return customFilter.test(filePath); + return defaultTestPatterns.some(p => p.test(filePath)); + } + + // BFS to find all transitive dependents of changed files, filtered to test files + const affectedTests = new Set(); + const allDependents = new Set(); + + for (const file of changedFiles) { + // If the changed file is itself a test file, include it + if (isTestFile(file)) { + affectedTests.add(file); + continue; + } + + // BFS through dependents + const queue: Array<{ file: string; depth: number }> = [{ file, depth: 0 }]; + const visited = new Set(); + visited.add(file); + + while (queue.length > 0) { + const current = queue.shift()!; + if (current.depth >= maxDepth) continue; + + const dependents = cg.getFileDependents(current.file); + for (const dep of dependents) { + if (visited.has(dep)) continue; + visited.add(dep); + allDependents.add(dep); + + if (isTestFile(dep)) { + affectedTests.add(dep); + } else { + queue.push({ file: dep, depth: current.depth + 1 }); + } + } + } + } + + const sortedTests = Array.from(affectedTests).sort(); + + // Output + if (options.json) { + console.log(JSON.stringify({ + changedFiles, + affectedTests: sortedTests, + totalDependentsTraversed: allDependents.size, + }, null, 2)); + } else if (options.quiet) { + for (const t of sortedTests) console.log(t); + } else { + if (sortedTests.length === 0) { + info('No test files affected by the changed files.'); + } else { + console.log(chalk.bold(`\nAffected test files (${sortedTests.length}):\n`)); + for (const t of sortedTests) { + console.log(' ' + chalk.cyan(t)); + } + console.log(); + } + } + + cg.destroy(); + } catch (err) { + error(`Affected analysis failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph install + */ +program + .command('install') + .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)') + .option('-t, --target ', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt') + .option('-l, --location ', 'Install location: "global" or "local". Default: prompt') + .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on') + .option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code only)') + .option('--print-config ', 'Print MCP config snippet for the named agent and exit (no file writes)') + .option('--refresh', 'Rewrite what previous installs configured, for already-configured agents only (never adds new ones). Run automatically by `codegraph upgrade`') + .action(async (opts: { + target?: string; + location?: string; + yes?: boolean; + permissions?: boolean; + printConfig?: string; + refresh?: boolean; + }) => { + if (opts.printConfig) { + const { getTarget, listTargetIds } = await import('../installer/targets/registry'); + const target = getTarget(opts.printConfig); + if (!target) { + const known = listTargetIds().join(', '); + error(`Unknown target "${opts.printConfig}". Known: ${known}.`); + process.exit(1); + } + const loc = (opts.location === 'local' ? 'local' : 'global') as 'global' | 'local'; + process.stdout.write(target.printConfig(loc)); + return; + } + + // --refresh: non-interactive sweep that re-writes what previous + // installs configured (instructions section, MCP entry, legacy-hook + // cleanups) for already-configured agents, so those surfaces match + // THIS binary's templates. Skips everything else — never a first + // install, never touches permissions or the prompt hook. Sweeps both + // locations unless --location narrows it. + if (opts.refresh) { + const { refreshTargets } = await import('../installer'); + const { ALL_TARGETS } = await import('../installer/targets/registry'); + if (opts.location && opts.location !== 'global' && opts.location !== 'local') { + error(`--location must be "global" or "local" (got "${opts.location}").`); + process.exit(1); + } + const locs: Array<'global' | 'local'> = opts.location + ? [opts.location as 'global' | 'local'] + : ['global', 'local']; + let changed = 0; + for (const loc of locs) { + for (const report of refreshTargets(ALL_TARGETS, loc)) { + for (const p of report.changedPaths) { + changed += 1; + console.log(` ${report.displayName}: refreshed ${p}`); + } + } + } + if (changed === 0) { + console.log('All configured agent surfaces are already current.'); + } + return; + } + + const { runInstallerWithOptions } = await import('../installer'); + if (opts.location && opts.location !== 'global' && opts.location !== 'local') { + error(`--location must be "global" or "local" (got "${opts.location}").`); + process.exit(1); + } + try { + // Commander's `--no-permissions` makes `opts.permissions === false`; + // omitting the flag leaves it `true` (the positive-form default). + // We MUST treat the default-true as "user did not override — let + // the orchestrator prompt" and only forward an explicit `false` + // (or `true` when --yes implies it). Otherwise the auto-allow + // prompt is silently skipped on every interactive run. + const explicitNoPermissions = opts.permissions === false; + const autoAllow: boolean | undefined = explicitNoPermissions + ? false + : opts.yes + ? true + : undefined; + + await runInstallerWithOptions({ + target: opts.target, + location: opts.location as 'global' | 'local' | undefined, + autoAllow, + yes: opts.yes, + }); + } catch (err) { + error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + +/** + * codegraph uninstall + * + * Inverse of `install`. Removes the codegraph MCP server entry, + * instructions block, and permissions from every agent (or a + * `--target` subset). Prompts global-vs-local when not given. Does NOT + * delete the `.codegraph/` index — that's `codegraph uninit`. + */ +program + .command('uninstall') + .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)') + .option('-t, --target ', 'Target agent(s): comma-separated ids, or "all". Default: all') + .option('-l, --location ', 'Uninstall location: "global" or "local". Default: prompt') + .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all') + .option('--keep-cli', 'Remove agent configs only — leave the codegraph CLI installed') + .action(async (opts: { + target?: string; + location?: string; + yes?: boolean; + keepCli?: boolean; + }) => { + const { runUninstaller } = await import('../installer'); + if (opts.location && opts.location !== 'global' && opts.location !== 'local') { + error(`--location must be "global" or "local" (got "${opts.location}").`); + process.exit(1); + } + try { + await runUninstaller({ + target: opts.target, + location: opts.location as 'global' | 'local' | undefined, + yes: opts.yes, + keepCli: opts.keepCli, + cliFilename: __filename, + }); + } catch (err) { + error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + +/** + * codegraph telemetry [on|off|status] + */ +program + .command('telemetry [action]') + .description('Show or change anonymous usage telemetry (status, on, off)') + .action((action?: string) => { + const t = getTelemetry(); + + if (action === 'on' || action === 'off') { + t.setEnabled(action === 'on', 'cli'); + if (action === 'on') { + success('Telemetry enabled — anonymous usage stats only (no code, paths, or names).'); + } else { + success('Telemetry disabled. Buffered, unsent data was deleted.'); + } + const effective = t.getStatus(); + if (effective.decidedBy === 'DO_NOT_TRACK' || effective.decidedBy === 'CODEGRAPH_TELEMETRY') { + warn( + `The ${effective.decidedBy} environment variable overrides this choice — ` + + `effective state right now: ${effective.enabled ? 'enabled' : 'disabled'}.` + ); + } + return; + } + + if (action !== undefined && action !== 'status') { + error(`Unknown action: ${action} (expected status, on, or off)`); + process.exit(1); + } + + const s = t.getStatus(); + const decidedBy: Record = { + DO_NOT_TRACK: 'DO_NOT_TRACK environment variable', + CODEGRAPH_TELEMETRY: 'CODEGRAPH_TELEMETRY environment variable', + config: 'your saved choice', + default: 'default', + }; + console.log(`\nTelemetry: ${s.enabled ? chalk.green('enabled') : chalk.yellow('disabled')} ${chalk.dim(`(${decidedBy[s.decidedBy]})`)}`); + console.log(`Machine ID: ${s.machineId ?? chalk.dim('(random UUID, created on first use)')}`); + console.log(`Config: ${s.configPath}`); + console.log(chalk.dim(`\nExactly what is collected (and never collected): ${TELEMETRY_DOCS}\n`)); + }); + +/** + * codegraph upgrade [version] + * + * Self-update, however CodeGraph was installed (bundle via install.sh/.ps1, + * npm-global, npx, or a source checkout). See ../upgrade for the detection and + * per-method upgrade logic. + */ +program + .command('upgrade [version]') + .description('Update CodeGraph to the latest release (or a specific version)') + .option('--check', 'Check whether an update is available without installing') + .option('-f, --force', 'Reinstall even if already on the target version') + .action(async (versionArg: string | undefined, options: { check?: boolean; force?: boolean }) => { + const up = await import('../upgrade'); + const method = up.detectInstallMethod({ + filename: __filename, + platform: process.platform, + cwd: process.cwd(), + }); + const pin = versionArg || process.env.CODEGRAPH_VERSION || undefined; + const code = await up.runUpgrade( + { version: pin, check: options.check, force: options.force }, + { + currentVersion: packageJson.version, + method, + resolveLatest: () => up.resolveLatestVersion(), + run: up.defaultRun, + capture: up.defaultCapture, + hasCommand: up.hasCommand, + log: (m: string) => console.log(m), + warn: (m: string) => warn(m), + error: (m: string) => error(m), + platform: process.platform, + } + ); + process.exit(code); + }); + +/** + * codegraph version + * + * The bare-noun form of `--version`. commander already provides `--version` + * and `-V`, and the `-v` / `-version` spellings are intercepted before parse + * (see top of main). This subcommand makes `codegraph version` work and lists + * the version affordance in `codegraph --help`. + */ +program + .command('version') + .description('Print the installed CodeGraph version (also: -v, --version)') + .action(() => { + console.log(packageJson.version); + }); + +// Parse and run +program.parse(); + +} // end main() diff --git a/src/bin/command-supervision.ts b/src/bin/command-supervision.ts new file mode 100644 index 0000000..6031a49 --- /dev/null +++ b/src/bin/command-supervision.ts @@ -0,0 +1,96 @@ +/** + * Process supervision for long-running CLI commands (`index` / `init --index`). + * + * Indexing a large repo can run for a while on the main thread, and #999 + * surfaced two ways that goes wrong when nothing is watching it: + * + * 1. **Orphaned worker.** `index` runs in a child re-exec'd with + * `--liftoff-only` (the WASM-flag relaunch). Its parent blocks in + * `spawnSync`, so when the parent shim is killed it cannot forward the + * signal — the child keeps running, now orphaned, pinning a core. The PPID + * watchdog (#277) notices the parent/host went away and exits the child. + * 2. **Wedged indexer.** The `#850` main-thread liveness watchdog — which + * SIGKILLs a process whose event loop stops turning — was wired only into + * the MCP `serve` path, so a wedged `index`/`init` was never auto-killed. + * + * Both reuse the exact mechanisms `serve` already uses; this just makes them + * available to a one-shot command. Best-effort and self-disabling: a missing + * watchdog never blocks the command from running. Both honour the same env + * switches as `serve` (`CODEGRAPH_NO_WATCHDOG`, `CODEGRAPH_PPID_POLL_MS=0`). + * + * Unlike the daemon — whose main thread only does fast, bounded work — the + * `index`/`init` path runs reference resolution and dynamic-edge synthesis + * SYNCHRONOUSLY on this thread, and on a large repo that is legitimately many + * seconds of work. So those spans yield cooperatively to the event loop + * (`src/resolution/cooperative-yield.ts`) to keep the heartbeat alive; without + * that the watchdog would SIGKILL a valid, in-progress index (#1091). The + * distinction it must preserve — kill a TRUE wedge, spare slow-but-progressing + * work — is exactly what cooperative yielding buys: a genuinely stuck span never + * reaches its next yield, so it still trips the timeout. + */ +import { installMainThreadWatchdog, WatchdogOptions } from '../mcp/liveness-watchdog'; +import { supervisionLostReason, parsePpidPollMs, parseHostPpid } from '../mcp/ppid-watchdog'; +import { isProcessAlive } from '../mcp/daemon-registry'; +import { EARLY_PPID } from '../mcp/early-ppid'; +import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags'; + +export interface CommandSupervision { + /** Tear down both watchdogs. Idempotent; call when the command finishes. */ + stop(): void; +} + +/** + * Install the liveness + PPID watchdogs for the duration of a CLI command. + * `label` is used in the shutdown notice (e.g. `"index"`). Returns a handle + * whose `stop()` must be called when the command completes so neither watchdog + * outlives it. + * + * Pass `watchdog.progressPaths` (the project's SQLite DB + `-wal`) so the + * liveness watchdog can tell a slow-but-progressing store on degraded storage + * (files advancing) from a true wedge (they aren't) — one long synchronous + * SQLite statement on a 150-IOPS disk otherwise gets a healthy index + * SIGKILLed (#1231). + */ +export function installCommandSupervision(label: string, watchdog: WatchdogOptions = {}): CommandSupervision { + // Liveness watchdog: a separate process that SIGKILLs us if our event loop + // stops turning for too long (a wedged synchronous loop). Self-disables on + // CODEGRAPH_NO_WATCHDOG. + const liveness = installMainThreadWatchdog(watchdog); + + // PPID watchdog: detect that the parent (or the host threaded past the + // relaunch shim) died and we've been orphaned, then exit instead of leaking. + // Baseline from the CLI entry's earliest-possible capture — reading + // process.ppid here would miss a launcher killed during startup (#1185). + const originalPpid = EARLY_PPID; + const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]); + const pollMs = parsePpidPollMs(process.env.CODEGRAPH_PPID_POLL_MS); + let ppidTimer: ReturnType | null = null; + if (pollMs > 0) { + ppidTimer = setInterval(() => { + const reason = supervisionLostReason({ + originalPpid, + currentPpid: process.ppid, + hostPpid, + isAlive: isProcessAlive, + }); + if (reason) { + try { + process.stderr.write(`[CodeGraph ${label}] Parent process exited (${reason}); aborting.\n`); + } catch { /* stderr gone with the parent — exit anyway */ } + process.exit(1); + } + }, pollMs); + // Never let the watchdog itself keep the process alive past its real work. + ppidTimer.unref(); + } + + let stopped = false; + return { + stop(): void { + if (stopped) return; + stopped = true; + if (ppidTimer) clearInterval(ppidTimer); + liveness?.stop(); + }, + }; +} diff --git a/src/bin/fatal-handler.ts b/src/bin/fatal-handler.ts new file mode 100644 index 0000000..689e7e5 --- /dev/null +++ b/src/bin/fatal-handler.ts @@ -0,0 +1,93 @@ +/** + * Last-resort handlers for uncaught exceptions and unhandled rejections. + * + * Reaching one of these means a fault escaped every boundary (per-request + * try/catch in the MCP transport, the file watcher's own `'error'` handlers, + * telemetry's fail-silent contract) — i.e. the process is in an undefined + * state. Node's default in that case is to print and exit non-zero. The CLI + * previously OVERRODE that to "log the error and keep running", which is the + * bug behind two production incidents: + * + * - #799 — a stdin socket `'error'` escalated here; the server logged it and + * kept running, orphaning the detached MCP daemon and (on Linux) spinning a + * POLLHUP fd at 100% CPU. Fixed for that one trigger by treating stdin + * failure as shutdown (`src/mcp/stdin-teardown.ts`). + * - #850 — a *different* uncaught exception hit the same handler. Logging it + * forced V8 to lazily format the Error's `.stack`, which entered a + * non-terminating source-position walk and pinned a core. Because the + * handler kept the process alive, the detached daemon was left wedged: its + * PPID watchdog and idle-timer (both `setInterval`s) could no longer fire, + * and nothing respawned it — unrecoverable without a manual `kill`. + * + * The fix restores the safe default: log a BOUNDED, hang-proof line, then exit + * non-zero so a fresh daemon starts on the next connection. + * + * Two properties are load-bearing and covered by tests: + * 1. {@link describeFatal} never reads `error.stack` and never hands the raw + * Error to `console.*`. The lazy stack getter is exactly the step that can + * wedge (#850); since it would run *inside* this handler, touching it could + * block the very `exit()` below. Name + message are plain string + * properties and are always safe. + * 2. We write synchronously to fd 2 and then exit, so the message is flushed + * even though `process.exit()` doesn't drain async streams. + */ +import * as fs from 'fs'; + +/** + * Render an uncaught value for the last-resort log WITHOUT triggering stack + * formatting. Pure and total — never throws, never touches `.stack`. + */ +export function describeFatal(value: unknown): string { + if (value instanceof Error) { + const name = typeof value.name === 'string' && value.name ? value.name : 'Error'; + // `message` is a plain own/proto string property — reading it does NOT + // format the stack (which is what can loop forever, #850). + const message = typeof value.message === 'string' ? value.message : ''; + return message ? `${name}: ${message}` : name; + } + try { + return String(value); + } catch { + // e.g. an object with a throwing `toString` / `Symbol.toPrimitive`. + return ''; + } +} + +/** Best-effort synchronous stderr write that can never keep a doomed process alive. */ +function writeStderr(line: string): void { + try { + fs.writeSync(2, line); + } catch { + /* stderr closed/gone — nothing more we can safely do */ + } +} + +/** Injectable seams so the wiring is testable without registering real handlers. */ +export interface FatalHandlerDeps { + /** Event target to attach to. Defaults to `process`. */ + target?: NodeJS.EventEmitter; + /** How to terminate. Defaults to `process.exit`. */ + exit?: (code: number) => void; + /** How to emit the bounded line. Defaults to a synchronous fd-2 write. */ + write?: (line: string) => void; +} + +/** + * Install the uncaught-exception / unhandled-rejection handlers. Both log a + * bounded line and then exit non-zero (Node's default fatal semantics). + */ +export function installFatalHandlers(deps: FatalHandlerDeps = {}): void { + const target = deps.target ?? process; + const exit = deps.exit ?? ((code: number) => process.exit(code)); + const write = deps.write ?? writeStderr; + + target.on('uncaughtException', (error: unknown) => { + write(`[CodeGraph] Uncaught exception: ${describeFatal(error)}\n`); + exit(1); + }); + + target.on('unhandledRejection', (reason: unknown) => { + write(`[CodeGraph] Unhandled rejection: ${describeFatal(reason)}\n`); + exit(1); + }); +} diff --git a/src/bin/node-version-check.ts b/src/bin/node-version-check.ts new file mode 100644 index 0000000..cea0a43 --- /dev/null +++ b/src/bin/node-version-check.ts @@ -0,0 +1,76 @@ +/** + * Node.js version compatibility check. + * + * Node 25.x has a V8 turboshaft WASM JIT Zone allocator bug that + * reliably crashes CodeGraph with `Fatal process out of memory: Zone` + * during tree-sitter grammar compilation. This module owns the + * user-facing banner shown before exit. Kept side-effect-free so it's + * safe to import from tests without triggering CLI bootstrap. + */ + +/** + * Build the bordered banner shown when CodeGraph detects an + * unsupported Node.js major version (currently 25+). Pinned via unit + * test so the recovery commands and override instructions can't be + * silently stripped by future edits. + * + * Uses ASCII glyphs to stay readable on Windows OEM-codepage consoles + * (see ../ui/glyphs.ts for the rationale). + */ +export function buildNode25BlockBanner(nodeVersion: string): string { + const sep = '-'.repeat(72); + return [ + sep, + `[CodeGraph] Unsupported Node.js version: ${nodeVersion}`, + sep, + 'Node.js 25.x has a V8 WASM JIT (turboshaft) Zone allocator bug that', + 'crashes with `Fatal process out of memory: Zone` when CodeGraph', + 'compiles tree-sitter grammars. CodeGraph WILL crash on this Node', + 'version mid-indexing. See https://github.com/colbymchenry/codegraph/issues/81', + '', + 'Fix: install Node.js 22 LTS:', + ' nvm install 22 && nvm use 22 # nvm', + ' brew install node@22 && brew link --overwrite --force node@22 # Homebrew', + '', + 'To override (NOT recommended - you will likely OOM):', + ' CODEGRAPH_ALLOW_UNSAFE_NODE=1 codegraph ...', + sep, + ].join('\n'); +} + +/** + * Lowest supported Node.js major version. Matches the `engines` floor in + * package.json. Below this, CodeGraph relies on language features / native APIs + * that aren't present, and the combination is untested. `engines` alone only + * *warns* on install (unless the user set `engine-strict`), so the CLI bootstrap + * also hard-blocks here to actually enforce the floor. + */ +export const MIN_NODE_MAJOR = 20; + +/** + * Build the bordered banner shown when CodeGraph detects a Node.js major below + * {@link MIN_NODE_MAJOR}. Pinned via unit test so the recovery commands and the + * override env var can't be silently stripped by future edits. + * + * Uses ASCII glyphs to stay readable on Windows OEM-codepage consoles + * (see ../ui/glyphs.ts for the rationale). + */ +export function buildNodeTooOldBanner(nodeVersion: string): string { + const sep = '-'.repeat(72); + return [ + sep, + `[CodeGraph] Unsupported Node.js version: ${nodeVersion}`, + sep, + `CodeGraph requires Node.js ${MIN_NODE_MAJOR} or newer. Older versions lack`, + 'language features and native APIs CodeGraph depends on, and are not', + 'tested or supported.', + '', + 'Fix: install Node.js 22 LTS:', + ' nvm install 22 && nvm use 22 # nvm', + ' brew install node@22 && brew link --overwrite --force node@22 # Homebrew', + '', + 'To override (NOT recommended - unsupported):', + ' CODEGRAPH_ALLOW_UNSAFE_NODE=1 codegraph ...', + sep, + ].join('\n'); +} diff --git a/src/bin/uninstall.ts b/src/bin/uninstall.ts new file mode 100644 index 0000000..a168d80 --- /dev/null +++ b/src/bin/uninstall.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * CodeGraph preuninstall cleanup script + * + * Runs automatically when `npm uninstall -g @colbymchenry/codegraph` + * is called. Loops over every known agent target's `uninstall(loc)` + * for the global location only — local-location entries live inside + * project working trees and aren't ours to nuke at npm-uninstall + * time. + * + * This script must never throw — a failed cleanup must not block + * uninstall. + */ + +try { + // Lazy require so any module-level error in the registry can't + // bubble out and abort the npm uninstall. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { ALL_TARGETS } = require('../installer/targets/registry') as + typeof import('../installer/targets/registry'); + + for (const target of ALL_TARGETS) { + if (!target.supportsLocation('global')) continue; + try { + target.uninstall('global'); + } catch { + // Each target is independently safe-to-skip; per-target failure + // must not stop the loop. + } + } +} catch { + // If the registry itself can't be loaded (e.g. partial install), + // we silently skip cleanup. Uninstall still completes. +} diff --git a/src/context/formatter.ts b/src/context/formatter.ts new file mode 100644 index 0000000..748d172 --- /dev/null +++ b/src/context/formatter.ts @@ -0,0 +1,290 @@ +/** + * Context Formatter + * + * Formats TaskContext as markdown or JSON for consumption by Claude. + */ + +import { Node, Edge, TaskContext, Subgraph } from '../types'; +import { isGeneratedFile } from '../extraction/generated-detection'; + +/** + * Format context as markdown + * + * Creates a compact markdown document optimized for Claude with minimal context usage: + * - Brief summary + * - Entry points with locations + * - Code blocks only for key symbols + */ +export function formatContextAsMarkdown(context: TaskContext): string { + const lines: string[] = []; + + // Header with query + lines.push('## Code Context\n'); + lines.push(`**Query:** ${context.query}\n`); + + // Entry points - compact format. Re-sort so generated files (.pb.go, + // .pulsar.go, mocks, …) rank LAST — a flow query should lead with the + // hand-written implementation, not protobuf scaffolding. + const orderedEntries = [...context.entryPoints].sort((a, b) => { + const aGen = isGeneratedFile(a.filePath) ? 1 : 0; + const bGen = isGeneratedFile(b.filePath) ? 1 : 0; + return aGen - bGen; + }); + if (orderedEntries.length > 0) { + lines.push('### Entry Points\n'); + for (const node of orderedEntries) { + const location = node.startLine ? `:${node.startLine}` : ''; + lines.push(`- **${node.name}** (${node.kind}) - ${node.filePath}${location}`); + if (node.signature) { + lines.push(` \`${node.signature}\``); + } + } + lines.push(''); + } + + // Related symbols - compact list (skip verbose structure tree). Drop nodes + // in generated source files (`.pb.go` / `.pulsar.go` / mocks / …) — agents + // chasing a flow never want to land on protobuf scaffolding (cosmos-Q3 used + // to list `gov.pulsar.go::GetExpeditedThreshold` and `1.pulsar.go::Get` in + // Related Symbols, pure noise that displaced real-flow entries). + const otherSymbols = Array.from(context.subgraph.nodes.values()) + .filter(n => !context.entryPoints.some(e => e.id === n.id)) + .filter(n => !isGeneratedFile(n.filePath)) + .slice(0, 10); // Limit to 10 related symbols + + if (otherSymbols.length > 0) { + lines.push('### Related Symbols\n'); + const byFile = new Map(); + for (const node of otherSymbols) { + const existing = byFile.get(node.filePath) || []; + existing.push(node); + byFile.set(node.filePath, existing); + } + + for (const [file, nodes] of byFile) { + const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', '); + lines.push(`- ${file}: ${nodeList}`); + } + lines.push(''); + } + + // Code blocks - only for key entry points. Re-sort so non-generated blocks + // show first (consistent with Entry Points reordering above). + if (context.codeBlocks.length > 0) { + const orderedBlocks = [...context.codeBlocks].sort((a, b) => { + const aGen = isGeneratedFile(a.filePath) ? 1 : 0; + const bGen = isGeneratedFile(b.filePath) ? 1 : 0; + return aGen - bGen; + }); + lines.push('### Code\n'); + for (const block of orderedBlocks) { + const nodeName = block.node?.name ?? 'Unknown'; + lines.push(`#### ${nodeName} (${block.filePath}:${block.startLine})\n`); + lines.push('```' + block.language); + lines.push(block.content); + lines.push('```\n'); + } + } + + return lines.join('\n'); +} + +/** + * Format context as JSON + * + * Returns a structured JSON representation suitable for programmatic use. + */ +export function formatContextAsJson(context: TaskContext): string { + // Convert Map to array for JSON serialization + const serializable = { + query: context.query, + summary: context.summary, + entryPoints: context.entryPoints.map(serializeNode), + nodes: Array.from(context.subgraph.nodes.values()).map(serializeNode), + edges: context.subgraph.edges.map(serializeEdge), + codeBlocks: context.codeBlocks.map((block) => ({ + filePath: block.filePath, + startLine: block.startLine, + endLine: block.endLine, + language: block.language, + content: block.content, + nodeName: block.node?.name, + nodeKind: block.node?.kind, + })), + relatedFiles: context.relatedFiles, + stats: context.stats, + }; + + return JSON.stringify(serializable, null, 2); +} + +/** + * Format a subgraph as an ASCII tree structure + */ +export function formatSubgraphTree(subgraph: Subgraph, entryPoints: Node[]): string { + const lines: string[] = []; + const printed = new Set(); + + // Build adjacency list for outgoing edges + const outgoing = new Map(); + for (const edge of subgraph.edges) { + const existing = outgoing.get(edge.source) ?? []; + existing.push(edge); + outgoing.set(edge.source, existing); + } + + // Print each entry point as a tree root + for (const entry of entryPoints) { + formatNodeTree(entry, subgraph, outgoing, printed, lines, 0, ''); + lines.push(''); // Blank line between trees + } + + // Print any remaining nodes not reached from entry points + const remaining: Node[] = []; + for (const node of subgraph.nodes.values()) { + if (!printed.has(node.id)) { + remaining.push(node); + } + } + + if (remaining.length > 0 && remaining.length <= 10) { + lines.push('Other relevant symbols:'); + for (const node of remaining) { + const location = node.startLine ? `:${node.startLine}` : ''; + lines.push(` ${node.kind}: ${node.name} (${node.filePath}${location})`); + } + } else if (remaining.length > 10) { + lines.push(`... and ${remaining.length} more related symbols`); + } + + return lines.join('\n').trim(); +} + +/** + * Format a single node and its relationships + */ +function formatNodeTree( + node: Node, + subgraph: Subgraph, + outgoing: Map, + printed: Set, + lines: string[], + depth: number, + prefix: string +): void { + if (printed.has(node.id)) { + return; + } + printed.add(node.id); + + // Node header + const location = node.startLine ? `:${node.startLine}` : ''; + const signature = node.signature ? ` - ${truncate(node.signature, 50)}` : ''; + lines.push(`${prefix}${node.kind}: ${node.name} (${node.filePath}${location})${signature}`); + + // Outgoing edges + const edges = outgoing.get(node.id) ?? []; + const significantEdges = edges.filter((e) => + ['calls', 'extends', 'implements', 'imports', 'references'].includes(e.kind) + ); + + // Group by kind + const edgesByKind = new Map(); + for (const edge of significantEdges) { + const existing = edgesByKind.get(edge.kind) ?? []; + existing.push(edge); + edgesByKind.set(edge.kind, existing); + } + + // Print edges grouped by kind + const newPrefix = prefix + ' '; + for (const [kind, kindEdges] of edgesByKind) { + if (kindEdges.length > 3) { + // Summarize if too many + const names = kindEdges + .slice(0, 3) + .map((e) => { + const target = subgraph.nodes.get(e.target); + return target?.name ?? 'unknown'; + }) + .join(', '); + lines.push(`${newPrefix}├── ${kind}: ${names} and ${kindEdges.length - 3} more`); + } else { + for (let i = 0; i < kindEdges.length; i++) { + const edge = kindEdges[i]!; + const target = subgraph.nodes.get(edge.target); + const targetName = target?.name ?? 'unknown'; + const connector = i === kindEdges.length - 1 ? '└──' : '├──'; + lines.push(`${newPrefix}${connector} ${kind} → ${targetName}`); + } + } + } + + // Recurse for directly connected nodes (limited depth) + if (depth < 1) { + for (const edge of significantEdges.slice(0, 3)) { + const target = subgraph.nodes.get(edge.target); + if (target && !printed.has(target.id)) { + formatNodeTree(target, subgraph, outgoing, printed, lines, depth + 1, newPrefix); + } + } + } +} + +/** + * Serialize a node for JSON output + */ +function serializeNode(node: Node): Record { + return { + id: node.id, + kind: node.kind, + name: node.name, + qualifiedName: node.qualifiedName, + filePath: node.filePath, + language: node.language, + startLine: node.startLine, + endLine: node.endLine, + signature: node.signature, + docstring: node.docstring, + visibility: node.visibility, + isExported: node.isExported, + isAsync: node.isAsync, + isStatic: node.isStatic, + }; +} + +/** + * Serialize an edge for JSON output + */ +function serializeEdge(edge: Edge): Record { + return { + source: edge.source, + target: edge.target, + kind: edge.kind, + line: edge.line, + column: edge.column, + }; +} + +/** + * Truncate a string with ellipsis + */ +function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) { + return str; + } + return str.slice(0, maxLength - 3) + '...'; +} + +/** + * Format bytes as human-readable string + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} bytes`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } else { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } +} diff --git a/src/context/index.ts b/src/context/index.ts new file mode 100644 index 0000000..68123c2 --- /dev/null +++ b/src/context/index.ts @@ -0,0 +1,1372 @@ +/** + * Context Builder + * + * Builds rich context for tasks by combining FTS search with graph traversal. + * Outputs structured context ready to inject into Claude. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { + Node, + Edge, + NodeKind, + EdgeKind, + Subgraph, + CodeBlock, + TaskContext, + TaskInput, + BuildContextOptions, + FindRelevantContextOptions, + SearchResult, +} from '../types'; +import { QueryBuilder } from '../db/queries'; +import { GraphTraverser } from '../graph'; +import { formatContextAsMarkdown, formatContextAsJson } from './formatter'; +import { logDebug } from '../errors'; +import { validatePathWithinRoot, isConfigLeafNode } from '../utils'; +import { isTestFile, extractSearchTerms, scorePathRelevance, getStemVariants, isDistinctiveIdentifier } from '../search/query-utils'; +import { LOW_CONFIDENCE_MARKER } from './markers'; + +/** + * Extract likely symbol names from a natural language query + * + * Identifies potential code symbols using patterns: + * - CamelCase: UserService, signInWithGoogle + * - snake_case: user_service, sign_in + * - SCREAMING_SNAKE: MAX_RETRIES + * - dot.notation: app.isPackaged (extracts both sides) + * - Single words that look like identifiers (no spaces, not common English words) + * + * @param query - Natural language query + * @returns Array of potential symbol names + */ +function extractSymbolsFromQuery(query: string): string[] { + const symbols = new Set(); + + // Extract CamelCase identifiers (2+ chars, starts with letter) + const camelCasePattern = /\b([A-Z][a-z]+(?:[A-Z][a-z]*)*|[a-z]+(?:[A-Z][a-z]*)+)\b/g; + let match; + while ((match = camelCasePattern.exec(query)) !== null) { + if (match[1] && match[1].length >= 2) { + symbols.add(match[1]); + } + } + + // Extract snake_case identifiers + const snakeCasePattern = /\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b/gi; + while ((match = snakeCasePattern.exec(query)) !== null) { + if (match[1] && match[1].length >= 3) { + symbols.add(match[1]); + } + } + + // Extract SCREAMING_SNAKE_CASE + const screamingPattern = /\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g; + while ((match = screamingPattern.exec(query)) !== null) { + if (match[1]) { + symbols.add(match[1]); + } + } + + // Extract ALL_CAPS acronyms (2+ chars, e.g., REST, HTTP, LRU, API) + const acronymPattern = /\b([A-Z]{2,})\b/g; + while ((match = acronymPattern.exec(query)) !== null) { + if (match[1]) { + symbols.add(match[1]); + } + } + + // Extract dot.notation and split into parts (e.g., "app.isPackaged" -> ["app", "isPackaged"]) + const dotPattern = /\b([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)+)\b/g; + while ((match = dotPattern.exec(query)) !== null) { + if (match[1]) { + // Add both the full path and individual parts + symbols.add(match[1]); + const parts = match[1].split('.'); + for (const part of parts) { + if (part.length >= 2) { + symbols.add(part); + } + } + } + } + + // Extract plain lowercase identifiers (3+ chars, not already matched) + // Catches symbol names like "undo", "redo", "history", "render", "parse" + const lowercasePattern = /\b([a-z][a-z0-9]{2,})\b/g; + while ((match = lowercasePattern.exec(query)) !== null) { + if (match[1]) { + symbols.add(match[1]); + } + } + + // Filter out common English words that aren't likely symbol names + const commonWords = new Set([ + 'the', 'and', 'for', 'with', 'from', 'this', 'that', 'have', 'been', + 'will', 'would', 'could', 'should', 'does', 'done', 'make', 'made', + 'use', 'used', 'using', 'work', 'works', 'find', 'found', 'show', + 'call', 'called', 'calling', 'get', 'set', 'add', 'all', 'any', + 'how', 'what', 'when', 'where', 'which', 'who', 'why', + 'not', 'but', 'are', 'was', 'were', 'has', 'had', 'its', + 'can', 'did', 'may', 'also', 'into', 'than', 'then', 'them', + 'each', 'other', 'some', 'such', 'only', 'same', 'about', + 'after', 'before', 'between', 'through', 'during', 'without', + 'again', 'further', 'once', 'here', 'there', 'both', 'just', + 'more', 'most', 'very', 'being', 'having', 'doing', + 'system', 'need', 'needs', 'want', 'wants', 'like', 'look', + 'change', 'changes', 'changed', 'changing', + // Common English nouns/verbs that match thousands of unrelated code symbols + 'layer', 'handle', 'handles', 'handling', 'incoming', 'outgoing', + 'data', 'flow', 'flows', 'level', 'levels', 'request', 'requests', + 'response', 'responses', 'implement', 'implements', 'implementation', + 'interface', 'interfaces', 'class', 'classes', 'method', 'methods', + 'trigger', 'triggers', 'affected', 'affect', 'affects', + 'else', 'code', 'failing', 'failed', 'silently', 'decide', 'decides', + 'return', 'returns', 'returned', 'take', 'takes', 'taken', + 'check', 'checks', 'checked', 'create', 'creates', 'created', + 'read', 'reads', 'write', 'writes', 'written', + 'start', 'starts', 'stop', 'stops', 'run', 'runs', 'running', + ]); + + return Array.from(symbols).filter(s => !commonWords.has(s.toLowerCase())); +} + +/** + * Default options for context building + * + * Tuned for minimal context usage while still providing useful results: + * - Fewer nodes and code blocks by default + * - Smaller code block size limit + * - Shallower traversal + */ +const DEFAULT_BUILD_OPTIONS: Required = { + maxNodes: 20, // Reduced from 50 - most tasks don't need 50 symbols + maxCodeBlocks: 5, // Reduced from 10 - only show most relevant code + maxCodeBlockSize: 1500, // Reduced from 2000 + includeCode: true, + format: 'markdown', + searchLimit: 3, // Reduced from 5 - fewer entry points + traversalDepth: 1, // Reduced from 2 - shallower graph expansion + minScore: 0.3, +}; + +/** + * Node kinds that provide high information value in context results. + * Imports/exports are excluded because they have near-zero information density - + * they tell you something exists, not how it works. + */ +const HIGH_VALUE_NODE_KINDS: NodeKind[] = [ + 'function', 'method', 'class', 'interface', 'type_alias', 'struct', 'trait', + 'component', 'route', 'variable', 'constant', 'enum', 'module', 'namespace', +]; + +/** + * Default options for finding relevant context + */ +const DEFAULT_FIND_OPTIONS: Required = { + searchLimit: 3, // Reduced from 5 + traversalDepth: 1, // Reduced from 2 + maxNodes: 20, // Reduced from 50 + minScore: 0.3, + edgeKinds: [], + nodeKinds: HIGH_VALUE_NODE_KINDS, // Filter out imports/exports by default +}; + +// Re-export the low-confidence sentinel (defined in a dependency-free leaf so +// the MCP layer can import it without pulling this module's deps onto the +// cold-start path). Builder code below uses the imported binding directly. +export { LOW_CONFIDENCE_MARKER } from './markers'; + +/** + * Context Builder + * + * Coordinates semantic search and graph traversal to build + * comprehensive context for tasks. + */ +export class ContextBuilder { + private projectRoot: string; + private queries: QueryBuilder; + private traverser: GraphTraverser; + + constructor( + projectRoot: string, + queries: QueryBuilder, + traverser: GraphTraverser + ) { + this.projectRoot = projectRoot; + this.queries = queries; + this.traverser = traverser; + } + + /** + * Build context for a task + * + * Pipeline: + * 1. Parse task input (string or {title, description}) + * 2. Run semantic search to find entry points + * 3. Expand graph around entry points + * 4. Extract code blocks for key nodes + * 5. Format output for Claude + * + * @param input - Task description or object with title/description + * @param options - Build options + * @returns TaskContext (structured) or formatted string + */ + async buildContext( + input: TaskInput, + options: BuildContextOptions = {} + ): Promise { + const opts = { ...DEFAULT_BUILD_OPTIONS, ...options }; + + // Parse input + const query = typeof input === 'string' ? input : `${input.title}${input.description ? `: ${input.description}` : ''}`; + + // Find relevant context (semantic search + graph expansion) + const subgraph = await this.findRelevantContext(query, { + searchLimit: opts.searchLimit, + traversalDepth: opts.traversalDepth, + maxNodes: opts.maxNodes, + minScore: opts.minScore, + }); + + // Get entry points (nodes from semantic search) + const entryPoints = this.getEntryPoints(subgraph); + + // Extract code blocks for key nodes + const codeBlocks = opts.includeCode + ? await this.extractCodeBlocks(subgraph, opts.maxCodeBlocks, opts.maxCodeBlockSize) + : []; + + // Get related files + const relatedFiles = this.getRelatedFiles(subgraph); + + // Generate summary + const summary = this.generateSummary(query, subgraph, entryPoints); + + // Calculate stats + const stats = { + nodeCount: subgraph.nodes.size, + edgeCount: subgraph.edges.length, + fileCount: relatedFiles.length, + codeBlockCount: codeBlocks.length, + totalCodeSize: codeBlocks.reduce((sum, block) => sum + block.content.length, 0), + }; + + const context: TaskContext = { + query, + subgraph, + entryPoints, + codeBlocks, + relatedFiles, + summary, + stats, + }; + + // Return formatted output or raw context + if (opts.format === 'markdown') { + return formatContextAsMarkdown(context) + + this.buildCallPathsSection(subgraph) + + (subgraph.confidence === 'low' ? this.buildLowConfidenceNote(entryPoints) : ''); + } else if (opts.format === 'json') { + return formatContextAsJson(context); + } + + return context; + } + + /** + * Honest handoff appended when retrieval confidence is low (the query matched + * mostly common words). Instead of the usual "this covers the surface" framing + * — which, when wrong, sends the agent off to Read/Grep — it admits the + * uncertainty and routes the agent to the precise tools (explore with real + * symbol names, search, or files to browse the closest areas we *did* surface). + */ + private buildLowConfidenceNote(entryPoints: Node[]): string { + const dirs: string[] = []; + const seen = new Set(); + for (const n of entryPoints) { + const slash = n.filePath.lastIndexOf('/'); + const dir = slash > 0 ? n.filePath.slice(0, slash) : n.filePath; + if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); } + if (dirs.length >= 4) break; + } + const dirLine = dirs.length + ? `\n- \`codegraph_files\` a likely area: ${dirs.map(d => `\`${d}\``).join(', ')}` + : ''; + return `\n\n${LOW_CONFIDENCE_MARKER}\n\n` + + 'This query matched mostly on common words, so the entry points above may ' + + 'be off-target — treat them as a starting point, not a complete answer. ' + + 'For a reliable result:\n' + + '- `codegraph_explore` with the **exact symbol names** you are after ' + + '(class / function / method names), or\n' + + '- `codegraph_search ` for one specific symbol' + + dirLine + + '\n\nDo not assume the list above is comprehensive.'; + } + + /** + * Surface short call-paths among the symbols this context already found, + * derived in-memory from the subgraph's `calls` edges (no extra queries). + * + * This bakes the value of path-finding INTO the always-loaded `context` tool. + * Agents reliably read context's output but do NOT discover/adopt a standalone + * trace tool (in deferred-MCP harnesses they only ToolSearch-select tools they + * already know). Delivering the flow here means "how does X reach Y" is + * answered without the agent needing to find, load, or choose a new tool. + * Chains stop where the static call graph ends (e.g. dynamic dispatch) — that + * truncation is honest, and the agent can codegraph_node the last hop to bridge. + */ + private buildCallPathsSection(subgraph: Subgraph): string { + const adj = new Map(); + for (const e of subgraph.edges) { + if (e.kind !== 'calls') continue; + if (!subgraph.nodes.has(e.source) || !subgraph.nodes.has(e.target)) continue; + const list = adj.get(e.source); + if (list) list.push(e.target); + else adj.set(e.source, [e.target]); + } + if (adj.size === 0) return ''; + + const MAX_HOPS = 6; + const chains: string[][] = []; + let budget = 2000; // bound DFS work on dense subgraphs + const dfs = (id: string, path: string[], seen: Set): void => { + if (budget-- <= 0) return; + const next = (adj.get(id) ?? []).filter((t) => !seen.has(t)); + if (next.length === 0 || path.length >= MAX_HOPS) { + if (path.length >= 3) chains.push([...path]); // >=3 nodes = a real flow, not a single call + return; + } + for (const t of next) { + seen.add(t); + dfs(t, [...path, t], seen); + seen.delete(t); + } + }; + const starts = (subgraph.roots.length > 0 + ? subgraph.roots.filter((id) => adj.has(id)) + : [...adj.keys()] + ).slice(0, 5); + for (const s of starts) dfs(s, [s], new Set([s])); + if (chains.length === 0) return ''; + + // Keep only chains that connect TWO OR MORE query-relevant symbols (roots). + // A chain from a root into an arbitrary callee (render → onMagicFrameGenerate) + // is structurally valid but tangential to the question; requiring ≥2 roots + // keeps the chain anchored to what the user actually asked about. Rank by + // #roots then length, and drop any that are a sub-path of a longer kept chain. + const rootSet = new Set(subgraph.roots); + const rootCount = (c: string[]): number => c.reduce((n, id) => n + (rootSet.has(id) ? 1 : 0), 0); + const relevant = chains.filter((c) => rootCount(c) >= 2); + relevant.sort((a, b) => rootCount(b) - rootCount(a) || b.length - a.length); + const kept: string[][] = []; + for (const c of relevant) { + const key = c.join('>'); + if (kept.some((k) => k.join('>').includes(key))) continue; + kept.push(c); + if (kept.length >= 3) break; + } + if (kept.length === 0) return ''; + const name = (id: string): string => subgraph.nodes.get(id)?.name ?? id; + + // Synthesized (dynamic-dispatch) hops are real `calls` edges but invisible to + // static parsing — mark them inline so the agent sees WHERE the callback was + // wired up (`registered @file:line`) instead of grepping for it. Keyed by + // "source>target". + const synthByPair = new Map(); + for (const e of subgraph.edges) { + if (e.kind !== 'calls' || e.provenance !== 'heuristic') continue; + const m = e.metadata as Record | undefined; + if (!m?.synthesizedBy) continue; + const at = typeof m.registeredAt === 'string' ? ` @${m.registeredAt}` : ''; + const label = m.synthesizedBy === 'callback' + ? `callback via ${m.via ? `\`${String(m.via)}\`` : 'registrar'}${at}` + : m.synthesizedBy === 'react-render' + ? `React re-render via setState${at}` + : m.synthesizedBy === 'jsx-render' + ? `renders <${String(m.via || 'child')}>` + : m.synthesizedBy === 'vue-handler' + ? `Vue @${String(m.event || 'event')} handler` + : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`; + synthByPair.set(`${e.source}>${e.target}`, label); + } + const renderChain = (c: string[]): string => { + let s = name(c[0]!); + for (let i = 1; i < c.length; i++) { + const synth = synthByPair.get(`${c[i - 1]}>${c[i]}`); + s += synth ? ` →[${synth}] ${name(c[i]!)}` : ` → ${name(c[i]!)}`; + } + return s; + }; + const hasSynth = kept.some((c) => c.some((_, i) => i > 0 && synthByPair.has(`${c[i - 1]}>${c[i]}`))); + const lines = [ + '', + '## Call paths', + '', + 'Execution flow among the key symbols (traced through the call graph):', + '', + ...kept.map((c) => `- ${renderChain(c)}`), + '', + hasSynth + ? '_Hops marked `[callback/event …]` are dynamic dispatch bridged by codegraph (with the registration site); the rest are direct calls. codegraph_node any symbol for its body._' + : '_codegraph_node any symbol above for its source + its own callers/callees._', + ]; + return '\n' + lines.join('\n') + '\n'; + } + + /** + * Find relevant subgraph for a query + * + * Uses hybrid search combining exact symbol lookup with semantic search: + * 1. Extract potential symbol names from query + * 2. Look up exact matches for those symbols (high confidence) + * 3. Use semantic search for concept matching + * 4. Merge results, prioritizing exact matches + * 5. Traverse graph from entry points + * + * @param query - Natural language query + * @param options - Search and traversal options + * @returns Subgraph of relevant nodes and edges + */ + async findRelevantContext( + query: string, + options: FindRelevantContextOptions = {} + ): Promise { + const opts = { ...DEFAULT_FIND_OPTIONS, ...options }; + + // Start with empty subgraph + const nodes = new Map(); + const edges: Edge[] = []; + const roots: string[] = []; + + // Handle empty query - return empty subgraph + if (!query || query.trim().length === 0) { + return { nodes, edges, roots }; + } + + // === HYBRID SEARCH === + + // Step 1: Extract potential symbol names from query + const symbolsFromQuery = extractSymbolsFromQuery(query); + logDebug('Extracted symbols from query', { query, symbols: symbolsFromQuery }); + + // Step 2: Look up exact matches for extracted symbols + let exactMatches: SearchResult[] = []; + if (symbolsFromQuery.length > 0) { + try { + // Get more results so we can apply co-location boosting before trimming + exactMatches = this.queries.findNodesByExactName(symbolsFromQuery, { + limit: Math.ceil(opts.searchLimit * 5), + kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined, + }); + + // Co-location boost: when multiple extracted symbols appear in the same file, + // those results are much more likely to be what the user is looking for. + // E.g., "scrapeLoop" + "run" both in scrape/scrape.go → boost both. + if (exactMatches.length > 1) { + // Build a map of files → how many distinct symbol names matched in that file + const fileSymbolCounts = new Map>(); + for (const r of exactMatches) { + const names = fileSymbolCounts.get(r.node.filePath) || new Set(); + names.add(r.node.name.toLowerCase()); + fileSymbolCounts.set(r.node.filePath, names); + } + // Boost results in files where multiple query symbols co-occur + exactMatches = exactMatches.map(r => { + const symbolCount = fileSymbolCounts.get(r.node.filePath)?.size || 1; + return { + ...r, + score: symbolCount > 1 ? r.score + (symbolCount - 1) * 20 : r.score, + }; + }); + exactMatches.sort((a, b) => b.score - a.score); + } + + // Trim back to reasonable size + exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 2)); + logDebug('Exact symbol matches', { count: exactMatches.length }); + } catch (error) { + logDebug('Exact symbol lookup failed', { error: String(error) }); + } + } + + // Step 2b: Search for extracted symbols as definition (class/interface) prefixes. + // When the user writes "REST", "bulk", or "allocation", they usually mean classes + // like RestController, BulkRequest, AllocationService — not nodes named exactly that. + // Also tries stem variants: "caching" → "cache" finds Cache, CacheBuilder. + if (symbolsFromQuery.length > 0) { + const definitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'trait', + 'protocol', 'enum', 'type_alias']; + // Expand symbols with stem variants for broader definition matching + const expandedSymbols = new Set(symbolsFromQuery); + for (const sym of symbolsFromQuery) { + for (const variant of getStemVariants(sym)) { + expandedSymbols.add(variant); + } + } + for (const sym of expandedSymbols) { + // Title-case the symbol: "REST" → "Rest", "bulk" → "Bulk", "allocation" → "Allocation" + const titleCased = sym.charAt(0).toUpperCase() + sym.slice(1).toLowerCase(); + if (titleCased === sym) continue; // already title-case (e.g., "Engine") — handled by exact match + // Fetch more results since popular prefixes have many matches + const prefixResults = this.queries.searchNodes(titleCased, { + limit: 30, + kinds: definitionKinds, + }); + const matched: SearchResult[] = []; + for (const r of prefixResults) { + if (r.node.name.toLowerCase().startsWith(titleCased.toLowerCase())) { + // Favor shorter names: "AllocationService" (18 chars) over + // "AllocationBalancingRoundMetrics" (31 chars). Core classes tend + // to have concise names; test/helper classes are verbose. + const brevityBonus = Math.max(0, 10 - (r.node.name.length - titleCased.length) / 3); + matched.push({ ...r, score: r.score + 15 + brevityBonus }); + } + } + matched.sort((a, b) => b.score - a.score); + for (const r of matched.slice(0, Math.ceil(opts.searchLimit))) { + const existing = exactMatches.find(e => e.node.id === r.node.id); + if (!existing) { + exactMatches.push(r); + } + } + } + exactMatches.sort((a, b) => b.score - a.score); + exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 3)); + } + + // Step 3: Run text search for natural language term matching + // This catches file-name and node-name matches that semantic search may miss, + // which is critical for template-heavy codebases (e.g., Liquid/Shopify themes) + // where file names are the primary identifiers. + let textResults: SearchResult[] = []; + try { + const searchTerms = extractSearchTerms(query); + if (searchTerms.length > 0) { + // Search each term individually to get broader coverage, + // then boost results that match multiple terms + const termResultsMap = new Map(); + // When no explicit kind filter is set, exclude imports — they flood FTS + // results with qualified name matches (e.g., "REST" matches 445K import paths) + // but are almost never what exploration queries want. + const searchKinds = opts.nodeKinds && opts.nodeKinds.length > 0 + ? opts.nodeKinds + : ['file', 'module', 'class', 'struct', 'interface', 'trait', 'protocol', + 'function', 'method', 'property', 'field', 'variable', 'constant', + 'enum', 'enum_member', 'type_alias', 'namespace', 'export', + 'route', 'component'] as NodeKind[]; + for (const term of searchTerms) { + const termResults = this.queries.searchNodes(term, { + limit: opts.searchLimit * 2, + kinds: searchKinds, + }); + for (const r of termResults) { + const existing = termResultsMap.get(r.node.id); + if (existing) { + existing.termHits++; + existing.result.score = Math.max(existing.result.score, r.score); + } else { + termResultsMap.set(r.node.id, { result: r, termHits: 1 }); + } + } + } + // Boost results matching multiple terms and sort + textResults = Array.from(termResultsMap.values()) + .map(({ result, termHits }) => ({ + ...result, + score: result.score + (termHits - 1) * 5, + })) + .sort((a, b) => b.score - a.score) + .slice(0, opts.searchLimit * 2); + } + logDebug('Text search results', { count: textResults.length }); + } catch (error) { + logDebug('Text search failed', { query, error: String(error) }); + } + + // Step 4: Merge results, taking the max score when duplicates appear + // across search channels. Exact matches may have lower scores than FTS + // results for the same node — use the best score from any channel. + const resultById = new Map(); + let searchResults: SearchResult[] = []; + + // Add exact matches first + for (const result of exactMatches) { + const existing = resultById.get(result.node.id); + if (existing) { + existing.score = Math.max(existing.score, result.score); + } else { + resultById.set(result.node.id, result); + searchResults.push(result); + } + } + + // Add text search results, upgrading scores for duplicates + for (const result of textResults) { + const existing = resultById.get(result.node.id); + if (existing) { + existing.score = Math.max(existing.score, result.score); + } else { + resultById.set(result.node.id, result); + searchResults.push(result); + } + } + + const queryLower = query.toLowerCase(); + const isTestQuery = queryLower.includes('test') || queryLower.includes('spec'); + + // Deprioritize test files early so they don't take multi-term boost slots + if (!isTestQuery) { + for (const result of searchResults) { + if (isTestFile(result.node.filePath)) { + result.score *= 0.3; + } + } + } + + // Iter7 — Core-directory boost. On projects with one file that holds + // the dense majority of internal call edges (e.g. sinatra's + // `lib/sinatra/base.rb` at 85% of all in-file edges), the agent's + // task usually asks about the framework's core. Without this boost, + // ranking favors small focused extension files (e.g. text search + // picks `sinatra-contrib/lib/sinatra/multi_route.rb`'s 10-line + // `route` method over `base.rb`'s `route!` because the extension + // file's `route` matches the query verbatim AND the file is small, + // dwarfing the longer name `route!` in a 1500-line file). Boost + // results that share a directory prefix with the dominant file's + // directory so the core file's siblings outrank sibling-package + // extensions. + try { + const dominant = this.queries.getDominantFile?.(); + if (dominant && dominant.edgeCount >= 3 * dominant.nextEdgeCount) { + // Take the directory of the dominant file (everything up to the + // last slash). For `lib/sinatra/base.rb` → `lib/sinatra/`. + const slash = dominant.filePath.lastIndexOf('/'); + if (slash > 0) { + const coreDir = dominant.filePath.slice(0, slash + 1); + for (const result of searchResults) { + if (result.node.filePath.startsWith(coreDir)) { + result.score += 25; + } + } + } + } + } catch { + // SQL failure — fall through, scoring works without the boost + } + + // Step 5a: Multi-term co-occurrence re-ranking (applied BEFORE truncation). + // For multi-word queries like "search execution from request to shard", + // nodes matching 2+ query terms in their name or path are far more relevant + // than nodes matching just one generic term. Without this, "ExecutionUtils" + // (matches only "execution") fills budget slots meant for "ShardSearchRequest" + // (matches "shard" + "search" + "request"). + const queryTermsForBoost = extractSearchTerms(query); + if (queryTermsForBoost.length >= 2) { + // Group terms that are substrings of each other (stem variants of the same + // root word). "indexed", "indexe", "index" should count as ONE concept match, + // not three. Without this, stem variants inflate matchCount and give false + // multi-term boosts to symbols matching one root word multiple times. + const termGroups: string[][] = []; + const sorted = [...queryTermsForBoost].sort((a, b) => b.length - a.length); + const assigned = new Set(); + for (const term of sorted) { + if (assigned.has(term)) continue; + const group = [term]; + assigned.add(term); + for (const other of sorted) { + if (assigned.has(other)) continue; + if (term.includes(other) || other.includes(term)) { + group.push(other); + assigned.add(other); + } + } + termGroups.push(group); + } + + // Build a set of exact-match node IDs so we can exempt them from dampening. + // When the query is "LiveEditMode DevServerPreview", these are specific + // symbols the user asked for — dampening them because they only match 1 + // term group is counter-productive. + const exactMatchIds = new Set(exactMatches.map(r => r.node.id)); + + // ...but only exempt exact matches the user *named as an identifier* + // (camelCase/snake_case/acronym). A plain dictionary word that happens to + // exact-match an unrelated symbol — query "flat object" → a constant named + // FLAT — must NOT be exempt, or the +exact-name bonus floats it to the top + // of a prose query with zero corroboration from any other term. Classify by + // the QUERY token (what the user typed), not the matched symbol's name. + const distinctiveTokens = new Set( + symbolsFromQuery.filter(isDistinctiveIdentifier).map(s => s.toLowerCase()) + ); + const distinctiveExactMatchIds = new Set( + exactMatches + .filter(r => distinctiveTokens.has(r.node.name.toLowerCase())) + .map(r => r.node.id) + ); + + for (const result of searchResults) { + // Check term matches in name (substring) and path DIRECTORIES (exact). + // Directory segments must match exactly — "search" matches directory + // "search/" but NOT "elasticsearch/". The class name is checked + // separately via substring match on the node name. + const nameLower = result.node.name.toLowerCase(); + const dirSegments = path.dirname(result.node.filePath).toLowerCase().split('/'); + let matchCount = 0; + for (const group of termGroups) { + const groupMatches = group.some(term => { + const inName = nameLower.includes(term); + const inDir = dirSegments.some(seg => seg === term); + return inName || inDir; + }); + if (groupMatches) matchCount++; + } + if (matchCount >= 2) { + // Multiplicative boost — 2 terms → 2x, 3 terms → 2.5x + result.score *= 1 + matchCount * 0.5; + } else if (distinctiveExactMatchIds.has(result.node.id)) { + // Exact match on a distinctive identifier the user explicitly named — + // keep full score (e.g. "LiveEditMode DevServerPreview"). + } else if (exactMatchIds.has(result.node.id)) { + // Exact match on a COMMON word (e.g. "flat" → FLAT): high-scoring noise + // inflated by the +exact-name bonus, corroborated by no other query + // term. Demote hard so corroborated matches win. + result.score *= 0.3; + } else { + // Mild dampen for generic single-term matches — they might be generic + // but could also be the right result (e.g., "Protocol" class for an IPC query). + result.score *= 0.6; + } + } + searchResults.sort((a, b) => b.score - a.score); + } + + // Step 5b: CamelCase-boundary matching via LIKE query. + // FTS can't find "Search" inside "TransportSearchAction" (one FTS token). + // LIKE reliably finds these substring matches. Results are appended with + // guaranteed slots so they don't compete with higher-scoring prefix matches. + if (symbolsFromQuery.length > 0) { + const camelDefinitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'trait', + 'protocol', 'enum', 'type_alias']; + const camelSearchedTerms = new Set(); + const searchIdSet = new Set(searchResults.map(r => r.node.id)); + // Track per-node term hits for multi-term boosting + const camelNodeTerms = new Map(); + const maxCamelPerTerm = Math.ceil(opts.searchLimit / 2); + + for (const sym of symbolsFromQuery) { + const titleCased = sym.charAt(0).toUpperCase() + sym.slice(1).toLowerCase(); + if (titleCased.length < 3) continue; + const termKey = titleCased.toLowerCase(); + if (camelSearchedTerms.has(termKey)) continue; + camelSearchedTerms.add(termKey); + + // Fetch a large batch — popular terms like "Search" in Elasticsearch + // have hundreds of substring matches. The LIKE scan cost is the same + // regardless of LIMIT (SQLite scans all matches to sort), so we fetch + // generously and let path-relevance scoring pick the best ones. + const likeResults = this.queries.findNodesByNameSubstring(titleCased, { + limit: 200, + kinds: camelDefinitionKinds, + excludePrefix: true, + }); + + // Filter to CamelCase boundaries, score by path relevance, and take top N + const termCandidates: SearchResult[] = []; + for (const r of likeResults) { + const name = r.node.name; + const idx = name.indexOf(titleCased); + if (idx <= 0) continue; + // Accept CamelCase boundary (lowercase before match) OR + // acronym boundary (uppercase before match, e.g., RPCProtocol) + if (!/[a-zA-Z]/.test(name.charAt(idx - 1))) continue; + if (searchIdSet.has(r.node.id)) continue; + if (isTestFile(r.node.filePath) && !isTestQuery) continue; + + const pathScore = scorePathRelevance(r.node.filePath, query); + const brevityBonus = Math.max(0, 6 - (name.length - titleCased.length) / 4); + termCandidates.push({ node: r.node, score: 8 + brevityBonus + pathScore }); + } + termCandidates.sort((a, b) => b.score - a.score); + + // Widen the per-term pool for accumulation so multi-term co-occurrences + // can be discovered. A class matching 3 query terms at CamelCase boundaries + // is far more relevant than one matching just 1, but it needs to survive + // the per-term cut for EACH term to accumulate its count. + const accumPerTerm = maxCamelPerTerm * 4; + for (const r of termCandidates.slice(0, accumPerTerm)) { + const existing = camelNodeTerms.get(r.node.id); + if (existing) { + existing.termCount++; + } else { + camelNodeTerms.set(r.node.id, { + result: r, + termCount: 1, + }); + } + } + } + + // Append CamelCase matches with multi-term boost. + // These are structurally important (class names containing query terms at + // CamelCase boundaries) but score much lower than FTS results. Scale their + // scores up so multi-term CamelCase matches can compete with FTS results. + const camelResults: SearchResult[] = []; + for (const [, info] of camelNodeTerms) { + // Multi-term CamelCase matches are extremely relevant — a class matching + // 3+ query terms in its name (e.g., ExtensionHostProcess) is almost + // certainly what the user wants. Scale aggressively. + info.result.score = info.result.score * (1 + info.termCount) + (info.termCount - 1) * 30; + camelResults.push(info.result); + } + camelResults.sort((a, b) => b.score - a.score); + const maxCamelTotal = opts.searchLimit; + for (const r of camelResults.slice(0, maxCamelTotal)) { + searchResults.push(r); + searchIdSet.add(r.node.id); + } + + // Step 5c: Compound term matching — find classes whose name contains 2+ + // query terms at ANY position (not just CamelCase boundaries). + // The CamelCase step above requires idx > 0, which misses classes that + // START with a query term (e.g., "SearchShardsRequest" starts with "Search"). + // For multi-word queries, a class matching multiple query terms in its name + // is almost certainly relevant regardless of position. + if (symbolsFromQuery.length >= 2) { + // Collect ALL LIKE results per term (reusing findNodesByNameSubstring) + // but without the CamelCase boundary or prefix exclusion filters. + const compoundTermMap = new Map }>(); + for (const sym of symbolsFromQuery) { + const titleCased = sym.charAt(0).toUpperCase() + sym.slice(1).toLowerCase(); + if (titleCased.length < 3) continue; + + const likeResults = this.queries.findNodesByNameSubstring(titleCased, { + limit: 200, + kinds: camelDefinitionKinds, + excludePrefix: false, + }); + + for (const r of likeResults) { + if (searchIdSet.has(r.node.id)) continue; + if (isTestFile(r.node.filePath) && !isTestQuery) continue; + const entry = compoundTermMap.get(r.node.id); + if (entry) { + entry.terms.add(titleCased); + } else { + compoundTermMap.set(r.node.id, { node: r.node, terms: new Set([titleCased]) }); + } + } + } + + // Keep only nodes matching 2+ distinct terms + const compoundResults: SearchResult[] = []; + for (const [, entry] of compoundTermMap) { + if (entry.terms.size >= 2) { + const pathScore = scorePathRelevance(entry.node.filePath, query); + const brevityBonus = Math.max(0, 6 - entry.node.name.length / 8); + compoundResults.push({ + node: entry.node, + score: 10 + (entry.terms.size - 1) * 20 + pathScore + brevityBonus, + }); + } + } + compoundResults.sort((a, b) => b.score - a.score); + const maxCompound = Math.ceil(opts.searchLimit / 2); + for (const r of compoundResults.slice(0, maxCompound)) { + searchResults.push(r); + searchIdSet.add(r.node.id); + } + } + } + + // Final sort and truncation — all search channels (exact, text, CamelCase, + // compound) have now contributed. Sort by score so multi-term matches from + // later steps can outrank dampened single-term matches from earlier steps. + searchResults.sort((a, b) => b.score - a.score); + searchResults = searchResults.slice(0, opts.searchLimit * 3); + + // Filter by minimum score + let filteredResults = searchResults.filter((r) => r.score >= opts.minScore); + + // Resolve imports/exports to their actual definitions + // If someone searches "terminal" and finds `import { TerminalPanel }`, + // they want the TerminalPanel class, not the import statement + filteredResults = this.resolveImportsToDefinitions(filteredResults); + + // Cap entry points so traversal budget isn't spread too thin. + // With 36 entry points and maxNodes=120, each gets only 3 nodes — useless. + // Cap to searchLimit so each entry point gets a meaningful traversal budget. + if (filteredResults.length > opts.searchLimit) { + filteredResults = filteredResults.slice(0, opts.searchLimit); + } + + // Confidence signal for the honest-handoff footer (consumed in buildContext). + // A multi-term prose query that resolves only to isolated common-word matches + // — no entry point corroborated by 2+ distinct query terms, and none a + // distinctive identifier the user explicitly named — is LOW confidence: the + // results are best-effort, not a located answer, so the agent should be told + // to drill in with explore/trace rather than trust the list as comprehensive. + // Single-keyword and symbol-name queries are exempt (their single match IS the + // answer), so the handoff never fires on them. + let confidence: 'high' | 'low' = 'high'; + const confTerms = extractSearchTerms(query, { stems: false }).filter(t => t.length >= 3); + if (confTerms.length >= 2 && filteredResults.length > 0) { + const distinctive = new Set( + symbolsFromQuery.filter(isDistinctiveIdentifier).map(s => s.toLowerCase()) + ); + const anyStrong = filteredResults.some(r => { + if (distinctive.has(r.node.name.toLowerCase())) return true; + const nameLower = r.node.name.toLowerCase(); + const dirSegs = path.dirname(r.node.filePath).toLowerCase().split('/'); + let hits = 0; + for (const t of confTerms) { + if (nameLower.includes(t) || dirSegs.includes(t)) { + if (++hits >= 2) return true; + } + } + return false; + }); + if (!anyStrong) confidence = 'low'; + } + + // Add entry points to subgraph + for (const result of filteredResults) { + nodes.set(result.node.id, result.node); + roots.push(result.node.id); + } + + // Expand type hierarchy for class/interface entry points. + // BFS often exhausts its per-entry-point budget on contained methods + // before reaching extends/implements neighbors. This dedicated step + // ensures subclasses and superclasses always appear in results. + // Budget: up to maxNodes/4 hierarchy nodes to avoid flooding. + const typeHierarchyKinds = new Set(['class', 'interface', 'struct', 'trait', 'protocol']); + const maxHierarchyNodes = Math.ceil(opts.maxNodes / 4); + let hierarchyNodesAdded = 0; + for (const result of filteredResults) { + if (hierarchyNodesAdded >= maxHierarchyNodes) break; + if (typeHierarchyKinds.has(result.node.kind)) { + const hierarchy = this.traverser.getTypeHierarchy(result.node.id); + for (const [id, node] of hierarchy.nodes) { + if (!nodes.has(id)) { + nodes.set(id, node); + hierarchyNodesAdded++; + } + } + for (const edge of hierarchy.edges) { + const exists = edges.some( + (e) => e.source === edge.source && e.target === edge.target && e.kind === edge.kind + ); + if (!exists) { + edges.push(edge); + } + } + } + } + + // Pass 2: expand hierarchy of newly-discovered parent types to find siblings. + // E.g., InternalEngine → Engine (parent, from pass 1) → ReadOnlyEngine (sibling). + if (hierarchyNodesAdded > 0) { + const pass2Candidates = [...nodes.values()].filter( + n => typeHierarchyKinds.has(n.kind) && !roots.includes(n.id) + ); + for (const candidate of pass2Candidates) { + if (hierarchyNodesAdded >= maxHierarchyNodes) break; + const siblingHierarchy = this.traverser.getTypeHierarchy(candidate.id); + for (const [id, node] of siblingHierarchy.nodes) { + if (!nodes.has(id) && hierarchyNodesAdded < maxHierarchyNodes) { + nodes.set(id, node); + hierarchyNodesAdded++; + } + } + for (const edge of siblingHierarchy.edges) { + if (nodes.has(edge.source) && nodes.has(edge.target)) { + const exists = edges.some( + (e) => e.source === edge.source && e.target === edge.target && e.kind === edge.kind + ); + if (!exists) { + edges.push(edge); + } + } + } + } + } + + // Traverse from each entry point + for (const result of filteredResults) { + const traversalResult = this.traverser.traverseBFS(result.node.id, { + maxDepth: opts.traversalDepth, + edgeKinds: opts.edgeKinds && opts.edgeKinds.length > 0 ? opts.edgeKinds : undefined, + nodeKinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined, + direction: 'both', + limit: Math.ceil(opts.maxNodes / Math.max(1, filteredResults.length)), + }); + + // Merge nodes + for (const [id, node] of traversalResult.nodes) { + if (!nodes.has(id)) { + nodes.set(id, node); + } + } + + // Merge edges (avoid duplicates) + for (const edge of traversalResult.edges) { + const exists = edges.some( + (e) => e.source === edge.source && e.target === edge.target && e.kind === edge.kind + ); + if (!exists) { + edges.push(edge); + } + } + } + + // Trim to max nodes if needed + let finalNodes = nodes; + let finalEdges = edges; + if (nodes.size > opts.maxNodes) { + // Prioritize entry points and their direct neighbors + const priorityIds = new Set(roots); + for (const edge of edges) { + if (priorityIds.has(edge.source)) { + priorityIds.add(edge.target); + } + if (priorityIds.has(edge.target)) { + priorityIds.add(edge.source); + } + } + + // Keep priority nodes, then fill remaining slots + finalNodes = new Map(); + for (const id of priorityIds) { + const node = nodes.get(id); + if (node && finalNodes.size < opts.maxNodes) { + finalNodes.set(id, node); + } + } + + // Fill remaining from other nodes + for (const [id, node] of nodes) { + if (finalNodes.size >= opts.maxNodes) break; + if (!finalNodes.has(id)) { + finalNodes.set(id, node); + } + } + + // Filter edges to only include kept nodes + finalEdges = edges.filter( + (e) => finalNodes.has(e.source) && finalNodes.has(e.target) + ); + } + + // Per-file diversity cap: prevent any single file from monopolizing the + // node budget. When BFS traverses from a method, it follows `contains` + // to the parent class, then back down to all sibling methods. With + // multiple entry points in the same class, one file can consume 30-40% + // of maxNodes. Cap each file to ~20% to ensure cross-file diversity. + const maxPerFile = Math.max(5, Math.ceil(opts.maxNodes * 0.2)); + const fileCounts = new Map(); + for (const [id, node] of finalNodes) { + const ids = fileCounts.get(node.filePath) || []; + ids.push(id); + fileCounts.set(node.filePath, ids); + } + const rootSet = new Set(roots); + for (const [, nodeIds] of fileCounts) { + if (nodeIds.length <= maxPerFile) continue; + // Sort: entry points first, then classes/interfaces, then others + const kindPriority: Record = { + class: 3, interface: 3, struct: 3, trait: 3, protocol: 3, enum: 3, + method: 1, function: 1, property: 0, field: 0, variable: 0, + }; + nodeIds.sort((a, b) => { + const aRoot = rootSet.has(a) ? 10 : 0; + const bRoot = rootSet.has(b) ? 10 : 0; + const aKind = kindPriority[finalNodes.get(a)!.kind] ?? 0; + const bKind = kindPriority[finalNodes.get(b)!.kind] ?? 0; + return (bRoot + bKind) - (aRoot + aKind); + }); + // Remove excess nodes (keep the highest-priority ones) + for (const id of nodeIds.slice(maxPerFile)) { + finalNodes.delete(id); + } + } + // Non-production node cap: limit test/sample/integration/example files to + // at most 15% of the budget. Many codebases have dozens of near-identical + // test implementations (e.g., 6 Guard classes in integration tests) that + // individually survive score dampening but collectively flood the result. + // Test entry points are NOT exempt — they should be evicted too. + if (!isTestQuery) { + const maxNonProd = Math.max(3, Math.ceil(opts.maxNodes * 0.15)); + const nonProdIds: string[] = []; + for (const [id, node] of finalNodes) { + if (isTestFile(node.filePath)) { + nonProdIds.push(id); + } + } + if (nonProdIds.length > maxNonProd) { + for (const id of nonProdIds.slice(maxNonProd)) { + finalNodes.delete(id); + // Also remove from roots — test file entry points shouldn't anchor results + const rootIdx = roots.indexOf(id); + if (rootIdx !== -1) roots.splice(rootIdx, 1); + } + } + } + + // Re-filter edges after per-file and non-production caps + finalEdges = finalEdges.filter( + (e) => finalNodes.has(e.source) && finalNodes.has(e.target) + ); + + // Edge recovery: BFS with many entry points leaves most nodes disconnected. + // Discover edges between already-selected nodes to recover connectivity. + const recoveryKinds: EdgeKind[] = ['calls', 'extends', 'implements', 'references', 'overrides']; + const recoveredEdges = this.queries.findEdgesBetweenNodes( + [...finalNodes.keys()], + recoveryKinds, + ); + const existingEdgeKeys = new Set( + finalEdges.map((e) => `${e.source}:${e.target}:${e.kind}`) + ); + for (const edge of recoveredEdges) { + const key = `${edge.source}:${edge.target}:${edge.kind}`; + if (!existingEdgeKeys.has(key)) { + finalEdges.push(edge); + existingEdgeKeys.add(key); + } + } + + return { nodes: finalNodes, edges: finalEdges, roots, confidence }; + } + + /** + * Get the source code for a node + * + * Reads the file and extracts the code between startLine and endLine. + * + * @param nodeId - ID of the node + * @returns Code string or null if not found + */ + async getCode(nodeId: string): Promise { + const node = this.queries.getNodeById(nodeId); + if (!node) { + return null; + } + + return this.extractNodeCode(node); + } + + /** + * Extract code from a node's source file + */ + private async extractNodeCode(node: Node): Promise { + // SECURITY (#383): a config-leaf node's on-disk line is `key = `. + // Return the KEY only — never read the value off disk. This closes the + // includeCode / buildContext code-block path, mirroring the explore source + // renderer; an agent that genuinely needs a value can read the file itself. + if (isConfigLeafNode(node)) { + return node.signature || node.qualifiedName || node.name; + } + + const filePath = validatePathWithinRoot(this.projectRoot, node.filePath); + + if (!filePath || !fs.existsSync(filePath)) { + return null; + } + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + // Extract lines (1-indexed to 0-indexed) + const startIdx = Math.max(0, node.startLine - 1); + const endIdx = Math.min(lines.length, node.endLine); + + return lines.slice(startIdx, endIdx).join('\n'); + } catch (error) { + logDebug('Failed to extract code from node', { nodeId: node.id, filePath: node.filePath, error: String(error) }); + return null; + } + } + + /** + * Get entry points from a subgraph (the root nodes) + */ + private getEntryPoints(subgraph: Subgraph): Node[] { + return subgraph.roots + .map((id) => subgraph.nodes.get(id)) + .filter((n): n is Node => n !== undefined); + } + + /** + * Extract code blocks for key nodes in the subgraph + */ + private async extractCodeBlocks( + subgraph: Subgraph, + maxBlocks: number, + maxBlockSize: number + ): Promise { + const blocks: CodeBlock[] = []; + + // Prioritize entry points, then functions/methods + const priorityNodes: Node[] = []; + + // First: entry points + for (const id of subgraph.roots) { + const node = subgraph.nodes.get(id); + if (node) { + priorityNodes.push(node); + } + } + + // Then: functions and methods + for (const node of subgraph.nodes.values()) { + if (!subgraph.roots.includes(node.id)) { + if (node.kind === 'function' || node.kind === 'method') { + priorityNodes.push(node); + } + } + } + + // Then: classes + for (const node of subgraph.nodes.values()) { + if (!subgraph.roots.includes(node.id)) { + if (node.kind === 'class') { + priorityNodes.push(node); + } + } + } + + // Extract code for priority nodes + for (const node of priorityNodes) { + if (blocks.length >= maxBlocks) break; + + const code = await this.extractNodeCode(node); + if (code) { + // Truncate if too long. Language-neutral marker (no `//` — not a + // comment in Python, Ruby, etc.); this renders inside a fenced + // source block whose language varies. + const truncated = code.length > maxBlockSize + ? code.slice(0, maxBlockSize) + '\n... (truncated) ...' + : code; + + blocks.push({ + content: truncated, + filePath: node.filePath, + startLine: node.startLine, + endLine: node.endLine, + language: node.language, + node, + }); + } + } + + return blocks; + } + + /** + * Get unique files from a subgraph + */ + private getRelatedFiles(subgraph: Subgraph): string[] { + const files = new Set(); + for (const node of subgraph.nodes.values()) { + files.add(node.filePath); + } + return Array.from(files).sort(); + } + + /** + * Generate a summary of the context + */ + private generateSummary(_query: string, subgraph: Subgraph, entryPoints: Node[]): string { + const nodeCount = subgraph.nodes.size; + const edgeCount = subgraph.edges.length; + const files = this.getRelatedFiles(subgraph); + + const entryPointNames = entryPoints + .slice(0, 3) + .map((n) => n.name) + .join(', '); + + const remaining = entryPoints.length > 3 ? ` and ${entryPoints.length - 3} more` : ''; + + return `Found ${nodeCount} relevant code symbols across ${files.length} files. ` + + `Key entry points: ${entryPointNames}${remaining}. ` + + `${edgeCount} relationships identified.`; + } + + /** + * Resolve import/export nodes to their actual definitions + * + * When search returns `import { TerminalPanel }`, users want the TerminalPanel + * class definition, not the import statement. This follows the `imports` edge + * to find and return the actual definition instead. + * + * @param results - Search results that may include import/export nodes + * @returns Results with imports resolved to definitions where possible + */ + private resolveImportsToDefinitions(results: SearchResult[]): SearchResult[] { + const resolved: SearchResult[] = []; + const seenIds = new Set(); + + for (const result of results) { + const { node, score } = result; + + // If it's not an import/export, keep it as-is + if (node.kind !== 'import' && node.kind !== 'export') { + if (!seenIds.has(node.id)) { + seenIds.add(node.id); + resolved.push(result); + } + continue; + } + + // For imports/exports, try to find what they reference + // Imports have outgoing 'imports' edges to the definition + // Exports have outgoing 'exports' edges to the definition + const edgeKind = node.kind === 'import' ? 'imports' : 'exports'; + const outgoingEdges = this.queries.getOutgoingEdges(node.id, [edgeKind as EdgeKind]); + + let foundDefinition = false; + for (const edge of outgoingEdges) { + const targetNode = this.queries.getNodeById(edge.target); + if (targetNode && !seenIds.has(targetNode.id)) { + // Found the definition - use it instead of the import + seenIds.add(targetNode.id); + resolved.push({ + node: targetNode, + score: score, // Preserve the original score + }); + foundDefinition = true; + logDebug('Resolved import to definition', { + import: node.name, + definition: targetNode.name, + kind: targetNode.kind, + }); + } + } + + // If we couldn't resolve the import, skip it (it's low-value on its own) + if (!foundDefinition) { + logDebug('Skipping unresolved import', { name: node.name, file: node.filePath }); + } + } + + return resolved; + } +} + +/** + * Create a context builder + */ +export function createContextBuilder( + projectRoot: string, + queries: QueryBuilder, + traverser: GraphTraverser +): ContextBuilder { + return new ContextBuilder(projectRoot, queries, traverser); +} + +// Re-export formatter +export { formatContextAsMarkdown, formatContextAsJson } from './formatter'; diff --git a/src/context/markers.ts b/src/context/markers.ts new file mode 100644 index 0000000..13f1968 --- /dev/null +++ b/src/context/markers.ts @@ -0,0 +1,19 @@ +/** + * Stable sentinel strings shared between the context builder (which emits them + * into its markdown) and the MCP layer (which detects them to adjust framing). + * + * Intentionally a dependency-free leaf module: the MCP tool layer imports this + * to recognise a low-confidence response, and routing that recognition through + * the full context module would drag its dependencies onto the cold-start path. + * Keep this file import-free. + */ + +/** + * Heading that leads the honest low-confidence handoff appended to a context + * response when the query resolved only to weak/isolated matches. The MCP layer + * checks for it to suppress the contradictory "this is comprehensive, don't call + * explore" small-repo footer. Changing the text is a breaking sentinel change — + * both the emitter (`ContextBuilder`) and the detector (`src/mcp/tools.ts`) + * import this constant, so they stay in sync automatically. + */ +export const LOW_CONFIDENCE_MARKER = '### ⚠️ Low-confidence match'; diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..0cd2823 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,475 @@ +/** + * Database Layer + * + * Handles SQLite database initialization and connection management. + */ + +import { SqliteDatabase, SqliteBackend, createDatabase } from './sqlite-adapter'; +import * as fs from 'fs'; +import * as path from 'path'; +import { SchemaVersion } from '../types'; +import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations'; +import { getCodeGraphDir } from '../directory'; + +export { SqliteDatabase, SqliteBackend } from './sqlite-adapter'; + +/** + * Apply connection-level PRAGMAs. Shared by `initialize` and `open` so the two + * paths can't drift. + * + * `busy_timeout` is set FIRST, before any pragma that might touch the database + * file (notably `journal_mode`). If another process holds a write lock at open + * time, the later pragmas — and the connection's first query — then wait out + * the lock instead of throwing "database is locked" immediately. See issue #238. + * + * The 5s window (was 120s) rides out a normal incremental sync; the old + * 2-minute wait presented as a frozen, hung agent. With WAL, reads never block + * on a writer, so this timeout only governs cross-process write contention + * (e.g. the git-hook `codegraph sync` running while the MCP server writes). + */ +function configureConnection(db: SqliteDatabase): void { + db.pragma('busy_timeout = 5000'); // MUST be first — see above + db.pragma('foreign_keys = ON'); + db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform + db.pragma('synchronous = NORMAL'); // safe with WAL mode + db.pragma('cache_size = -64000'); // 64 MB page cache + db.pragma('temp_store = MEMORY'); // temp tables in memory + db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O +} + +/** + * Database connection wrapper with lifecycle management + */ +export class DatabaseConnection { + private db: SqliteDatabase; + private dbPath: string; + private backend: SqliteBackend; + /** + * `dev:ino` of the DB file at the moment we opened it (or null when the + * platform/filesystem reports no usable inode). Lets us notice when the file + * we hold open has been unlinked and REPLACED by a new file at the same path + * — a git worktree removed and re-added, or `.codegraph/` deleted and + * re-`init`ed under a long-lived server — at which point our fd reads a now + * dead inode forever (#925). See `isReplacedOnDisk`. + */ + private openedInode: string | null; + + private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend) { + this.db = db; + this.dbPath = dbPath; + this.backend = backend; + this.openedInode = statInode(dbPath); + } + + /** + * Initialize a new database at the given path + */ + static initialize(dbPath: string): DatabaseConnection { + // Ensure parent directory exists + const dir = path.dirname(dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Create and configure database + const { db, backend } = createDatabase(dbPath); + + configureConnection(db); + + // Run schema initialization + const schemaPath = path.join(__dirname, 'schema.sql'); + const schema = fs.readFileSync(schemaPath, 'utf-8'); + db.exec(schema); + + // Record current schema version so migrations aren't re-applied on open + const currentVersion = getCurrentVersion(db); + if (currentVersion < CURRENT_SCHEMA_VERSION) { + db.prepare( + 'INSERT OR IGNORE INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)' + ).run(CURRENT_SCHEMA_VERSION, Date.now(), 'Initial schema includes all migrations'); + } + + return new DatabaseConnection(db, dbPath, backend); + } + + /** + * Open an existing database + */ + static open(dbPath: string): DatabaseConnection { + if (!fs.existsSync(dbPath)) { + throw new Error(`Database not found: ${dbPath}`); + } + + const { db, backend } = createDatabase(dbPath); + + configureConnection(db); + + // Check and run migrations if needed + const conn = new DatabaseConnection(db, dbPath, backend); + const currentVersion = getCurrentVersion(db); + + if (currentVersion < CURRENT_SCHEMA_VERSION) { + runMigrations(db, currentVersion); + } + + return conn; + } + + /** + * Get the underlying database instance + */ + getDb(): SqliteDatabase { + return this.db; + } + + /** + * Get the SQLite backend serving this connection. Per-instance so + * MCP cross-project queries report the right backend even when + * multiple project DBs are open in the same process. + */ + getBackend(): SqliteBackend { + return this.backend; + } + + /** + * Get database file path + */ + getPath(): string { + return this.dbPath; + } + + /** + * The journal mode actually in effect (e.g. 'wal', 'delete'). + * + * SQLite silently keeps the prior mode if WAL can't be enabled — e.g. on + * filesystems without shared-memory support (some network/virtualized mounts, + * WSL2 /mnt). So the effective mode can differ + * from what `configureConnection` requested. Surfaced in `codegraph status` so + * a "database is locked" report is triageable: 'wal' ⇒ readers never block on a + * writer; anything else ⇒ they can. See issue #238. + */ + getJournalMode(): string { + const raw = this.db.pragma('journal_mode'); + const row = Array.isArray(raw) ? raw[0] : raw; + const mode = row && typeof row === 'object' + ? (row as Record).journal_mode + : row; + return String(mode ?? '').toLowerCase(); + } + + /** + * Get current schema version + */ + getSchemaVersion(): SchemaVersion | null { + const row = this.db + .prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version DESC LIMIT 1') + .get() as { version: number; applied_at: number; description: string | null } | undefined; + + if (!row) return null; + + return { + version: row.version, + appliedAt: row.applied_at, + description: row.description ?? undefined, + }; + } + + /** + * Execute a function within a transaction + */ + transaction(fn: () => T): T { + return this.db.transaction(fn)(); + } + + /** + * Get database file size in bytes + */ + getSize(): number { + const stats = fs.statSync(this.dbPath); + return stats.size; + } + + /** + * Size of the `-wal` sidecar file in bytes. 0 when it doesn't exist (non-WAL + * journal mode, in-memory DB, or no write since the last checkpoint+reset). + */ + getWalSizeBytes(): number { + if (!this.dbPath || this.dbPath === ':memory:') return 0; + try { + return fs.statSync(`${this.dbPath}-wal`).size; + } catch { + return 0; + } + } + + /** Current `wal_autocheckpoint` interval in pages (0 = disabled). */ + getWalAutocheckpoint(): number { + const v = this.db.pragma('wal_autocheckpoint', { simple: true }); + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } + + /** + * Set the connection's `wal_autocheckpoint` interval (pages; 0 disables). + * Bulk indexing defers checkpoints entirely (#1231): the default 1000-page + * auto-checkpoint re-writes hot B-tree/FTS pages into the main DB file over + * and over — measured at ~95% of ALL disk I/O during a bulk index, and the + * difference between 45s and 19+ minutes on HDD-class storage. During + * deferral a {@link WalCheckpointValve} bounds WAL growth off-thread. + */ + setWalAutocheckpoint(pages: number): void { + this.db.pragma(`wal_autocheckpoint = ${Math.max(0, Math.floor(pages))}`); + } + + /** + * `PRAGMA wal_checkpoint(PASSIVE)` on a worker thread with its own + * connection. PASSIVE never blocks the writer, and running it off-thread + * means the main thread — and the #850 watchdog heartbeat — keep turning + * even when the backfill is minutes of I/O on slow storage (a synchronous + * checkpoint that exceeds the watchdog's 60s window gets a healthy index + * SIGKILLed — observed in the #1231 repro). + * + * Returns SQLite's checkpoint result row — `log === checkpointed` with + * `busy === 0` means the ENTIRE WAL was backfilled, so the writer's next + * commit restarts the WAL from the top and the file stops growing. The + * WAL valve needs that signal because a WAL file's SIZE never shrinks: + * after the first wrap, raw file size says nothing about the un-backfilled + * backlog. Best-effort: returns null on any failure (including worker + * threads being unavailable — a potentially minutes-long checkpoint must + * never run inline on the main thread). + */ + async checkpointWalPassive(): Promise<{ busy: number; log: number; checkpointed: number } | null> { + if (!this.dbPath || this.dbPath === ':memory:') { + try { + const row = this.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get() as Record | undefined; + return row ? { busy: Number(row.busy), log: Number(row.log), checkpointed: Number(row.checkpointed) } : null; + } catch { + return null; + } + } + try { + const { Worker } = await import('node:worker_threads'); + const workerSource = ` + const { workerData, parentPort } = require('node:worker_threads'); + let row = null; + try { + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(workerData.dbPath); + try { row = db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get(); } catch {} + try { db.close(); } catch {} + } catch {} + parentPort.postMessage({ row }); + `; + return await new Promise((resolve) => { + let settled = false; + const finish = (row?: Record | null): void => { + if (settled) return; + settled = true; + resolve(row ? { busy: Number(row.busy), log: Number(row.log), checkpointed: Number(row.checkpointed) } : null); + }; + try { + const worker = new Worker(workerSource, { eval: true, workerData: { dbPath: this.dbPath } }); + worker.once('message', (m: { row?: Record | null }) => { void worker.terminate(); finish(m?.row ?? null); }); + worker.once('error', () => { void worker.terminate(); finish(null); }); + worker.once('exit', () => finish(null)); + } catch { + finish(null); + } + }); + } catch { + return null; + } + } + + /** + * Optimize database (vacuum and analyze) + */ + optimize(): void { + this.db.exec('VACUUM'); + this.db.exec('ANALYZE'); + } + + /** + * Lightweight maintenance to run after bulk writes (indexAll, sync). + * Two operations: + * + * - `PRAGMA optimize` — incremental ANALYZE; SQLite only re-analyzes + * tables whose row counts changed materially since the last + * ANALYZE. Without it, the query planner has no statistics on the + * freshly-bulk-loaded tables and can pick suboptimal indexes. + * + * - `PRAGMA wal_checkpoint(PASSIVE)` — fold pending WAL pages back + * into the main database file so the WAL file doesn't grow + * unboundedly between automatic checkpoints (auto-fires at 1000 + * pages by default; large indexAll runs blow past that). + * + * Runs on a WORKER THREAD with its own connection: on a multi-GB index + * these pragmas are minutes of synchronous IO (a 95k-file kernel index + * left a 593MB WAL whose checkpoint alone blew the #850 watchdog's 60s + * window and got a COMPLETED index SIGKILLed at the finish line). WAL + * checkpointing from a second connection is standard SQLite; `PRAGMA + * optimize` persists its statistics in sqlite_stat tables, so the main + * connection benefits the same. The main thread just awaits a message, + * so the event loop — and the watchdog heartbeat — keep turning. + * + * Everything is silently swallowed on failure — best-effort + * optimization, never load-bearing for correctness. If worker threads + * are unavailable, falls back to a bounded in-line `PRAGMA optimize` + * and SKIPS the checkpoint (the final close() checkpoints after the + * CLI has already disarmed its watchdog). + */ + async runMaintenance(): Promise { + // In-memory / test databases: nothing worth a worker round-trip. + if (!this.dbPath || this.dbPath === ':memory:') { + try { this.db.exec('PRAGMA optimize'); } catch { /* ignore */ } + try { this.db.exec('PRAGMA wal_checkpoint(PASSIVE)'); } catch { /* ignore */ } + return; + } + await this.runPragmasOffThread( + ['PRAGMA analysis_limit=1000', 'PRAGMA optimize', 'PRAGMA wal_checkpoint(PASSIVE)'], + // Worker threads unavailable — bounded in-line fallback, no checkpoint. + ['PRAGMA analysis_limit=1000', 'PRAGMA optimize'] + ); + } + + /** + * Run pragmas on a worker thread against its own connection to this DB + * (shared machinery for {@link runMaintenance} and + * {@link checkpointWalPassive}). Each pragma is individually best-effort; + * the whole call is best-effort. `inlineFallback` (if any) runs on THIS + * connection only when worker threads are unavailable — keep it to pragmas + * that are safe to run synchronously on the main thread. + */ + private async runPragmasOffThread(pragmas: string[], inlineFallback: string[] = []): Promise { + try { + const { Worker } = await import('node:worker_threads'); + const workerSource = ` + const { workerData, parentPort } = require('node:worker_threads'); + try { + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(workerData.dbPath); + for (const p of workerData.pragmas) { try { db.exec(p); } catch {} } + try { db.close(); } catch {} + } catch {} + parentPort.postMessage('done'); + `; + await new Promise((resolve) => { + let settled = false; + const finish = (): void => { + if (!settled) { settled = true; resolve(); } + }; + try { + const worker = new Worker(workerSource, { eval: true, workerData: { dbPath: this.dbPath, pragmas } }); + worker.once('message', () => { void worker.terminate(); finish(); }); + worker.once('error', () => { void worker.terminate(); finish(); }); + worker.once('exit', finish); + } catch { + finish(); + } + }); + } catch { + for (const p of inlineFallback) { + try { this.db.exec(p); } catch { /* ignore */ } + } + } + } + + /** + * Close the database connection + */ + close(): void { + this.db.close(); + } + + /** + * Check if the database connection is open + */ + isOpen(): boolean { + return this.db.open; + } + + /** + * True when the DB file at our path has been REPLACED on disk since we opened + * it — a different inode now lives at the same path, so the fd we still hold + * points at a now-unlinked inode that can never receive new writes (#925). + * The trigger is removing and recreating `.codegraph/` at the same path under + * a long-lived process (`git worktree remove` + re-add, or `rm -rf + * .codegraph` + `codegraph init`). Returns false when the inode is unchanged, + * when the file is momentarily absent (mid-recreate — nothing to reopen onto + * yet), or when the platform doesn't report a usable inode (Windows can't + * unlink an open file and its st_ino is unreliable, so this never fires there). + */ + isReplacedOnDisk(): boolean { + if (this.openedInode === null) return false; + const current = statInode(this.dbPath); + return current !== null && current !== this.openedInode; + } +} + +/** + * `dev:ino` for a path, or null if it can't be stat'd or the platform doesn't + * report a usable inode. Windows st_ino is unreliable across handle reopens, so + * we deliberately return null there — the deleted-but-open-inode hazard this + * guards (#925) is a POSIX file-semantics issue that doesn't arise on Windows + * (an open file can't be unlinked). + */ +function statInode(p: string): string | null { + if (process.platform === 'win32') return null; + try { + const s = fs.statSync(p); + return `${s.dev}:${s.ino}`; + } catch { + return null; + } +} + +/** + * Default database filename + */ +export const DATABASE_FILENAME = 'codegraph.db'; + +/** + * SQLite's sidecar files in WAL mode — the write-ahead log and its shared-memory + * index. They sit beside the main DB file and are removed alongside it when the + * database is discarded (see `removeDatabaseFiles`). + */ +const WAL_SIDECAR_SUFFIXES = ['-wal', '-shm'] as const; + +/** + * Get the default database path for a project + */ +export function getDatabasePath(projectRoot: string): string { + return path.join(getCodeGraphDir(projectRoot), DATABASE_FILENAME); +} + +/** + * Delete a database file and its WAL sidecars (`-wal`/`-shm`). + * + * This is how a FULL re-index discards an existing database — rather than + * opening the old graph and DELETE-ing every row. On a large or pre-fix + * poisoned index (e.g. an old graph that scanned an ignored gitlink corpus into + * ~1.6M nodes with a multi-GB WAL, #1065) the per-row `nodes_fts` delete-trigger + * churn blocks the main thread long enough to trip the #850 liveness watchdog + * before indexing even starts, so the rebuild could never recover the bad state + * (#1067). Unlinking is O(1) regardless of DB size and also reclaims the disk + * the bloated WAL would otherwise keep. + * + * POSIX removes the directory entry even while another process (a daemon/MCP + * server) still holds the file open; that holder heals via `reopenIfReplaced` + * (#925). On Windows a live holder can make the unlink fail with EBUSY/EPERM — + * that is thrown for the caller to surface ("stop the other process and retry"). + * The `-wal`/`-shm` sidecars are best-effort: SQLite recreates them on the next + * open, so a leftover sidecar is harmless. + */ +export function removeDatabaseFiles(dbPath: string): void { + // The main DB file first — its removal is the operation that must succeed (or + // report why it couldn't). force:true treats an already-missing file as done. + fs.rmSync(dbPath, { force: true }); + for (const suffix of WAL_SIDECAR_SUFFIXES) { + try { + fs.rmSync(dbPath + suffix, { force: true }); + } catch { + // A sidecar still held/locked is harmless — SQLite rebuilds it on open. + } + } +} diff --git a/src/db/migrations.ts b/src/db/migrations.ts new file mode 100644 index 0000000..d083095 --- /dev/null +++ b/src/db/migrations.ts @@ -0,0 +1,234 @@ +/** + * Database Migrations + * + * Schema versioning and migration support. + */ + +import { SqliteDatabase } from './sqlite-adapter'; + +/** + * Current schema version + */ +export const CURRENT_SCHEMA_VERSION = 8; + +/** + * Migration definition + */ +interface Migration { + version: number; + description: string; + up: (db: SqliteDatabase) => void; +} + +/** + * All migrations in order + * + * Note: Version 1 is the initial schema, handled by schema.sql + * Future migrations go here. + */ +const migrations: Migration[] = [ + { + version: 2, + description: 'Add project metadata, provenance tracking, and unresolved ref context', + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS project_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + ALTER TABLE unresolved_refs ADD COLUMN file_path TEXT NOT NULL DEFAULT ''; + ALTER TABLE unresolved_refs ADD COLUMN language TEXT NOT NULL DEFAULT 'unknown'; + ALTER TABLE edges ADD COLUMN provenance TEXT DEFAULT NULL; + CREATE INDEX IF NOT EXISTS idx_unresolved_file_path ON unresolved_refs(file_path); + CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance); + `); + }, + }, + { + version: 3, + description: 'Add lower(name) expression index for memory-efficient case-insensitive lookups', + up: (db) => { + db.exec(` + CREATE INDEX IF NOT EXISTS idx_nodes_lower_name ON nodes(lower(name)); + `); + }, + }, + { + version: 4, + description: + 'Drop redundant idx_edges_source / idx_edges_target (covered by source_kind / target_kind composites)', + up: (db) => { + db.exec(` + DROP INDEX IF EXISTS idx_edges_source; + DROP INDEX IF EXISTS idx_edges_target; + `); + }, + }, + { + version: 5, + description: + 'Add nodes.return_type — normalized return/result type for receiver-type inference (C++ singletons/factories, #645)', + up: (db) => { + db.exec(` + ALTER TABLE nodes ADD COLUMN return_type TEXT; + `); + }, + }, + { + version: 6, + description: + 'Dedup duplicate edge rows and add a UNIQUE identity index so INSERT OR IGNORE actually dedups (#1034)', + up: (db) => { + // `insertEdge` has always used `INSERT OR IGNORE`, but the edges table had + // no UNIQUE constraint, so nothing conflicted and byte-identical rows + // accumulated whenever two passes emitted the same edge. Collapse each + // identity group to its lowest id, then add the constraint that makes + // `OR IGNORE` keep its promise. IFNULL folds nullable line/col so + // coordinate-less edges dedup too (SQLite treats each NULL as distinct) — + // and it MUST match the GROUP BY exactly, or the index creation would + // fail on a pair the DELETE left behind. Idempotent: the index is + // `IF NOT EXISTS` and the DELETE is a no-op once the table is unique. + db.exec(` + DELETE FROM edges + WHERE id NOT IN ( + SELECT MIN(id) FROM edges + GROUP BY source, target, kind, IFNULL(line, -1), IFNULL(col, -1) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_edges_identity + ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1)); + `); + }, + }, + { + version: 7, + description: + 'Add name_segment_vocab — prose-word → symbol-name lookup for the prompt hook’s graph-derived gate', + up: (db) => { + // DDL only — instant on any size database (the row-churn hazards of #1067 + // don't apply). The table starts EMPTY on migrated databases; `sync` + // detects that over a populated graph and backfills batched+yielding + // (CodeGraph.rebuildNameSegmentVocab), and any full index rebuilds it + // from scratch. Keep the definition in lockstep with schema.sql. + db.exec(` + CREATE TABLE IF NOT EXISTS name_segment_vocab ( + segment TEXT NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (segment, name) + ) WITHOUT ROWID; + `); + }, + }, + { + version: 8, + description: + 'Track attempted-but-unresolvable refs as status=failed so sync can retry them when a changed file adds a matching symbol (#1240)', + up: (db) => { + // DDL only — instant on any size database. No backfill needed: rows are + // only ever queried by name_tail once they carry status='failed', and + // both fields are written together by markReferencesFailed. Legacy rows + // (all 'pending' after this migration) are orphans from interrupted runs + // that the #1187 sweep grinds down on the next sync, marking survivors + // failed with their tails as it goes. The tail index is partial: on a + // healthy index the pending set is empty and the failed set is the only + // population worth indexing. Keep the definitions in lockstep with + // schema.sql. ALTER TABLE has no IF NOT EXISTS, so guard each column for + // idempotency — a database created from current schema.sql already has + // both (matters when migrations are re-run from an older recorded + // version, as the v6 regression test does). + const cols = db.prepare('PRAGMA table_info(unresolved_refs)').all() as Array<{ name: string }>; + const hasColumn = (name: string) => cols.some((c) => c.name === name); + if (!hasColumn('status')) { + db.exec("ALTER TABLE unresolved_refs ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'"); + } + if (!hasColumn('name_tail')) { + db.exec("ALTER TABLE unresolved_refs ADD COLUMN name_tail TEXT NOT NULL DEFAULT ''"); + } + db.exec(` + CREATE INDEX IF NOT EXISTS idx_unresolved_status ON unresolved_refs(status); + CREATE INDEX IF NOT EXISTS idx_unresolved_failed_tail ON unresolved_refs(name_tail) WHERE status = 'failed'; + `); + }, + }, +]; + +/** + * Get the current schema version from the database + */ +export function getCurrentVersion(db: SqliteDatabase): number { + try { + const row = db + .prepare('SELECT MAX(version) as version FROM schema_versions') + .get() as { version: number | null } | undefined; + return row?.version ?? 0; + } catch { + // Table doesn't exist yet + return 0; + } +} + +/** + * Record a migration as applied + */ +function recordMigration(db: SqliteDatabase, version: number, description: string): void { + db.prepare( + 'INSERT INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)' + ).run(version, Date.now(), description); +} + +/** + * Run all pending migrations + */ +export function runMigrations(db: SqliteDatabase, fromVersion: number): void { + const pending = migrations.filter((m) => m.version > fromVersion); + + if (pending.length === 0) { + return; + } + + // Sort by version + pending.sort((a, b) => a.version - b.version); + + // Run each migration in a transaction + for (const migration of pending) { + db.transaction(() => { + migration.up(db); + recordMigration(db, migration.version, migration.description); + })(); + } +} + +/** + * Check if the database needs migration + */ +export function needsMigration(db: SqliteDatabase): boolean { + const current = getCurrentVersion(db); + return current < CURRENT_SCHEMA_VERSION; +} + +/** + * Get list of pending migrations + */ +export function getPendingMigrations(db: SqliteDatabase): Migration[] { + const current = getCurrentVersion(db); + return migrations + .filter((m) => m.version > current) + .sort((a, b) => a.version - b.version); +} + +/** + * Get migration history from database + */ +export function getMigrationHistory( + db: SqliteDatabase +): Array<{ version: number; appliedAt: number; description: string | null }> { + const rows = db + .prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version') + .all() as Array<{ version: number; applied_at: number; description: string | null }>; + + return rows.map((row) => ({ + version: row.version, + appliedAt: row.applied_at, + description: row.description, + })); +} diff --git a/src/db/queries.ts b/src/db/queries.ts new file mode 100644 index 0000000..15f2611 --- /dev/null +++ b/src/db/queries.ts @@ -0,0 +1,2245 @@ +/** + * Database Queries + * + * Prepared statements for CRUD operations on the knowledge graph. + */ + +import { SqliteDatabase, SqliteStatement } from './sqlite-adapter'; +import { + Node, + Edge, + FileRecord, + UnresolvedReference, + NodeKind, + EdgeKind, + Language, + GraphStats, + SearchOptions, + SearchResult, +} from '../types'; +import { safeJsonParse } from '../utils'; +import { kindBonus, nameMatchBonus, scorePathRelevance } from '../search/query-utils'; +import { parseQuery, boundedEditDistance } from '../search/query-parser'; +import { isGeneratedFile } from '../extraction/generated-detection'; +import { splitIdentifierSegments } from '../search/identifier-segments'; + +/** + * Path-only heuristic for files that should not be candidates for + * "dominant file" detection: test/spec files and tool-generated files. + * Generated files (`*.pb.go`, `*.pulsar.go`, mock outputs, …) often + * have huge in-file edge counts that dwarf the real source — etcd's + * `rpc.pb.go` has 4× the in-file edges of `server.go`. + */ +function isLowValueFile(filePath: string): boolean { + const lp = filePath.toLowerCase(); + return ( + /(?:^|\/)(tests?|__tests?__|spec)\//.test(lp) || + /_test\.go$/.test(lp) || + /(?:^|\/)test_[^/]+\.py$/.test(lp) || + /_test\.py$/.test(lp) || + /_spec\.rb$/.test(lp) || + /_test\.rb$/.test(lp) || + /\.(test|spec)\.[jt]sx?$/.test(lp) || + /(test|spec|tests)\.(java|kt|scala)$/.test(lp) || + /(tests?|spec)\.cs$/.test(lp) || + /tests?\.swift$/.test(lp) || + /_test\.dart$/.test(lp) || + isGeneratedFile(filePath) + ); +} + +const SQLITE_PARAM_CHUNK_SIZE = 500; + +/** + * Database row types (snake_case from SQLite) + */ +interface NodeRow { + id: string; + kind: string; + name: string; + qualified_name: string; + file_path: string; + language: string; + start_line: number; + end_line: number; + start_column: number; + end_column: number; + docstring: string | null; + signature: string | null; + visibility: string | null; + is_exported: number; + is_async: number; + is_static: number; + is_abstract: number; + decorators: string | null; + type_parameters: string | null; + return_type: string | null; + updated_at: number; +} + +interface EdgeRow { + id: number; + source: string; + target: string; + kind: string; + metadata: string | null; + line: number | null; + col: number | null; + provenance: string | null; +} + +interface FileRow { + path: string; + content_hash: string; + language: string; + size: number; + modified_at: number; + indexed_at: number; + node_count: number; + errors: string | null; +} + +interface UnresolvedRefRow { + id: number; + from_node_id: string; + reference_name: string; + reference_kind: string; + line: number; + col: number; + candidates: string | null; + file_path: string; + language: string; + status: string; + name_tail: string; +} + +/** + * Last segment of a (possibly dotted/qualified) reference name — the part a + * new symbol's plain node name could match: 'util.greet' → 'greet', + * 'mod::fn' → 'fn', 'greet' → 'greet'. Written to unresolved_refs.name_tail + * when a ref is marked failed, so the #1240 retry lookup can match dotted + * refs against newly-added node names. + */ +function referenceNameTail(referenceName: string): string { + const idx = Math.max(referenceName.lastIndexOf('.'), referenceName.lastIndexOf(':')); + return idx >= 0 ? referenceName.slice(idx + 1) : referenceName; +} + +/** + * Convert database row to Node object + */ +function rowToNode(row: NodeRow): Node { + return { + id: row.id, + kind: row.kind as NodeKind, + name: row.name, + qualifiedName: row.qualified_name, + filePath: row.file_path, + language: row.language as Language, + startLine: row.start_line, + endLine: row.end_line, + startColumn: row.start_column, + endColumn: row.end_column, + docstring: row.docstring ?? undefined, + signature: row.signature ?? undefined, + visibility: row.visibility as Node['visibility'], + isExported: row.is_exported === 1, + isAsync: row.is_async === 1, + isStatic: row.is_static === 1, + isAbstract: row.is_abstract === 1, + decorators: row.decorators ? safeJsonParse(row.decorators, undefined) : undefined, + typeParameters: row.type_parameters ? safeJsonParse(row.type_parameters, undefined) : undefined, + returnType: row.return_type ?? undefined, + updatedAt: row.updated_at, + }; +} + +/** + * Convert database row to Edge object + */ +function rowToEdge(row: EdgeRow): Edge { + return { + source: row.source, + target: row.target, + kind: row.kind as EdgeKind, + metadata: row.metadata ? safeJsonParse(row.metadata, undefined) : undefined, + line: row.line ?? undefined, + column: row.col ?? undefined, + provenance: row.provenance as Edge['provenance'], + }; +} + +/** + * Convert database row to FileRecord object + */ +function rowToFileRecord(row: FileRow): FileRecord { + return { + path: row.path, + contentHash: row.content_hash, + language: row.language as Language, + size: row.size, + modifiedAt: row.modified_at, + indexedAt: row.indexed_at, + nodeCount: row.node_count, + errors: row.errors ? safeJsonParse(row.errors, undefined) : undefined, + }; +} + +/** + * Query builder for the knowledge graph database + */ +export class QueryBuilder { + private db: SqliteDatabase; + + // Project-name tokens (go.mod / package.json / repo dir), normalized. A query + // word matching one is dropped from path-relevance scoring — it names the + // whole project, not a symbol, so it carries no discriminative signal (#720). + // Set once by the CodeGraph instance; empty by default (no down-weighting). + private projectNameTokens: Set = new Set(); + + // Node cache for frequently accessed nodes (LRU-style, max 1000 entries) + private nodeCache: Map = new Map(); + private readonly maxCacheSize = 1000; + + // Prepared statements (lazily initialized) + private stmts: { + insertNode?: SqliteStatement; + updateNode?: SqliteStatement; + deleteNode?: SqliteStatement; + deleteNodesByFile?: SqliteStatement; + getNodeById?: SqliteStatement; + getNodesByFile?: SqliteStatement; + getNodesByKind?: SqliteStatement; + insertEdge?: SqliteStatement; + upsertFile?: SqliteStatement; + deleteEdgesBySource?: SqliteStatement; + deleteEdgesByTarget?: SqliteStatement; + getEdgesBySource?: SqliteStatement; + getEdgesByTarget?: SqliteStatement; + insertFile?: SqliteStatement; + updateFile?: SqliteStatement; + deleteFile?: SqliteStatement; + getFileByPath?: SqliteStatement; + getAllFiles?: SqliteStatement; + insertUnresolved?: SqliteStatement; + deleteUnresolvedByNode?: SqliteStatement; + getUnresolvedByName?: SqliteStatement; + getNodesByName?: SqliteStatement; + getNodesByNamePrefix?: SqliteStatement; + getNodesByQualifiedNameExact?: SqliteStatement; + getNodesByLowerName?: SqliteStatement; + getUnresolvedCount?: SqliteStatement; + getUnresolvedBatch?: SqliteStatement; + getAllFilePaths?: SqliteStatement; + getAllNodeNames?: SqliteStatement; + getDominantFile?: SqliteStatement; + getTopRouteFile?: SqliteStatement; + getRoutingManifest?: SqliteStatement; + insertNameSegment?: SqliteStatement; + } = {}; + + // Names whose segments were already written this session — skips re-splitting + // and re-inserting for the same-named nodes that repeat across files ("get", + // "render", …). Purely a write-path fast path; INSERT OR IGNORE is the + // correctness backstop. Bounded so a pathological repo can't grow it forever. + private segmentedNames: Set = new Set(); + private static readonly MAX_SEGMENTED_NAMES = 65536; + + constructor(db: SqliteDatabase) { + this.db = db; + } + + /** Set the normalized project-name tokens used to down-weight non-discriminative + * query words in path scoring (#720). Called once when the project opens. */ + setProjectNameTokens(tokens: Set): void { + this.projectNameTokens = tokens; + } + + /** The normalized project-name tokens (#720); empty if none were derived. */ + getProjectNameTokens(): Set { + return this.projectNameTokens; + } + + // =========================================================================== + // Node Operations + // =========================================================================== + + /** + * Insert a new node + */ + insertNode(node: Node): void { + if (!this.stmts.insertNode) { + this.stmts.insertNode = this.db.prepare(` + INSERT OR REPLACE INTO nodes ( + id, kind, name, qualified_name, file_path, language, + start_line, end_line, start_column, end_column, + docstring, signature, visibility, + is_exported, is_async, is_static, is_abstract, + decorators, type_parameters, return_type, updated_at + ) VALUES ( + @id, @kind, @name, @qualifiedName, @filePath, @language, + @startLine, @endLine, @startColumn, @endColumn, + @docstring, @signature, @visibility, + @isExported, @isAsync, @isStatic, @isAbstract, + @decorators, @typeParameters, @returnType, @updatedAt + ) + `); + } + + // Validate required fields to prevent SQLite bind errors + if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) { + console.error('[CodeGraph] Skipping node with missing required fields:', { + id: node.id, + kind: node.kind, + name: node.name, + filePath: node.filePath, + language: node.language, + }); + return; + } + + // INSERT OR REPLACE may overwrite a node we have cached. Drop the + // stale entry so the next getNodeById sees the new row, not the old + // one (matches the cache-invalidation pattern used by updateNode and + // deleteNode below). + this.nodeCache.delete(node.id); + + this.stmts.insertNode.run({ + id: node.id, + kind: node.kind, + name: node.name, + qualifiedName: node.qualifiedName ?? node.name, + filePath: node.filePath, + language: node.language, + startLine: node.startLine ?? 0, + endLine: node.endLine ?? 0, + startColumn: node.startColumn ?? 0, + endColumn: node.endColumn ?? 0, + docstring: node.docstring ?? null, + signature: node.signature ?? null, + visibility: node.visibility ?? null, + isExported: node.isExported ? 1 : 0, + isAsync: node.isAsync ? 1 : 0, + isStatic: node.isStatic ? 1 : 0, + isAbstract: node.isAbstract ? 1 : 0, + decorators: node.decorators ? JSON.stringify(node.decorators) : null, + typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null, + returnType: node.returnType ?? null, + updatedAt: node.updatedAt ?? Date.now(), + }); + + // Segment vocabulary rides the same write path (and transaction) so it can + // never drift ahead of the nodes it describes. Deletes intentionally leave + // orphans behind — vocab rows are proposals re-verified against nodes + // before use, and a full index clears the table at its start. File nodes + // are excluded: a file's basename duplicates the symbols inside it + // (state-machine.ts / OrderStateMachine), which double-counts every + // concept and defeats the singleton-vs-cluster rarity statistics. Import + // nodes are excluded too (#1144): they're named after module specifiers + // ("external-unindexed-pkg", "./utils/helpers"), not symbols — an + // import-only name can never be surfaced (getSegmentMatches requires a + // real definition), so its rows would only inflate the rarity statistics. + if (this.isSegmentableKind(node.kind)) this.insertNameSegments(node.name); + } + + /** Which node kinds contribute their name to the segment vocabulary — the + * single gate shared by insertNode, updateNode, and the rebuild page query + * (getDistinctNodeNames), so the write paths can't drift apart. */ + private isSegmentableKind(kind: string): boolean { + return kind !== 'file' && kind !== 'import'; + } + + /** Write `name`'s segments into name_segment_vocab (idempotent). */ + private insertNameSegments(name: string): void { + if (this.segmentedNames.has(name)) return; + if (this.segmentedNames.size >= QueryBuilder.MAX_SEGMENTED_NAMES) this.segmentedNames.clear(); + this.segmentedNames.add(name); + if (!this.stmts.insertNameSegment) { + this.stmts.insertNameSegment = this.db.prepare( + 'INSERT OR IGNORE INTO name_segment_vocab (segment, name) VALUES (?, ?)', + ); + } + for (const segment of splitIdentifierSegments(name)) { + this.stmts.insertNameSegment.run(segment, name); + } + } + + /** + * Insert multiple nodes in a transaction + */ + insertNodes(nodes: Node[]): void { + this.db.transaction(() => { + for (const node of nodes) { + this.insertNode(node); + } + })(); + } + + /** + * Update an existing node + */ + updateNode(node: Node): void { + if (!this.stmts.updateNode) { + this.stmts.updateNode = this.db.prepare(` + UPDATE nodes SET + kind = @kind, + name = @name, + qualified_name = @qualifiedName, + file_path = @filePath, + language = @language, + start_line = @startLine, + end_line = @endLine, + start_column = @startColumn, + end_column = @endColumn, + docstring = @docstring, + signature = @signature, + visibility = @visibility, + is_exported = @isExported, + is_async = @isAsync, + is_static = @isStatic, + is_abstract = @isAbstract, + decorators = @decorators, + type_parameters = @typeParameters, + return_type = @returnType, + updated_at = @updatedAt + WHERE id = @id + `); + } + + // Invalidate cache before update + this.nodeCache.delete(node.id); + + // Validate required fields + if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) { + console.error('[CodeGraph] Skipping node update with missing required fields:', node.id); + return; + } + + this.stmts.updateNode.run({ + id: node.id, + kind: node.kind, + name: node.name, + qualifiedName: node.qualifiedName ?? node.name, + filePath: node.filePath, + language: node.language, + startLine: node.startLine ?? 0, + endLine: node.endLine ?? 0, + startColumn: node.startColumn ?? 0, + endColumn: node.endColumn ?? 0, + docstring: node.docstring ?? null, + signature: node.signature ?? null, + visibility: node.visibility ?? null, + isExported: node.isExported ? 1 : 0, + isAsync: node.isAsync ? 1 : 0, + isStatic: node.isStatic ? 1 : 0, + isAbstract: node.isAbstract ? 1 : 0, + decorators: node.decorators ? JSON.stringify(node.decorators) : null, + typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null, + returnType: node.returnType ?? null, + updatedAt: node.updatedAt ?? Date.now(), + }); + + // updateNode is a second real write path to `nodes` — framework + // post-extract passes rewrite names through it (NestJS route prefixing), + // and a renamed node's new name must reach the segment vocabulary just + // like an inserted one's (#1141). Without this the rename left the new + // name permanently unsearchable: the old name's rows became honest-gate + // orphans and the only backfill is gated on the vocab being EMPTY. + // insertNameSegments is idempotent (in-memory set + INSERT OR IGNORE), + // so no name-changed check is needed. + if (this.isSegmentableKind(node.kind)) this.insertNameSegments(node.name); + } + + /** + * Delete a node by ID + */ + deleteNode(id: string): void { + if (!this.stmts.deleteNode) { + this.stmts.deleteNode = this.db.prepare('DELETE FROM nodes WHERE id = ?'); + } + // Invalidate cache + this.nodeCache.delete(id); + this.stmts.deleteNode.run(id); + } + + /** + * Delete all nodes for a file + */ + deleteNodesByFile(filePath: string): void { + if (!this.stmts.deleteNodesByFile) { + this.stmts.deleteNodesByFile = this.db.prepare('DELETE FROM nodes WHERE file_path = ?'); + } + // Invalidate cache for nodes in this file + for (const [id, node] of this.nodeCache) { + if (node.filePath === filePath) { + this.nodeCache.delete(id); + } + } + this.stmts.deleteNodesByFile.run(filePath); + } + + // =========================================================================== + // Name-segment vocabulary (prompt-hook graph-derived gate) + // =========================================================================== + + /** Wipe the segment vocabulary. A full index calls this at its start; the + * node write path repopulates it as files (re-)index, so the end state is + * exactly the current names with no orphan rows. */ + clearNameSegmentVocab(): void { + this.db.exec('DELETE FROM name_segment_vocab'); + this.segmentedNames.clear(); + } + + /** True when the vocab has no rows — an index built before the table existed. + * `sync` uses this to heal such databases (see rebuildNameSegmentVocabFrom). */ + isNameSegmentVocabEmpty(): boolean { + const row = this.db.prepare('SELECT 1 FROM name_segment_vocab LIMIT 1').get(); + return row === undefined; + } + + /** One page of distinct segmentable node names, for batched vocab rebuilds + * (file basenames and import specifiers are excluded from the vocab — see + * insertNode). */ + getDistinctNodeNames(limit: number, offset: number): string[] { + const rows = this.db + .prepare("SELECT DISTINCT name FROM nodes WHERE kind NOT IN ('file', 'import') ORDER BY name LIMIT ? OFFSET ?") + .all(limit, offset) as Array<{ name: string }>; + return rows.map((r) => r.name); + } + + /** Insert segments for a batch of names in one transaction (vocab heal path). */ + insertNameSegmentsBatch(names: string[]): void { + this.db.transaction(() => { + for (const name of names) this.insertNameSegments(name); + })(); + } + + /** + * Names whose segments cover at least `minWords` distinct PROMPT WORDS — + * the co-occurrence probe behind the prompt hook's medium tier: the words + * "state" and "machine" both being segments of `OrderStateMachine` is strong + * evidence the prompt names that symbol in prose. Ordered by coverage. + * + * Takes (segment variant → original word) pairs and folds variants back to + * their word INSIDE the SQL: a name matching both `service` and `services` + * counts ONE word, not two. Counting raw variants let plural-variant pairs + * of a single word tie with genuine two-word matches and — because ORDER + * BY/LIMIT run here, before any JS-side re-check — crowd a real match past + * the LIMIT on vocab-heavy repos (#1146). + */ + getSegmentCoOccurrence( + variants: Array<{ segment: string; word: string }>, + minWords: number, + limit: number, + ): Array<{ name: string; matches: number }> { + if (variants.length === 0) return []; + const placeholders = variants.map(() => '?').join(', '); + const whens = variants.map(() => 'WHEN ? THEN ?').join(' '); + const rows = this.db + .prepare( + `SELECT name, COUNT(DISTINCT CASE segment ${whens} END) AS matches + FROM name_segment_vocab + WHERE segment IN (${placeholders}) + GROUP BY name + HAVING matches >= ? + ORDER BY matches DESC, length(name) ASC + LIMIT ?`, + ) + .all( + ...variants.flatMap((v) => [v.segment, v.word]), + ...variants.map((v) => v.segment), + minWords, + limit, + ) as Array<{ name: string; matches: number }>; + return rows; + } + + /** How many distinct names each segment appears in — the rarity signal that + * separates a discriminative word ("checkout") from a ubiquitous one ("state"). */ + getSegmentNameCounts(segments: string[]): Map { + if (segments.length === 0) return new Map(); + const placeholders = segments.map(() => '?').join(', '); + const rows = this.db + .prepare( + `SELECT segment, COUNT(*) AS n FROM name_segment_vocab + WHERE segment IN (${placeholders}) GROUP BY segment`, + ) + .all(...segments) as Array<{ segment: string; n: number }>; + return new Map(rows.map((r) => [r.segment, r.n])); + } + + /** Names containing the given segment (rare-single-word tier). */ + getNamesForSegment(segment: string, limit: number): string[] { + const rows = this.db + .prepare('SELECT name FROM name_segment_vocab WHERE segment = ? ORDER BY length(name) ASC LIMIT ?') + .all(segment, limit) as Array<{ name: string }>; + return rows.map((r) => r.name); + } + + /** + * Get a node by ID + */ + getNodeById(id: string): Node | null { + // Check cache first + if (this.nodeCache.has(id)) { + const cached = this.nodeCache.get(id)!; + // Move to end to implement LRU (delete and re-add) + this.nodeCache.delete(id); + this.nodeCache.set(id, cached); + return cached; + } + + if (!this.stmts.getNodeById) { + this.stmts.getNodeById = this.db.prepare('SELECT * FROM nodes WHERE id = ?'); + } + const row = this.stmts.getNodeById.get(id) as NodeRow | undefined; + if (!row) { + return null; + } + + const node = rowToNode(row); + this.cacheNode(node); + return node; + } + + /** + * Batch lookup: fetch many nodes by ID in a single SQL round-trip. + * + * Replaces the N+1 pattern in graph traversal where every edge would + * trigger its own `getNodeById` call. For a function with 50 callers + * this collapses 50 point reads into one IN-list query (~10-50x + * faster end-to-end). + * + * Returns a Map keyed by id so callers can preserve their own ordering + * (typically the order edges were returned from the graph). Missing IDs + * are simply absent from the map. + * + * Cache-aware: ids already in the LRU cache are served from memory and + * the SQL query only touches the misses. + */ + getNodesByIds(ids: readonly string[]): Map { + const out = new Map(); + if (ids.length === 0) return out; + + // Serve cache hits first; build the miss list for SQL. + const misses: string[] = []; + for (const id of ids) { + const cached = this.nodeCache.get(id); + if (cached !== undefined) { + // LRU touch + this.nodeCache.delete(id); + this.nodeCache.set(id, cached); + out.set(id, cached); + } else { + misses.push(id); + } + } + if (misses.length === 0) return out; + + // Chunk under SQLite's parameter limit (default 999, raised to 32766 + // in better-sqlite3 builds — chunk at 500 for safety across both + // backends and to keep the query plan simple). + for (let i = 0; i < misses.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = misses.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare(`SELECT * FROM nodes WHERE id IN (${placeholders})`) + .all(...chunk) as NodeRow[]; + for (const row of rows) { + const node = rowToNode(row); + out.set(node.id, node); + this.cacheNode(node); + } + } + return out; + } + + private getExistingNodeIds(ids: readonly string[]): Set { + const out = new Set(); + if (ids.length === 0) return out; + + const uniqueIds = [...new Set(ids)]; + for (let i = 0; i < uniqueIds.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = uniqueIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare(`SELECT id FROM nodes WHERE id IN (${placeholders})`) + .all(...chunk) as { id: string }[]; + for (const row of rows) { + out.add(row.id); + } + } + + return out; + } + + /** + * Add a node to the cache, evicting oldest if needed + */ + private cacheNode(node: Node): void { + if (this.nodeCache.size >= this.maxCacheSize) { + // Evict oldest (first) entry + const firstKey = this.nodeCache.keys().next().value; + if (firstKey) { + this.nodeCache.delete(firstKey); + } + } + this.nodeCache.set(node.id, node); + } + + /** + * Clear the node cache + */ + clearCache(): void { + this.nodeCache.clear(); + } + + /** + * Get all nodes in a file + */ + getNodesByFile(filePath: string): Node[] { + if (!this.stmts.getNodesByFile) { + this.stmts.getNodesByFile = this.db.prepare( + 'SELECT * FROM nodes WHERE file_path = ? ORDER BY start_line' + ); + } + const rows = this.stmts.getNodesByFile.all(filePath) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Find the file that holds the densest concentration of the project's + * internal call graph — the "core" file. Used by context-builder to + * boost ranking of symbols in that file's directory (so e.g. sinatra + * queries surface `lib/sinatra/base.rb`'s `route!` instead of + * `sinatra-contrib/lib/sinatra/multi_route.rb`'s `route` extension). + * + * Returns null if no file has a meaningful concentration (e.g. spread + * evenly across many files, or empty index). + * + * "Internal" = source and target are in the same file. Cross-file + * edges aren't useful here — they don't tell us which file is the + * functional center. + * + * Excludes test/spec files from candidacy via path-pattern. The agent's + * typical question is "how does X work", not "how is X tested", so + * boosting a test file's directory would be a misfire. + */ + getDominantFile(): { filePath: string; edgeCount: number; nextEdgeCount: number } | null { + if (!this.stmts.getDominantFile) { + // Pull top 20 candidates; we then filter out test/generated files + // in code (regex-grade matching that SQL LIKE can't express). The + // generated-file filter is critical — without it, etcd's + // `api/etcdserverpb/rpc.pb.go` (1916 in-file edges, generated + // protobuf stub) outranks the real `server/etcdserver/server.go` + // (470 edges) by 4×, and the boost would push the agent toward + // generated code. + this.stmts.getDominantFile = this.db.prepare(` + SELECT n.file_path AS file_path, COUNT(*) AS edge_count + FROM edges e + JOIN nodes n ON e.source = n.id + JOIN nodes m ON e.target = m.id + WHERE n.file_path = m.file_path + GROUP BY n.file_path + ORDER BY edge_count DESC + LIMIT 20 + `); + } + const rows = this.stmts.getDominantFile.all() as Array<{ file_path: string; edge_count: number }>; + const filtered = rows.filter(r => !isLowValueFile(r.file_path)); + if (filtered.length === 0 || filtered[0]!.edge_count < 20) return null; + return { + filePath: filtered[0]!.file_path, + edgeCount: filtered[0]!.edge_count, + nextEdgeCount: filtered[1]?.edge_count ?? 0, + }; + } + + /** + * Find the file that holds the densest concentration of the project's + * `route` nodes (framework-emitted: Express/Gin/Flask/Rails/Drupal/etc.). + * Used by handleContext on small repos to inline the project's routing + * config when the agent's query is about request flow — eliminating the + * "Glob + Read routes.rb" pattern that beats codegraph on tiny realworld + * template repos. + * + * Excludes test/generated files from candidacy. Returns null if there + * are fewer than 3 non-test routes total, or if no file holds at least + * 30% of them (diffuse routing → no single answer file). + */ + getTopRouteFile(): { filePath: string; routeCount: number; totalRoutes: number } | null { + if (!this.stmts.getTopRouteFile) { + this.stmts.getTopRouteFile = this.db.prepare(` + SELECT file_path, COUNT(*) AS cnt + FROM nodes + WHERE kind = 'route' + GROUP BY file_path + ORDER BY cnt DESC + LIMIT 20 + `); + } + const rows = this.stmts.getTopRouteFile.all() as Array<{ file_path: string; cnt: number }>; + const filtered = rows.filter(r => !isLowValueFile(r.file_path)); + if (filtered.length === 0) return null; + const totalRoutes = filtered.reduce((sum, r) => sum + r.cnt, 0); + const top = filtered[0]!; + if (totalRoutes < 3 || top.cnt < 3) return null; + if (top.cnt / totalRoutes < 0.30) return null; + return { filePath: top.file_path, routeCount: top.cnt, totalRoutes }; + } + + /** + * Build a URL → handler manifest from the index. Each route node's + * `references` edge points at the function/method that handles the + * request. We join them in one pass; the agent gets the canonical + * routing answer ("POST /users/login → AuthController#login") without + * having to parse the framework's route DSL itself. + * + * Also returns the file with the most handler endpoints — used as the + * "top handler file" to inline source for, so the agent has both the + * mapping AND the handler implementations. + */ + getRoutingManifest(limit: number = 40): { + entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>; + topHandlerFile: string | null; + topHandlerFileCount: number; + totalRoutes: number; + } | null { + if (!this.stmts.getRoutingManifest) { + // Edge kind varies across framework resolvers: Spring/Rails/ + // Laravel/Drupal emit `references`, Express emits `calls`. Accept + // both — the semantic is the same (route → its handler). + this.stmts.getRoutingManifest = this.db.prepare(` + SELECT + r.name AS url, + h.name AS handler, + h.file_path AS handler_file, + h.start_line AS handler_line, + h.kind AS handler_kind + FROM nodes r + JOIN edges e ON e.source = r.id + JOIN nodes h ON e.target = h.id + WHERE r.kind = 'route' + AND e.kind IN ('references', 'calls') + AND h.kind IN ('function', 'method', 'class') + ORDER BY r.file_path, r.start_line + LIMIT ? + `); + } + const rows = this.stmts.getRoutingManifest.all(limit) as Array<{ + url: string; handler: string; handler_file: string; handler_line: number; handler_kind: string; + }>; + // Drop test/generated handlers — same hygiene as elsewhere. + const filtered = rows.filter(r => !isLowValueFile(r.handler_file)); + if (filtered.length < 3) return null; + // Identify the file holding the most handlers (the "primary handler file"). + const fileCounts = new Map(); + for (const r of filtered) { + fileCounts.set(r.handler_file, (fileCounts.get(r.handler_file) ?? 0) + 1); + } + let topHandlerFile: string | null = null; + let topHandlerFileCount = 0; + for (const [file, count] of fileCounts) { + if (count > topHandlerFileCount) { + topHandlerFile = file; + topHandlerFileCount = count; + } + } + return { + entries: filtered.map(r => ({ + url: r.url, + handler: r.handler, + handlerFile: r.handler_file, + handlerLine: r.handler_line, + handlerKind: r.handler_kind, + })), + topHandlerFile, + topHandlerFileCount, + totalRoutes: filtered.length, + }; + } + + /** + * Get all nodes of a specific kind + */ + getNodesByKind(kind: NodeKind): Node[] { + if (!this.stmts.getNodesByKind) { + this.stmts.getNodesByKind = this.db.prepare('SELECT * FROM nodes WHERE kind = ?'); + } + const rows = this.stmts.getNodesByKind.all(kind) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Stream every node of a kind one at a time (lazy) instead of materializing + * them all like {@link getNodesByKind}. For unbounded kinds (`function`, + * `method`) on a symbol-dense project the full array is gigabytes; the + * dynamic-edge synthesizers only scan-and-filter, so they iterate to keep + * memory O(1) in the node count rather than O(nodes) (#610). + */ + *iterateNodesByKind(kind: NodeKind): IterableIterator { + // Fresh statement per call (not a cached one): an iterator holds an open + // cursor, so a shared statement would conflict across overlapping scans. + const stmt = this.db.prepare('SELECT * FROM nodes WHERE kind = ?'); + for (const row of stmt.iterate(kind)) { + yield rowToNode(row as NodeRow); + } + } + + /** + * Get all nodes in the database + */ + getAllNodes(): Node[] { + const rows = this.db.prepare('SELECT * FROM nodes').all() as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Stream nodes of one language whose `decorators` JSON array contains + * `decorator`. The LIKE on the JSON text is a cheap index-free pre-filter + * (a decorator name can appear as a substring of another), so callers must + * still exact-check `node.decorators.includes(decorator)`. Exists so the + * kotlin expect/actual synthesizer never materializes the whole node table + * the way `getAllNodes().filter(...)` did — that array alone OOM'd Node's + * default heap on a 2M-node graph (#1212). + */ + *iterateNodesByLanguageWithDecorator(language: Language, decorator: string): IterableIterator { + // Fresh statement per call — an iterator holds an open cursor (see + // iterateNodesByKind). + const stmt = this.db.prepare( + "SELECT * FROM nodes WHERE language = ? AND decorators LIKE '%' || ? || '%'" + ); + for (const row of stmt.iterate(language, `"${decorator}"`)) { + yield rowToNode(row as NodeRow); + } + } + + /** + * Distinct languages present in the files table. One indexed aggregate — + * lets the dynamic-edge synthesizers skip passes for languages the project + * doesn't contain at all (a Kotlin pass has no work on a pure-C repo), so + * their cost is zero rather than a full-graph scan that finds nothing (#1212). + */ + getDistinctFileLanguages(): Set { + const rows = this.db.prepare('SELECT DISTINCT language FROM files').all() as Array<{ language: string }>; + return new Set(rows.map((r) => r.language)); + } + + /** + * Get nodes by exact name match (uses idx_nodes_name index) + */ + getNodesByName(name: string): Node[] { + if (!this.stmts.getNodesByName) { + this.stmts.getNodesByName = this.db.prepare('SELECT * FROM nodes WHERE name = ?'); + } + const rows = this.stmts.getNodesByName.all(name) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Nodes whose name starts with `prefix`, by index range scan (a LIKE would + * skip idx_nodes_name under SQLite's default case-insensitive LIKE). + */ + getNodesByNamePrefix(prefix: string, limit = 20): Node[] { + if (!this.stmts.getNodesByNamePrefix) { + this.stmts.getNodesByNamePrefix = this.db.prepare( + 'SELECT * FROM nodes WHERE name >= ? AND name < ? ORDER BY name LIMIT ?' + ); + } + const rows = this.stmts.getNodesByNamePrefix.all(prefix, prefix + '￿', limit) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Get nodes by exact qualified name match (uses idx_nodes_qualified_name index) + */ + getNodesByQualifiedNameExact(qualifiedName: string): Node[] { + if (!this.stmts.getNodesByQualifiedNameExact) { + this.stmts.getNodesByQualifiedNameExact = this.db.prepare( + 'SELECT * FROM nodes WHERE qualified_name = ?' + ); + } + const rows = this.stmts.getNodesByQualifiedNameExact.all(qualifiedName) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Get nodes by lowercase name match (uses idx_nodes_lower_name expression index) + */ + getNodesByLowerName(lowerName: string): Node[] { + if (!this.stmts.getNodesByLowerName) { + this.stmts.getNodesByLowerName = this.db.prepare( + 'SELECT * FROM nodes WHERE lower(name) = ?' + ); + } + const rows = this.stmts.getNodesByLowerName.all(lowerName) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Search nodes by name using FTS with fallback to LIKE for better matching + * + * Search strategy: + * 1. Try FTS5 prefix match (query*) for word-start matching + * 2. If no results, try LIKE for substring matching (e.g., "signIn" finds "signInWithGoogle") + * 3. Score results based on match quality + */ + searchNodes(query: string, options: SearchOptions = {}): SearchResult[] { + const { limit = 100, offset = 0 } = options; + + // Parse field-qualified bits out of the raw query (kind:, lang:, + // path:, name:). Anything not recognised stays in `text` and goes + // to FTS unchanged. Filters compose with the SearchOptions arg — + // both are applied (intersection-style). + const parsed = parseQuery(query); + const mergedKinds = + parsed.kinds.length > 0 + ? Array.from(new Set([...(options.kinds ?? []), ...parsed.kinds])) + : options.kinds; + const mergedLanguages = + parsed.languages.length > 0 + ? Array.from(new Set([...(options.languages ?? []), ...parsed.languages])) + : options.languages; + const pathFilters = parsed.pathFilters; + const nameFilters = parsed.nameFilters; + // The text portion drives FTS/LIKE; if all the user typed was + // filters (`kind:function`), we still need *some* candidate set, + // so synthesise an empty-text path that returns everything matching + // the filters. + const text = parsed.text; + const kinds = mergedKinds; + const languages = mergedLanguages; + + // First try FTS5 with prefix matching + let results = text + ? this.searchNodesFTS(text, { kinds, languages, limit, offset }) + // Over-fetch by 5× when running filter-only (no text). The + // post-scoring path: + name: filters can be very selective, so + // a smaller multiplier risks returning fewer than `limit` + // results despite the DB having plenty of matches. + : this.searchAllByFilters({ kinds, languages, limit: limit * 5 }); + + // If no FTS results, try LIKE-based substring search + if (results.length === 0 && text.length >= 2) { + results = this.searchNodesLike(text, { kinds, languages, limit, offset }); + } + + // Final fuzzy fallback: scan all known names and keep those within + // a tight Levenshtein distance. Only fires when both FTS and LIKE + // returned nothing AND there's a text portion long enough to be + // worth fuzzing (1-char queries would match too much). + if (results.length === 0 && text.length >= 3) { + results = this.searchNodesFuzzy(text, { kinds, languages, limit }); + } + + // Supplement: ensure exact name matches are always candidates. + // BM25 can bury short exact-match names (e.g. "getBean") under hundreds of + // compound names (e.g. "getBeanDescriptor") in large codebases, + // pushing them past the FTS fetch limit before post-hoc scoring can help. + // Use the max BM25 score as the base so the nameMatchBonus (exact=30 vs + // prefix=20) actually differentiates them after rescoring. + if (results.length > 0 && query) { + const existingIds = new Set(results.map(r => r.node.id)); + const maxFtsScore = Math.max(...results.map(r => r.score)); + const terms = query.split(/\s+/).filter(t => t.length >= 2); + for (const term of terms) { + let sql = 'SELECT * FROM nodes WHERE name = ? COLLATE NOCASE'; + const params: (string | number)[] = [term]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + sql += ' LIMIT 20'; + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + for (const row of rows) { + if (!existingIds.has(row.id)) { + results.push({ node: rowToNode(row), score: maxFtsScore }); + existingIds.add(row.id); + } + } + } + } + + // Apply multi-signal scoring + if (results.length > 0 && (text || query)) { + const scoringQuery = text || query; + results = results.map(r => ({ + ...r, + score: r.score + + kindBonus(r.node.kind) + + scorePathRelevance(r.node.filePath, scoringQuery, this.projectNameTokens) + + nameMatchBonus(r.node.name, scoringQuery), + })); + results.sort((a, b) => b.score - a.score); + // Trim to requested limit after rescoring + if (results.length > limit) { + results = results.slice(0, limit); + } + } + + // Apply path: + name: filters AFTER scoring. Scoring already uses + // path/name as a soft signal; the explicit filters here are a hard + // gate. Done last so the FTS limit fetched plenty of candidates to + // narrow from. + if (pathFilters.length > 0) { + const lowered = pathFilters.map((p) => p.toLowerCase()); + results = results.filter((r) => { + const fp = r.node.filePath.toLowerCase(); + return lowered.some((p) => fp.includes(p)); + }); + } + if (nameFilters.length > 0) { + const lowered = nameFilters.map((n) => n.toLowerCase()); + results = results.filter((r) => { + const nm = r.node.name.toLowerCase(); + return lowered.some((n) => nm.includes(n)); + }); + } + + return results; + } + + /** + * Match-everything path used when the user supplied only field + * filters (`kind:function lang:typescript`) with no text. Returns + * candidates ordered by name; the caller's filter pass narrows to + * what was asked for. + */ + private searchAllByFilters(options: { + kinds?: NodeKind[]; + languages?: Language[]; + limit: number; + }): SearchResult[] { + const { kinds, languages, limit } = options; + let sql = 'SELECT * FROM nodes WHERE 1=1'; + const params: (string | number)[] = []; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + sql += ' ORDER BY name LIMIT ?'; + params.push(limit); + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + return rows.map((row) => ({ node: rowToNode(row), score: 1 })); + } + + /** + * Fuzzy fallback: when zero FTS/LIKE hits, try an edit-distance + * sweep over the distinct symbol-name set. Caps `maxDist` at 2 so + * `getUssr` finds `getUser` but `process` doesn't match `prosody`. + * Bounded edit distance keeps each comparison cheap; the per-query + * scan is O(distinct-name-count) which is far smaller than total + * node count on any real codebase. + */ + private searchNodesFuzzy( + text: string, + options: { kinds?: NodeKind[]; languages?: Language[]; limit: number } + ): SearchResult[] { + const { kinds, languages, limit } = options; + const lowered = text.toLowerCase(); + const maxDist = lowered.length <= 4 ? 1 : 2; + + // Pull the distinct name list once. The set is cached on QueryBuilder + // by getAllNodeNames(); even on a 200k-node project the distinct + // name set is typically O(10k) because most names repeat. The + // candidate-cap below bounds memory regardless. + const allNames = this.getAllNodeNames(); + const candidates: Array<{ name: string; dist: number }> = []; + for (const name of allNames) { + const dist = boundedEditDistance(name.toLowerCase(), lowered, maxDist); + if (dist <= maxDist) candidates.push({ name, dist }); + } + candidates.sort((a, b) => a.dist - b.dist); + + // Cap the per-name follow-up queries. Each survivor triggers a + // separate `SELECT * FROM nodes WHERE name = ?`; without this cap + // a project with many similar names (`getUser1`, `getUser2`...) + // could fan out far beyond `limit` queries before the inner-loop + // limit kicks in. + const FUZZY_FOLLOWUP_CAP = Math.max(limit * 2, 50); + const cappedCandidates = candidates.slice(0, FUZZY_FOLLOWUP_CAP); + + const results: SearchResult[] = []; + const seen = new Set(); + for (const c of cappedCandidates) { + if (results.length >= limit) break; + let sql = 'SELECT * FROM nodes WHERE name = ?'; + const params: (string | number)[] = [c.name]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + sql += ' LIMIT 5'; + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + for (const row of rows) { + if (seen.has(row.id)) continue; + seen.add(row.id); + // Lower the score for each edit step away from the query so + // exact-match fallbacks (dist 0) outrank dist-2 typos. + results.push({ node: rowToNode(row), score: 1 / (1 + c.dist) }); + if (results.length >= limit) break; + } + } + return results; + } + + /** + * FTS5 search with prefix matching + */ + private searchNodesFTS(query: string, options: SearchOptions): SearchResult[] { + const { kinds, languages, limit = 100, offset = 0 } = options; + + // Add prefix wildcard for better matching (e.g., "auth" matches "AuthService", "authenticate") + // Escape special FTS5 characters and add prefix wildcard. + // + // `::` is a qualifier separator in Rust/C++/Ruby, not a token char, + // so treat it as whitespace before the strip step. Otherwise queries + // like `stage_apply::run` collapse to `stage_applyrun` (the colons + // are stripped without splitting) and find nothing. See #173. + const ftsQuery = query + .replace(/::/g, ' ') // Rust/C++/Ruby qualifier separator + .replace(/['"*():^]/g, '') // Remove FTS5 special chars + .split(/\s+/) + .filter(term => term.length > 0) + // Strip FTS5 boolean operators to prevent query manipulation + .filter(term => !/^(AND|OR|NOT|NEAR)$/i.test(term)) + .map(term => `"${term}"*`) // Prefix match each term + .join(' OR '); + + if (!ftsQuery) { + return []; + } + + // BM25 column weights: id=0, name=20, qualified_name=5, docstring=1, signature=2 + // Heavy name weight ensures exact/prefix name matches rank above incidental + // mentions in long docstrings or qualified names of nested symbols. + // Fetch 5x requested limit so post-hoc rescoring (kindBonus, pathRelevance, + // nameMatchBonus) can promote results that BM25 alone undervalues. + const ftsLimit = Math.max(limit * 5, 100); + + let sql = ` + SELECT nodes.*, bm25(nodes_fts, 0, 20, 5, 1, 2) as score + FROM nodes_fts + JOIN nodes ON nodes_fts.id = nodes.id + WHERE nodes_fts MATCH ? + `; + + const params: (string | number)[] = [ftsQuery]; + + if (kinds && kinds.length > 0) { + sql += ` AND nodes.kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (languages && languages.length > 0) { + sql += ` AND nodes.language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + + sql += ' ORDER BY score LIMIT ? OFFSET ?'; + params.push(ftsLimit, offset); + + try { + const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + return rows.map((row) => ({ + node: rowToNode(row), + score: Math.abs(row.score), // bm25 returns negative scores + })); + } catch { + // FTS query failed, return empty + return []; + } + } + + /** + * LIKE-based substring search for cases where FTS doesn't match + * Useful for camelCase matching (e.g., "signIn" finds "signInWithGoogle") + */ + private searchNodesLike(query: string, options: SearchOptions): SearchResult[] { + const { kinds, languages, limit = 100, offset = 0 } = options; + + let sql = ` + SELECT nodes.*, + CASE + WHEN name = ? THEN 1.0 + WHEN name LIKE ? THEN 0.9 + WHEN name LIKE ? THEN 0.8 + WHEN qualified_name LIKE ? THEN 0.7 + ELSE 0.5 + END as score + FROM nodes + WHERE ( + name LIKE ? OR + qualified_name LIKE ? OR + name LIKE ? + ) + `; + + // Pattern variants for better matching + const exactMatch = query; + const startsWith = `${query}%`; + const contains = `%${query}%`; + + const params: (string | number)[] = [ + exactMatch, // Exact match score + startsWith, // Starts with score + contains, // Contains score + contains, // Qualified name score + contains, // WHERE: name contains + contains, // WHERE: qualified_name contains + startsWith, // WHERE: name starts with + ]; + + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + + sql += ' ORDER BY score DESC, length(name) ASC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + + return rows.map((row) => ({ + node: rowToNode(row), + score: row.score, + })); + } + + /** + * Find nodes by exact name match + * + * Used for hybrid search - looks up symbols by exact name or case-insensitive match. + * Returns high-confidence matches for known symbol names extracted from query. + * + * @param names - Array of symbol names to look up + * @param options - Search options (kinds, languages, limit) + * @returns SearchResult array with exact matches scored at 1.0 + */ + findNodesByExactName(names: string[], options: SearchOptions = {}): SearchResult[] { + if (names.length === 0) return []; + + const { kinds, languages, limit = 50 } = options; + + // Two-pass approach to handle common names (e.g., "run" has 40+ matches): + // Pass 1: Find which files contain distinctive (rare) symbols from the query. + // Pass 2: Query each name, boosting results that co-locate with distinctive symbols. + + // Pass 1: Find files containing each queried name, identify distinctive names + const nameToFiles = new Map>(); + for (const name of names) { + let sql = 'SELECT DISTINCT file_path FROM nodes WHERE name COLLATE NOCASE = ?'; + const params: (string | number)[] = [name]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + sql += ' LIMIT 100'; + const rows = this.db.prepare(sql).all(...params) as { file_path: string }[]; + nameToFiles.set(name.toLowerCase(), new Set(rows.map(r => r.file_path))); + } + + // Distinctive names are those with fewer than 10 file matches (e.g., "scrapeLoop" = 1 file) + const distinctiveFiles = new Set(); + for (const [, files] of nameToFiles) { + if (files.size > 0 && files.size < 10) { + for (const f of files) distinctiveFiles.add(f); + } + } + + // Pass 2: Query each name with per-name limit, scoring by co-location + const perNameLimit = Math.max(8, Math.ceil(limit / names.length)); + const allResults: SearchResult[] = []; + const seenIds = new Set(); + + for (const name of names) { + let sql = ` + SELECT nodes.*, 1.0 as score + FROM nodes + WHERE name COLLATE NOCASE = ? + `; + const params: (string | number)[] = [name]; + + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + + // Fetch enough to find co-located results among common names + sql += ' LIMIT ?'; + params.push(Math.max(perNameLimit * 3, 50)); + + const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + const nameResults: SearchResult[] = []; + for (const row of rows) { + const node = rowToNode(row); + if (seenIds.has(node.id)) continue; + // Boost results in files that also contain distinctive symbols + const coLocationBoost = distinctiveFiles.has(node.filePath) ? 20 : 0; + nameResults.push({ node, score: row.score + coLocationBoost }); + } + + // Sort by score (co-located first), take per-name limit + nameResults.sort((a, b) => b.score - a.score); + for (const r of nameResults.slice(0, perNameLimit)) { + seenIds.add(r.node.id); + allResults.push(r); + } + } + + // Sort all results by score so co-located results bubble up + allResults.sort((a, b) => b.score - a.score); + return allResults.slice(0, limit); + } + + /** + * Find nodes whose name contains a substring (LIKE-based). + * Useful for CamelCase-part matching where FTS fails because + * e.g. "TransportSearchAction" is one FTS token, not matchable by "Search"*. + * + * Results are ordered by name length (shorter = more likely to be the core type). + */ + findNodesByNameSubstring( + substring: string, + options: SearchOptions & { excludePrefix?: boolean } = {} + ): SearchResult[] { + const { kinds, languages, limit = 30, excludePrefix } = options; + + let sql = ` + SELECT nodes.*, 1.0 as score + FROM nodes + WHERE name LIKE ? + `; + const params: (string | number)[] = [`%${substring}%`]; + + // Exclude prefix matches (handled by FTS-based prefix search in Step 2b) + if (excludePrefix) { + sql += ` AND name NOT LIKE ?`; + params.push(`${substring}%`); + } + + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + + sql += ' ORDER BY length(name) ASC LIMIT ?'; + params.push(limit); + + const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + return rows.map((row) => ({ + node: rowToNode(row), + score: row.score, + })); + } + + // =========================================================================== + // Edge Operations + // =========================================================================== + + /** + * Insert a new edge + */ + insertEdge(edge: Edge): void { + if (!this.stmts.insertEdge) { + this.stmts.insertEdge = this.db.prepare(` + INSERT OR IGNORE INTO edges (source, target, kind, metadata, line, col, provenance) + VALUES (@source, @target, @kind, @metadata, @line, @col, @provenance) + `); + } + + this.stmts.insertEdge.run({ + source: edge.source, + target: edge.target, + kind: edge.kind, + metadata: edge.metadata ? JSON.stringify(edge.metadata) : null, + line: edge.line ?? null, + col: edge.column ?? null, + provenance: edge.provenance ?? null, + }); + } + + /** + * Insert multiple edges in a transaction + */ + insertEdges(edges: Edge[]): void { + if (edges.length === 0) return; + + this.db.transaction(() => { + const endpointIds = new Set(); + for (const edge of edges) { + endpointIds.add(edge.source); + endpointIds.add(edge.target); + } + const existingNodeIds = this.getExistingNodeIds([...endpointIds]); + + for (const edge of edges) { + if (!existingNodeIds.has(edge.source) || !existingNodeIds.has(edge.target)) { + continue; + } + this.insertEdge(edge); + } + })(); + } + + /** + * Delete all edges from a source node + */ + deleteEdgesBySource(sourceId: string): void { + if (!this.stmts.deleteEdgesBySource) { + this.stmts.deleteEdgesBySource = this.db.prepare('DELETE FROM edges WHERE source = ?'); + } + this.stmts.deleteEdgesBySource.run(sourceId); + } + + /** + * Get outgoing edges from a node + */ + getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string): Edge[] { + if ((kinds && kinds.length > 0) || provenance) { + let sql = 'SELECT * FROM edges WHERE source = ?'; + const params: (string | number)[] = [sourceId]; + + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (provenance) { + sql += ' AND provenance = ?'; + params.push(provenance); + } + + const rows = this.db.prepare(sql).all(...params) as EdgeRow[]; + return rows.map(rowToEdge); + } + + if (!this.stmts.getEdgesBySource) { + this.stmts.getEdgesBySource = this.db.prepare('SELECT * FROM edges WHERE source = ?'); + } + const rows = this.stmts.getEdgesBySource.all(sourceId) as EdgeRow[]; + return rows.map(rowToEdge); + } + + /** + * Get incoming edges to a node + */ + getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] { + if (kinds && kinds.length > 0) { + const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')})`; + const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[]; + return rows.map(rowToEdge); + } + + if (!this.stmts.getEdgesByTarget) { + this.stmts.getEdgesByTarget = this.db.prepare('SELECT * FROM edges WHERE target = ?'); + } + const rows = this.stmts.getEdgesByTarget.all(targetId) as EdgeRow[]; + return rows.map(rowToEdge); + } + + /** + * Find all edges where both source and target are in the given node set. + * Useful for recovering inter-node connectivity after BFS. + */ + findEdgesBetweenNodes(nodeIds: string[], kinds?: EdgeKind[]): Edge[] { + if (nodeIds.length === 0) return []; + + const idsJson = JSON.stringify(nodeIds); + let sql = `SELECT * FROM edges WHERE source IN (SELECT value FROM json_each(?)) AND target IN (SELECT value FROM json_each(?))`; + const params: string[] = [idsJson, idsJson]; + + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + const rows = this.db.prepare(sql).all(...params) as EdgeRow[]; + return rows.map(rowToEdge); + } + + /** + * Distinct file paths that DEPEND ON `filePath`: every file containing a + * symbol with a cross-file edge (any kind except `contains`) into a symbol + * of this file. This is the file-level projection of the symbol dependency + * graph and the basis for blast-radius / `affected` test selection. + * + * It deliberately does NOT restrict to `imports` edges. In this graph an + * `imports` edge connects a file to its own local import declarations + * (it is always same-file), so an imports-only lookup returns zero + * cross-file dependents for every file. The real cross-file dependency + * signal is the resolved call/reference graph — calls, references, + * instantiates, extends, implements, overrides, type_of, returns, + * decorates — exactly what {@link GraphTraverser.getImpactRadius} traverses. + * `contains` is excluded: a parent containing a symbol does not *depend* on + * it. One indexed query (idx_nodes_file_path + idx_edges_target_kind). + */ + getDependentFilePaths(filePath: string): string[] { + const sql = `SELECT DISTINCT src.file_path AS fp + FROM edges e + JOIN nodes tgt ON tgt.id = e.target + JOIN nodes src ON src.id = e.source + WHERE tgt.file_path = ? + AND e.kind != 'contains' + AND src.file_path != ?`; + const rows = this.db.prepare(sql).all(filePath, filePath) as Array<{ fp: string }>; + return rows.map((r) => r.fp); + } + + /** + * Distinct file paths that `filePath` DEPENDS ON — the inverse of + * {@link getDependentFilePaths}: every file containing a symbol that a + * symbol of this file has a cross-file edge into. Same edge-kind rules + * (all kinds except `contains`); same reason imports-only is insufficient. + */ + getDependencyFilePaths(filePath: string): string[] { + const sql = `SELECT DISTINCT tgt.file_path AS fp + FROM edges e + JOIN nodes src ON src.id = e.source + JOIN nodes tgt ON tgt.id = e.target + WHERE src.file_path = ? + AND e.kind != 'contains' + AND tgt.file_path != ?`; + const rows = this.db.prepare(sql).all(filePath, filePath) as Array<{ fp: string }>; + return rows.map((r) => r.fp); + } + + /** + * Cross-file edges whose TARGET is a node in `filePath` and whose SOURCE is a + * node in a *different* file, paired with the target node's (name, kind) so a + * caller can re-resolve the edge to the re-indexed target's new ID (node IDs + * are `sha256(filePath:kind:name:line)`, so any line shift in the callee file + * changes target IDs and a naive re-insert by old ID silently drops them). + * Used by `storeExtractionResult` to preserve incoming edges across a file + * re-index (issue #899). Same edge-kind rules as + * {@link getDependentFilePaths}: all kinds except `contains`. + */ + getCrossFileIncomingEdgesWithTarget( + filePath: string + ): Array { + const sql = `SELECT e.*, tgt.name AS target_name, tgt.kind AS target_kind, + src.file_path AS source_file_path, src.language AS source_language + FROM edges e + JOIN nodes tgt ON tgt.id = e.target + JOIN nodes src ON src.id = e.source + WHERE tgt.file_path = ? + AND e.kind != 'contains' + AND src.file_path != ?`; + const rows = this.db.prepare(sql).all(filePath, filePath) as Array< + EdgeRow & { target_name: string; target_kind: NodeKind; source_file_path: string; source_language: Language } + >; + return rows.map(row => ({ + ...rowToEdge(row), + targetName: row.target_name, + targetKind: row.target_kind, + sourceFilePath: row.source_file_path, + sourceLanguage: row.source_language, + })); + } + + // =========================================================================== + // File Operations + // =========================================================================== + + /** + * Insert or update a file record + */ + upsertFile(file: FileRecord): void { + if (!this.stmts.upsertFile) { + this.stmts.upsertFile = this.db.prepare(` + INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors) + VALUES (@path, @contentHash, @language, @size, @modifiedAt, @indexedAt, @nodeCount, @errors) + ON CONFLICT(path) DO UPDATE SET + content_hash = @contentHash, + language = @language, + size = @size, + modified_at = @modifiedAt, + indexed_at = @indexedAt, + node_count = @nodeCount, + errors = @errors + `); + } + + this.stmts.upsertFile.run({ + path: file.path, + contentHash: file.contentHash, + language: file.language, + size: file.size, + modifiedAt: file.modifiedAt, + indexedAt: file.indexedAt, + nodeCount: file.nodeCount, + errors: file.errors ? JSON.stringify(file.errors) : null, + }); + } + + /** + * Delete a file record and its nodes + */ + deleteFile(filePath: string): void { + this.db.transaction(() => { + this.deleteNodesByFile(filePath); + if (!this.stmts.deleteFile) { + this.stmts.deleteFile = this.db.prepare('DELETE FROM files WHERE path = ?'); + } + this.stmts.deleteFile.run(filePath); + })(); + } + + /** + * Get a file record by path + */ + getFileByPath(filePath: string): FileRecord | null { + if (!this.stmts.getFileByPath) { + this.stmts.getFileByPath = this.db.prepare('SELECT * FROM files WHERE path = ?'); + } + const row = this.stmts.getFileByPath.get(filePath) as FileRow | undefined; + return row ? rowToFileRecord(row) : null; + } + + /** + * Get all tracked files + */ + getAllFiles(): FileRecord[] { + if (!this.stmts.getAllFiles) { + this.stmts.getAllFiles = this.db.prepare('SELECT * FROM files ORDER BY path'); + } + const rows = this.stmts.getAllFiles.all() as FileRow[]; + return rows.map(rowToFileRecord); + } + + /** + * Most recent index timestamp (ms since epoch) across all tracked files, or + * null when nothing is indexed yet. One indexed aggregate, no per-row scan. (#329) + */ + getLastIndexedAt(): number | null { + const row = this.db + .prepare('SELECT MAX(indexed_at) AS last FROM files') + .get() as { last: number | null } | undefined; + return row?.last ?? null; + } + + /** + * Get files that need re-indexing (hash changed) + */ + getStaleFiles(currentHashes: Map): FileRecord[] { + const files = this.getAllFiles(); + return files.filter((f) => { + const currentHash = currentHashes.get(f.path); + return currentHash && currentHash !== f.contentHash; + }); + } + + // =========================================================================== + // Unresolved References + // =========================================================================== + + /** + * Insert an unresolved reference + */ + insertUnresolvedRef(ref: UnresolvedReference): void { + if (!this.stmts.insertUnresolved) { + this.stmts.insertUnresolved = this.db.prepare(` + INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language) + VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates, @filePath, @language) + `); + } + + this.stmts.insertUnresolved.run({ + fromNodeId: ref.fromNodeId, + referenceName: ref.referenceName, + referenceKind: ref.referenceKind, + line: ref.line, + col: ref.column, + candidates: ref.candidates ? JSON.stringify(ref.candidates) : null, + filePath: ref.filePath ?? '', + language: ref.language ?? 'unknown', + }); + } + + /** + * Insert multiple unresolved references in a transaction + */ + insertUnresolvedRefsBatch(refs: UnresolvedReference[]): void { + if (refs.length === 0) return; + const insert = this.db.transaction(() => { + for (const ref of refs) { + this.insertUnresolvedRef(ref); + } + }); + insert(); + } + + /** + * Delete unresolved references from a node + */ + deleteUnresolvedByNode(nodeId: string): void { + if (!this.stmts.deleteUnresolvedByNode) { + this.stmts.deleteUnresolvedByNode = this.db.prepare( + 'DELETE FROM unresolved_refs WHERE from_node_id = ?' + ); + } + this.stmts.deleteUnresolvedByNode.run(nodeId); + } + + /** + * Get unresolved references by name (for resolution) + */ + getUnresolvedByName(name: string): UnresolvedReference[] { + if (!this.stmts.getUnresolvedByName) { + this.stmts.getUnresolvedByName = this.db.prepare( + 'SELECT * FROM unresolved_refs WHERE reference_name = ?' + ); + } + const rows = this.stmts.getUnresolvedByName.all(name) as UnresolvedRefRow[]; + return rows.map((row) => ({ + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + rowId: row.id, + })); + } + + /** + * Get all unresolved references + */ + getUnresolvedReferences(): UnresolvedReference[] { + const rows = this.db.prepare('SELECT * FROM unresolved_refs').all() as UnresolvedRefRow[]; + return rows.map((row) => ({ + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + rowId: row.id, + })); + } + + /** + * Get the count of PENDING (never-attempted) references without loading + * them into memory. Rows marked status='failed' — attempted by a completed + * pass, no match — are excluded: they are not outstanding work, only retry + * candidates for the #1240 sweep, so they must not trip the #1187 orphan + * sweep or the `status` pending-refs warning. + */ + getUnresolvedReferencesCount(): number { + if (!this.stmts.getUnresolvedCount) { + this.stmts.getUnresolvedCount = this.db.prepare( + "SELECT COUNT(*) as count FROM unresolved_refs WHERE status = 'pending'" + ); + } + const row = this.stmts.getUnresolvedCount.get() as { count: number }; + return row.count; + } + + /** + * Get a batch of PENDING unresolved references using LIMIT/OFFSET + * pagination. Used to process references in bounded memory chunks; failed + * rows are excluded so the batched drain loop terminates once every row + * has been attempted. + */ + getUnresolvedReferencesBatch(offset: number, limit: number): UnresolvedReference[] { + if (!this.stmts.getUnresolvedBatch) { + this.stmts.getUnresolvedBatch = this.db.prepare( + "SELECT * FROM unresolved_refs WHERE status = 'pending' LIMIT ? OFFSET ?" + ); + } + const rows = this.stmts.getUnresolvedBatch.all(limit, offset) as UnresolvedRefRow[]; + return rows.map((row) => ({ + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + rowId: row.id, + })); + } + + /** + * Get all tracked file paths (lightweight — no full FileRecord objects) + */ + getAllFilePaths(): string[] { + if (!this.stmts.getAllFilePaths) { + this.stmts.getAllFilePaths = this.db.prepare('SELECT path FROM files ORDER BY path'); + } + const rows = this.stmts.getAllFilePaths.all() as Array<{ path: string }>; + return rows.map((r) => r.path); + } + + /** + * Get all distinct node names (lightweight — just name strings for pre-filtering) + */ + getAllNodeNames(): string[] { + if (!this.stmts.getAllNodeNames) { + this.stmts.getAllNodeNames = this.db.prepare('SELECT DISTINCT name FROM nodes'); + } + const rows = this.stmts.getAllNodeNames.all() as Array<{ name: string }>; + return rows.map((r) => r.name); + } + + /** + * Stream the distinct node names one row at a time — the incremental + * counterpart to {@link getAllNodeNames} for callers that need to yield + * to the event loop mid-scan (resolver cache warm-up on multi-million-node + * indexes). Fresh statement per call: the iterator holds an open cursor. + */ + *iterateNodeNames(): IterableIterator { + const stmt = this.db.prepare('SELECT DISTINCT name FROM nodes'); + for (const row of stmt.iterate()) { + yield (row as { name: string }).name; + } + } + + /** + * Get unresolved references scoped to specific file paths. + * Uses the idx_unresolved_file_path index for efficient lookup. + */ + getUnresolvedReferencesByFiles(filePaths: string[]): UnresolvedReference[] { + if (filePaths.length === 0) return []; + + // Chunk under SQLite's parameter limit: the first sync of a very large repo + // passes every changed file here, which an unbounded `IN (...)` would bind + // as one parameter each — exceeding MAX_VARIABLE_NUMBER and aborting with + // "too many SQL variables". (#540) + const rows: UnresolvedRefRow[] = []; + for (let i = 0; i < filePaths.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const chunkRows = this.db + .prepare(`SELECT * FROM unresolved_refs WHERE status = 'pending' AND file_path IN (${placeholders})`) + .all(...chunk) as UnresolvedRefRow[]; + rows.push(...chunkRows); + } + + return rows.map((row) => ({ + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + rowId: row.id, + })); + } + + /** + * Delete all unresolved references (after resolution) + */ + clearUnresolvedReferences(): void { + this.db.exec('DELETE FROM unresolved_refs'); + } + + /** + * Delete resolved references by their IDs + */ + deleteResolvedReferences(fromNodeIds: string[]): void { + if (fromNodeIds.length === 0) return; + // Chunk under SQLite's parameter limit, matching every other IN-list in + // this file. The internal resolution path uses deleteSpecificResolvedReferences + // instead, but QueryBuilder is part of the public API, so a library consumer + // passing more ids than SQLITE_MAX_VARIABLE_NUMBER (32766 on the bundled + // node:sqlite) would otherwise hit "too many SQL variables". (#540, #1001) + for (let i = 0; i < fromNodeIds.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = fromNodeIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + this.db.prepare(`DELETE FROM unresolved_refs WHERE from_node_id IN (${placeholders})`).run(...chunk); + } + } + + /** + * Delete specific resolved references by (fromNodeId, referenceName, referenceKind) tuples. + * More precise than deleteResolvedReferences — only removes refs that were actually resolved. + */ + deleteSpecificResolvedReferences(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): void { + if (refs.length === 0) return; + const stmt = this.db.prepare( + 'DELETE FROM unresolved_refs WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?' + ); + const deleteMany = this.db.transaction((items: typeof refs) => { + for (const ref of items) { + stmt.run(ref.fromNodeId, ref.referenceName, ref.referenceKind); + } + }); + deleteMany(refs); + } + + /** + * Delete unresolved-ref rows by row id — the precise cleanup for refs a + * resolution pass actually processed. The key-tuple variant above also + * deletes SIBLING rows (same caller calling the same callee at other lines) + * that a later batch hasn't attempted yet, so when a batch boundary split a + * caller's same-named call sites, the later sites' edges were silently never + * created (#1269). + */ + deleteReferencesByRowIds(rowIds: number[]): void { + if (rowIds.length === 0) return; + for (let i = 0; i < rowIds.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = rowIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + this.db.prepare(`DELETE FROM unresolved_refs WHERE id IN (${placeholders})`).run(...chunk); + } + } + + /** + * Mark refs a completed resolution pass could not resolve as status='failed' + * instead of deleting them (#1240). Failed rows are invisible to the pending + * count/batch readers (so drain loops and the #1187 orphan sweep still + * terminate) but stay queryable by name_tail so a later sync can retry them + * when a changed file introduces a symbol that could satisfy them. name_tail + * is (re)written here so rows inserted before the v8 migration get their + * tail the first time they're attempted. + */ + markReferencesFailed(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): void { + if (refs.length === 0) return; + const stmt = this.db.prepare( + "UPDATE unresolved_refs SET status = 'failed', name_tail = ? WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?" + ); + const markMany = this.db.transaction((items: typeof refs) => { + for (const ref of items) { + stmt.run(referenceNameTail(ref.referenceName), ref.fromNodeId, ref.referenceName, ref.referenceKind); + } + }); + markMany(refs); + } + + /** + * Park refs as status='failed' by row id — the precise counterpart of + * markReferencesFailed, for the same reason as deleteReferencesByRowIds: + * the key-tuple variant also flips same-key sibling rows in later batches + * to 'failed' before they were ever attempted (#1269). Resolution outcome + * can differ per call site (receiver-type inference reads the ref's line), + * so a sibling must not inherit this row's failure. + */ + markReferencesFailedByRowIds(refs: Array<{ rowId: number; referenceName: string }>): void { + if (refs.length === 0) return; + const stmt = this.db.prepare( + "UPDATE unresolved_refs SET status = 'failed', name_tail = ? WHERE id = ?" + ); + const markMany = this.db.transaction((items: typeof refs) => { + for (const ref of items) { + stmt.run(referenceNameTail(ref.referenceName), ref.rowId); + } + }); + markMany(refs); + } + + /** + * Failed refs whose name tail matches one of the given symbol names — the + * candidates a sync should retry after files carrying those names changed + * (#1240). Names matching more than `perNameCeiling` failed refs are + * skipped entirely: at that population a name is external/builtin noise + * (`get`, `map`, …) that one new definition won't resolve — the same + * rationale as resolution's AMBIGUOUS_NAME_CEILING (#999) — and retrying an + * arbitrary subset would be both wasted work and incoherent coverage. + */ + getRetryableFailedReferences(names: string[], perNameCeiling: number = 500): UnresolvedReference[] { + if (names.length === 0) return []; + + // Pass 1: per-tail counts, chunked under the SQLite parameter limit. + const retryNames: string[] = []; + for (let i = 0; i < names.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = names.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const counts = this.db + .prepare( + `SELECT name_tail, COUNT(*) as count FROM unresolved_refs WHERE status = 'failed' AND name_tail IN (${placeholders}) GROUP BY name_tail` + ) + .all(...chunk) as Array<{ name_tail: string; count: number }>; + for (const row of counts) { + if (row.count <= perNameCeiling) retryNames.push(row.name_tail); + } + } + if (retryNames.length === 0) return []; + + // Pass 2: load the surviving rows. + const rows: UnresolvedRefRow[] = []; + for (let i = 0; i < retryNames.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = retryNames.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const chunkRows = this.db + .prepare(`SELECT * FROM unresolved_refs WHERE status = 'failed' AND name_tail IN (${placeholders})`) + .all(...chunk) as UnresolvedRefRow[]; + rows.push(...chunkRows); + } + + return rows.map((row) => ({ + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + rowId: row.id, + })); + } + + /** + * Distinct node names present in the given files — the symbol names a sync + * pass uses to look up retryable failed refs after those files changed. + */ + getNodeNamesByFiles(filePaths: string[]): string[] { + if (filePaths.length === 0) return []; + const names = new Set(); + for (let i = 0; i < filePaths.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare(`SELECT DISTINCT name FROM nodes WHERE file_path IN (${placeholders})`) + .all(...chunk) as Array<{ name: string }>; + for (const row of rows) names.add(row.name); + } + return [...names]; + } + + // =========================================================================== + // Statistics + // =========================================================================== + + /** + * Lightweight (nodes, edges) count snapshot. Used around an index/sync + * run to compute true additions across extraction + resolution + + * synthesis — the per-phase counter in the orchestrator only sees + * extraction's contribution, which is why the CLI summary under-reported + * the edge count (resolution + synthesizer edges were invisible). + */ + getNodeAndEdgeCount(): { nodes: number; edges: number } { + return this.db + .prepare('SELECT (SELECT COUNT(*) FROM nodes) AS nodes, (SELECT COUNT(*) FROM edges) AS edges') + .get() as { nodes: number; edges: number }; + } + + /** + * Get graph statistics + */ + getStats(): GraphStats { + // Single query for all three aggregate counts + const counts = this.db.prepare(` + SELECT + (SELECT COUNT(*) FROM nodes) AS node_count, + (SELECT COUNT(*) FROM edges) AS edge_count, + (SELECT COUNT(*) FROM files) AS file_count + `).get() as { node_count: number; edge_count: number; file_count: number }; + + const nodesByKind = {} as Record; + const nodeKindRows = this.db + .prepare('SELECT kind, COUNT(*) as count FROM nodes GROUP BY kind') + .all() as Array<{ kind: string; count: number }>; + for (const row of nodeKindRows) { + nodesByKind[row.kind as NodeKind] = row.count; + } + + const edgesByKind = {} as Record; + const edgeKindRows = this.db + .prepare('SELECT kind, COUNT(*) as count FROM edges GROUP BY kind') + .all() as Array<{ kind: string; count: number }>; + for (const row of edgeKindRows) { + edgesByKind[row.kind as EdgeKind] = row.count; + } + + const filesByLanguage = {} as Record; + const languageRows = this.db + .prepare('SELECT language, COUNT(*) as count FROM files GROUP BY language') + .all() as Array<{ language: string; count: number }>; + for (const row of languageRows) { + filesByLanguage[row.language as Language] = row.count; + } + + return { + nodeCount: counts.node_count, + edgeCount: counts.edge_count, + fileCount: counts.file_count, + nodesByKind, + edgesByKind, + filesByLanguage, + dbSizeBytes: 0, // Set by caller using DatabaseConnection.getSize() + lastUpdated: Date.now(), + }; + } + + // =========================================================================== + // Project Metadata + // =========================================================================== + + /** + * Get a metadata value by key + */ + getMetadata(key: string): string | null { + const row = this.db.prepare('SELECT value FROM project_metadata WHERE key = ?').get(key) as { value: string } | undefined; + return row?.value ?? null; + } + + /** + * Set a metadata key-value pair (upsert) + */ + setMetadata(key: string, value: string): void { + this.db.prepare( + 'INSERT INTO project_metadata (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at' + ).run(key, value, Date.now()); + } + + /** + * Get all metadata as a key-value record + */ + getAllMetadata(): Record { + const rows = this.db.prepare('SELECT key, value FROM project_metadata').all() as { key: string; value: string }[]; + const result: Record = {}; + for (const row of rows) { + result[row.key] = row.value; + } + return result; + } + + /** + * Clear all data from the database + */ + clear(): void { + this.nodeCache.clear(); + this.db.transaction(() => { + this.db.exec('DELETE FROM unresolved_refs'); + this.db.exec('DELETE FROM edges'); + this.db.exec('DELETE FROM nodes'); + this.db.exec('DELETE FROM files'); + })(); + } +} diff --git a/src/db/schema.sql b/src/db/schema.sql new file mode 100644 index 0000000..75df8c0 --- /dev/null +++ b/src/db/schema.sql @@ -0,0 +1,194 @@ +-- CodeGraph SQLite Schema +-- Version 1 + +-- Schema version tracking +CREATE TABLE IF NOT EXISTS schema_versions ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL, + description TEXT +); + +-- Insert initial version +INSERT INTO schema_versions (version, applied_at, description) +VALUES (1, strftime('%s', 'now') * 1000, 'Initial schema'); + +-- ============================================================================= +-- Core Tables +-- ============================================================================= + +-- Nodes: Code symbols (functions, classes, variables, etc.) +CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + name TEXT NOT NULL, + qualified_name TEXT NOT NULL, + file_path TEXT NOT NULL, + language TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + start_column INTEGER NOT NULL, + end_column INTEGER NOT NULL, + docstring TEXT, + signature TEXT, + visibility TEXT, + is_exported INTEGER DEFAULT 0, + is_async INTEGER DEFAULT 0, + is_static INTEGER DEFAULT 0, + is_abstract INTEGER DEFAULT 0, + decorators TEXT, -- JSON array + type_parameters TEXT, -- JSON array + return_type TEXT, -- normalized return/result type name (e.g. C++ method return, for receiver-type inference) + updated_at INTEGER NOT NULL +); + +-- Edges: Relationships between nodes +CREATE TABLE IF NOT EXISTS edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + target TEXT NOT NULL, + kind TEXT NOT NULL, + metadata TEXT, -- JSON object + line INTEGER, + col INTEGER, + provenance TEXT DEFAULT NULL, + FOREIGN KEY (source) REFERENCES nodes(id) ON DELETE CASCADE, + FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE +); + +-- Files: Tracked source files +CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, + content_hash TEXT NOT NULL, + language TEXT NOT NULL, + size INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + indexed_at INTEGER NOT NULL, + node_count INTEGER DEFAULT 0, + errors TEXT -- JSON array +); + +-- Unresolved References: References that need resolution after full indexing. +-- status lifecycle: rows are inserted 'pending' by extraction; a completed +-- resolution pass either deletes a row (resolved) or marks it 'failed' +-- (attempted, no match — kept so a later sync can retry it when a changed +-- file introduces a symbol that could satisfy it, #1240). name_tail is the +-- last segment of reference_name ('util.greet' → 'greet'), written when a +-- row is marked failed, so the retry lookup matches new node names against +-- dotted refs too. Rows follow their from_node via ON DELETE CASCADE, so +-- re-extracting or deleting a file clears its stale rows in any status. +CREATE TABLE IF NOT EXISTS unresolved_refs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_node_id TEXT NOT NULL, + reference_name TEXT NOT NULL, + reference_kind TEXT NOT NULL, + line INTEGER NOT NULL, + col INTEGER NOT NULL, + candidates TEXT, -- JSON array + file_path TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT 'unknown', + status TEXT NOT NULL DEFAULT 'pending', + name_tail TEXT NOT NULL DEFAULT '', + FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE +); + +-- ============================================================================= +-- Indexes for Query Performance +-- ============================================================================= + +-- Node indexes +CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind); +CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name); +CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name ON nodes(qualified_name); +CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path); +CREATE INDEX IF NOT EXISTS idx_nodes_language ON nodes(language); +CREATE INDEX IF NOT EXISTS idx_nodes_file_line ON nodes(file_path, start_line); +CREATE INDEX IF NOT EXISTS idx_nodes_lower_name ON nodes(lower(name)); + +-- Full-text search index on node names, docstrings, and signatures +CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5( + id, + name, + qualified_name, + docstring, + signature, + content='nodes', + content_rowid='rowid' +); + +-- Triggers to keep FTS index in sync +CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN + INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature) + VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); +END; + +CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature) + VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature); +END; + +CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature) + VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature); + INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature) + VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); +END; + +-- Prose-word → symbol-name lookup for the prompt hook's graph-derived gate. +-- One row per (segment, name): segment is a lowercased word of a symbol name +-- ("OrderStateMachine" → order, state, machine — see identifier-segments.ts), +-- which lets natural-language prompt words be verified against the graph in +-- any language whose technical nouns are Latin script. File nodes are +-- excluded — a file's basename duplicates the symbols inside it and skews the +-- singleton-vs-cluster rarity statistics. FTS can't serve this lookup (its +-- tokenizer keeps camelCase names as single tokens), so segments are +-- materialized on the node write path. +-- Deletions leave orphan rows ON PURPOSE: rows are PROPOSALS, always +-- re-verified against nodes before being surfaced (CodeGraph.getSegmentMatches), +-- and a full index clears the table at its start. Populated lazily on old +-- databases (empty until the next index/sync heals it). +CREATE TABLE IF NOT EXISTS name_segment_vocab ( + segment TEXT NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (segment, name) +) WITHOUT ROWID; + +-- Edge indexes. +-- idx_edges_source / idx_edges_target are intentionally omitted — +-- the (source, kind) and (target, kind) composites below cover the +-- corresponding source-only / target-only lookups via SQLite's +-- left-prefix scan, so the narrow indexes are dead weight on writes. +-- Migration v4 drops them on existing databases. +CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind); +CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source, kind); +CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target, kind); + +-- Edge identity uniqueness. An edge IS uniquely (source, target, kind, line, +-- col); insertEdge uses `INSERT OR IGNORE`, but without something UNIQUE to +-- conflict on it behaved like a plain INSERT, so two passes emitting the same +-- edge produced byte-identical duplicate rows that inflated counts and flowed +-- into callers/impact (#1034). IFNULL folds the nullable line/col so +-- coordinate-less edges (synthesized / file-level) dedup too — SQLite treats +-- each NULL as distinct otherwise. Migration v6 dedups existing rows + adds +-- this on older databases. +CREATE UNIQUE INDEX IF NOT EXISTS idx_edges_identity + ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1)); + +-- File indexes +CREATE INDEX IF NOT EXISTS idx_files_language ON files(language); +CREATE INDEX IF NOT EXISTS idx_files_modified_at ON files(modified_at); + +-- Unresolved refs indexes +CREATE INDEX IF NOT EXISTS idx_unresolved_from_node ON unresolved_refs(from_node_id); +CREATE INDEX IF NOT EXISTS idx_unresolved_name ON unresolved_refs(reference_name); +CREATE INDEX IF NOT EXISTS idx_unresolved_file_path ON unresolved_refs(file_path); +CREATE INDEX IF NOT EXISTS idx_unresolved_from_name ON unresolved_refs(from_node_id, reference_name); +CREATE INDEX IF NOT EXISTS idx_unresolved_status ON unresolved_refs(status); +CREATE INDEX IF NOT EXISTS idx_unresolved_failed_tail ON unresolved_refs(name_tail) WHERE status = 'failed'; +CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance); + +-- Project metadata for version/provenance tracking +CREATE TABLE IF NOT EXISTS project_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/src/db/sqlite-adapter.ts b/src/db/sqlite-adapter.ts new file mode 100644 index 0000000..ab0376c --- /dev/null +++ b/src/db/sqlite-adapter.ts @@ -0,0 +1,149 @@ +/** + * SQLite Adapter + * + * Thin wrapper over Node's built-in `node:sqlite` (`DatabaseSync`), exposed + * through a small better-sqlite3-shaped interface so the rest of the codebase + * is storage-agnostic. + * + * CodeGraph ships with a bundled Node runtime, so `node:sqlite` (real SQLite, + * with WAL + FTS5) is always available — there is no native build step and no + * wasm fallback. When run from source instead, it requires Node >= 22.5. + */ + +export interface SqliteStatement { + run(...params: any[]): { changes: number; lastInsertRowid: number | bigint }; + get(...params: any[]): any; + all(...params: any[]): any[]; + /** + * Lazily yield result rows one at a time instead of materializing the whole + * set with `all()`. Use for unbounded scans (e.g. every function/method node) + * so memory stays O(1) in the row count rather than O(rows) — see #610, where + * `all()`-ing every symbol on a dense project spiked the heap into an OOM. + */ + iterate(...params: any[]): IterableIterator; +} + +export interface SqliteDatabase { + prepare(sql: string): SqliteStatement; + exec(sql: string): void; + pragma(str: string, options?: { simple?: boolean }): any; + transaction(fn: (...args: any[]) => T): (...args: any[]) => T; + close(): void; + readonly open: boolean; +} + +/** + * The active SQLite backend. Only one now (`node:sqlite`); kept as a named type + * so `codegraph status` and the per-instance reporting have a stable shape. + */ +export type SqliteBackend = 'node-sqlite'; + +/** + * Wraps Node's built-in `node:sqlite` (`DatabaseSync`) to match the + * better-sqlite3 interface the rest of the code expects. + * + * node:sqlite is real SQLite compiled into Node, so it supports WAL, FTS5, + * mmap, and `@named` params natively — the only shims needed are the + * better-sqlite3 conveniences node:sqlite omits: a `.pragma()` helper, a + * `.transaction()` helper, and `open` (node:sqlite exposes `isOpen`). + */ +class NodeSqliteAdapter implements SqliteDatabase { + private _db: any; + + constructor(dbPath: string) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseSync } = require('node:sqlite'); + this._db = new DatabaseSync(dbPath); + } + + get open(): boolean { + return this._db.isOpen; + } + + prepare(sql: string): SqliteStatement { + // node:sqlite matches better-sqlite3's calling convention (variadic + // positional args, or a single object for @named params), so params forward + // through unchanged. + const stmt = this._db.prepare(sql); + return { + run(...params: any[]) { + const r = stmt.run(...params); + return { + changes: Number(r?.changes ?? 0), + lastInsertRowid: r?.lastInsertRowid ?? 0, + }; + }, + get(...params: any[]) { + return stmt.get(...params); + }, + all(...params: any[]) { + return stmt.all(...params); + }, + iterate(...params: any[]) { + return stmt.iterate(...params); + }, + }; + } + + exec(sql: string): void { + this._db.exec(sql); + } + + pragma(str: string, options?: { simple?: boolean }): any { + const trimmed = str.trim(); + // Write pragma ("key = value"): node:sqlite is real SQLite, so every pragma + // (WAL, mmap, synchronous, …) applies as-is. + if (trimmed.includes('=')) { + this._db.exec(`PRAGMA ${trimmed}`); + return; + } + // Read pragma. Default: the row object (e.g. { journal_mode: 'wal' }). + // `{ simple: true }` returns just the single column value, like better-sqlite3. + const row = this._db.prepare(`PRAGMA ${trimmed}`).get(); + if (options?.simple) { + return row && typeof row === 'object' ? Object.values(row)[0] : row; + } + return row; + } + + transaction(fn: (...args: any[]) => T): (...args: any[]) => T { + return (...args: any[]) => { + this._db.exec('BEGIN'); + try { + const result = fn(...args); + this._db.exec('COMMIT'); + return result; + } catch (error) { + this._db.exec('ROLLBACK'); + throw error; + } + }; + } + + close(): void { + // node:sqlite's DatabaseSync.close() throws if already closed; make it + // idempotent to match better-sqlite3 (callers may close more than once). + if (this._db.isOpen) this._db.close(); + } +} + +/** + * Create a database connection backed by `node:sqlite`. + * + * Returns the active backend alongside the db so each `DatabaseConnection` can + * report it per-instance — MCP can open multiple project DBs in one process, so + * a process-global would race. + */ +export function createDatabase(dbPath: string): { db: SqliteDatabase; backend: SqliteBackend } { + try { + return { db: new NodeSqliteAdapter(dbPath), backend: 'node-sqlite' }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error( + 'Failed to open SQLite via the built-in node:sqlite module.\n' + + 'CodeGraph requires node:sqlite (Node.js 22.5+). Install the self-contained\n' + + 'CodeGraph release (it bundles a compatible Node), or run on Node 22.5+.\n' + + `Underlying error: ${msg}` + ); + } +} diff --git a/src/db/wal-valve.ts b/src/db/wal-valve.ts new file mode 100644 index 0000000..b509527 --- /dev/null +++ b/src/db/wal-valve.ts @@ -0,0 +1,206 @@ +/** + * WAL checkpoint valve — bounds WAL growth while auto-checkpointing is + * deferred during a bulk index (#1231). + * + * Why deferral: SQLite's default `wal_autocheckpoint` (1000 pages) re-writes + * hot B-tree/FTS pages into the main DB file over and over during a bulk + * index — measured at ~95% of ALL disk I/O, and the difference between 45s + * and 19+ minutes on HDD-class storage (150 random IOPS). Deferring + * checkpoints turns the store into pure sequential WAL appends; each backfill + * pass writes distinct pages once, in page order (≈ sequential). + * + * Why a valve: unbounded deferral is its own failure mode, both measured in + * the #1231 repro. The WAL duplicates hot pages per COMMIT, so it grows far + * faster than the DB (5.9GB WAL for a ~340MB DB on a 3.3k-file index) — + * filling the disk, and poisoning every subsequent read that must page + * through it (the first resolution-phase read blocked the main thread >60s + * and the #850 liveness watchdog killed the healthy index). The valve + * watches WAL growth on a timer and, past a soft threshold, backfills with + * `PRAGMA wal_checkpoint(PASSIVE)` on a worker-thread connection — PASSIVE + * never blocks the writer, and off-thread means the main thread (and the + * watchdog heartbeat) keep turning regardless of how long a backfill takes. + * + * The load-bearing subtlety: a WAL file's SIZE never shrinks. After a full + * backfill, the writer's next commit RESTARTS the WAL from the top and the + * frames recycle inside the same file — so raw size says nothing about the + * un-backfilled backlog, and a size-triggered valve degenerates into firing + * (and pausing the writer) forever once the file passes its threshold + * (measured: guava crawled at ~9min per 160 files). Instead the valve + * tracks `sizeAtLastFullBackfill` — refreshed whenever a checkpoint reports + * `log === checkpointed` (everything backfilled) — and triggers on GROWTH + * beyond that baseline, which only happens when genuinely un-backfilled + * frames push past the file's high-water mark. + * + * Backpressure: if the writer outruns the checkpointer past a hard cap of + * growth (2× soft), {@link backpressure} pauses the writer (at a safe, + * between-transactions boundary) until a FULL backfill lands. One in-flight + * pass is not enough: on a disk saturated by the writer, every concurrent + * PASSIVE pass is already stale by the time it finishes (the writer appended + * past its snapshot), so neither SQLite's WAL wrap nor the baseline ever + * trigger and the WAL grows without bound (measured: 5.9GB on guava at 150 + * IOPS, then a >60s read stall and a watchdog kill). With the writer parked, + * the next pass covers everything, the WAL wraps on the following commit, + * and the pause is the disk's honest catch-up cost — the correct terminal + * mode when hardware genuinely can't keep up with the append rate. + */ + +import type { DatabaseConnection } from './index'; + +/** Soft WAL-growth threshold (MB) that triggers an off-thread passive checkpoint. */ +const DEFAULT_WAL_VALVE_MB = 256; +/** Hard cap = this × soft threshold; past it the writer pauses for a full backfill. */ +const HARD_CAP_MULTIPLIER = 2; +/** Passes attempted per writer pause before giving up (a pinned reader could stall forever). */ +const MAX_PAUSED_BACKFILL_PASSES = 20; +/** How often the timer looks at the WAL file size. */ +const CHECK_INTERVAL_MS = 2000; + +/** + * Resolve the valve's soft threshold from the `CODEGRAPH_WAL_VALVE_MB` + * override; non-numeric / non-positive values fall back to the default. + */ +export function resolveWalValveMb(envVal: string | undefined): number { + if (envVal !== undefined && envVal !== '') { + const n = Number(envVal); + if (Number.isFinite(n) && n > 0) return Math.floor(n); + } + return DEFAULT_WAL_VALVE_MB; +} + +export class WalCheckpointValve { + private timer: ReturnType | null = null; + private inflight: Promise | null = null; + /** Writer pause in progress (hard cap breached): passes loop until a full backfill. */ + private pause: Promise | null = null; + /** + * WAL file size observed when a checkpoint last reported the ENTIRE WAL + * backfilled. Growth is measured against this baseline — see the header + * comment for why absolute size cannot be used. + */ + private sizeAtLastFullBackfill = 0; + private readonly softBytes: number; + private readonly hardBytes: number; + + constructor( + private readonly db: DatabaseConnection, + softMb: number = resolveWalValveMb(process.env.CODEGRAPH_WAL_VALVE_MB), + private readonly intervalMs: number = CHECK_INTERVAL_MS, + private readonly log: (msg: string) => void = () => {} + ) { + this.softBytes = softMb * 1024 * 1024; + this.hardBytes = this.softBytes * HARD_CAP_MULTIPLIER; + } + + private mb(n: number): string { + return `${Math.round(n / 1024 / 1024)}MB`; + } + + /** Un-backfilled growth estimate: bytes the WAL has grown past the last full backfill. */ + private growthBytes(): number { + return this.db.getWalSizeBytes() - this.sizeAtLastFullBackfill; + } + + /** Begin watching the WAL. Idempotent; the timer never holds the loop open. */ + start(): void { + if (this.timer) return; + this.timer = setInterval(() => this.check(), this.intervalMs); + this.timer.unref?.(); + } + + /** Stop watching. Any in-flight checkpoint keeps running — await drain(). */ + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** One poll: fire an off-thread passive checkpoint when growth passes the soft threshold. */ + check(): void { + if (!this.pause && !this.inflight && this.growthBytes() > this.softBytes) this.fire(); + } + + /** + * Writer-side backstop, called at a between-transactions boundary. Returns + * null (no wait) while growth is under the hard cap; past it, returns a + * promise that resolves only once a FULL backfill has landed — see the + * header comment for why a single pass is not enough on a saturated disk. + */ + backpressure(): Promise | null { + if (this.pause) return this.pause; + if (this.growthBytes() <= this.hardBytes) return null; + this.log(`backpressure: wal=${this.mb(this.db.getWalSizeBytes())} baseline=${this.mb(this.sizeAtLastFullBackfill)} — pausing writer for full backfill`); + const t0 = Date.now(); + this.pause = this.backfillFully().finally(() => { + this.pause = null; + this.log(`backpressure released after ${Date.now() - t0}ms: wal=${this.mb(this.db.getWalSizeBytes())} baseline=${this.mb(this.sizeAtLastFullBackfill)}`); + }); + return this.pause; + } + + /** Await any in-flight checkpoint and writer pause. */ + async drain(): Promise { + while (this.pause || this.inflight) { + if (this.pause) await this.pause; + if (this.inflight) await this.inflight; + } + } + + /** + * Phase-boundary fold: backfill the ENTIRE WAL now (off-thread, awaited). + * Called between bulk phases — e.g. after parsing, before resolution's + * first reads — so the next phase never pages a bulk-write-sized WAL on + * the main thread (the post-parse read against a multi-GB WAL is what + * blew the #850 watchdog's 60s window in the #1231 repro). The await + * keeps the event loop (and the watchdog heartbeat) turning. + */ + async foldNow(): Promise { + await this.drain(); + if (this.growthBytes() <= 0) return; + this.log(`foldNow: wal=${this.mb(this.db.getWalSizeBytes())} baseline=${this.mb(this.sizeAtLastFullBackfill)}`); + this.pause = this.backfillFully().finally(() => { this.pause = null; }); + await this.pause; + } + + /** + * With the writer parked on the returned promise, loop passive passes until + * one reports the entire WAL backfilled (typically the second: the first + * drains the pass that was already running against a stale snapshot). Gives + * up after a bounded number of passes — e.g. a reader pinning the WAL — + * because unbounded WAL growth degrades; a wedged writer never recovers. + */ + private async backfillFully(): Promise { + for (let i = 0; i < MAX_PAUSED_BACKFILL_PASSES; i++) { + if (this.inflight) await this.inflight; // fold in the stale in-flight pass first + const res = await this.db.checkpointWalPassive(); + if (!res) return; // checkpoint machinery unavailable — don't spin + this.log(`backfill pass ${i + 1}: busy=${res.busy} log=${res.log} checkpointed=${res.checkpointed} wal=${this.mb(this.db.getWalSizeBytes())}`); + if (res.busy === 0 && res.log === res.checkpointed) { + this.sizeAtLastFullBackfill = this.db.getWalSizeBytes(); + return; + } + } + this.log(`backfill gave up after ${MAX_PAUSED_BACKFILL_PASSES} passes — WAL stays unbounded this cycle`); + } + + private fire(): void { + const p = this.db + .checkpointWalPassive() + .then((res) => { + // Full backfill (busy 0, every log frame checkpointed) ⇒ the writer's + // next commit wraps the WAL; the file's current size becomes the new + // growth baseline. A partial pass (writer appended during it, or a + // read transaction pinned frames) leaves the baseline alone, so the + // next tick fires again and copies the remainder. In non-WAL mode + // SQLite reports log = checkpointed = -1, which is harmless here. + if (res && res.busy === 0 && res.log === res.checkpointed) { + this.sizeAtLastFullBackfill = this.db.getWalSizeBytes(); + } + }) + .catch(() => { /* best-effort */ }) + .finally(() => { + if (this.inflight === p) this.inflight = null; + }); + this.inflight = p; + } +} diff --git a/src/directory.ts b/src/directory.ts new file mode 100644 index 0000000..fd4a1aa --- /dev/null +++ b/src/directory.ts @@ -0,0 +1,813 @@ +/** + * Directory Management + * + * Manages the .codegraph/ directory structure for CodeGraph data. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +/** The default per-project data directory name. */ +const DEFAULT_CODEGRAPH_DIR = '.codegraph'; + +let warnedBadDirName = false; + +/** + * Resolve the per-project data directory name, honoring the `CODEGRAPH_DIR` + * environment override (default `.codegraph`). The override is a single path + * segment that lives in the project root. + * + * Why this exists: two environments that share one working tree must NOT share + * one `.codegraph/` — most concretely Windows-native and WSL (issue #636). The + * daemon lockfile (`.codegraph/daemon.pid`) records a platform-specific pid and + * socket path (a Windows named pipe vs a WSL Unix socket), and SQLite file + * locking across the WSL2 ↔ Windows filesystem boundary is unreliable, so two + * daemons sharing one index risks corruption. Setting `CODEGRAPH_DIR=.codegraph-win` + * on one side gives each environment its own index in the same tree. + * + * Read live (not captured at load) so it is both process-accurate and testable. + * An override that isn't a plain directory name — empty, containing a path + * separator, `.`, `..`/traversal, or absolute — is ignored (we keep the + * default) rather than risk writing the index outside the project or into the + * project root itself; we warn once to stderr so the misconfiguration is seen. + */ +export function codeGraphDirName(): string { + const raw = process.env.CODEGRAPH_DIR?.trim(); + if (!raw) return DEFAULT_CODEGRAPH_DIR; + const invalid = + raw === '.' || + raw.includes('..') || + raw.includes('/') || + raw.includes('\\') || + path.isAbsolute(raw); + if (invalid) { + if (!warnedBadDirName) { + warnedBadDirName = true; + // stderr only — stdout is the MCP protocol channel. + console.warn( + `[codegraph] Ignoring invalid CODEGRAPH_DIR="${raw}" — it must be a plain ` + + `directory name (no path separators, no "..", not absolute). Using "${DEFAULT_CODEGRAPH_DIR}".` + ); + } + return DEFAULT_CODEGRAPH_DIR; + } + return raw; +} + +/** + * CodeGraph directory name — a load-time snapshot of {@link codeGraphDirName}. + * A running process's environment is fixed, so this equals the live value; + * it's kept as a stable string export for backward compatibility. Internal code + * resolves the name through {@link codeGraphDirName} / {@link getCodeGraphDir} + * so the `CODEGRAPH_DIR` override always applies. + */ +export const CODEGRAPH_DIR = codeGraphDirName(); + +/** + * Is `name` (a single path segment) a CodeGraph data directory? Matches the + * default `.codegraph`, the active `CODEGRAPH_DIR` override, and any + * `.codegraph-*` sibling. File-watching and the indexer skip ALL of these, so + * when two environments share one working tree (Windows + WSL, issue #636) + * neither indexes or watches the other's index directory. + */ +export function isCodeGraphDataDir(name: string): boolean { + return ( + name === DEFAULT_CODEGRAPH_DIR || + name === codeGraphDirName() || + name.startsWith(DEFAULT_CODEGRAPH_DIR + '-') + ); +} + +/** + * Get the .codegraph directory path for a project + */ +export function getCodeGraphDir(projectRoot: string): string { + return path.join(projectRoot, codeGraphDirName()); +} + +/** + * Check if a project has been initialized with CodeGraph + * Requires both .codegraph/ directory AND codegraph.db to exist + */ +export function isInitialized(projectRoot: string): boolean { + const codegraphDir = getCodeGraphDir(projectRoot); + if (!fs.existsSync(codegraphDir) || !fs.statSync(codegraphDir).isDirectory()) { + return false; + } + // Must have codegraph.db, not just .codegraph folder + const dbPath = path.join(codegraphDir, 'codegraph.db'); + return fs.existsSync(dbPath); +} + +/** + * Find the nearest parent directory containing .codegraph/ + * + * Walks up from the given path to find a CodeGraph-initialized project, + * similar to how git finds .git/ directories. + * + * @param startPath - Directory to start searching from + * @returns The project root containing .codegraph/, or null if not found + */ +/** + * Reason a directory is unsafe to use as an index ROOT, or null when it's fine. + * + * Indexing your home directory or a filesystem root drags in caches, `Library`, + * every other project, etc. — a multi-GB index, constant file-watcher churn, and + * (pre-1.0 on macOS) a file-descriptor blowup that exhausted `kern.maxfiles` and + * took unrelated apps / the whole machine down (#845). The classic trigger: + * running the installer or `codegraph init` from `$HOME`, which auto-indexes the + * current directory. These are never intended project roots, so the installer + * and `init`/`index` refuse them (overridable with `--force`). + * + * Pure-ish (reads only `os.homedir()` + realpath) so it's easy to unit-test. + * The returned string is a human phrase that slots into "… looks like {reason}". + */ +export function unsafeIndexRootReason(projectRoot: string): string | null { + const resolve = (p: string): string => { + try { + return fs.realpathSync(path.resolve(p)); + } catch { + return path.resolve(p); + } + }; + const resolved = resolve(projectRoot); + + // Filesystem root: `/` on POSIX, a drive root like `C:\` on Windows. + if (path.parse(resolved).root === resolved) { + return 'the filesystem root'; + } + + const home = resolve(os.homedir()); + // Case-insensitive on macOS/Windows (case-preserving but case-insensitive FS). + const norm = (p: string): string => + process.platform === 'darwin' || process.platform === 'win32' ? p.toLowerCase() : p; + const r = norm(resolved); + const h = norm(home); + + if (r === h) { + return 'your home directory'; + } + // An ancestor of home (e.g. `/Users`, `/home`) — even broader than home. + if (h.startsWith(r + path.sep)) { + return 'a parent of your home directory'; + } + return null; +} + +export function findNearestCodeGraphRoot(startPath: string): string | null { + let current = path.resolve(startPath); + const root = path.parse(current).root; + + while (current !== root) { + if (isInitialized(current)) { + return current; + } + const parent = path.dirname(current); + if (parent === current) break; // Reached filesystem root + current = parent; + } + + // Check root as well + if (isInitialized(current)) { + return current; + } + + return null; +} + +/** Heavy/irrelevant directory names the sub-project scan never descends into. */ +const SUBPROJECT_SCAN_SKIP = new Set([ + 'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out', 'target', + 'vendor', 'bin', 'obj', '.next', '.nuxt', '.svelte-kit', '.cache', 'coverage', + '.venv', 'venv', '__pycache__', '.turbo', '.idea', '.vscode', 'tmp', 'temp', +]); + +/** Manifests that mark a directory as a project/workspace root. The down-scan + * is gated on one of these so a non-project cwd (e.g. `$HOME`) is a cheap + * no-op instead of a deep filesystem crawl. */ +const WORKSPACE_ROOT_MANIFESTS = [ + 'package.json', 'pnpm-workspace.yaml', 'lerna.json', 'nx.json', 'turbo.json', + 'go.work', 'go.mod', 'Cargo.toml', 'pom.xml', 'build.gradle', 'build.gradle.kts', + 'settings.gradle', 'pyproject.toml', 'composer.json', 'Gemfile', 'rush.json', + 'WORKSPACE', 'WORKSPACE.bazel', +]; + +function looksLikeProjectRoot(dir: string): boolean { + return WORKSPACE_ROOT_MANIFESTS.some((m) => fs.existsSync(path.join(dir, m))); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Indexed sub-project roots beneath `root` (bounded breadth-first scan). For + * the monorepo case behind #964: the index lives in a CHILD + * (`packages/x/.codegraph/`), not at the workspace root the agent's cwd points + * at. Descent stops at the first indexed directory on a branch (a project's + * own sub-dirs aren't separate projects) and is bounded by depth + count so it + * never turns into a full-tree crawl on a large repo. + */ +export function findIndexedSubprojectRoots( + root: string, + opts: { maxDepth?: number; max?: number } = {}, +): string[] { + const maxDepth = opts.maxDepth ?? 4; + const max = opts.max ?? 64; + const out: string[] = []; + const walk = (dir: string, depth: number): void => { + if (out.length >= max || depth > maxDepth) return; + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (out.length >= max) return; + if (!e.isDirectory()) continue; + if (e.name.startsWith('.') || SUBPROJECT_SCAN_SKIP.has(e.name)) continue; + const child = path.join(dir, e.name); + if (isInitialized(child)) { out.push(child); continue; } // don't descend into an indexed project + walk(child, depth + 1); + } + }; + walk(root, 1); + return out; +} + +/** + * Unicode-aware word-boundary emulation for the keyword lists below. JS's `\b` + * is ASCII-only — it fires only at `[A-Za-z0-9_]` edges — so it can never bound + * a keyword whose first or last character is accented or non-Latin: `/\boù\b/` + * NEVER matches "où est …" (ù isn't an ASCII word char, so no boundary exists + * next to it). That is the #994 CJK mechanism resurfaced for Latin scripts and + * Cyrillic (#1126). A lookaround — "not flanked by a letter, digit, or + * underscore" — is the script-independent equivalent. + */ +const NOT_WORD_BEFORE = /(? 的调用链?" / "where is 定義" prompts no keyword list would. + * + * These are *candidates*, not a verdict: a tech brand like `JavaScript` or + * `GitHub` is identifier-shaped too, so the front-load hook checks each token + * against the actual index ({@link getNodesByName}) and only fires when one is a + * real symbol here — otherwise a brand-name prompt would inject ~16KB of + * low-relevance context (issue #994 follow-up). A doc/data filename ("README.md") + * is excluded from the member-access form since it's a file reference, not a symbol. + */ +export function extractCodeTokens(prompt: string): string[] { + if (!prompt) return []; + const out = new Set(); + // camelCase / PascalCase-with-inner-cap (getUserId, parseToken, UserService) or + // snake_case (article_publish, get_user) — a whole identifier run that has an + // inner lower→upper transition or an underscore flanked by alphanumerics. + for (const m of prompt.matchAll(/[A-Za-z_$][\w$]*/g)) { + const w = m[0]; + if (/[a-z][A-Z]/.test(w) || /[A-Za-z0-9]_[A-Za-z0-9]/.test(w)) out.add(w); + } + // call form: an identifier directly before '(' — parseToken(, render(). No + // whitespace before '(' so prose like "the function (entry point)" doesn't trip it. + for (const m of prompt.matchAll(/([A-Za-z_$][\w$]*)\(/g)) out.add(m[1]!); + // member access on identifiers (user.login) — but not a doc/data filename. + for (const m of prompt.matchAll(/([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)/g)) { + if (!DOC_DATA_EXT.test(m[0])) { out.add(m[1]!); out.add(m[2]!); } + } + return [...out]; +} + +/** + * Cheap, graph-free candidate gate for the front-load hook: could `prompt` be a + * structural / flow / impact / "where-how" question worth front-loading context + * for? True on an explicit keyword in any covered language (#994, #1126) OR an + * identifier-shaped token. A keyword is sufficient to fire on its own; a + * token-only match is only a candidate the hook then verifies against the graph + * (a brand name like `JavaScript` is token-shaped but isn't a symbol). Every + * non-candidate prompt ("fix this typo", in any language) stays a zero-cost no-op. + */ +export function isStructuralPrompt(prompt: string): boolean { + return hasStructuralKeyword(prompt) || extractCodeTokens(prompt).length > 0; +} + +/** + * What the front-load hook should do for a prompt issued from a directory. + */ +export interface FrontloadPlan { + /** Open + explore this project and inject its source as context. `null` when + * there's no single project to front-load (none indexed, or several indexed + * sub-projects with no clear match — see {@link nudgeProjects}). */ + exploreRoot: string | null; + /** Indexed sub-projects to surface in a "pass `projectPath`" nudge: the rest + * of a monorepo's indexed projects alongside `exploreRoot`, or — when no one + * project clearly matches — the full list (with `exploreRoot` null). */ + nudgeProjects: string[]; + /** True when the plan came from scanning DOWN into sub-projects (cwd itself + * is not under any index) — the monorepo case, where a follow-up + * `codegraph_explore` needs an explicit `projectPath`. */ + viaSubScan: boolean; +} + +/** + * Decide what the front-load hook injects for a `prompt` issued from `cwd`, + * shaped by where the `.codegraph/` index(es) actually are: + * 1. **cwd (or an ancestor) is indexed** → front-load that project. The + * normal single-project / nested-file case. + * 2. **cwd isn't indexed but looks like a workspace root** → the indexes live + * in sub-projects (the monorepo case behind #964). One indexed + * sub-project → front-load it; several → front-load the one the prompt + * names (by relative path like `packages/api`, or package directory name) + * and nudge about the rest; several with no match → nudge the full list so + * the agent passes `projectPath`, rather than guessing wrong. + * 3. **nothing indexed reachable** → do nothing (the agent's own tools apply). + */ +export function planFrontload(cwd: string, prompt: string): FrontloadPlan { + const none: FrontloadPlan = { exploreRoot: null, nudgeProjects: [], viaSubScan: false }; + + // 1. up-walk — nearest indexed ancestor (incl. cwd). Cheap; covers the common + // single-project case without a down-scan. + let dir = path.resolve(cwd); + for (let i = 0; i < 6; i++) { + if (isInitialized(dir)) return { exploreRoot: dir, nudgeProjects: [], viaSubScan: false }; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + // 2. down-scan — only from something that looks like a workspace root, so a + // non-project cwd (e.g. $HOME) is a cheap no-op, not a deep crawl. + const base = path.resolve(cwd); + if (!looksLikeProjectRoot(base)) return none; + const subs = findIndexedSubprojectRoots(base); + if (subs.length === 0) return none; + if (subs.length === 1) return { exploreRoot: subs[0]!, nudgeProjects: [], viaSubScan: true }; + + // Several indexed sub-projects — pick the one the prompt points at, if any. + const p = prompt.toLowerCase(); + let best: { root: string; score: number; relLen: number } | null = null; + for (const s of subs) { + const rel = path.relative(base, s); + const relLc = rel.split(path.sep).join('/').toLowerCase(); + const name = path.basename(s).toLowerCase(); + let score = 0; + if (relLc && p.includes(relLc)) score = 10; // "packages/api" + else if (name.length >= 3 && new RegExp(`\\b${escapeRegExp(name)}\\b`).test(p)) score = 5; // "api" + if (score > 0 && (!best || score > best.score || (score === best.score && rel.length < best.relLen))) { + best = { root: s, score, relLen: rel.length }; + } + } + if (best) { + return { exploreRoot: best.root, nudgeProjects: subs.filter((s) => s !== best!.root), viaSubScan: true }; + } + // No clear match — nudge the full list rather than front-load a guess. + return { exploreRoot: null, nudgeProjects: subs, viaSubScan: true }; +} + +/** + * Contents of `.codegraph/.gitignore`. A single wildcard ignore keeps every + * transient file in the index dir — the database, `daemon.pid`, the socket, + * logs, cache, and anything future versions add — out of git, without having + * to enumerate each name (issues #788, #492, #484). Older versions wrote an + * explicit allowlist that never listed `daemon.pid` or the socket, so those + * runtime files were silently committed. + */ +const GITIGNORE_CONTENT = `# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore +`; + +/** Header line that prefixes every .gitignore CodeGraph has auto-generated. */ +const GITIGNORE_MARKER = '# CodeGraph data files'; + +/** + * Is `content` a stale CodeGraph-generated `.gitignore` that should be + * regenerated in place? True when it carries our header but predates the + * wildcard ignore (it has no bare `*` line) — i.e. one of the old explicit + * allowlists (`*.db`, `cache/`, `.dirty`, …) that never ignored `daemon.pid` + * or the socket (issue #788). A file WITHOUT our header is user-authored and + * is left untouched; one that already has the wildcard is current. Matching + * on the header (not a byte-exact list of past defaults) heals every old + * variant — v0.7.x through 0.9.9 — and is idempotent once upgraded. + */ +function isStaleDefaultGitignore(content: string): boolean { + if (!content.trimStart().startsWith(GITIGNORE_MARKER)) return false; + return !content.split('\n').some((line) => line.trim() === '*'); +} + +/** + * Write `.codegraph/.gitignore` if it's absent, or upgrade a stale + * CodeGraph-generated default in place; a user-customized file is left alone. + * Best-effort — returns `false` only if a needed write failed. + */ +function ensureGitignore(gitignorePath: string): boolean { + let existing: string | null; + try { + existing = fs.readFileSync(gitignorePath, 'utf-8'); + } catch { + existing = null; // absent (ENOENT) or unreadable — (re)create below + } + // Current default or a user-authored file: nothing to do. + if (existing !== null && !isStaleDefaultGitignore(existing)) return true; + try { + fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8'); + return true; + } catch { + return false; + } +} + +/** + * Create the .codegraph directory structure + * Note: Only throws if codegraph.db already exists, not just if .codegraph/ exists. + */ +export function createDirectory(projectRoot: string): void { + const codegraphDir = getCodeGraphDir(projectRoot); + const dbPath = path.join(codegraphDir, 'codegraph.db'); + + // Only throw if CodeGraph is actually initialized (db exists) + // .codegraph/ folder alone is fine + if (fs.existsSync(dbPath)) { + throw new Error(`CodeGraph already initialized in ${projectRoot}`); + } + + // Create main directory (if it doesn't exist) + fs.mkdirSync(codegraphDir, { recursive: true }); + + // Write .gitignore inside .codegraph (create if absent, upgrade a stale + // pre-wildcard default left by an older version — issue #788). + ensureGitignore(path.join(codegraphDir, '.gitignore')); +} + +/** + * Remove the .codegraph directory + */ +export function removeDirectory(projectRoot: string): void { + const codegraphDir = getCodeGraphDir(projectRoot); + + if (!fs.existsSync(codegraphDir)) { + return; + } + + // Verify .codegraph is a real directory, not a symlink pointing elsewhere + const lstat = fs.lstatSync(codegraphDir); + if (lstat.isSymbolicLink()) { + // Only remove the symlink itself, never follow it for recursive delete + fs.unlinkSync(codegraphDir); + return; + } + + if (!lstat.isDirectory()) { + // Not a directory - remove the single file + fs.unlinkSync(codegraphDir); + return; + } + + // Recursively remove directory + fs.rmSync(codegraphDir, { recursive: true, force: true }); +} + +/** + * Get all files in the .codegraph directory + */ +export function listDirectoryContents(projectRoot: string): string[] { + const codegraphDir = getCodeGraphDir(projectRoot); + + if (!fs.existsSync(codegraphDir)) { + return []; + } + + const files: string[] = []; + + function walkDir(dir: string, prefix: string = ''): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + + // Skip symlinks to prevent following links outside .codegraph + if (entry.isSymbolicLink()) { + continue; + } + + if (entry.isDirectory()) { + walkDir(path.join(dir, entry.name), relativePath); + } else { + files.push(relativePath); + } + } + } + + walkDir(codegraphDir); + return files; +} + +/** + * Get the total size of the .codegraph directory in bytes + */ +export function getDirectorySize(projectRoot: string): number { + const codegraphDir = getCodeGraphDir(projectRoot); + + if (!fs.existsSync(codegraphDir)) { + return 0; + } + + let totalSize = 0; + + function walkDir(dir: string): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + // Skip symlinks to prevent following links outside .codegraph + if (entry.isSymbolicLink()) { + continue; + } + + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + walkDir(fullPath); + } else { + const stats = fs.statSync(fullPath); + totalSize += stats.size; + } + } + } + + walkDir(codegraphDir); + return totalSize; +} + +/** + * Ensure a subdirectory exists within .codegraph + */ +export function ensureSubdirectory(projectRoot: string, subdirName: string): string { + if (subdirName.includes('..') || subdirName.includes(path.sep) || subdirName.includes('/')) { + throw new Error(`Invalid subdirectory name: ${subdirName}`); + } + + const subdirPath = path.join(getCodeGraphDir(projectRoot), subdirName); + + if (!fs.existsSync(subdirPath)) { + fs.mkdirSync(subdirPath, { recursive: true }); + } + + return subdirPath; +} + +/** + * Check if the .codegraph directory has valid structure + */ +export function validateDirectory(projectRoot: string): { + valid: boolean; + errors: string[]; +} { + const errors: string[] = []; + const codegraphDir = getCodeGraphDir(projectRoot); + + if (!fs.existsSync(codegraphDir)) { + errors.push('CodeGraph directory does not exist'); + return { valid: false, errors }; + } + + if (!fs.statSync(codegraphDir).isDirectory()) { + errors.push('.codegraph exists but is not a directory'); + return { valid: false, errors }; + } + + // Auto-repair / upgrade .gitignore (non-critical file). A missing one is + // recreated; a stale pre-wildcard default that never ignored daemon.pid is + // regenerated in place (issue #788); a user-authored file is left alone. + const gitignorePath = path.join(codegraphDir, '.gitignore'); + const existedBefore = fs.existsSync(gitignorePath); + if (!ensureGitignore(gitignorePath) && !existedBefore) { + // Only a missing-and-uncreatable file is surfaced; a failed in-place + // upgrade of an existing file is non-fatal — the index still works. + errors.push('.gitignore missing in .codegraph directory and could not be created'); + } + + return { + valid: errors.length === 0, + errors, + }; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..acd440c --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,240 @@ +/** + * CodeGraph Error Classes + * + * Custom error types for better error handling and debugging. + * + * @module errors + * + * @example + * ```typescript + * import { FileError, ParseError, setLogger, silentLogger } from 'codegraph'; + * + * // Catch specific error types + * try { + * await cg.indexAll(); + * } catch (error) { + * if (error instanceof FileError) { + * console.log(`File error at ${error.filePath}: ${error.message}`); + * } else if (error instanceof ParseError) { + * console.log(`Parse error at ${error.filePath}:${error.line}`); + * } + * } + * + * // Disable logging for tests + * setLogger(silentLogger); + * ``` + */ + +/** + * Base error class for all CodeGraph errors. + * + * All CodeGraph-specific errors extend this class, allowing you to catch + * all CodeGraph errors with a single catch block. + * + * @example + * ```typescript + * try { + * await cg.indexAll(); + * } catch (error) { + * if (error instanceof CodeGraphError) { + * console.log(`CodeGraph error [${error.code}]: ${error.message}`); + * } + * } + * ``` + */ +export class CodeGraphError extends Error { + /** Error code for categorization (e.g., 'FILE_ERROR', 'PARSE_ERROR') */ + readonly code: string; + /** Additional context about the error */ + readonly context?: Record; + + constructor(message: string, code: string, context?: Record) { + super(message); + this.name = 'CodeGraphError'; + this.code = code; + this.context = context; + + // Maintain proper stack trace for V8 + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +/** + * Error reading or accessing files + */ +export class FileError extends CodeGraphError { + readonly filePath: string; + + constructor(message: string, filePath: string, cause?: Error) { + super(message, 'FILE_ERROR', { filePath, cause: cause?.message }); + this.name = 'FileError'; + this.filePath = filePath; + if (cause) { + this.cause = cause; + } + } +} + +/** + * Error parsing source code + */ +export class ParseError extends CodeGraphError { + readonly filePath: string; + readonly line?: number; + readonly column?: number; + + constructor( + message: string, + filePath: string, + options?: { line?: number; column?: number; cause?: Error } + ) { + super(message, 'PARSE_ERROR', { + filePath, + line: options?.line, + column: options?.column, + cause: options?.cause?.message, + }); + this.name = 'ParseError'; + this.filePath = filePath; + this.line = options?.line; + this.column = options?.column; + if (options?.cause) { + this.cause = options.cause; + } + } +} + +/** + * Error with database operations + */ +export class DatabaseError extends CodeGraphError { + readonly operation: string; + + constructor(message: string, operation: string, cause?: Error) { + super(message, 'DATABASE_ERROR', { operation, cause: cause?.message }); + this.name = 'DatabaseError'; + this.operation = operation; + if (cause) { + this.cause = cause; + } + } +} + +/** + * Error with search operations + */ +export class SearchError extends CodeGraphError { + readonly query: string; + + constructor(message: string, query: string, cause?: Error) { + super(message, 'SEARCH_ERROR', { query, cause: cause?.message }); + this.name = 'SearchError'; + this.query = query; + if (cause) { + this.cause = cause; + } + } +} + +/** + * Error with vector/embedding operations + */ +export class VectorError extends CodeGraphError { + constructor(message: string, operation: string, cause?: Error) { + super(message, 'VECTOR_ERROR', { operation, cause: cause?.message }); + this.name = 'VectorError'; + if (cause) { + this.cause = cause; + } + } +} + +/** + * Error with configuration + */ +export class ConfigError extends CodeGraphError { + constructor(message: string, details?: Record) { + super(message, 'CONFIG_ERROR', details); + this.name = 'ConfigError'; + } +} + +/** + * Simple logger for CodeGraph operations + * + * By default, logs to console.warn for warnings and console.error for errors. + * Can be configured to use custom logging. + */ +export interface Logger { + debug(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, context?: Record): void; +} + +/** + * Default console-based logger + */ +export const defaultLogger: Logger = { + debug(message: string, context?: Record): void { + if (process.env.CODEGRAPH_DEBUG) { + console.debug(`[CodeGraph] ${message}`, context ?? ''); + } + }, + warn(message: string, context?: Record): void { + console.warn(`[CodeGraph] ${message}`, context ?? ''); + }, + error(message: string, context?: Record): void { + console.error(`[CodeGraph] ${message}`, context ?? ''); + }, +}; + +/** + * Silent logger (no output) - useful for tests + */ +export const silentLogger: Logger = { + debug(): void {}, + warn(): void {}, + error(): void {}, +}; + +/** + * Current logger instance (can be replaced) + */ +let currentLogger: Logger = defaultLogger; + +/** + * Set the global logger + */ +export function setLogger(logger: Logger): void { + currentLogger = logger; +} + +/** + * Get the current logger + */ +export function getLogger(): Logger { + return currentLogger; +} + +/** + * Log a debug message + */ +export function logDebug(message: string, context?: Record): void { + currentLogger.debug(message, context); +} + +/** + * Log a warning message + */ +export function logWarn(message: string, context?: Record): void { + currentLogger.warn(message, context); +} + +/** + * Log an error message + */ +export function logError(message: string, context?: Record): void { + currentLogger.error(message, context); +} diff --git a/src/extraction/astro-extractor.ts b/src/extraction/astro-extractor.ts new file mode 100644 index 0000000..e389893 --- /dev/null +++ b/src/extraction/astro-extractor.ts @@ -0,0 +1,365 @@ +import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; +import { TreeSitterExtractor } from './tree-sitter'; +import { isLanguageSupported } from './grammars'; + +/** + * Astro built-in components — compiler-provided (``) or shipped by + * `astro:components` (``, ``), not user code. + */ +const ASTRO_BUILTIN_COMPONENTS = new Set(['Fragment', 'Code', 'Debug']); + +/** + * AstroExtractor - Extracts code relationships from Astro component files + * + * Astro files are multi-language: a TypeScript frontmatter block fenced by + * `---` lines, a JSX-like HTML template, and optional and ranges + const tagRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g; + let tagMatch; + while ((tagMatch = tagRegex.exec(this.source)) !== null) { + const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length; + const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length; + coveredRanges.push([startLine, endLine]); + } + + // Find template expressions: {...} outside of script/style blocks + // Matches curly-brace expressions, excluding Svelte block syntax ({#if}, {:else}, {/if}, {@html}, {@render}) + const lines = this.source.split('\n'); + const exprRegex = /\{([^}#/:@][^}]*)\}/g; + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + // Skip lines inside script/style blocks + if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue; + + const line = lines[lineIdx]!; + let exprMatch; + while ((exprMatch = exprRegex.exec(line)) !== null) { + const expr = exprMatch[1]!; + // Extract function calls: identifiers followed by ( + // Matches: cn(...), buttonVariants(...), obj.method(...) + const callRegex = /\b([a-zA-Z_$][\w$.]*)\s*\(/g; + let callMatch; + while ((callMatch = callRegex.exec(expr)) !== null) { + const calleeName = callMatch[1]!; + // Skip Svelte runes, control flow keywords, and common non-function patterns + if (SVELTE_RUNES.has(calleeName)) continue; + if (calleeName === 'if' || calleeName === 'else' || calleeName === 'each' || calleeName === 'await') continue; + + this.unresolvedReferences.push({ + fromNodeId: componentNodeId, + referenceName: calleeName, + referenceKind: 'calls', + line: lineIdx + 1, // 1-indexed + column: exprMatch.index + callMatch.index, + filePath: this.filePath, + language: 'svelte', + }); + } + } + } + } + + /** + * Extract component usages from the Svelte template. + * + * PascalCase tags like ,