commit 4a9833e67b18b589081f09ab28a20628f217ee84 Author: xiaowei Date: Sun May 24 17:16:20 2026 +0800 Initial commit: codegraph (local-first code intelligence library + MCP server) 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..3dcc875 --- /dev/null +++ b/.claude/skills/agent-eval/corpus.json @@ -0,0 +1,73 @@ +{ + "_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?" } + ], + "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?" } + ] +} diff --git a/.cursor/rules/codegraph.mdc b/.cursor/rules/codegraph.mdc new file mode 100644 index 0000000..3f23cf6 --- /dev/null +++ b/.cursor/rules/codegraph.mdc @@ -0,0 +1,38 @@ +--- +description: CodeGraph MCP usage guide — when to use which tool +alwaysApply: true +--- + +## CodeGraph + +This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot. + +### When to prefer codegraph over native search + +Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open. + +| Question | Tool | +|---|---| +| "Where is X defined?" / "Find symbol named X" | `codegraph_search` | +| "What calls function Y?" | `codegraph_callers` | +| "What does Y call?" | `codegraph_callees` | +| "What would break if I changed Z?" | `codegraph_impact` | +| "Show me Y's signature / source / docstring" | `codegraph_node` | +| "Give me focused context for a task/area" | `codegraph_context` | +| "See several related symbols' source at once" | `codegraph_explore` | +| "What files exist under path/" | `codegraph_files` | +| "Is the index healthy?" | `codegraph_status` | + +### Rules of thumb + +- **Answer directly — don't delegate exploration.** For "how does X work" / architecture / trace questions, answer with 2-3 codegraph calls: `codegraph_context` first, then ONE `codegraph_explore` for the source of the symbols it surfaces. 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 first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call. +- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call. +- **Don't loop `codegraph_node` over many symbols** — one `codegraph_explore` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more. +- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn. + +### 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/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..51dea15 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,121 @@ +name: Release + +# Manually triggered ("Run workflow"). On trigger it: +# 1. reads the version from package.json, +# 2. builds a self-contained bundle for every platform (one runner — there's no +# native compilation, so cross-packaging is fine), +# 3. creates the GitHub Release (tag v) with all archives, using the +# release notes from CHANGELOG.md, +# 4. publishes the npm thin-installer (shim + per-platform packages). +# +# Before triggering: bump package.json and make sure CHANGELOG.md has the matching +# section (## [], or ## [Unreleased]). Set the NPM_TOKEN repo secret. +on: + workflow_dispatch: {} + +permissions: + contents: write # create the GitHub Release + tag + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - run: npm ci + - name: Ensure zip/unzip + run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip + + - 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: Resolve version + id: ver + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Release notes from CHANGELOG.md + 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..f7aa9d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# 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 + +# Parallels Windows VM SSH/connection config (local machine, see CLAUDE.md) +.parallels + +# CodeGraph data directories (in test projects) +.codegraph/ + +test_frameworks + +# Test language repos for manual testing +test-languages/ + +nul +release/ 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..3cfadd1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,597 @@ +# 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). + +## [0.9.4] - 2026-05-22 + +### Fixed +- **Orphaned `codegraph serve --mcp` processes after a parent SIGKILL.** When + the MCP host (Claude Code, opencode, …) was force-killed — OOM killer, a + `kill -9`, a container teardown — the child kept running indefinitely on + Linux, holding inotify watches, file descriptors, and the SQLite WAL. The + kernel doesn't propagate parent death to children, and the stdin + `end`/`close` handlers we relied on don't always fire. The MCP server now + polls `process.ppid` and shuts down the moment it changes from the value + observed at startup; the poll interval is `CODEGRAPH_PPID_POLL_MS` (default + `5000`, `0` disables). Resolves + [#277](https://github.com/colbymchenry/codegraph/issues/277). + +### Added +- **Release archives now ship with a `SHA256SUMS` file**, and the npm launcher + verifies the bundle it downloads against it — a mismatch aborts before + anything runs. Releases published before this change have no checksum file, so + the verification is skipped (not failed) when none is available. + +### Fixed +- **`codegraph: no prebuilt bundle for ` after installing through a + registry mirror.** Installing `@colbymchenry/codegraph` from a registry that + hadn't mirrored the matching per-platform package — most often the + npmmirror/cnpm mirrors, but any lazily-syncing mirror or corporate proxy can + do it — left every command failing with `no prebuilt bundle for `. + The runtime ships as a per-platform `optionalDependency`, and npm treats an + optional package it can't fetch as a success and silently skips it, so the + bundle simply went missing. The launcher now self-heals: when the platform + bundle isn't installed, it downloads the same archive from GitHub Releases + (cached under `~/.codegraph/bundles/` for next time) and runs that — so a + global install works even on a mirror that never carried the platform package. + Set `CODEGRAPH_NO_DOWNLOAD=1` to disable the network fallback, or + `CODEGRAPH_DOWNLOAD_BASE=` to point it at your own mirror of the release + archives; the standalone `install.sh` remains the no-Node alternative. Resolves + [#303](https://github.com/colbymchenry/codegraph/issues/303). +- **`install.sh` failing with `403` / "could not resolve latest version" on + shared or cloud hosts.** The standalone installer resolved the latest release + through the GitHub API, whose unauthenticated limit is 60 requests/hour per IP + — routinely exhausted on cloud devboxes and CI where many users share an + address, returning `403` (issue #325). It now resolves the version from the + `releases/latest` web redirect, which isn't rate-limited (and still falls back + to the API). `CODEGRAPH_VERSION` also accepts a bare `0.9.4` in addition to + `v0.9.4`. Resolves + [#325](https://github.com/colbymchenry/codegraph/issues/325). + +## [0.9.3] - 2026-05-22 + +### Added +- **`codegraph uninstall` command.** Cleanly removes CodeGraph from every agent + it's configured on — Claude Code, Cursor, Codex CLI, opencode, and Hermes + Agent — in one step. It asks up front whether to remove the global config + (`~/.claude`, `~/.codex`, …) or just this project's local config (no flags + required), then prints exactly which agents it touched so you can see what + changed. `--location`, `--target`, and `--yes` are accepted for scripted / + non-interactive use. It removes only what `install` wrote (MCP server entry, + instructions block, permissions) and leaves your `.codegraph/` index alone + (use `codegraph uninit` for that). Resolves + [#313](https://github.com/colbymchenry/codegraph/issues/313) — previously the + only cleanup path was an npm `preuninstall` hook that the published bundle + never shipped, so `npm uninstall -g` left every agent pointing at a CodeGraph + MCP server that no longer existed. + +### Fixed +- **`Fatal process out of memory: Zone` crash while indexing large projects.** + On Node.js 22 and 24 — including CodeGraph's own bundled runtime — running + `codegraph index` / `codegraph init` on a large multi-language repo could + abort the entire process partway through parsing with + `Fatal process out of memory: Zone`, even with tens of GB of RAM free (the + failure is in a V8-internal compilation arena, not the JS heap). The cause is + V8's "turboshaft" optimizing WASM compiler exhausting its Zone budget while + compiling tree-sitter's large WebAssembly grammars on a background thread. + CodeGraph now runs with V8's `--liftoff-only`, which keeps grammar compilation + on the baseline compiler and never reaches the optimizing tier, eliminating + the crash; indexing output is otherwise unchanged. The bundled launcher passes + the flag directly, and any other launch path (from source, `npx`, a globally + linked dev build) re-execs once with it automatically. Resolves + [#298](https://github.com/colbymchenry/codegraph/issues/298) and + [#293](https://github.com/colbymchenry/codegraph/issues/293). (Node 25 stays + blocked — its variant of this V8 bug is not resolved by `--liftoff-only`.) +- **Cursor uninstall left an orphaned `.cursor/rules/codegraph.mdc`.** It + stripped the rule body but left the file and its `description: CodeGraph …` + frontmatter behind. The dedicated rules file is now deleted outright on + uninstall, while any content you added outside CodeGraph's markers is kept. + +## [0.9.2] - 2026-05-21 + +### Added +- **Installer target: Hermes Agent (Nous Research).** `codegraph install` now + supports Hermes Agent — it writes the `mcp_servers.codegraph` entry and ensures + `platform_toolsets.cli` includes `mcp-codegraph` in `$HERMES_HOME/config.yaml`, + so Hermes can drive the CodeGraph knowledge graph like the other agents. +- **Framework support: Drupal 8/9/10/11** — CodeGraph now detects Drupal + projects (via a `drupal/*` dependency in `composer.json`) and adds three + levels of intelligence: + - **Route extraction**: `*.routing.yml` files emit a `route` node per route, + linked by a `references` edge to the `_controller`, `_form`, or + entity-handler class/method, so querying a controller method surfaces the + URL route that binds it. + - **Hook detection**: hook implementations in `.module`, `.install`, `.theme`, + and `.inc` files are detected via docblock (`Implements hook_X()`) with a + module-name-prefix fallback. Each emits a `references` edge to the canonical + `hook_X` name so `codegraph_callers("hook_form_alter")` returns every + implementation across modules. + - **Resolution**: `_controller`/`_form` FQCNs resolve to their PHP + class/method nodes. + New `yaml`/`twig` languages are tracked at the file level, the Drupal PHP + extensions (`.module`/`.install`/`.theme`/`.inc`) are indexed with the PHP + grammar, and `web/core`, `web/modules/contrib`, `web/themes/contrib` are + excluded by default. Resolves [#268](https://github.com/colbymchenry/codegraph/issues/268). + +### Changed +- **Zero-config indexing that respects `.gitignore`.** CodeGraph no longer has a + config file. It indexes every file whose extension maps to a supported language + and honors your `.gitignore` everywhere: in git repos via git itself, and in + non-git projects (e.g. a freshly-scaffolded app before `git init`) by reading + `.gitignore` files directly — root and nested, the same way git does (via the + `ignore` library, so negation/anchoring/nested rules all behave correctly). To + keep something out of the graph, add it to `.gitignore`. **Behavior change:** + committed files that are *not* gitignored are now indexed even under `vendor/`, + `Pods/`, or a committed `dist/` — previously a hardcoded exclude list skipped + those names; now `.gitignore` is the single source of truth. Resolves + [#283](https://github.com/colbymchenry/codegraph/issues/283). + +### Fixed +- **Windows: `npm i -g @colbymchenry/codegraph` then any `codegraph` command + failed with `spawnSync …\codegraph.cmd EINVAL`.** The npm launcher spawned the + bundle's `.cmd` file directly, which modern Node refuses to do on Windows + (the CVE-2024-27980 hardening — seen on Node 24). The launcher now invokes the + bundled `node.exe` against the app directly, so `codegraph` works on Windows + regardless of your Node version. Resolves + [#289](https://github.com/colbymchenry/codegraph/issues/289). + +### Removed +- **`.codegraph/config.json` and the entire config surface.** Every field was + either inert or now redundant with `.gitignore`: + - `languages`/`frameworks` never affected indexing (languages are detected per + file from extensions; frameworks are auto-detected). `languages` was also + broken — its validator only knew the original 8 languages, so setting it to + anything newer (C#, PHP, Ruby, C/C++, Swift, Kotlin, Dart, Vue, Scala, Lua, …) + threw `Invalid configuration format`. + - `extractDocstrings`/`trackCallSites`/`customPatterns` were never read by any + extractor. + - `include` is now derived from the supported language extensions, `exclude` is + replaced by `.gitignore`, and `maxFileSize` (1 MB) is a constant. + + **Breaking (library API):** the `CodeGraphConfig` type, the `config` option on + `CodeGraph.init()`, and the `getConfig()`/`updateConfig()`/`getConfigPath` + exports are gone. Existing `.codegraph/config.json` files are simply ignored. + The `.codegraphignore` marker is no longer supported — use `.gitignore`. + +### Security +- **MCP session marker no longer follows symlinks** (CWE-59). Every + `codegraph_context` call writes a `codegraph-consulted-*` marker into the + system temp dir; the previous write followed symlinks, so on a multi-user + system another local user could pre-plant that path as a symlink and redirect + the write onto a victim-writable file. The marker is now opened with + `O_NOFOLLOW` and mode `0600`, and a planted symlink is refused rather than + followed. Resolves [#280](https://github.com/colbymchenry/codegraph/issues/280). + +## [0.9.1] - 2026-05-21 + +### Fixed +- **Standalone installers** (`curl … | sh`, `irm … | iex`): the bundled launcher + failed with `exec: …/node: not found` because it didn't resolve the symlink the + installer puts on your PATH. Installing on a machine with **no Node** now works. +- **npm**: `@colbymchenry/codegraph-linux-x64` is now published — the 0.9.0 + release silently shipped 6 of 7 packages, so `npm i -g` on linux-x64 couldn't + find its bundle. The release pipeline now verifies every package reached the + registry (and is idempotent), so a release can't pass green-but-broken again. + +[0.9.4]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.4 +[0.9.3]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.3 +[0.9.2]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.2 +[0.9.1]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.1 + +## [0.9.0] - 2026-05-21 + +### 🎉 Self-contained: CodeGraph bundles its own runtime — install anywhere, on any Node (or none) + +**No more `database is locked`. No more native build failures. No more "WASM fallback active."** + +CodeGraph used to need `better-sqlite3`, a native module compiled against your exact +Node version. When that build failed (common on Windows and locked-down machines) it +silently dropped to a slow WASM SQLite build with **no WAL** — the root cause of the +intermittent `database is locked` errors on concurrent MCP tool calls +([#238](https://github.com/colbymchenry/codegraph/issues/238)). That entire class of +problem is **gone**: CodeGraph now ships a self-contained Node runtime and uses Node's +built-in `node:sqlite` (real SQLite, full WAL + FTS5). + +- ✅ **Zero native compilation** — nothing to build, ever; nothing to rebuild when Node changes. +- ✅ **Runs on any Node version — or with no Node at all.** Install via the standalone installers with no Node present, or keep using `npm`/`npx` on any version (your Node only launches the bundled runtime). +- ✅ **`database is locked` fixed at the root** — real WAL means readers never block on a writer. +- ⚡ **5–10× faster** than the old WASM fallback for anyone who was stuck on it. + +```bash +# macOS / Linux — no Node required +curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh +# Windows (PowerShell) — no Node required +irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex +# or, if you have Node (any version): +npm i -g @colbymchenry/codegraph +``` + +### Added +- **Standalone installers** — one-line install with no Node.js required: + `curl -fsSL .../install.sh | sh` (macOS/Linux) and `irm .../install.ps1 | iex` + (Windows). They fetch the matching self-contained bundle from GitHub Releases + and put `codegraph` on your PATH. +- **Lua**: CodeGraph now indexes Lua (`.lua`) — functions, methods (table `t.f` + and `t:m` definitions become methods with a `t::f` receiver-qualified name), + local variables, `require(...)` imports, and the call edges between them. + Querying a Lua project (Neovim plugins, Kong, OpenResty, game code) now + surfaces its modules, methods, and call graph. +- **Luau** ([#232](https://github.com/colbymchenry/codegraph/issues/232)): + CodeGraph now indexes Luau (`.luau`), Roblox's typed superset of Lua — + everything Lua extracts, plus `type` / `export type` aliases, typed function + signatures, generics, and Roblox instance-path `require(script.Parent.X)` + imports. + +### Changed +- **SQLite backend is now Node's built-in `node:sqlite`** (real SQLite, WAL + + FTS5), shipped inside a bundled Node runtime. This fixes the concurrent-read + `database is locked` errors ([#238](https://github.com/colbymchenry/codegraph/issues/238)) + at the root and removes the native build step entirely. +- **`npm i -g` / `npx` now install a self-contained bundle.** The main package is + a tiny shim; the runtime ships as per-platform `optionalDependencies`, so the + install works on any Node version (your Node only launches the bundle). +- **`codegraph status`** now reports the effective journal mode (`wal` vs not), + so a `database is locked` report is triageable at a glance. + +### Removed +- **`better-sqlite3`** (optional native dependency) and **`node-sqlite3-wasm`** + (WASM fallback) — along with the native-build banner, the WASM fallback path, + and the no-WAL lock retries they required. The dependency tree now has zero + native addons. + +### Fixed +- **Installer**: re-running `codegraph install` now removes the broken + auto-sync hooks that pre-0.8 versions wrote to Claude Code's + `settings.json`. Those builds added a `Stop → codegraph sync-if-dirty` + hook (and a `PostToolUse → codegraph mark-dirty` partner); both + subcommands were later removed from the CLI, so Claude Code reported + `Stop hook error: ... unknown command 'sync-if-dirty'` on every turn. + The cleanup is surgical — only codegraph's own hook entries are + stripped, so unrelated hooks sharing the same file or event (e.g. a + GitKraken `gk ai hook run` hook) are left untouched — and it also runs + on uninstall, so the npm `preuninstall` step fully reverses a legacy + install. Re-run `codegraph install` once on an affected machine to + clear the error. + +[0.9.0]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.0 + +## [0.8.0] - 2026-05-20 + +### Added +- **Framework routes (NestJS)**: CodeGraph now recognises NestJS projects and + emits `route` nodes — each linked by a `references` edge to its handler + method — across all four transport layers: HTTP controllers (the + `@Controller` prefix joined with `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`/ + `@Head`/`@Options`/`@All`, including empty `@Controller()`/`@Get()`), + GraphQL resolvers (`@Query`/`@Mutation`/`@Subscription`), microservice + handlers (`@MessagePattern`/`@EventPattern`), and WebSocket gateways + (`@SubscribeMessage`, prefixed with the gateway namespace). Detected + automatically from any `@nestjs/*` dependency in `package.json`. Querying a + controller method or resolver now surfaces the route that binds it. + Resolves [#220](https://github.com/colbymchenry/codegraph/issues/220). +- **MCP / explore**: `codegraph_explore` source sections now carry line + numbers (cat -n style `\t`, matching the Read tool). This lets + the agent cite `file:line` straight from the explore payload instead of + re-opening the file just to find a line number — the dominant residual + cost on precise-tracing questions. In an isolated A/B (answer a + "which exact line" question with the relevant code already in the + payload), the no-line-numbers arm spent 2 file Reads + a grep recovering + the line number while the line-numbered arm answered with zero follow-up + tool calls. Payload cost is small (~3-5%). Set + `CODEGRAPH_EXPLORE_LINENUMS=0` to disable. +- **MCP / watcher**: CodeGraph now skips the live file watcher on WSL2 + `/mnt/*` drives, where recursive `fs.watch` is slow enough to break MCP + startup (see Fixed). When the watcher is off, `codegraph init` / + `codegraph install` offer to keep the index fresh via git hooks + (`post-commit`, `post-merge`, `post-checkout`) that run `codegraph sync` + in the background — accept for automatic refresh on commit / pull / + checkout, or decline and sync by hand. Either way you're told the index + stays frozen until it's re-synced. New controls: `CODEGRAPH_NO_WATCH=1` + (or `codegraph serve --mcp --no-watch`) forces the watcher off anywhere; + `CODEGRAPH_FORCE_WATCH=1` overrides the WSL auto-detect when your `/mnt` + setup is actually fast. `codegraph uninit` removes any hooks it installed. + +### Changed +- **MCP / agent guidance**: CodeGraph now tells agents to answer "how does X + work" / architecture questions *directly* — `codegraph_context`, then one + `codegraph_explore` for the surfaced symbols — instead of delegating to a + file-reading sub-agent or a grep+read loop. The server instructions and the + installed instruction files (`CLAUDE.md`, `.cursor/rules/codegraph.mdc`, + `AGENTS.md`) previously suggested *spawning a sub-agent* for explore-class + questions, which produced the opposite, more expensive behavior: the + sub-agent reads files regardless of the index, so CodeGraph became overhead + stacked on top of the reads. In rigorous N≥4-per-arm benchmarks this cut the + cost of an architecture question by ~42–47% versus a no-CodeGraph agent on + medium and large repos (Excalidraw ~600 files, VS Code ~10k), with + equal-or-better, `file:line`-cited answers and ~6× fewer tool calls; on a + tiny repo (~25 files) it's a wash, since native grep is already trivially + cheap there. +- **MCP / codegraph_node**: `includeCode=true` on a class/interface/struct/enum + now returns a compact member outline (fields + method signatures + line + numbers) instead of the entire class body — which could be thousands of + characters and was rarely needed in full. Functions and methods still return + their full body; request a specific member for its source. +- **Minimum Node.js is now 20** (was 18). Node 18 is end-of-life and the + native SQLite binding (`better-sqlite3` 12.x) no longer ships a Node 18 + prebuilt binary. Node 22 LTS and Node 24 get the native backend out of the + box; on other Node versions CodeGraph still runs via the WASM fallback + (slower, but functional). Node 25+ remains blocked (V8 WASM JIT crash, see + [#81](https://github.com/colbymchenry/codegraph/issues/81)). +- **MCP / explore**: `codegraph_explore` output is now adaptive to project + size. The tool used to apply a fixed 35KB cap regardless of how large the + codebase was, which on small projects (~100 files) produced bigger + responses than the agent's native grep+Read flow would have — exactly the + scenario reported in + [#185](https://github.com/colbymchenry/codegraph/issues/185). The budget + now scales with indexed file count: small projects (<500 files) cap at + ~18KB and skip the "Additional relevant files" / completeness / explore- + budget reminders that earn their keep on bigger codebases; medium + (<5,000) caps at ~13KB; large (<15,000) keeps the historical ~35KB; very + large goes up to ~38KB. A new per-file char cap also prevents a single + file with many adjacent symbols from collapsing into one whole-file dump + (the Alamofire `Session.swift` case from #185). Per-file cluster + selection ranks clusters that contain a query entry point ahead of dense + declaration blocks, and whole-file "envelope" nodes (a class/struct that + spans most of the file) are excluded from clustering so the methods the + query asked about aren't buried under the container's opening lines. + Measured against the same repos used in the README benchmark, end state + with line numbers on: Alamofire ~60% smaller per call, Excalidraw ~32%, + VS Code ~12%. Agent-trust floor still holds — the Relationships section, + scored cluster selection, and structured-source output are all retained. + Thanks to [@essopsp](https://github.com/essopsp) for the repro. +- **Search ranking (Kotlin / Swift / Scala / C#)**: test files in these + languages are now correctly de-prioritized in `codegraph_search`, + `codegraph_context`, and `codegraph affected`. Detection previously only + recognized `snake_case`/`.test.`-style names plus a handful of Java + suffixes, so CamelCase test files (`FooTest.kt`, `BarTests.swift`, + `BazSpec.scala`, `QuxTestCase.cs`) and Gradle / Kotlin-Multiplatform / + Xcode test source-set directories (`jvmTest/`, `commonTest/`, + `androidTest/`, `iosTest/`, `integrationTest/`) were treated as production + code and could outrank the real implementation. Detection now matches + capital-led `*Test` / `*Tests` / `*Spec` / `*TestCase` filenames and + source-set directories — deliberately capital-led so lowercase look-alikes + like `latest.kt` and `manifest.kt` are not misclassified. + +### Fixed +- **MCP / explore**: `codegraph_explore` output is now hard-capped to its + adaptive size budget. It could previously overrun (e.g. ~30K against a 28K + cap) once the relationship map and trailer sections were appended; the + oversized payload then sat in the agent's context and was re-read on every + later turn. +- **Sync / status**: git-untracked files are no longer reported as pending + "Added" forever. After `codegraph sync` indexed a newly-created untracked + source file, `codegraph status` kept listing it under Pending Changes and + every subsequent `sync` re-indexed it from scratch — even though its symbols + were already queryable. Change detection trusted `git status` and counted + every untracked (`??`) entry as new without checking the index, but indexing + a file doesn't make git track it, so the file stayed `??` and got re-added on + each run. CodeGraph now hash-compares untracked files against the index the + same way it does tracked files: a file counts as "added" only if it's missing + from the index, "modified" if its contents changed, and is skipped otherwise. + Closes [#206](https://github.com/colbymchenry/codegraph/issues/206). Thanks to + [@15290391025](https://github.com/15290391025) for the report. +- **Indexing**: `codegraph init -i` now finds source inside nested, independent + git repositories — separate clones living inside the workspace that are **not** + git submodules (common in CMake "super-repo" layouts). When the top-level + workspace is itself a git repo, `git ls-files` reports an embedded repo only as + an opaque `subdir/` entry and never lists its files, so indexing from the + workspace root reported "No files found to index" even though indexing each + sub-repo individually worked. CodeGraph now detects these embedded repos and + indexes their tracked and untracked source, honoring each repo's own + `.gitignore`. Closes + [#193](https://github.com/colbymchenry/codegraph/issues/193). Thanks to + [@timxx](https://github.com/timxx) for the report. +- **Native SQLite backend on Node 24**: indexing on Node 24 always dropped to + the 5-10x-slower WASM backend, printing a `better-sqlite3 unavailable` + warning that `npm rebuild better-sqlite3` / `xcode-select --install` could + not clear ([#203](https://github.com/colbymchenry/codegraph/issues/203)). + The bundled `better-sqlite3` was pinned to a v11 release that ships no + prebuilt binary for Node 24's ABI (`node-v137`), so every Node 24 install + silently degraded — and because CodeGraph is usually installed globally, the + `npm install` / `npm rebuild` people ran in their own project never touched + CodeGraph's copy. CodeGraph now requires `better-sqlite3` `^12.4.1`, whose + prebuilds include Node 24, so a fresh install on Node 22 or Node 24 gets the + native backend with no compiler. On an already-broken install, reinstall + CodeGraph (e.g. `npm install -g @colbymchenry/codegraph`) to pull the new + binding; `codegraph status` should then report `Backend: native`. Thanks to + [@Finndersen](https://github.com/Finndersen) for the report. +- **MCP**: tools no longer fail with "CodeGraph not initialized" when the index + actually exists. This hit clients that launch the MCP server from a directory + other than your project and don't report a workspace root in `initialize` + (some IDE/JetBrains-family integrations) — the server fell back to its own + working directory, missed the project's `.codegraph/`, and returned the + misleading "Run 'codegraph init' first" on every call. The only workaround + was passing `projectPath` to each tool by hand. Now, when no project path is + supplied, the server asks the client for its workspace root via the standard + MCP `roots/list` request (when the client advertises the `roots` capability) + before falling back to the working directory — so detection just works for + spec-compliant clients. When it still can't resolve a project, the error is + now actionable: it names the directory it searched and tells you to pass + `projectPath` or add `--path /abs/project` to the server's MCP config args, + instead of pointing you at a re-init you don't need. Closes + [#196](https://github.com/colbymchenry/codegraph/issues/196). Thanks to + [@zhangyu1197](https://github.com/zhangyu1197) for the report and the + `projectPath` workaround. +- **MCP**: the server no longer hangs on startup under WSL2 when the project + lives on an NTFS `/mnt/*` mount. Setting up the recursive file watcher + there took tens of seconds — every directory read crosses the Windows/9p + boundary — which blew past the host's initialization timeout (opencode's + 30s), so the codegraph tools silently never appeared, even on small + projects. This is the file-watcher half of the + [#172](https://github.com/colbymchenry/codegraph/issues/172) startup fix: + that one moved the database/WASM open off the handshake, but the watcher + setup was still on the critical path. CodeGraph now auto-skips the watcher + on those mounts, with manual and git-hook sync fallbacks (see Added). + Closes [#199](https://github.com/colbymchenry/codegraph/issues/199). + Thanks to [@mengfanbo123](https://github.com/mengfanbo123) for the precise + root-cause analysis and workaround. +- **Installer (Claude Code)**: project-local installs (`Just this project`) + now write the MCP server to `.mcp.json` in the project root — the file + Claude Code actually reads for project-scoped servers. Previously they + wrote `.claude.json`, which Claude Code ignores, so the codegraph tools + silently never appeared and you had to rename the file by hand to make it + work. Re-running `codegraph install` (or `codegraph init`) on an affected + project migrates the stale `.claude.json` entry into `.mcp.json` + automatically; uninstall cleans up both. Global (`All projects`) installs + were unaffected — they correctly target `~/.claude.json`. Closes + [#207](https://github.com/colbymchenry/codegraph/issues/207). Thanks to + [@Jhsmit](https://github.com/Jhsmit) for the report and the workaround. +- **MCP**: source-omission markers in `codegraph_explore` and + `codegraph_context` output are now language-neutral (`... (gap) ...`, + `... (trimmed) ...`, `... (truncated) ...`) instead of C-style `//` + comments, which were misleading inside Python, Ruby, and other non-C + fenced source blocks. + +## [0.7.10] - 2026-05-19 + +### Fixed +- **MCP**: tools no longer silently fail to appear in clients on slow + filesystems (Docker Desktop VirtioFS on macOS, WSL2). The `initialize` + handshake was blocking on opening the SQLite database and bootstrapping + the tree-sitter WASM runtime, which on slow I/O could exceed Claude + Code's ~30s handshake timeout — leaving the codegraph process alive but + unresponsive and no tools visible. The handshake now returns immediately + and defers project open to the background; tool calls wait on the + in-flight init rather than racing it with a second open. Closes + [#172](https://github.com/colbymchenry/codegraph/issues/172). Thanks to + [@sashanclrp](https://github.com/sashanclrp) for the original report and + detailed reproduction, and [@sgrimm](https://github.com/sgrimm) for the + decisive wire capture that isolated the actual root cause. +- **CLI**: terminal output no longer mojibakes on Windows PowerShell / + cmd.exe during `codegraph index` and `codegraph sync`. The shimmer + progress renderer writes from a worker thread via `fs.writeSync(1, …)` + to keep the animation smooth while the main thread is busy in SQLite, + which bypasses Node's TTY-aware UTF-8→codepage conversion — so glyphs + like `│ ◆ —` were emitted as raw UTF-8 bytes and reinterpreted as the + console's OEM codepage (CP437, CP936, …), producing strings like + `鋍?[0m 鉒?[0m Scanning files 鈥?N found`. CodeGraph now picks an ASCII + glyph set on Windows by default (`| * -` instead of `│ ◆ —`); set + `CODEGRAPH_UNICODE=1` to opt back into the Unicode glyphs (e.g. on + pwsh 7 with UTF-8 codepage), or `CODEGRAPH_ASCII=1` on any platform to + force ASCII (useful for log collectors / non-TTY pipelines). Closes + [#168](https://github.com/colbymchenry/codegraph/issues/168). Thanks to + [@starkleek](https://github.com/starkleek) for the report and to + [@Bortlesboat](https://github.com/Bortlesboat) for the initial PR. +- **MCP / search**: module-qualified symbol lookups now resolve. The + MCP tools (`codegraph_node`, `codegraph_callees`, `codegraph_impact`, + …) accept `module::symbol` (Rust / C++ / Ruby), `Module.symbol` + (TS / JS / Python), and `module/symbol` (path-style) — multi-level + forms (`crate::configurator::stage_apply::run`) and Rust path + prefixes (`crate`, `super`, `self`) are handled. Closes + [#173](https://github.com/colbymchenry/codegraph/issues/173). Thanks + to [@joselhurtado](https://github.com/joselhurtado) for the detailed + reproduction. Three underlying fixes: + - The FTS5 query builder now treats `::` as a token separator + instead of stripping it to nothing, so `stage_apply::run` no + longer collapses to the unsearchable `stage_applyrun`. + - `matchesSymbol` falls back to a file-path containment check when + `qualifiedName` doesn't carry the module hierarchy (Rust + file-level functions, Python free functions in a package): a + `run` in `src/configurator/stage_apply.rs` now matches + `stage_apply::run` because `stage_apply` appears as a path + segment. + - Qualified lookups that don't match the qualifier no longer fall + through to fuzzy text matches — `stage_apply::nonexistent_fn` + returns `null` instead of resolving to an unrelated `rollback` + in the same file. + +[0.8.0]: https://github.com/colbymchenry/codegraph/releases/tag/v0.8.0 +[0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10 + +## [0.7.8] - 2026-05-17 + +### Fixed +- **opencode**: install actually wires up the MCP server now. v0.7.7 wrote + `~/.config/opencode/opencode.json`, but opencode reads `opencode.jsonc` by + default — so the `codegraph` entry never showed up in any opencode session. + The installer now prefers an existing `.jsonc`, falls back to `.json` when + only that exists, and creates `.jsonc` for greenfield installs. **Re-run + `codegraph install --target=opencode` after upgrading** so the entry lands + in the file opencode actually reads. + +### Added +- **opencode**: installer now writes `AGENTS.md` (global + `~/.config/opencode/AGENTS.md`, local `./AGENTS.md`) with the same + codegraph usage guidance the other agents already received. Without it, + opencode's model would call native `Grep` instead of the `codegraph_*` + tools it could see in its MCP list. +- User comments and formatting in `opencode.jsonc` survive install / + re-install / uninstall round-trips — surgical edits via `jsonc-parser` + rather than full-file rewrites. + +[0.7.8]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.8 + +## [0.7.7] - 2026-05-17 + +### Added +- **Multi-agent installer** (closes [#137](https://github.com/colbymchenry/codegraph/issues/137)). + `codegraph install` now opens with a multi-select prompt for **Claude Code**, + **Cursor**, **Codex CLI**, and **opencode** — detected agents are pre-checked. + Each writes its native MCP config + instructions file (e.g. `~/.cursor/mcp.json` + + `.cursor/rules/codegraph.mdc`, `~/.codex/config.toml` + `~/.codex/AGENTS.md`, + `~/.config/opencode/opencode.json`). The runtime MCP server was already + agent-agnostic; this brings the installer to parity. +- Non-interactive install flags for scripting / CI: + `--target=`, `--location=`, `--yes`, + `--no-permissions`, `--print-config `. +- `codegraph init` now auto-wires project-local agent surfaces for any agent + configured globally. In practice: Cursor's `.cursor/rules/codegraph.mdc` + is dropped on `init` so a single global `codegraph install` works in every + project you open — no per-project re-install needed. + +### Fixed +- **Cursor**: globally-installed codegraph reported "not initialized" in every + workspace because Cursor launches MCP-server subprocesses with the wrong + working directory and doesn't pass `rootUri` in the MCP initialize call. + We now inject `--path` into Cursor's MCP args — absolute path for local + installs, `${workspaceFolder}` for global installs. + +### Changed +- Agent-instructions template is now agent-agnostic. The previous template was + inherited from the Claude-only era and prescribed "spawn an Explore agent" — + a Claude Code-specific concept that confused Cursor's and Codex's agents and + caused them to fall back to native grep even with codegraph available. The + new template adds explicit "trust codegraph results, don't re-verify with + grep" guidance and a clear tool-by-question matrix. Applies to + `~/.claude/CLAUDE.md`, `.cursor/rules/codegraph.mdc`, and `~/.codex/AGENTS.md`. +- `codegraph install` prompt order: agent picker is now step 1, before the + PATH-install and location prompts. +- Disambiguated "global" wording in install prompts ("Install codegraph CLI on + your PATH?" vs "Apply agent configs to all your projects, or just this one?") + — both used to say "Global" and read as duplicates. + +### Internal +- New `AgentTarget` interface in `src/installer/targets/` — adding a 5th agent + (Continue, Zed, Windsurf, …) is a new file + one entry in `registry.ts`. +- Hand-rolled TOML serializer for Codex (`src/installer/targets/toml.ts`) — no + new dependency, scoped to the `[mcp_servers.codegraph]` table only, sibling + tables and `[[array_of_tables]]` preserved verbatim. +- +47 parameterized contract tests across the 4 targets — install idempotency, + sibling preservation, uninstall reverses install, byte-equal re-runs return + `unchanged`, partial-state recovery for Codex. + +Based on substantive draft by [@andreinknv](https://github.com/andreinknv) +([fork commit `c5165e4`](https://github.com/andreinknv/codegraph/commit/c5165e4)). +Thank you. + +[0.7.7]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.7 + +## [0.7.6] - 2026-05-13 + +### Fixed +- `codegraph` CLI failing with `zsh: permission denied: codegraph` after a fresh + global install. The published 0.7.5 tarball shipped `dist/bin/codegraph.js` + without the executable bit, so the shell refused to run it through the npm + symlink. The build now `chmod +x`'s the binary before packing. + + Already on 0.7.5? Either upgrade to 0.7.6, or unblock yourself in place: + ```bash + chmod +x "$(npm root -g)/@colbymchenry/codegraph/dist/bin/codegraph.js" + ``` + +[0.7.6]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.6 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be63c67 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,166 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +CodeGraph is a local-first code intelligence library + CLI + MCP server. It parses any supported codebase with tree-sitter, stores symbols/edges/files in SQLite (FTS5), and exposes a knowledge graph to AI agents (Claude Code, Cursor, Codex CLI, opencode) over MCP. Per-project data lives in `.codegraph/`. Extraction is deterministic — derived from AST, not LLM-summarized. + +Distributed as `@colbymchenry/codegraph` on npm; same binary serves as installer, indexer, and MCP server. + +## Build, Test, Run + +```bash +npm run build # tsc + copy schema.sql and *.wasm into dist/; chmods dist/bin/codegraph.js +npm run dev # tsc --watch +npm run clean # rm -rf dist + +npm test # vitest run (all) +npm run test:watch +npm run test:eval # only __tests__/evaluation/ +npm run eval # build then run __tests__/evaluation/runner.ts via tsx + +npm run cli # build then run the local dist binary + +# Single test file / pattern +npx vitest run __tests__/installer-targets.test.ts +npx vitest run __tests__/extraction.test.ts -t "TypeScript" +``` + +`copy-assets` (called from `build`) copies `src/db/schema.sql` and all `src/extraction/wasm/*.wasm` files into `dist/`. **Any new SQL or grammar wasm must be copied or it won't ship.** + +Node engines: `>=18.0.0 <25.0.0`. There is a hard exit on Node 25.x (see `src/bin/node-version-check.ts`). + +## Architecture + +### Layered pipeline + +``` +files → ExtractionOrchestrator (tree-sitter) → DB (nodes/edges/files) + ↓ + ReferenceResolver (imports, name-matching, framework patterns) + ↓ + GraphQueryManager / GraphTraverser (callers, callees, impact) + ↓ + ContextBuilder (markdown/JSON for AI consumption) +``` + +The public API surface is `src/index.ts` — the `CodeGraph` class wires all the layers and re-exports types. Library users only touch this file; the MCP server and CLI also drive it. + +### Module layout + +- `src/index.ts` — `CodeGraph` class: `init`/`open`/`close`, `indexAll`, `sync`, `searchNodes`, `getCallers`/`getCallees`, `getImpactRadius`, `buildContext`, `watch`/`unwatch`. +- `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`. Backed by `better-sqlite3` (native) when available, transparently falls back to `node-sqlite3-wasm`. `codegraph status` surfaces which backend is live; wasm is the slow path. +- `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread. +- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. +- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries). +- `src/context/` — `ContextBuilder` + formatter for markdown/JSON output. +- `src/search/` — full-text query parser and helpers for FTS5. +- `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers. +- `src/mcp/` — MCP server (`MCPServer`, `tools.ts`, `transport.ts`). `server-instructions.ts` is what the server returns in the MCP `initialize` response — keep it in sync with the user-facing tool guidance. +- `src/installer/` — see below. +- `src/bin/codegraph.ts` — CLI (commander). Subcommands: `install`, `init`, `uninit`, `index`, `sync`, `status`, `query`, `files`, `context`, `affected`, `serve --mcp`. +- `src/ui/` — terminal UI (shimmer progress, worker). + +### NodeKind / EdgeKind + +Defined in `src/types.ts`. Both extractors and resolvers must use these exact strings. + +- **NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component`. +- **EdgeKind**: `contains`, `calls`, `imports`, `exports`, `extends`, `implements`, `references`, `type_of`, `returns`, `instantiates`, `overrides`, `decorates`. + +### Multi-agent installer + +`src/installer/` is the entry point for `codegraph install` (and the bare `codegraph`/`npx @colbymchenry/codegraph` invocation). Architecture: + +- `targets/registry.ts` lists every supported agent. +- `targets/types.ts` defines the `AgentTarget` interface — adding a 5th agent (Continue, Zed, Windsurf…) is **one new file in `targets/` + one entry in `registry.ts`**. Each target owns its config-file location, MCP-server JSON/TOML/JSONC writing, and instructions-file path. +- Current targets: `claude.ts`, `cursor.ts`, `codex.ts`, `opencode.ts`. +- `targets/toml.ts` is a hand-rolled TOML serializer scoped to `[mcp_servers.codegraph]` (used by Codex). Sibling tables and `[[array_of_tables]]` are preserved verbatim. No new dependency. +- opencode reads `opencode.jsonc` by default; the installer prefers existing `.jsonc`, falls back to `.json`, and creates `.jsonc` for greenfield installs. Edits are surgical via `jsonc-parser` so user comments and formatting survive install/re-install/uninstall round-trips. +- `instructions-template.ts` is the agent-agnostic instructions file written to each target (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md`). It explicitly says "trust codegraph results, don't re-verify with grep" — earlier versions prescribed Claude-specific "spawn an Explore agent" and confused other agents. +- `claude-md-template.ts` is the legacy Claude-only template, retained for compatibility paths. +- All installer changes need matching coverage in `__tests__/installer-targets.test.ts` — there are ~47 parameterized contract tests covering install idempotency, sibling preservation, uninstall reverses install, byte-equal re-runs returning `unchanged`, and partial-state recovery for Codex. + +### Cursor MCP working-directory quirk + +Cursor launches MCP subprocesses with the wrong cwd and doesn't pass `rootUri` in `initialize`. The installer injects `--path` into Cursor's MCP args — absolute path for local installs, `${workspaceFolder}` for global installs. If you touch Cursor wiring, preserve this. + +### MCP server instructions + +`src/mcp/server-instructions.ts` is sent back to the agent in the MCP `initialize` response. This is the *first* thing every agent sees about how to use the tools — treat it as the authoritative tool guidance and keep it in sync with `instructions-template.ts` and `.cursor/rules/codegraph.mdc`. + +## Tests + +Tests live in `__tests__/` and mirror the module they cover. Notable ones beyond the obvious: + +- `installer-targets.test.ts` — parameterized contract suite across all 4 agent targets (see installer notes above). +- `evaluation/` — `runner.ts` + `test-cases.ts` exercise codegraph against synthetic projects and score the results; run via `npm run eval` (builds first). Not part of `npm test`. +- `sqlite-backend.test.ts` — covers native + wasm backend selection and fallback. +- `pr19-improvements.test.ts`, `frameworks-integration.test.ts` — regression coverage for specific past PRs/incidents; don't rename these, the names anchor to git history. + +Tests create temp dirs with `fs.mkdtempSync` and clean up in `afterEach`. They write real files and exercise real SQLite — there is no DB mocking. + +### Windows-gated tests + +Behavior that differs by platform (path resolution, drive letters, `SENSITIVE_PATHS`, `%APPDATA%` config dirs, CRLF) must be gated, not assumed. Use `it.runIf(process.platform === 'win32')(...)` for Windows-only assertions and `it.runIf(process.platform !== 'win32')(...)` for POSIX-only ones — e.g. `/etc` is sensitive on POSIX but resolves to `C:\etc` (non-existent) on Windows, so an ungated `/etc` assertion fails on Windows. Validate the Windows side for real (see below); don't merge a Windows-gated test you haven't seen run. + +## Windows validation (Parallels + SSH) + +For any Windows-specific PR, bug, or implementation, validate it on the real Windows VM rather than guessing. Connection details live in the gitignored **`.parallels`** file at the repo root (VM name, guest IP, SSH user/key). `prlctl exec` needs Parallels Pro and is unavailable, so SSH is the bridge. + +- Connect / run from the Mac host: `ssh @ "..."`. For multi-line work, pipe PowerShell over stdin and **refresh PATH from the registry** first (sshd's session has a stale PATH after winget installs): + ``` + ssh colby@10.211.55.3 "powershell -NoProfile -ExecutionPolicy Bypass -Command -" <<'PS' + $env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User") + Set-Location C:\dev\codegraph + PS + ``` +- Clone fresh into a **Windows-local** path (`C:\dev\codegraph`) and `npm ci` there — never run npm against the shared Mac repo, since `esbuild`/`rollup` ship platform-specific binaries. +- Guest toolchain (winget): Node LTS, Git, and the **VC++ ARM64 redistributable** (required by `@rollup/rollup-win32-arm64-msvc`, which vitest pulls in). +- Fetch a contributor PR head straight from their fork to dodge `pull//head` lag: `git fetch ` then `git checkout -f FETCH_HEAD`. +- Known pre-existing Windows failure: `security.test.ts > Session marker symlink resistance > does not follow a pre-planted symlink` (symlink creation needs privileges on Windows). Unrelated to current work; don't let it mask new regressions. + +## Releases + +Released to npm and mirrored as [GitHub Releases](https://github.com/colbymchenry/codegraph/releases). `CHANGELOG.md` is the source of truth; GitHub Release notes are extracted from it. + +### Writing changelog entries + +When asked for an entry for a new version: + +1. Add a new `## [X.Y.Z] - YYYY-MM-DD` block at the **top** of `CHANGELOG.md` (under the intro, above the previous version). +2. Group under `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security` — omit empty sections. +3. Write from the **user's perspective**, not the implementation's. Lead with the observable symptom or capability; mention internals only if a user needs them (e.g., to work around an existing bad install). +4. Add the link reference at the bottom: `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`. + +### Release flow (the user runs these) + +Releases are built and published by the **GitHub Actions "Release" workflow** +(`.github/workflows/release.yml`). It bundles a Node runtime per platform +(`scripts/build-bundle.sh`) and publishes both the GitHub Release and the npm +thin-installer (`scripts/pack-npm.sh`: a shim package + per-platform packages). +Publishing manually is **wrong** now — a plain `npm publish` ships the root +package (non-bundled), which breaks anyone on Node < 22.5. + +After the changelog entry is written and `package.json` is bumped: + +```bash +git add package.json package-lock.json CHANGELOG.md +git commit -m "release: X.Y.Z ()" +git push +``` + +Then trigger **Actions → Release → Run workflow** (on `main`). It reads the +version from `package.json`, builds every platform bundle on one runner, creates +the GitHub Release with notes from the matching `CHANGELOG.md` section, and +publishes to npm. Requires the `NPM_TOKEN` repo secret. + +**Do not run `npm publish`, `git push`, or `git tag` yourself** — these are +publish actions on shared state. Write the files, hand the user the commands. + +## House rules + +- The `0.7.x` line is in active multi-agent rollout. Any change to `src/installer/` (especially `targets/`) needs corresponding test coverage and a CHANGELOG entry — installer regressions break every new install silently. +- When changing what the MCP tools do or how agents should use them, update **all three** of `src/mcp/server-instructions.ts`, `src/installer/instructions-template.ts`, and `.cursor/rules/codegraph.mdc` — they're written to different places but say the same thing. +- CodeGraph provides **code context**, not product requirements. For new features, ask the user about UX, edge cases, and acceptance criteria — the graph won't tell you. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31c84c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Colby Mchenry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..faf357b --- /dev/null +++ b/README.md @@ -0,0 +1,541 @@ +
+ +# CodeGraph + +### Supercharge Claude Code, Cursor, Codex, OpenCode, and Hermes Agent with Semantic Code Intelligence + +**~35% cheaper · ~70% fewer tool calls · 100% local** + +[![npm version](https://img.shields.io/npm/v/@colbymchenry/codegraph.svg)](https://www.npmjs.com/package/@colbymchenry/codegraph) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Self-contained](https://img.shields.io/badge/Node.js-bundled%20%C2%B7%20none%20required-brightgreen.svg)](https://nodejs.org/) + +[![Windows](https://img.shields.io/badge/Windows-supported-blue.svg)](#supported-platforms) +[![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#supported-platforms) +[![Linux](https://img.shields.io/badge/Linux-supported-blue.svg)](#supported-platforms) + +[![Claude Code](https://img.shields.io/badge/Claude_Code-supported-blueviolet.svg)](#supported-agents) +[![Cursor](https://img.shields.io/badge/Cursor-supported-blueviolet.svg)](#supported-agents) +[![Codex CLI](https://img.shields.io/badge/Codex_CLI-supported-blueviolet.svg)](#supported-agents) +[![opencode](https://img.shields.io/badge/opencode-supported-blueviolet.svg)](#supported-agents) +[![Hermes Agent](https://img.shields.io/badge/Hermes_Agent-supported-blueviolet.svg)](#supported-agents) + +
+ +## Get Started + +**No Node.js required** — one command grabs the right build for your OS: + +```bash +# macOS / Linux +curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh + +# Windows (PowerShell) +irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex +``` + +Already have Node? Use npm instead (works on any version): + +```bash +npx @colbymchenry/codegraph # zero-install, or: +npm i -g @colbymchenry/codegraph +``` + +CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent. + +### Initialize Projects + +```bash +cd your-project +codegraph init -i +``` + +
+ +![1_C_VYnhpys0UHrOuOgpgoyw](https://github.com/user-attachments/assets/f168182f-4d9a-44e0-94d7-08d018cc8a3a) + +
+ +### Uninstall + +Changed your mind? One command removes CodeGraph from every agent it configured: + +```bash +codegraph uninstall +``` + +Reverses the installer — strips CodeGraph's MCP server config, instructions, and permissions from each configured agent. Your project indexes (`.codegraph/`) are left untouched; remove those per-project with `codegraph uninit`. Use `--target` to remove from specific agents, or `--yes` to run non-interactively. + +--- + +## Why CodeGraph? + +When Claude Code explores a codebase, it spawns **Explore agents** that scan files with grep, glob, and Read — consuming tokens on every tool call. + +**CodeGraph gives those agents a pre-indexed knowledge graph** — symbol relationships, call graphs, and code structure. Agents query the graph instantly instead of scanning files. + +### Benchmark Results + +Tested across **7 real-world open-source codebases** spanning 7 languages, comparing an agent (Claude Code, headless) answering one architecture question **with** and **without** CodeGraph. Each cell is the savings at the **median of 4 runs per arm**. + +> **Average: 35% cheaper · 59% fewer tokens · 49% faster · 70% fewer tool calls** + +| Codebase | Language | Cost | Tokens | Time | Tool calls | +|----------|----------|------|--------|------|------------| +| **VS Code** | TypeScript · ~10k files | 35% cheaper | 73% fewer | 41% faster | 72% fewer | +| **Excalidraw** | TypeScript · ~600 | 47% cheaper | 73% fewer | 60% faster | 86% fewer | +| **Django** | Python · ~2.7k | 34% cheaper | 64% fewer | 59% faster | 81% fewer | +| **Tokio** | Rust · ~700 | 52% cheaper | 81% fewer | 63% faster | 89% fewer | +| **OkHttp** | Java · ~640 | 17% cheaper | 41% fewer | 36% faster | 64% fewer | +| **Gin** | Go · ~150 | 22% cheaper | 23% fewer | 34% faster | 19% fewer | +| **Alamofire** | Swift · ~100 | 38% cheaper | 59% fewer | 51% faster | 77% fewer | + +The gains scale with codebase size: on large repos the agent answers from the index in a handful of calls with **zero file reads**, while the no-CodeGraph agent fans out across grep/find/Read (and the sub-agents it spawns). On a small repo like Gin (~150 files) native search is already cheap, so the margin narrows. + +
+Full benchmark details + +**Methodology.** Each arm is `claude -p` (Claude Opus 4.7, Claude Code v2.1.145) run headlessly against the repo with `--strict-mcp-config`: **WITH** = CodeGraph's MCP server enabled, **WITHOUT** = an empty MCP config. Built-in Read/Grep/Bash stay available to both. Same question per repo, **4 runs per arm, median reported**. Cost = the run's `total_cost_usd`; Tokens = total tokens processed (input incl. cached + output); Time = wall-clock; Tool calls = every tool invocation, including those inside any sub-agents the model spawns. Repos cloned at `--depth 1` and indexed by the same CodeGraph build that served them. + +**Queries:** +| Codebase | Query | +|----------|-------| +| VS Code | "How does the extension host communicate with the main process?" | +| Excalidraw | "How does Excalidraw render and update canvas elements?" | +| Django | "How does Django's ORM build and execute a query from a QuerySet?" | +| Tokio | "How does tokio schedule and run async tasks on its runtime?" | +| OkHttp | "How does OkHttp process a request through its interceptor chain?" | +| Gin | "How does gin route requests through its middleware chain?" | +| Alamofire | "How does Alamofire build, send, and validate a request?" | + +**Raw medians — WITH → WITHOUT:** +| Codebase | Cost | Tokens | Time | Tool calls | +|----------|------|--------|------|------------| +| VS Code | $0.42 → $0.64 | 393k → 1.4M | 1m 0s → 1m 43s | 7 → 23 | +| Excalidraw | $0.54 → $1.02 | 851k → 3.2M | 1m 17s → 3m 14s | 12 → 83 | +| Django | $0.41 → $0.62 | 499k → 1.4M | 1m 0s → 2m 25s | 9 → 48 | +| Tokio | $0.50 → $1.04 | 657k → 3.4M | 1m 5s → 2m 56s | 9 → 75 | +| OkHttp | $0.36 → $0.44 | 352k → 596k | 45s → 1m 11s | 5 → 14 | +| Gin | $0.36 → $0.46 | 431k → 562k | 47s → 1m 11s | 7 → 8 | +| Alamofire | $0.61 → $0.99 | 1.1M → 2.6M | 1m 19s → 2m 41s | 15 → 64 | + +**Why CodeGraph wins:** with the index available, the agent answers directly — `codegraph_context` to map the area, then one `codegraph_explore` for the relevant source — and stops, usually with zero file reads. Without it, the agent (and the Explore sub-agents it spawns) spends most of its budget on discovery (find/ls/grep) before reading the right code. CodeGraph only helps when queried *directly*, so its instructions steer agents to answer directly rather than delegate exploration to file-reading sub-agents — otherwise a sub-agent reads files regardless and CodeGraph becomes overhead. + +
+ +--- + +## Key Features + +| | | +|---|---| +| **Smart Context Building** | One tool call returns entry points, related symbols, and code snippets — no expensive exploration agents | +| **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | +| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | +| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | +| **19+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi | +| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 frameworks | +| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | + +--- + +## Framework-aware Routes + +CodeGraph detects web-framework routing files and emits `route` nodes linked by `references` edges to their handler classes or functions. Querying callers of a view/controller now surfaces the URL pattern that binds it. + +| Framework | Shapes recognized | +|---|---| +| **Django** | `path()`, `re_path()`, `url()`, `include()` in `urls.py` (CBV `.as_view()`, dotted paths) | +| **Flask** | `@app.route('/path', methods=[...])`, blueprint routes | +| **FastAPI** | `@app.get(...)`, `@router.post(...)`, all standard methods | +| **Express** | `app.get(...)`, `router.post(...)` with middleware chains | +| **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` | +| **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax | +| **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` | +| **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax | +| **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods | +| **Gin / chi / gorilla / mux** | `r.GET(...)`, `router.HandleFunc(...)` | +| **Axum / actix / Rocket** | `.route("/x", get(handler))` | +| **ASP.NET** | `[HttpGet("/x")]` attributes on action methods | +| **Vapor** | `app.get("x", use: handler)` | +| **React Router** / **SvelteKit** | Route component nodes | + +--- + +## Quick Start + +### 1. Run the Installer + +```bash +npx @colbymchenry/codegraph +``` + +The installer will: +- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent** +- Prompt to install `codegraph` on your PATH (so agents can launch the MCP server) +- Ask whether configs apply to all your projects or just this one +- Write each chosen agent's MCP server config + an instructions file (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`) +- Set up auto-allow permissions when Claude Code is one of the targets +- Initialize your current project (local installs only) + +**Non-interactive (scripting / CI):** + +```bash +codegraph install --yes # auto-detect agents, install global +codegraph install --target=cursor,claude --yes # explicit target list +codegraph install --target=auto --location=local # detected agents, project-local +codegraph install --print-config codex # print snippet, no file writes +``` + +| Flag | Values | Default | +|---|---|---| +| `--target` | `auto`, `all`, `none`, or csv (`claude,cursor,...`) | prompt | +| `--location` | `global`, `local` | prompt | +| `--yes` | (boolean) | prompt every step | +| `--no-permissions` | (boolean) skip Claude auto-allow list | permissions on | +| `--print-config ` | dump snippet for one agent and exit | — | + +### 2. Restart Your Agent + +Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent) for the MCP server to load. + +### 3. Initialize Projects + +```bash +cd your-project +codegraph init -i +``` + +Builds the per-project knowledge graph index. Also wires up any project-local agent surfaces (e.g. Cursor's `.cursor/rules/codegraph.mdc`) so a single global `codegraph install` works in every project you open — no need to re-run the installer per project. + +That's it — your agent will use CodeGraph tools automatically when a `.codegraph/` directory exists. + +
+Manual Setup (Alternative) + +**Install globally:** +```bash +npm install -g @colbymchenry/codegraph +``` + +**Add to `~/.claude.json`:** +```json +{ + "mcpServers": { + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": ["serve", "--mcp"] + } + } +} +``` + +**Add to `~/.claude/settings.json` (optional, for auto-allow):** +```json +{ + "permissions": { + "allow": [ + "mcp__codegraph__codegraph_search", + "mcp__codegraph__codegraph_context", + "mcp__codegraph__codegraph_callers", + "mcp__codegraph__codegraph_callees", + "mcp__codegraph__codegraph_impact", + "mcp__codegraph__codegraph_node", + "mcp__codegraph__codegraph_status", + "mcp__codegraph__codegraph_files" + ] + } +} +``` + +
+ +
+Global Instructions Reference + +The installer automatically adds these instructions to `~/.claude/CLAUDE.md`: + +```markdown +## CodeGraph + +CodeGraph builds a semantic knowledge graph of codebases for faster, smarter code exploration. + +### If `.codegraph/` exists in the project + +**NEVER call `codegraph_explore` or `codegraph_context` directly in the main session.** These tools return large amounts of source code that fills up main session context. Instead, ALWAYS spawn an Explore agent for any exploration question (e.g., "how does X work?", "explain the Y system", "where is Z implemented?"). + +**When spawning Explore agents**, include this instruction in the prompt: + +> This project has CodeGraph initialized (.codegraph/ exists). Use `codegraph_explore` as your PRIMARY tool — it returns full source code sections from all relevant files in one call. +> +> **Rules:** +> 1. Follow the explore call budget in the `codegraph_explore` tool description — it scales automatically based on project size. +> 2. Do NOT re-read files that codegraph_explore already returned source code for. The source sections are complete and authoritative. +> 3. Only fall back to grep/glob/read for files listed under "Additional relevant files" if you need more detail, or if codegraph returned no results. + +**The main session may only use these lightweight tools directly** (for targeted lookups before making edits, not for exploration): + +| Tool | Use For | +|------|---------| +| `codegraph_search` | Find symbols by name | +| `codegraph_callers` / `codegraph_callees` | Trace call flow | +| `codegraph_impact` | Check what's affected before editing | +| `codegraph_node` | Get a single symbol's details | + +### If `.codegraph/` does NOT exist + +At the start of a session, ask the user if they'd like to initialize CodeGraph: + +"I notice this project doesn't have CodeGraph initialized. Would you like me to run `codegraph init -i` to build a code knowledge graph?" +``` + +
+ +--- + +## How It Works + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Claude Code │ +│ │ +│ "Implement user authentication" │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Explore Agent │ ──── │ Explore Agent │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +└───────────┼────────────────────────┼─────────────────────────────┘ + │ │ + ▼ ▼ +┌───────────────────────────────────────────────────────────────────┐ +│ CodeGraph MCP Server │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Search │ │ Callers │ │ Context │ │ +│ │ "auth" │ │ "login()" │ │ for task │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └────────────────┼────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────┐ │ +│ │ SQLite Graph DB │ │ +│ │ • 387 symbols │ │ +│ │ • 1,204 edges │ │ +│ │ • Instant lookups │ │ +│ └───────────────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +1. **Extraction** — [tree-sitter](https://tree-sitter.github.io/) parses source code into ASTs. Language-specific queries extract nodes (functions, classes, methods) and edges (calls, imports, extends, implements). + +2. **Storage** — Everything goes into a local SQLite database (`.codegraph/codegraph.db`) with FTS5 full-text search. + +3. **Resolution** — After extraction, references are resolved: function calls → definitions, imports → source files, class inheritance, and framework-specific patterns. + +4. **Auto-Sync** — The MCP server watches your project using native OS file events. Changes are debounced (2-second quiet window), filtered to source files only, and incrementally synced. The graph stays fresh as you code — no configuration needed. + +--- + +## CLI Reference + +```bash +codegraph # Run interactive installer +codegraph install # Run installer (explicit) +codegraph uninstall # Remove CodeGraph from your agents (inverse of install) +codegraph init [path] # Initialize in a project (--index to also index) +codegraph uninit [path] # Remove CodeGraph from a project (--force to skip prompt) +codegraph index [path] # Full index (--force to re-index, --quiet for less output) +codegraph sync [path] # Incremental update +codegraph status [path] # Show statistics +codegraph query # Search symbols (--kind, --limit, --json) +codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json) +codegraph context # Build context for AI (--format, --max-nodes) +codegraph callers # Find what calls a function/method (--limit, --json) +codegraph callees # Find what a function/method calls (--limit, --json) +codegraph impact # Analyze what code is affected by changing a symbol (--depth, --json) +codegraph affected [files...] # Find test files affected by changes (see below) +codegraph serve --mcp # Start MCP server +``` + +### `codegraph affected` + +Traces import dependencies transitively to find which test files are affected by changed source files. + +```bash +codegraph affected src/utils.ts src/api.ts # Pass files as arguments +git diff --name-only | codegraph affected --stdin # Pipe from git diff +codegraph affected src/auth.ts --filter "e2e/*" # Custom test file pattern +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `--stdin` | Read file list from stdin | `false` | +| `-d, --depth ` | Max dependency traversal depth | `5` | +| `-f, --filter ` | Custom glob to identify test files | auto-detect | +| `-j, --json` | Output as JSON | `false` | +| `-q, --quiet` | Output file paths only | `false` | + +**CI/hook example:** + +```bash +#!/usr/bin/env bash +AFFECTED=$(git diff --name-only HEAD | codegraph affected --stdin --quiet) +if [ -n "$AFFECTED" ]; then + npx vitest run $AFFECTED +fi +``` + +--- + +## MCP Tools + +When running as an MCP server, CodeGraph exposes these tools to Claude Code: + +| Tool | Purpose | +|------|---------| +| `codegraph_search` | Find symbols by name across the codebase | +| `codegraph_context` | Build relevant code context for a task | +| `codegraph_callers` | Find what calls a function | +| `codegraph_callees` | Find what a function calls | +| `codegraph_impact` | Analyze what code is affected by changing a symbol | +| `codegraph_node` | Get details about a specific symbol (optionally with source code) | +| `codegraph_explore` | Return source for several related symbols grouped by file, plus a relationship map, in one call | +| `codegraph_files` | Get indexed file structure (faster than filesystem scanning) | +| `codegraph_status` | Check index health and statistics | + +--- + +## Library Usage + +```typescript +import CodeGraph from '@colbymchenry/codegraph'; + +const cg = await CodeGraph.init('/path/to/project'); +// Or: const cg = await CodeGraph.open('/path/to/project'); + +await cg.indexAll({ + onProgress: (p) => console.log(`${p.phase}: ${p.current}/${p.total}`) +}); + +const results = cg.searchNodes('UserService'); +const callers = cg.getCallers(results[0].node.id); +const context = await cg.buildContext('fix login bug', { maxNodes: 20, includeCode: true, format: 'markdown' }); +const impact = cg.getImpactRadius(results[0].node.id, 2); + +cg.watch(); // auto-sync on file changes +cg.unwatch(); // stop watching +cg.close(); +``` + +--- + +## Configuration + +There isn't any — CodeGraph is zero-config. It indexes every file whose +extension maps to a [supported language](#supported-languages) and **respects +your `.gitignore`**: in git repos via git itself, and in non-git projects by +reading `.gitignore` files directly (root and nested, the same way git would). + +What that means in practice: + +- Anything git ignores — `node_modules`, build output, secrets in `.env` — is + never indexed. **To keep something out of the graph, add it to `.gitignore`.** +- There's no config file to write or keep in sync, and nothing to wire up per + language: support is automatic from the file extension. +- Files larger than 1 MB are skipped (generated bundles, minified JS, vendored + blobs) — they cost parse budget for no useful symbols. + +> Committed files that aren't gitignored *are* indexed, even under `vendor/` or a +> committed `dist/`. If you commit a dependency or build directory you don't want +> in the graph, add it to `.gitignore`. + +## Supported Platforms + +Every release ships a self-contained build (bundled Node runtime — nothing to +compile) for all three desktop OSes, on both Intel/AMD (x64) and ARM (arm64): + +| Platform | Architectures | Install | +|----------|---------------|---------| +| Windows | x64, arm64 | PowerShell installer or npm | +| macOS | x64, arm64 | shell installer or npm | +| Linux | x64, arm64 | shell installer or npm | + +See [Get Started](#get-started) for the one-line install commands. + +## Supported Agents + +The interactive installer auto-detects and configures each of these — wiring up +the MCP server and writing its instructions file: + +- **Claude Code** +- **Cursor** +- **Codex CLI** +- **opencode** +- **Hermes Agent** + +## Supported Languages + +| Language | Extension | Status | +|----------|-----------|--------| +| TypeScript | `.ts`, `.tsx` | Full support | +| JavaScript | `.js`, `.jsx`, `.mjs` | Full support | +| Python | `.py` | Full support | +| Go | `.go` | Full support | +| Rust | `.rs` | Full support | +| Java | `.java` | Full support | +| C# | `.cs` | Full support | +| PHP | `.php` | Full support | +| Ruby | `.rb` | Full support | +| C | `.c`, `.h` | Full support | +| C++ | `.cpp`, `.hpp`, `.cc` | Full support | +| Swift | `.swift` | Full support | +| Kotlin | `.kt`, `.kts` | Full support | +| Scala | `.scala`, `.sc` | Full support (classes, traits, methods, type aliases, Scala 3 enums) | +| Dart | `.dart` | Full support | +| Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) | +| Vue | `.vue` | Full support (script + script-setup extraction, Nuxt page/API/middleware routes) | +| Liquid | `.liquid` | Full support | +| Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX form files) | +| Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) | +| Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) | + +## Troubleshooting + +**"CodeGraph not initialized"** — Run `codegraph init` in your project directory first. + +**Indexing is slow** — Check that `node_modules` and other large directories are excluded. Use `--quiet` to reduce output overhead. + +**MCP hits `database is locked`** — current builds shouldn't: CodeGraph bundles its own Node runtime and uses Node's built-in `node:sqlite` in WAL mode, where concurrent reads never block on a writer. If you still see it: + +- **You're on an old (pre-0.9) install.** Reinstall to get the bundled runtime — `curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh` (macOS/Linux), `irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex` (Windows), or `npm i -g @colbymchenry/codegraph@latest`. +- **`codegraph status` shows `Journal:` other than `wal`** — WAL couldn't be enabled on this filesystem (common on network shares and WSL2 `/mnt`), so reads can block on writes. Move the project (with its `.codegraph/` folder) onto a local disk. + +**MCP server not connecting** — Ensure the project is initialized/indexed, verify the path in your MCP config, and check that `codegraph serve --mcp` works from the command line. + +**Missing symbols** — The MCP server auto-syncs on save (wait a couple seconds). Run `codegraph sync` manually if needed. Check that the file's language is supported and isn't excluded by config patterns. + +## Star History + + + + + + Star History Chart + + + +## License + +MIT + +--- + +
+ +**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, and Hermes Agent** + +[Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues) + +
diff --git a/__tests__/concurrent-locking.test.ts b/__tests__/concurrent-locking.test.ts new file mode 100644 index 0000000..5c8ab51 --- /dev/null +++ b/__tests__/concurrent-locking.test.ts @@ -0,0 +1,152 @@ +/** + * Issue #238 — "database is locked" on concurrent MCP tool calls. + * + * With node:sqlite (real WAL) as the backend, the fixes that remain relevant: + * 1. busy_timeout is a bounded few-second wait (not a 2-minute hang) and WAL is + * active — so a reader never blocks on a concurrent writer. + * 2. The MCP ToolHandler reuses the default instance when a tool passes a + * projectPath pointing at the default project, instead of opening a SECOND + * connection to the same DB. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +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'; +import { DatabaseConnection } from '../src/db'; + +/** 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; +} + +describe('issue #238 — connection PRAGMAs (#1)', () => { + let dir: string; + let conn: DatabaseConnection; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-pragma-')); + conn = DatabaseConnection.initialize(path.join(dir, 'codegraph.db')); + }); + + afterAll(() => { + conn.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('uses a bounded busy_timeout, not the old 2-minute hang', () => { + const ms = Number(pragmaValue(conn.getDb().pragma('busy_timeout'), 'timeout')); + expect(ms).toBeGreaterThan(0); + expect(ms).toBeLessThanOrEqual(30000); // far below the old 120000 + }); + + it('runs in WAL mode — the mode that lets readers proceed during a write', () => { + const mode = String(pragmaValue(conn.getDb().pragma('journal_mode'), 'journal_mode')).toLowerCase(); + expect(mode).toBe('wal'); + }); + + it('getJournalMode() surfaces the effective mode for status triage', () => { + expect(conn.getJournalMode()).toBe('wal'); + }); +}); + +describe('issue #238 — WAL lets a reader proceed during a writer', () => { + let dir: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-wal-')); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('a read on a 2nd connection succeeds while a writer holds the lock', () => { + const dbPath = path.join(dir, 'codegraph.db'); + const writer = DatabaseConnection.initialize(dbPath); + // The property only holds under WAL; skip if the filesystem couldn't enable it. + if (writer.getJournalMode() !== 'wal') { + writer.close(); + return; + } + const reader = DatabaseConnection.open(dbPath); + try { + writer.getDb().prepare('BEGIN EXCLUSIVE').run(); // hard write lock, held open + const t0 = Date.now(); + const row = reader.getDb().prepare('SELECT COUNT(*) AS c FROM nodes').get() as { c: number }; + const waited = Date.now() - t0; + expect(row.c).toBe(0); + expect(waited).toBeLessThan(1000); // proceeds immediately, no busy wait + } finally { + try { writer.getDb().prepare('COMMIT').run(); } catch { /* ignore */ } + reader.close(); + writer.close(); + } + }); +}); + +describe('issue #238 — ToolHandler reuses the default instance (#2)', () => { + let dir: string; + let cg: CodeGraph; + let root: string; + let handler: ToolHandler; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-tools-')); + fs.writeFileSync(path.join(dir, 'a.ts'), 'export function helper(): number { return 1; }\n'); + fs.writeFileSync( + path.join(dir, 'b.ts'), + "import { helper } from './a';\nexport function main(): number { return helper(); }\n" + ); + cg = await CodeGraph.init(dir, { index: true }); + root = cg.getProjectRoot(); + handler = new ToolHandler(cg); + }); + + afterAll(() => { + cg.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', () => { + const openSpy = vi.spyOn(CodeGraph, 'openSync'); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolved = (handler as any).getCodeGraph(root); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nested = (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist')); + expect(resolved).toBe(cg); + expect(nested).toBe(cg); // a sub-path resolves up to the same default project + expect(openSpy).not.toHaveBeenCalled(); // no second connection opened + } finally { + openSpy.mockRestore(); + } + }); + + it('concurrent read tool calls (mixed projectPath) all succeed without "database is locked"', async () => { + const openSpy = vi.spyOn(CodeGraph, 'openSync'); + try { + const calls: Promise<{ content: Array<{ text: string }>; isError?: boolean }>[] = [ + handler.execute('codegraph_search', { query: 'helper' }), + handler.execute('codegraph_search', { query: 'helper', projectPath: root }), + handler.execute('codegraph_callers', { symbol: 'helper', projectPath: root }), + handler.execute('codegraph_callees', { symbol: 'main' }), + handler.execute('codegraph_files', { projectPath: root }), + handler.execute('codegraph_status', { projectPath: root }), + ]; + const results = await Promise.all(calls); + for (const r of results) { + expect(r.isError).not.toBe(true); + expect(r.content[0]?.text ?? '').not.toMatch(/database is locked/i); + } + // Passing the default project's own path must not open a second connection. + expect(openSpy).not.toHaveBeenCalled(); + } finally { + openSpy.mockRestore(); + } + }); +}); diff --git a/__tests__/context.test.ts b/__tests__/context.test.ts new file mode 100644 index 0000000..52dae1f --- /dev/null +++ b/__tests__/context.test.ts @@ -0,0 +1,374 @@ +/** + * Context Builder Tests + * + * Tests for the context building 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'; + +describe('Context Builder', () => { + let testDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-context-test-')); + + // Create a sample codebase + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir); + + // Create a payment service file + fs.writeFileSync( + path.join(srcDir, 'payment.ts'), + `/** + * Payment Service + * Handles payment processing logic. + */ + +export interface PaymentResult { + success: boolean; + transactionId: string; + amount: number; +} + +export class PaymentService { + private apiKey: string; + + constructor(apiKey: string) { + this.apiKey = apiKey; + } + + /** + * Process a payment for the given amount + */ + async processPayment(amount: number): Promise { + // Validate amount + if (amount <= 0) { + throw new Error('Invalid amount'); + } + + // Process payment + const transactionId = this.generateTransactionId(); + return { + success: true, + transactionId, + amount, + }; + } + + private generateTransactionId(): string { + return 'txn_' + Math.random().toString(36).substring(2); + } +} + +export function createPaymentService(apiKey: string): PaymentService { + return new PaymentService(apiKey); +} +` + ); + + // Create a checkout controller file + fs.writeFileSync( + path.join(srcDir, 'checkout.ts'), + `/** + * Checkout Controller + * Handles the checkout flow. + */ + +import { PaymentService, PaymentResult } from './payment'; + +export interface CartItem { + id: string; + name: string; + price: number; + quantity: number; +} + +export class CheckoutController { + private paymentService: PaymentService; + + constructor(paymentService: PaymentService) { + this.paymentService = paymentService; + } + + /** + * Process checkout for the given cart + */ + async processCheckout(cart: CartItem[]): Promise { + const total = this.calculateTotal(cart); + + if (total === 0) { + throw new Error('Cart is empty'); + } + + return this.paymentService.processPayment(total); + } + + /** + * Calculate the total price of the cart + */ + calculateTotal(cart: CartItem[]): number { + return cart.reduce((sum, item) => sum + item.price * item.quantity, 0); + } +} +` + ); + + // Create a utilities file + fs.writeFileSync( + path.join(srcDir, 'utils.ts'), + `/** + * Utility functions + */ + +export function formatCurrency(amount: number): string { + return '$' + amount.toFixed(2); +} + +export function validateEmail(email: string): boolean { + return email.includes('@'); +} +` + ); + + // Initialize CodeGraph + cg = CodeGraph.initSync(testDir, { + config: { + include: ['**/*.ts'], + exclude: [], + }, + }); + + // Index the codebase + await cg.indexAll(); + }); + + afterEach(() => { + if (cg) { + cg.destroy(); + } + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('getCode()', () => { + it('should extract code for a node', async () => { + // Find the PaymentService class + const nodes = cg.getNodesByKind('class'); + const paymentService = nodes.find((n) => n.name === 'PaymentService'); + + expect(paymentService).toBeDefined(); + + const code = await cg.getCode(paymentService!.id); + + expect(code).not.toBeNull(); + expect(code).toContain('class PaymentService'); + expect(code).toContain('processPayment'); + }); + + it('should return null for non-existent node', async () => { + const code = await cg.getCode('non-existent-id'); + expect(code).toBeNull(); + }); + }); + + describe('findRelevantContext()', () => { + it('should find relevant nodes for a query', async () => { + // Use simple query that matches symbol names (FTS5 treats spaces as AND) + const result = await cg.findRelevantContext('PaymentService'); + + expect(result.nodes.size).toBeGreaterThan(0); + // Should find payment-related nodes + const nodeNames = Array.from(result.nodes.values()).map((n) => n.name); + expect( + nodeNames.some( + (name) => + name.toLowerCase().includes('payment') || + name.toLowerCase().includes('checkout') + ) + ).toBe(true); + }); + + it('should include edges in the result', async () => { + const result = await cg.findRelevantContext('checkout', { + traversalDepth: 2, + }); + + // Should have some edges from traversal + expect(result.edges).toBeDefined(); + }); + + it('should respect maxNodes option', async () => { + const result = await cg.findRelevantContext('function', { + maxNodes: 5, + }); + + expect(result.nodes.size).toBeLessThanOrEqual(5); + }); + }); + + describe('buildContext()', () => { + it('should build context with markdown format', async () => { + const result = await cg.buildContext('Fix checkout error', { + format: 'markdown', + maxCodeBlocks: 3, + }); + + expect(typeof result).toBe('string'); + const markdown = result as string; + + // Should contain markdown structure + expect(markdown).toContain('## Code Context'); + expect(markdown).toContain('**Query:** Fix checkout error'); + }); + + it('should build context with JSON format', async () => { + const result = await cg.buildContext('payment processing', { + format: 'json', + }); + + expect(typeof result).toBe('string'); + const parsed = JSON.parse(result as string); + + expect(parsed.query).toBe('payment processing'); + expect(parsed.nodes).toBeDefined(); + expect(Array.isArray(parsed.nodes)).toBe(true); + }); + + it('should accept object input with title and description', async () => { + const result = await cg.buildContext( + { + title: 'Checkout bug', + description: 'Cart total calculation is wrong', + }, + { format: 'markdown' } + ); + + expect(typeof result).toBe('string'); + expect(result).toContain('Checkout bug: Cart total calculation is wrong'); + }); + + it('should include code blocks when requested', async () => { + const result = await cg.buildContext('PaymentService', { + format: 'markdown', + includeCode: true, + maxCodeBlocks: 2, + }); + + const markdown = result as string; + + // Should contain code blocks + expect(markdown).toContain('### Code'); + expect(markdown).toContain('```typescript'); + }); + + it('should exclude code blocks when requested', async () => { + const result = await cg.buildContext('payment', { + format: 'markdown', + includeCode: false, + }); + + const markdown = result as string; + + // Should not contain code section + expect(markdown).not.toContain('### Code'); + }); + + it('should include related symbols in compact format', async () => { + const result = await cg.buildContext('checkout', { + format: 'markdown', + maxNodes: 10, + }); + + const markdown = result as string; + + // Compact format uses "Related Symbols" instead of verbose "Related Files" + // and groups symbols by file for compactness + expect(markdown).toContain('### Entry Points'); + }); + + it('should have compact output without verbose stats footer', async () => { + const result = await cg.buildContext('payment', { + format: 'markdown', + }); + + const markdown = result as string; + + // Compact format should NOT have verbose stats footer + expect(markdown).not.toMatch(/\*Context:.*symbols.*relationships.*files/); + // But should still have query + expect(markdown).toContain('**Query:**'); + }); + }); + + describe('Context structure', () => { + it('should find entry points from search', async () => { + const result = await cg.buildContext('PaymentService', { + format: 'json', + }); + + const parsed = JSON.parse(result as string); + + expect(parsed.entryPoints).toBeDefined(); + expect(parsed.entryPoints.length).toBeGreaterThan(0); + }); + + it('should traverse graph from entry points', async () => { + const result = await cg.buildContext('CheckoutController', { + format: 'json', + traversalDepth: 2, + }); + + const parsed = JSON.parse(result as string); + + // Should have found related nodes through traversal + const nodeNames = parsed.nodes.map((n: { name: string }) => n.name); + + // CheckoutController calls PaymentService, so both should be present + expect( + nodeNames.some((name: string) => name.includes('Checkout')) + ).toBe(true); + }); + }); + + describe('Edge cases', () => { + it('should handle empty query', async () => { + const result = await cg.buildContext('', { format: 'markdown' }); + + expect(typeof result).toBe('string'); + }); + + it('should handle query with no matches', async () => { + const result = await cg.buildContext('xyznonexistent123', { + format: 'json', + }); + + const parsed = JSON.parse(result as string); + + // Should return empty or minimal results + expect(parsed.nodes).toBeDefined(); + }); + + it('should truncate long code blocks', async () => { + const result = await cg.buildContext('PaymentService', { + format: 'markdown', + maxCodeBlockSize: 100, + includeCode: true, + }); + + const markdown = result as string; + + // Long code blocks should be truncated + if (markdown.includes('```typescript')) { + // If there's a code block, check for truncation marker if content was long + // This test validates the truncation logic works + expect(typeof markdown).toBe('string'); + } + }); + }); +}); diff --git a/__tests__/db-perf.test.ts b/__tests__/db-perf.test.ts new file mode 100644 index 0000000..256cf92 --- /dev/null +++ b/__tests__/db-perf.test.ts @@ -0,0 +1,161 @@ +/** + * DB Performance / Correctness Tests + * + * Regression tests for three changes: + * 1. Batch `getNodesByIds` collapses graph-traversal N+1 reads. + * 2. `insertNode` invalidates the LRU cache so INSERT OR REPLACE + * doesn't serve a stale cached row on next `getNodeById`. + * 3. `runMaintenance` runs `PRAGMA optimize` + `wal_checkpoint(PASSIVE)` + * after indexAll/sync without throwing. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { DatabaseConnection } from '../src/db'; +import { QueryBuilder } from '../src/db/queries'; +import { Node } from '../src/types'; + +function makeNode(id: string, name = id): Node { + return { + id, + kind: 'function', + name, + qualifiedName: name, + filePath: 'a.ts', + language: 'typescript', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; +} + +describe('getNodesByIds (batch lookup)', () => { + let dir: string; + let db: DatabaseConnection; + let q: QueryBuilder; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-batch-')); + db = DatabaseConnection.initialize(path.join(dir, 'test.db')); + q = new QueryBuilder(db.getDb()); + }); + + afterEach(() => { + db.close(); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('returns a Map keyed by id, with one entry per existing node', () => { + q.insertNodes([makeNode('n1'), makeNode('n2'), makeNode('n3')]); + const out = q.getNodesByIds(['n1', 'n2', 'n3']); + expect(out.size).toBe(3); + expect(out.get('n1')!.name).toBe('n1'); + expect(out.get('n3')!.name).toBe('n3'); + }); + + it('omits missing IDs from the result map (no nulls, no exceptions)', () => { + q.insertNodes([makeNode('n1'), makeNode('n2')]); + const out = q.getNodesByIds(['n1', 'missing', 'n2']); + expect(out.size).toBe(2); + expect(out.has('missing')).toBe(false); + expect(out.has('n1')).toBe(true); + expect(out.has('n2')).toBe(true); + }); + + it('handles an empty input array', () => { + expect(q.getNodesByIds([]).size).toBe(0); + }); + + it('handles batches over the SQLite parameter limit (chunking)', () => { + // Insert 1500 nodes; the helper chunks at 500 internally. + const nodes = Array.from({ length: 1500 }, (_, i) => makeNode(`n${i}`)); + q.insertNodes(nodes); + const ids = nodes.map((n) => n.id); + const out = q.getNodesByIds(ids); + expect(out.size).toBe(1500); + // Spot-check a few from the first / middle / last chunk. + expect(out.has('n0')).toBe(true); + expect(out.has('n750')).toBe(true); + expect(out.has('n1499')).toBe(true); + }); + + it('serves cache hits from memory and queries only the misses', () => { + q.insertNodes([makeNode('n1'), makeNode('n2'), makeNode('n3')]); + // Warm the cache for n1 only. + q.getNodeById('n1'); + // Replace the underlying row to make a miss-vs-cache-hit detectable. + db.getDb().prepare('UPDATE nodes SET name = ? WHERE id = ?').run('changed', 'n1'); + const out = q.getNodesByIds(['n1', 'n2']); + // The cached n1 (still 'n1', not 'changed') must be returned. + expect(out.get('n1')!.name).toBe('n1'); + expect(out.get('n2')!.name).toBe('n2'); + }); +}); + +describe('insertNode cache invalidation', () => { + let dir: string; + let db: DatabaseConnection; + let q: QueryBuilder; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-cache-')); + db = DatabaseConnection.initialize(path.join(dir, 'test.db')); + q = new QueryBuilder(db.getDb()); + }); + + afterEach(() => { + db.close(); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('does not serve a stale cached node after INSERT OR REPLACE', () => { + // Regression: insertNode (which uses INSERT OR REPLACE) used to skip + // cache invalidation, so the next getNodeById returned the pre-replace + // version until LRU eviction. + const original = makeNode('n1', 'oldName'); + q.insertNode(original); + const beforeReplace = q.getNodeById('n1'); + expect(beforeReplace!.name).toBe('oldName'); + + // Replace via insertNode (the bug path). + q.insertNode({ ...original, name: 'newName', updatedAt: Date.now() }); + const afterReplace = q.getNodeById('n1'); + expect(afterReplace!.name).toBe('newName'); + }); +}); + +describe('runMaintenance', () => { + let dir: string; + let db: DatabaseConnection; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-maint-')); + db = DatabaseConnection.initialize(path.join(dir, 'test.db')); + }); + + afterEach(() => { + db.close(); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('runs without throwing on a fresh database', () => { + expect(() => db.runMaintenance()).not.toThrow(); + }); + + it('runs without throwing after writes', () => { + const q = new QueryBuilder(db.getDb()); + q.insertNodes([makeNode('n1'), makeNode('n2')]); + expect(() => db.runMaintenance()).not.toThrow(); + }); + + it('swallows failures rather than propagating (best-effort)', () => { + // Close the DB so the underlying handle would normally throw on any + // exec(). runMaintenance must still not propagate. + db.close(); + expect(() => db.runMaintenance()).not.toThrow(); + }); +}); diff --git a/__tests__/drupal.test.ts b/__tests__/drupal.test.ts new file mode 100644 index 0000000..fda5415 --- /dev/null +++ b/__tests__/drupal.test.ts @@ -0,0 +1,518 @@ +/** + * Tests for Drupal framework resolver. + * + * Unit tests cover drupalResolver.detect(), extract() (routes + hooks), and resolve(). + * Integration tests use a real CodeGraph instance with a temporary Drupal project layout. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; +import { drupalResolver } from '../src/resolution/frameworks/drupal'; +import type { ResolutionContext } from '../src/resolution/types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeContext( + overrides: Partial = {}, +): ResolutionContext { + return { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/project', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// detect() +// --------------------------------------------------------------------------- + +describe('drupalResolver.detect', () => { + it('returns true when composer.json has a drupal/ dependency', () => { + const ctx = makeContext({ + readFile: (f) => + f === 'composer.json' + ? JSON.stringify({ + require: { + 'drupal/core-recommended': '~10.5', + 'drush/drush': '^13', + }, + }) + : null, + }); + expect(drupalResolver.detect(ctx)).toBe(true); + }); + + it('returns true when drupal/ dependency is in require-dev', () => { + const ctx = makeContext({ + readFile: (f) => + f === 'composer.json' + ? JSON.stringify({ 'require-dev': { 'drupal/core': '^10' } }) + : null, + }); + expect(drupalResolver.detect(ctx)).toBe(true); + }); + + it('returns false when composer.json has no drupal/ dependencies', () => { + const ctx = makeContext({ + readFile: (f) => + f === 'composer.json' + ? JSON.stringify({ + require: { 'laravel/framework': '^10', php: '>=8.1' }, + }) + : null, + }); + expect(drupalResolver.detect(ctx)).toBe(false); + }); + + it('returns false when composer.json is absent', () => { + const ctx = makeContext({ readFile: () => null }); + expect(drupalResolver.detect(ctx)).toBe(false); + }); + + it('returns false when composer.json is malformed JSON', () => { + const ctx = makeContext({ readFile: () => '{ bad json' }); + expect(drupalResolver.detect(ctx)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// extract() — routing.yml +// --------------------------------------------------------------------------- + +describe('drupalResolver.extract — routing.yml', () => { + const routing = ` +mymodule.example: + path: '/mymodule/example' + defaults: + _controller: '\\Drupal\\mymodule\\Controller\\MyController::build' + _title: 'Example page' + requirements: + _permission: 'access content' +`; + + it('emits a route node for each YAML route', () => { + const { nodes } = drupalResolver.extract!( + 'mymodule/mymodule.routing.yml', + routing, + ); + expect(nodes).toHaveLength(1); + expect(nodes[0]!.kind).toBe('route'); + expect(nodes[0]!.name).toBe('/mymodule/example'); + }); + + it('sets qualifiedName to filePath::routeName', () => { + const { nodes } = drupalResolver.extract!( + 'mymodule/mymodule.routing.yml', + routing, + ); + expect(nodes[0]!.qualifiedName).toBe( + 'mymodule/mymodule.routing.yml::mymodule.example', + ); + }); + + it('emits a references edge to the controller FQCN', () => { + const { references } = drupalResolver.extract!( + 'mymodule/mymodule.routing.yml', + routing, + ); + expect(references).toHaveLength(1); + expect(references[0]!.referenceName).toBe( + '\\Drupal\\mymodule\\Controller\\MyController::build', + ); + expect(references[0]!.referenceKind).toBe('references'); + }); + + it('emits a references edge to a _form handler', () => { + const src = ` +mymodule.settings_form: + path: '/admin/config/mymodule' + defaults: + _form: '\\Drupal\\mymodule\\Form\\SettingsForm' + _title: 'MyModule settings' + requirements: + _permission: 'administer site configuration' +`; + const { nodes, references } = drupalResolver.extract!( + 'mymodule/mymodule.routing.yml', + src, + ); + expect(nodes).toHaveLength(1); + expect(references[0]!.referenceName).toBe( + '\\Drupal\\mymodule\\Form\\SettingsForm', + ); + }); + + it('handles multiple routes in one file', () => { + const src = ` +mod.page_one: + path: '/page-one' + defaults: + _controller: '\\Drupal\\mod\\Controller\\PageController::one' + requirements: + _permission: 'access content' + +mod.page_two: + path: '/page-two' + defaults: + _controller: '\\Drupal\\mod\\Controller\\PageController::two' + requirements: + _permission: 'access content' +`; + const { nodes, references } = drupalResolver.extract!( + 'mod/mod.routing.yml', + src, + ); + expect(nodes).toHaveLength(2); + expect(nodes.map((n) => n.name)).toContain('/page-one'); + expect(nodes.map((n) => n.name)).toContain('/page-two'); + expect(references).toHaveLength(2); + }); + + it('skips commented-out lines', () => { + const src = ` +mod.page: + path: '/page' + defaults: + #_controller: '\\Drupal\\mod\\Controller\\Old::build' + _controller: '\\Drupal\\mod\\Controller\\New::build' + requirements: + _permission: 'access content' +`; + const { references } = drupalResolver.extract!('mod/mod.routing.yml', src); + expect(references).toHaveLength(1); + expect(references[0]!.referenceName).toContain('New'); + }); + + it('includes HTTP methods in the route node name when present', () => { + const src = ` +mod.api: + path: '/api/resource' + defaults: + _controller: '\\Drupal\\mod\\Controller\\ApiController::get' + methods: [GET, POST] + requirements: + _permission: 'access content' +`; + const { nodes } = drupalResolver.extract!('mod/mod.routing.yml', src); + expect(nodes[0]!.name).toContain('GET'); + expect(nodes[0]!.name).toContain('POST'); + }); + + it('returns empty result for non-routing-yml files', () => { + const { nodes, references } = drupalResolver.extract!( + 'mymodule.module', + ' { + const { nodes, references } = drupalResolver.extract!( + 'some.routing.yml', + '# empty\n', + ); + expect(nodes).toHaveLength(0); + expect(references).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// extract() — hook detection in .module files +// --------------------------------------------------------------------------- + +describe('drupalResolver.extract — hook detection', () => { + it('detects hook implementation via docblock (Strategy A)', () => { + const src = ` r.referenceName === 'hook_form_alter', + ); + expect(hookRef).toBeDefined(); + expect(hookRef!.referenceKind).toBe('references'); + }); + + it('detects hook implementation via name pattern (Strategy B)', () => { + const src = ` r.referenceName === 'hook_views_data', + ); + expect(hookRef).toBeDefined(); + }); + + it('does not emit a hook ref for non-hook helper functions', () => { + // 'other_module_helper' doesn't start with 'mymodule_', so no hook ref + const src = ` { + const src = ` r.referenceName === 'hook_schema'); + expect(hookRef).toBeDefined(); + }); + + it('detects hooks in .theme files', () => { + const src = ` r.referenceName === 'hook_preprocess_node', + ); + expect(hookRef).toBeDefined(); + }); + + it('does not duplicate refs when both docblock and name pattern match', () => { + // Strategy A matches first and adds to docblockMatched set; + // Strategy B skips already-matched functions. + const src = ` r.referenceName === 'hook_form_alter', + ); + expect(hookRefs).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// resolve() +// --------------------------------------------------------------------------- + +describe('drupalResolver.resolve', () => { + it('resolves a _controller FQCN with ::method to the method node', () => { + const methodNode = { + id: 'method:abc123', + kind: 'method' as const, + name: 'build', + qualifiedName: 'MyController::build', + filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php', + language: 'php' as const, + startLine: 10, + endLine: 20, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; + const classNode = { + id: 'class:def456', + kind: 'class' as const, + name: 'MyController', + qualifiedName: 'MyController', + filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php', + language: 'php' as const, + startLine: 5, + endLine: 30, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; + const ctx = makeContext({ + getNodesByName: (name) => (name === 'MyController' ? [classNode] : []), + getNodesInFile: () => [classNode, methodNode], + }); + const ref = { + fromNodeId: 'route:x', + referenceName: '\\Drupal\\mymodule\\Controller\\MyController::build', + referenceKind: 'references' as const, + line: 1, + column: 0, + filePath: 'mymodule.routing.yml', + language: 'yaml' as const, + }; + const resolved = drupalResolver.resolve(ref, ctx); + expect(resolved).not.toBeNull(); + expect(resolved!.targetNodeId).toBe('method:abc123'); + expect(resolved!.confidence).toBeGreaterThanOrEqual(0.85); + }); + + it('resolves a _form FQCN (no ::method) to the class node', () => { + const classNode = { + id: 'class:form123', + kind: 'class' as const, + name: 'SettingsForm', + qualifiedName: 'SettingsForm', + filePath: 'web/modules/custom/mymodule/src/Form/SettingsForm.php', + language: 'php' as const, + startLine: 1, + endLine: 50, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; + const ctx = makeContext({ + getNodesByName: (name) => (name === 'SettingsForm' ? [classNode] : []), + }); + const ref = { + fromNodeId: 'route:x', + referenceName: '\\Drupal\\mymodule\\Form\\SettingsForm', + referenceKind: 'references' as const, + line: 1, + column: 0, + filePath: 'mymodule.routing.yml', + language: 'yaml' as const, + }; + const resolved = drupalResolver.resolve(ref, ctx); + expect(resolved).not.toBeNull(); + expect(resolved!.targetNodeId).toBe('class:form123'); + }); + + it('returns null when the target class cannot be found', () => { + const ctx = makeContext({ getNodesByName: () => [] }); + const ref = { + fromNodeId: 'route:x', + referenceName: '\\Drupal\\mymodule\\Controller\\Missing::method', + referenceKind: 'references' as const, + line: 1, + column: 0, + filePath: 'mymodule.routing.yml', + language: 'yaml' as const, + }; + expect(drupalResolver.resolve(ref, ctx)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end integration test +// --------------------------------------------------------------------------- + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('Drupal end-to-end — route node linked to controller method', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('creates a route→controller edge from routing.yml to PHP class', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-drupal-')); + + // Minimal composer.json to trigger Drupal detection + fs.writeFileSync( + path.join(tmpDir, 'composer.json'), + JSON.stringify({ require: { 'drupal/core-recommended': '~10.5' } }), + ); + + // Module directory structure + const modDir = path.join(tmpDir, 'web', 'modules', 'custom', 'my_module'); + fs.mkdirSync(path.join(modDir, 'src', 'Controller'), { recursive: true }); + + // routing.yml + fs.writeFileSync( + path.join(modDir, 'my_module.routing.yml'), + [ + 'my_module.hello:', + " path: '/hello'", + ' defaults:', + " _controller: '\\Drupal\\my_module\\Controller\\HelloController::build'", + " _title: 'Hello'", + ' requirements:', + " _permission: 'access content'", + ].join('\n') + '\n', + ); + + // PHP controller + fs.writeFileSync( + path.join(modDir, 'src', 'Controller', 'HelloController.php'), + [ + ' 'Hello'];", + ' }', + '}', + ].join('\n') + '\n', + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // Route node must exist + const routes = cg.getNodesByKind('route'); + expect(routes.length).toBeGreaterThan(0); + const route = routes.find((n) => n.name.includes('/hello')); + expect(route).toBeDefined(); + + // Controller method must be indexed + const methods = cg.getNodesByKind('method'); + const buildMethod = methods.find((n) => n.name === 'build'); + expect(buildMethod).toBeDefined(); + + // Edge: route → build method (or class fallback) + const edges = cg.getOutgoingEdges(route!.id); + expect(edges.length).toBeGreaterThan(0); + + cg.close(); + }); +}); diff --git a/__tests__/evaluation/runner.ts b/__tests__/evaluation/runner.ts new file mode 100644 index 0000000..7ff0435 --- /dev/null +++ b/__tests__/evaluation/runner.ts @@ -0,0 +1,123 @@ +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { CodeGraph } from '../../src/index.js'; +import { scoreSearchNodes, scoreFindRelevantContext } from './scoring.js'; +import { testCases } from './test-cases.js'; +import type { EvalReport, EvalResult } from './types.js'; + +const codebasePath = process.env.EVAL_CODEBASE || process.argv[2]; +if (!codebasePath) { + console.error('Usage: EVAL_CODEBASE=/path/to/codebase npx tsx __tests__/evaluation/runner.ts'); + console.error(' or: npx tsx __tests__/evaluation/runner.ts /path/to/codebase'); + process.exit(1); +} + +const resolvedPath = path.resolve(codebasePath); +if (!fs.existsSync(path.join(resolvedPath, '.codegraph', 'codegraph.db'))) { + console.error(`No .codegraph/codegraph.db found at ${resolvedPath}`); + process.exit(1); +} + +let codegraphSha = 'unknown'; +try { + codegraphSha = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim(); +} catch {} + +console.log(`\nCodeGraph Eval — ${path.basename(resolvedPath)}`); +console.log(`Codebase: ${resolvedPath}`); +console.log(`Commit: ${codegraphSha}`); +console.log(`Cases: ${testCases.length}`); +console.log(''); + +async function run() { + const cg = CodeGraph.openSync(resolvedPath); + const results: EvalResult[] = []; + + for (const tc of testCases) { + const start = performance.now(); + + if (tc.api === 'searchNodes') { + const searchResults = cg.searchNodes(tc.query, { + limit: 10, + kinds: tc.kinds, + ...(tc.options as Record), + }); + const latency = performance.now() - start; + const result = scoreSearchNodes(tc.id, tc.expectedSymbols, searchResults, latency); + results.push(result); + } else { + const subgraph = await cg.findRelevantContext(tc.query, { + searchLimit: 8, + traversalDepth: 3, + maxNodes: 80, + minScore: 0.2, + ...(tc.options as Record), + }); + const latency = performance.now() - start; + const result = scoreFindRelevantContext(tc.id, tc.expectedSymbols, subgraph, latency); + results.push(result); + } + } + + cg.close(); + + // Print results table + const maxIdLen = Math.max(...results.map((r) => r.caseId.length)); + + for (const r of results) { + const status = r.pass ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + const id = r.caseId.padEnd(maxIdLen); + const recall = `recall=${r.recall.toFixed(2)}`; + const extra = + r.edgeDensity !== undefined + ? `density=${r.edgeDensity.toFixed(2)}` + : `mrr=${r.mrr.toFixed(2)}`; + const latency = `${Math.round(r.latencyMs)}ms`; + + console.log(` ${id} ${status} ${recall} ${extra} ${latency}`); + + if (r.missedSymbols.length > 0) { + console.log(` ${' '.repeat(maxIdLen)} missed: ${r.missedSymbols.join(', ')}`); + } + } + + // Summary + const passed = results.filter((r) => r.pass).length; + const failed = results.length - passed; + const meanRecall = results.reduce((s, r) => s + r.recall, 0) / results.length; + const mrrResults = results.filter((r) => r.mrr > 0 || r.caseId.startsWith('search-')); + const meanMRR = + mrrResults.length > 0 ? mrrResults.reduce((s, r) => s + r.mrr, 0) / mrrResults.length : 0; + + console.log(''); + const summaryColor = failed === 0 ? '\x1b[32m' : '\x1b[33m'; + console.log( + `${summaryColor}SUMMARY: ${passed}/${results.length} passed | recall=${meanRecall.toFixed(2)} | mrr=${meanMRR.toFixed(2)}\x1b[0m` + ); + + // Save JSON report + const report: EvalReport = { + timestamp: new Date().toISOString(), + codebasePath: resolvedPath, + codegraphSha, + summary: { total: results.length, passed, failed, meanRecall, meanMRR }, + results, + }; + + const resultsDir = path.join(__dirname, 'results'); + fs.mkdirSync(resultsDir, { recursive: true }); + const reportFile = path.join( + resultsDir, + `${new Date().toISOString().replace(/[:.]/g, '-')}.json` + ); + fs.writeFileSync(reportFile, JSON.stringify(report, null, 2)); + console.log(`\nReport saved: ${reportFile}`); + + process.exit(failed > 0 ? 1 : 0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/__tests__/evaluation/scoring.ts b/__tests__/evaluation/scoring.ts new file mode 100644 index 0000000..b20f604 --- /dev/null +++ b/__tests__/evaluation/scoring.ts @@ -0,0 +1,82 @@ +import type { EvalResult } from './types.js'; + +export const PASS_THRESHOLD = 0.5; + +export function scoreSearchNodes( + caseId: string, + expectedSymbols: string[], + results: Array<{ node: { name: string }; score: number }>, + latencyMs: number +): EvalResult { + const expectedLower = expectedSymbols.map((s) => s.toLowerCase()); + const resultNames = results.map((r) => r.node.name.toLowerCase()); + + const found: string[] = []; + const missed: string[] = []; + let firstRank = 0; + + for (let i = 0; i < expectedLower.length; i++) { + const idx = resultNames.indexOf(expectedLower[i]); + if (idx !== -1) { + found.push(expectedSymbols[i]); + if (firstRank === 0) firstRank = idx + 1; + } else { + missed.push(expectedSymbols[i]); + } + } + + const recall = expectedSymbols.length > 0 ? found.length / expectedSymbols.length : 0; + const mrr = firstRank > 0 ? 1 / firstRank : 0; + + return { + caseId, + pass: recall >= PASS_THRESHOLD, + recall, + mrr, + foundSymbols: found, + missedSymbols: missed, + latencyMs, + }; +} + +export function scoreFindRelevantContext( + caseId: string, + expectedSymbols: string[], + subgraph: { nodes: Map; edges: unknown[]; roots: string[] }, + latencyMs: number +): EvalResult { + const expectedLower = new Set(expectedSymbols.map((s) => s.toLowerCase())); + const nodeNames = new Set(); + for (const node of subgraph.nodes.values()) { + nodeNames.add(node.name.toLowerCase()); + } + + const found: string[] = []; + const missed: string[] = []; + + for (const sym of expectedSymbols) { + if (nodeNames.has(sym.toLowerCase())) { + found.push(sym); + } else { + missed.push(sym); + } + } + + const recall = expectedSymbols.length > 0 ? found.length / expectedSymbols.length : 0; + const nodeCount = subgraph.nodes.size; + const edgeCount = subgraph.edges.length; + const edgeDensity = nodeCount > 0 ? edgeCount / nodeCount : 0; + + return { + caseId, + pass: recall >= PASS_THRESHOLD, + recall, + mrr: 0, + foundSymbols: found, + missedSymbols: missed, + nodeCount, + edgeCount, + edgeDensity, + latencyMs, + }; +} diff --git a/__tests__/evaluation/test-cases.ts b/__tests__/evaluation/test-cases.ts new file mode 100644 index 0000000..b9db233 --- /dev/null +++ b/__tests__/evaluation/test-cases.ts @@ -0,0 +1,93 @@ +import type { EvalTestCase } from './types.js'; + +export const testCases: EvalTestCase[] = [ + // === searchNodes: Symbol Lookup Precision === + + { + id: 'search-class-exact', + query: 'TransportService', + api: 'searchNodes', + expectedSymbols: ['TransportService'], + kinds: ['class'], + }, + { + id: 'search-method-qualified', + query: 'TransportService sendRequest', + api: 'searchNodes', + expectedSymbols: ['sendRequest'], + kinds: ['method'], + }, + { + id: 'search-interface', + query: 'ActionListener', + api: 'searchNodes', + expectedSymbols: ['ActionListener'], + kinds: ['interface'], + }, + { + id: 'search-enum', + query: 'RestStatus', + api: 'searchNodes', + expectedSymbols: ['RestStatus'], + kinds: ['enum'], + }, + { + id: 'search-exception', + query: 'SearchPhaseExecutionException', + api: 'searchNodes', + expectedSymbols: ['SearchPhaseExecutionException'], + kinds: ['class'], + }, + { + id: 'search-nested-class', + query: 'Engine Index', + api: 'searchNodes', + expectedSymbols: ['Index'], + kinds: ['class'], + }, + + // === findRelevantContext: Exploration Quality === + + { + id: 'explore-rest-layer', + query: 'How does the REST layer handle HTTP requests?', + api: 'findRelevantContext', + expectedSymbols: ['RestController', 'RestHandler', 'BaseRestHandler', 'RestRequest'], + options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, + }, + { + id: 'explore-search-execution', + query: 'How does search execution work from request to shard?', + api: 'findRelevantContext', + expectedSymbols: ['ShardSearchRequest', 'SearchShardsRequest', 'SearchShardsGroup'], + options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, + }, + { + id: 'explore-bulk-indexing', + query: 'How does bulk indexing work?', + api: 'findRelevantContext', + expectedSymbols: ['TransportBulkAction', 'BulkRequest', 'BulkResponse'], + options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, + }, + { + id: 'explore-shard-allocation', + query: 'How does shard rebalancing and allocation work?', + api: 'findRelevantContext', + expectedSymbols: ['AllocationService', 'BalancedShardsAllocator'], + options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, + }, + { + id: 'explore-transport-search', + query: 'How does TransportService connect to SearchTransportService?', + api: 'findRelevantContext', + expectedSymbols: ['TransportService', 'SearchTransportService'], + options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, + }, + { + id: 'explore-engine-implementations', + query: 'What are the Engine implementations for indexing?', + api: 'findRelevantContext', + expectedSymbols: ['InternalEngine', 'ReadOnlyEngine', 'Engine'], + options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, + }, +]; diff --git a/__tests__/evaluation/types.ts b/__tests__/evaluation/types.ts new file mode 100644 index 0000000..64a2427 --- /dev/null +++ b/__tests__/evaluation/types.ts @@ -0,0 +1,37 @@ +import type { NodeKind } from '../../src/types.js'; + +export interface EvalTestCase { + id: string; + query: string; + api: 'searchNodes' | 'findRelevantContext'; + expectedSymbols: string[]; + kinds?: NodeKind[]; + options?: Record; +} + +export interface EvalResult { + caseId: string; + pass: boolean; + recall: number; + mrr: number; + foundSymbols: string[]; + missedSymbols: string[]; + nodeCount?: number; + edgeCount?: number; + edgeDensity?: number; + latencyMs: number; +} + +export interface EvalReport { + timestamp: string; + codebasePath: string; + codegraphSha: string; + summary: { + total: number; + passed: number; + failed: number; + meanRecall: number; + meanMRR: number; + }; + results: EvalResult[]; +} diff --git a/__tests__/explore-output-budget.test.ts b/__tests__/explore-output-budget.test.ts new file mode 100644 index 0000000..65ddc64 --- /dev/null +++ b/__tests__/explore-output-budget.test.ts @@ -0,0 +1,234 @@ +/** + * Adaptive output budget for codegraph_explore (#185). + * + * The explore tool used to apply a fixed 35KB output cap regardless of + * project size, which on small codebases was a net loss vs. native + * grep+Read. These tests pin the per-tier budget shape so future tuning + * doesn't silently drift the small-project case back into bloat. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { getExploreOutputBudget, getExploreBudget, ToolHandler } from '../src/mcp/tools'; +import CodeGraph from '../src/index'; + +describe('getExploreOutputBudget', () => { + it('returns a strictly smaller total cap for small projects than for huge ones', () => { + const small = getExploreOutputBudget(100); + const huge = getExploreOutputBudget(30000); + expect(small.maxOutputChars).toBeLessThan(huge.maxOutputChars); + expect(small.defaultMaxFiles).toBeLessThan(huge.defaultMaxFiles); + expect(small.maxCharsPerFile).toBeLessThan(huge.maxCharsPerFile); + }); + + it('caps total output well under 8000 tokens (~32k chars) on small projects', () => { + const small = getExploreOutputBudget(100); + expect(small.maxOutputChars).toBeLessThanOrEqual(20000); + }); + + it('keeps the historical 35k+ ceiling for medium-large projects so existing benchmarks do not regress', () => { + const large = getExploreOutputBudget(10000); + expect(large.maxOutputChars).toBeGreaterThanOrEqual(35000); + }); + + it('uses tier breakpoints matching getExploreBudget so call-count and output-budget agree on a project', () => { + // Anything in the same tier should pick the same total-output cap. + const tier1a = getExploreOutputBudget(50); + const tier1b = getExploreOutputBudget(499); + expect(tier1a.maxOutputChars).toBe(tier1b.maxOutputChars); + expect(getExploreBudget(50)).toBe(getExploreBudget(499)); + + const tier2a = getExploreOutputBudget(500); + const tier2b = getExploreOutputBudget(4999); + expect(tier2a.maxOutputChars).toBe(tier2b.maxOutputChars); + expect(getExploreBudget(500)).toBe(getExploreBudget(4999)); + + const tier3a = getExploreOutputBudget(5000); + const tier3b = getExploreOutputBudget(14999); + expect(tier3a.maxOutputChars).toBe(tier3b.maxOutputChars); + + // And crossing a breakpoint changes the cap. + expect(tier1a.maxOutputChars).not.toBe(tier2a.maxOutputChars); + expect(tier2a.maxOutputChars).not.toBe(tier3a.maxOutputChars); + }); + + it('gates off "Additional relevant files", completeness signal, and budget note on small projects', () => { + const small = getExploreOutputBudget(100); + expect(small.includeAdditionalFiles).toBe(false); + expect(small.includeCompletenessSignal).toBe(false); + expect(small.includeBudgetNote).toBe(false); + }); + + it('keeps all meta-text on for projects that earn the breadth signal (>=500 files)', () => { + const medium = getExploreOutputBudget(1000); + expect(medium.includeAdditionalFiles).toBe(true); + expect(medium.includeCompletenessSignal).toBe(true); + expect(medium.includeBudgetNote).toBe(true); + }); + + it('keeps the Relationships section on for every tier — it is the cheapest structural signal', () => { + expect(getExploreOutputBudget(50).includeRelationships).toBe(true); + expect(getExploreOutputBudget(1000).includeRelationships).toBe(true); + expect(getExploreOutputBudget(10000).includeRelationships).toBe(true); + expect(getExploreOutputBudget(30000).includeRelationships).toBe(true); + }); + + it('caps the per-file header symbol list more tightly on small projects', () => { + // Without this cap, a file like Alamofire's Session.swift produced + // a 3.4KB symbol list in the `#### path — sym, sym, ...` header, + // dwarfing the per-file body cap. + const small = getExploreOutputBudget(100); + const huge = getExploreOutputBudget(30000); + expect(small.maxSymbolsInFileHeader).toBeLessThan(huge.maxSymbolsInFileHeader); + expect(small.maxSymbolsInFileHeader).toBeGreaterThan(0); + }); + + it('uses a tighter clustering gap threshold on small projects to break runaway single clusters', () => { + const small = getExploreOutputBudget(100); + const huge = getExploreOutputBudget(30000); + expect(small.gapThreshold).toBeLessThanOrEqual(huge.gapThreshold); + }); + + it('handles the boundary file counts exactly (off-by-one regression guard)', () => { + // 499 -> small tier, 500 -> medium tier + expect(getExploreOutputBudget(499).maxOutputChars).toBe(getExploreOutputBudget(100).maxOutputChars); + expect(getExploreOutputBudget(500).maxOutputChars).toBe(getExploreOutputBudget(1000).maxOutputChars); + // 4999 -> medium, 5000 -> large + expect(getExploreOutputBudget(4999).maxOutputChars).toBe(getExploreOutputBudget(1000).maxOutputChars); + expect(getExploreOutputBudget(5000).maxOutputChars).toBe(getExploreOutputBudget(10000).maxOutputChars); + // 14999 -> large, 15000 -> xlarge + expect(getExploreOutputBudget(14999).maxOutputChars).toBe(getExploreOutputBudget(10000).maxOutputChars); + expect(getExploreOutputBudget(15000).maxOutputChars).toBe(getExploreOutputBudget(30000).maxOutputChars); + }); +}); + +/** + * End-to-end check that the budget is actually applied by handleExplore. + * + * Builds a tiny synthetic project (<500 files, so the small tier), indexes + * it, and confirms the output: + * - stays under the small-tier maxOutputChars cap + * - omits the meta-text the small tier gates off (completeness signal, + * budget note, "Additional relevant files") + * + * Regression guard for #185 — protects against future edits to handleExplore + * silently re-introducing the fixed 35KB cap on small projects. + */ +describe('codegraph_explore output respects the adaptive budget', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeAll(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-budget-')); + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir); + + // A handful of files with one fat target file. The fat file mimics the + // Alamofire Session.swift case: many methods stacked on top of each other, + // which collapsed into one giant cluster pre-#185. + const fatLines: string[] = ['export class Session {']; + for (let i = 0; i < 30; i++) { + fatLines.push(` method${i}(arg: string): string {`); + fatLines.push(` return this.helper${i}(arg) + "${i}";`); + fatLines.push(` }`); + fatLines.push(` private helper${i}(arg: string): string {`); + fatLines.push(` return arg.repeat(${i + 1});`); + fatLines.push(` }`); + } + fatLines.push('}'); + fs.writeFileSync(path.join(srcDir, 'session.ts'), fatLines.join('\n')); + + // A few small supporting files so the project has >1 indexed file. + for (let i = 0; i < 5; i++) { + fs.writeFileSync( + path.join(srcDir, `support${i}.ts`), + `import { Session } from './session';\nexport function callSession${i}(s: Session) { return s.method${i}('hi'); }\n` + ); + } + + cg = CodeGraph.initSync(testDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterAll(() => { + if (cg) cg.destroy(); + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('keeps total output under the small-project cap', async () => { + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + const smallBudget = getExploreOutputBudget(100); + // Allow a small overshoot for the trailing markers — the cap is enforced + // per-file rather than as an absolute output ceiling. + expect(text.length).toBeLessThan(smallBudget.maxOutputChars + 500); + }); + + it('omits the meta-text gated off for small projects', async () => { + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + expect(text).not.toContain('### Additional relevant files'); + expect(text).not.toContain('Complete source code is included above'); + expect(text).not.toContain('Explore budget:'); + }); + + it('still includes the Relationships section — it is the cheapest structural signal', async () => { + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + // Either there are relationships, or no edges were significant — both are fine. + // We just want to confirm we did not accidentally gate it off. + const hasRelationships = text.includes('### Relationships'); + const sourceFollowsHeader = text.indexOf('### Source Code') > 0; + expect(hasRelationships || sourceFollowsHeader).toBe(true); + }); + + it('prefixes source lines with line numbers by default (cat -n style)', async () => { + delete process.env.CODEGRAPH_EXPLORE_LINENUMS; + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + // At least one fenced source line should look like `\t`. + expect(/\n\d+\t/.test(text)).toBe(true); + }); + + it('omits line numbers when CODEGRAPH_EXPLORE_LINENUMS=0', async () => { + process.env.CODEGRAPH_EXPLORE_LINENUMS = '0'; + try { + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + // The synthetic source has no tab-prefixed numeric lines of its own, + // so none should appear when the toggle is off. + expect(/\n\d+\t(?:export| )/.test(text)).toBe(false); + } finally { + delete process.env.CODEGRAPH_EXPLORE_LINENUMS; + } + }); + + it('uses language-neutral omission markers (no C-style // in the output)', async () => { + // The gap/trimmed separators must not assume `//` is a comment — that's + // wrong in Python, Ruby, etc. They render inside fenced source blocks. + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + expect(text).not.toContain('// ... (gap)'); + expect(text).not.toContain('// ... trimmed'); + }); + + it('does not collapse a whole-file class into just its header (envelope filter)', async () => { + // The synthetic `Session` class spans the entire file. Without the + // envelope filter it would form one giant cluster that tail-trims to + // the class declaration, hiding the methods. Confirm real method bodies + // make it into the output. Regression guard for the #185 follow-up. + const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); + const text = result.content?.[0]?.text ?? ''; + // A method body line (`methodN(arg: string)`) should appear, not just + // the `export class Session {` opener. + const hasMethodBody = /method\d+\(arg: string\)/.test(text); + expect(hasMethodBody).toBe(true); + }); +}); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts new file mode 100644 index 0000000..9271775 --- /dev/null +++ b/__tests__/extraction.test.ts @@ -0,0 +1,3897 @@ +/** + * Extraction Tests + * + * Tests for the tree-sitter extraction system. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { extractFromSource, scanDirectory } from '../src/extraction'; +import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars } from '../src/extraction/grammars'; +import { normalizePath } from '../src/utils'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +// Create a temporary directory for each test +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-')); +} + +// Clean up temporary directory +function cleanupTempDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe('Language Detection', () => { + it('should detect TypeScript files', () => { + expect(detectLanguage('src/index.ts')).toBe('typescript'); + expect(detectLanguage('components/Button.tsx')).toBe('tsx'); + }); + + it('should detect JavaScript files', () => { + expect(detectLanguage('index.js')).toBe('javascript'); + expect(detectLanguage('App.jsx')).toBe('jsx'); + expect(detectLanguage('config.mjs')).toBe('javascript'); + }); + + it('should detect Python files', () => { + expect(detectLanguage('main.py')).toBe('python'); + }); + + it('should detect Go files', () => { + expect(detectLanguage('main.go')).toBe('go'); + }); + + it('should detect Rust files', () => { + expect(detectLanguage('lib.rs')).toBe('rust'); + }); + + it('should detect Java files', () => { + expect(detectLanguage('Main.java')).toBe('java'); + }); + + it('should detect C files', () => { + expect(detectLanguage('main.c')).toBe('c'); + expect(detectLanguage('utils.h')).toBe('c'); + }); + + it('should detect C++ files', () => { + expect(detectLanguage('main.cpp')).toBe('cpp'); + expect(detectLanguage('class.hpp')).toBe('cpp'); + }); + + it('should detect C# files', () => { + expect(detectLanguage('Program.cs')).toBe('csharp'); + }); + + it('should detect PHP files', () => { + expect(detectLanguage('index.php')).toBe('php'); + }); + + it('should detect Ruby files', () => { + expect(detectLanguage('app.rb')).toBe('ruby'); + }); + + it('should detect Swift files', () => { + expect(detectLanguage('ViewController.swift')).toBe('swift'); + }); + + it('should detect Kotlin files', () => { + expect(detectLanguage('MainActivity.kt')).toBe('kotlin'); + expect(detectLanguage('build.gradle.kts')).toBe('kotlin'); + }); + + it('should detect Dart files', () => { + expect(detectLanguage('main.dart')).toBe('dart'); + }); + + it('should return unknown for unsupported extensions', () => { + expect(detectLanguage('styles.css')).toBe('unknown'); + expect(detectLanguage('data.json')).toBe('unknown'); + }); +}); + +describe('Language Support', () => { + it('should report supported languages', () => { + expect(isLanguageSupported('typescript')).toBe(true); + expect(isLanguageSupported('python')).toBe(true); + expect(isLanguageSupported('go')).toBe(true); + expect(isLanguageSupported('unknown')).toBe(false); + }); + + it('should list all supported languages', () => { + const languages = getSupportedLanguages(); + expect(languages).toContain('typescript'); + expect(languages).toContain('javascript'); + expect(languages).toContain('python'); + expect(languages).toContain('go'); + expect(languages).toContain('rust'); + expect(languages).toContain('java'); + expect(languages).toContain('csharp'); + expect(languages).toContain('php'); + expect(languages).toContain('ruby'); + expect(languages).toContain('swift'); + expect(languages).toContain('kotlin'); + expect(languages).toContain('dart'); + }); +}); + +describe('TypeScript Extraction', () => { + it('should extract function declarations', () => { + const code = ` +export function processPayment(amount: number): Promise { + return stripe.charge(amount); +} +`; + const result = extractFromSource('payment.ts', code); + + // File node + function node + const fileNode = result.nodes.find((n) => n.kind === 'file'); + expect(fileNode).toBeDefined(); + expect(fileNode?.name).toBe('payment.ts'); + + const funcNode = result.nodes.find((n) => n.kind === 'function'); + expect(funcNode).toMatchObject({ + kind: 'function', + name: 'processPayment', + language: 'typescript', + isExported: true, + }); + expect(funcNode?.signature).toContain('amount: number'); + }); + + it('should extract class declarations', () => { + const code = ` +export class PaymentService { + private stripe: StripeClient; + + constructor(apiKey: string) { + this.stripe = new StripeClient(apiKey); + } + + async charge(amount: number): Promise { + return this.stripe.charge(amount); + } +} +`; + const result = extractFromSource('service.ts', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + const methodNodes = result.nodes.filter((n) => n.kind === 'method'); + + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('PaymentService'); + expect(classNode?.isExported).toBe(true); + + expect(methodNodes.length).toBeGreaterThanOrEqual(1); + const chargeMethod = methodNodes.find((m) => m.name === 'charge'); + expect(chargeMethod).toBeDefined(); + }); + + it('should extract interfaces', () => { + const code = ` +export interface User { + id: string; + name: string; + email: string; +} +`; + const result = extractFromSource('types.ts', code); + + const fileNode = result.nodes.find((n) => n.kind === 'file'); + expect(fileNode).toBeDefined(); + + const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); + expect(ifaceNode).toMatchObject({ + kind: 'interface', + name: 'User', + isExported: true, + }); + }); + + it('should track function calls', () => { + const code = ` +function main() { + const result = processData(); + console.log(result); +} +`; + const result = extractFromSource('main.ts', code); + + expect(result.unresolvedReferences.length).toBeGreaterThan(0); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + expect(calls.some((c) => c.referenceName === 'processData')).toBe(true); + }); +}); + +describe('Arrow Function Export Extraction', () => { + it('should extract exported arrow functions assigned to const', () => { + const code = ` +export const useAuth = (): AuthContextValue => { + return useContext(AuthContext); +}; +`; + const result = extractFromSource('hooks.ts', code); + + const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'useAuth'); + expect(funcNode).toBeDefined(); + expect(funcNode).toMatchObject({ + kind: 'function', + name: 'useAuth', + isExported: true, + }); + }); + + it('should extract exported function expressions assigned to const', () => { + const code = ` +export const processData = function(input: string): string { + return input.trim(); +}; +`; + const result = extractFromSource('utils.ts', code); + + const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'processData'); + expect(funcNode).toBeDefined(); + expect(funcNode).toMatchObject({ + kind: 'function', + name: 'processData', + isExported: true, + }); + }); + + it('should not extract non-exported arrow functions as exported', () => { + const code = ` +const internalHelper = () => { + return 42; +}; +`; + const result = extractFromSource('internal.ts', code); + + const helperNode = result.nodes.find((n) => n.name === 'internalHelper'); + expect(helperNode).toBeDefined(); + expect(helperNode?.isExported).toBeFalsy(); + }); + + it('should still skip truly anonymous arrow functions', () => { + const code = ` +const items = [1, 2, 3].map((x) => x * 2); +`; + const result = extractFromSource('anon.ts', code); + + // The inline arrow function passed to .map() has no variable_declarator parent + // and should remain anonymous (skipped) + const anonFunctions = result.nodes.filter( + (n) => n.kind === 'function' && n.name === '' + ); + expect(anonFunctions).toHaveLength(0); + }); + + it('should extract multiple exported arrow functions from the same file', () => { + const code = ` +export const add = (a: number, b: number): number => a + b; + +export const subtract = (a: number, b: number): number => a - b; + +const internal = () => 'not exported'; +`; + const result = extractFromSource('math.ts', code); + + const exported = result.nodes.filter((n) => n.kind === 'function' && n.isExported); + expect(exported).toHaveLength(2); + expect(exported.map((n) => n.name).sort()).toEqual(['add', 'subtract']); + + const internalNode = result.nodes.find((n) => n.name === 'internal'); + expect(internalNode).toBeDefined(); + expect(internalNode?.isExported).toBeFalsy(); + }); + + it('should extract arrow functions in JavaScript files', () => { + const code = ` +export const fetchData = async () => { + const response = await fetch('/api/data'); + return response.json(); +}; +`; + const result = extractFromSource('api.js', code); + + const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'fetchData'); + expect(funcNode).toBeDefined(); + expect(funcNode).toMatchObject({ + kind: 'function', + name: 'fetchData', + isExported: true, + }); + }); +}); + +describe('Type Alias Extraction', () => { + it('should extract exported type aliases in TypeScript', () => { + const code = ` +export type AuthContextValue = { + user: User | null; + login: () => void; + logout: () => void; +}; +`; + const result = extractFromSource('types.ts', code); + + const typeNode = result.nodes.find((n) => n.kind === 'type_alias'); + expect(typeNode).toMatchObject({ + kind: 'type_alias', + name: 'AuthContextValue', + isExported: true, + }); + }); + + it('should extract non-exported type aliases', () => { + const code = ` +type InternalState = { + loading: boolean; + error: string | null; +}; +`; + const result = extractFromSource('internal.ts', code); + + const typeNode = result.nodes.find((n) => n.kind === 'type_alias'); + expect(typeNode).toMatchObject({ + kind: 'type_alias', + name: 'InternalState', + isExported: false, + }); + }); + + it('should extract multiple type aliases from the same file', () => { + const code = ` +export type UnitSystem = 'metric' | 'imperial'; +export type DateFormat = 'ISO' | 'US' | 'EU'; +type Internal = string; +`; + const result = extractFromSource('config.ts', code); + + const typeAliases = result.nodes.filter((n) => n.kind === 'type_alias'); + expect(typeAliases).toHaveLength(3); + + const exported = typeAliases.filter((n) => n.isExported); + expect(exported).toHaveLength(2); + expect(exported.map((n) => n.name).sort()).toEqual(['DateFormat', 'UnitSystem']); + }); +}); + +describe('Exported Variable Extraction', () => { + it('should extract exported const with call expression (Zustand store)', () => { + const code = ` +export const useUIStore = create((set) => ({ + isOpen: false, + toggle: () => set((s) => ({ isOpen: !s.isOpen })), +})); +`; + const result = extractFromSource('store.ts', code); + + const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'useUIStore'); + expect(varNode).toBeDefined(); + expect(varNode?.isExported).toBe(true); + }); + + it('should extract exported const with object literal', () => { + const code = ` +export const config = { + apiUrl: 'https://api.example.com', + timeout: 5000, +}; +`; + const result = extractFromSource('config.ts', code); + + const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'config'); + expect(varNode).toBeDefined(); + expect(varNode?.isExported).toBe(true); + }); + + it('should extract exported const with array literal', () => { + const code = ` +export const SCREEN_NAMES = ['home', 'settings', 'profile'] as const; +`; + const result = extractFromSource('constants.ts', code); + + const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'SCREEN_NAMES'); + expect(varNode).toBeDefined(); + expect(varNode?.isExported).toBe(true); + }); + + it('should extract exported const with primitive value', () => { + const code = ` +export const MAX_RETRIES = 3; +export const API_VERSION = "v2"; +`; + const result = extractFromSource('constants.ts', code); + + const variables = result.nodes.filter((n) => n.kind === 'constant'); + expect(variables).toHaveLength(2); + expect(variables.map((n) => n.name).sort()).toEqual(['API_VERSION', 'MAX_RETRIES']); + }); + + it('should NOT duplicate arrow functions as both function and variable', () => { + const code = ` +export const useAuth = () => { + return useContext(AuthContext); +}; +`; + const result = extractFromSource('hooks.ts', code); + + // Should be extracted as function (from arrow function handler), NOT as variable + const funcNodes = result.nodes.filter((n) => n.kind === 'function' && n.name === 'useAuth'); + const varNodes = result.nodes.filter((n) => n.kind === 'variable' && n.name === 'useAuth'); + expect(funcNodes).toHaveLength(1); + expect(varNodes).toHaveLength(0); + }); + + it('should extract non-exported const as non-exported variable', () => { + const code = ` +const internalConfig = { + debug: true, +}; +`; + const result = extractFromSource('internal.ts', code); + + // Non-exported const at file level should be extracted as a constant (not exported) + const varNodes = result.nodes.filter((n) => (n.kind === 'variable' || n.kind === 'constant') && n.name === 'internalConfig'); + expect(varNodes).toHaveLength(1); + expect(varNodes[0]?.isExported).toBeFalsy(); + }); + + it('should extract Zod schema exports', () => { + const code = ` +export const userSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), +}); +`; + const result = extractFromSource('schemas.ts', code); + + const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'userSchema'); + expect(varNode).toBeDefined(); + expect(varNode?.isExported).toBe(true); + }); + + it('should extract XState machine exports', () => { + const code = ` +export const authMachine = createMachine({ + id: "auth", + initial: "idle", + states: { + idle: {}, + authenticated: {}, + }, +}); +`; + const result = extractFromSource('machine.ts', code); + + const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'authMachine'); + expect(varNode).toBeDefined(); + expect(varNode?.isExported).toBe(true); + }); +}); + +describe('File Node Extraction', () => { + it('should create a file-kind node for each parsed file', () => { + const code = ` +export function greet(name: string): string { + return "Hello " + name; +} +`; + const result = extractFromSource('greeter.ts', code); + + const fileNode = result.nodes.find((n) => n.kind === 'file'); + expect(fileNode).toBeDefined(); + expect(fileNode?.name).toBe('greeter.ts'); + expect(fileNode?.filePath).toBe('greeter.ts'); + expect(fileNode?.language).toBe('typescript'); + expect(fileNode?.startLine).toBe(1); + }); + + it('should create file nodes for Python files', () => { + const code = ` +def main(): + pass +`; + const result = extractFromSource('main.py', code); + + const fileNode = result.nodes.find((n) => n.kind === 'file'); + expect(fileNode).toBeDefined(); + expect(fileNode?.name).toBe('main.py'); + expect(fileNode?.language).toBe('python'); + }); + + it('should create containment edges from file node to top-level declarations', () => { + const code = ` +export function foo() {} +export function bar() {} +`; + const result = extractFromSource('fns.ts', code); + + const fileNode = result.nodes.find((n) => n.kind === 'file'); + expect(fileNode).toBeDefined(); + + // There should be contains edges from the file node to each function + const containsEdges = result.edges.filter( + (e) => e.source === fileNode?.id && e.kind === 'contains' + ); + expect(containsEdges.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe('Python Extraction', () => { + it('should extract function definitions', () => { + const code = ` +def calculate_total(items: list, tax_rate: float) -> float: + """Calculate total with tax.""" + subtotal = sum(item.price for item in items) + return subtotal * (1 + tax_rate) +`; + const result = extractFromSource('calc.py', code); + + const fileNode = result.nodes.find((n) => n.kind === 'file'); + expect(fileNode).toBeDefined(); + + const funcNode = result.nodes.find((n) => n.kind === 'function'); + expect(funcNode).toMatchObject({ + kind: 'function', + name: 'calculate_total', + language: 'python', + }); + }); + + it('should extract class definitions', () => { + const code = ` +class UserService: + """Service for managing users.""" + + def __init__(self, db): + self.db = db + + def get_user(self, user_id: str) -> User: + return self.db.find_user(user_id) +`; + const result = extractFromSource('service.py', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('UserService'); + }); +}); + +describe('Go Extraction', () => { + it('should extract function declarations', () => { + const code = ` +package main + +func ProcessOrder(order Order) (Receipt, error) { + // Process the order + return Receipt{}, nil +} +`; + const result = extractFromSource('main.go', code); + + const funcNode = result.nodes.find((n) => n.kind === 'function'); + expect(funcNode).toBeDefined(); + expect(funcNode?.name).toBe('ProcessOrder'); + }); + + it('should extract method declarations', () => { + const code = ` +package main + +type Service struct { + db *Database +} + +func (s *Service) GetUser(id string) (*User, error) { + return s.db.FindUser(id) +} +`; + const result = extractFromSource('service.go', code); + + const methodNode = result.nodes.find((n) => n.kind === 'method'); + expect(methodNode).toBeDefined(); + expect(methodNode?.name).toBe('GetUser'); + }); +}); + +describe('Rust Extraction', () => { + it('should extract function declarations', () => { + const code = ` +pub fn process_data(input: &str) -> Result { + // Process data + Ok(Output::new()) +} +`; + const result = extractFromSource('lib.rs', code); + + const funcNode = result.nodes.find((n) => n.kind === 'function'); + expect(funcNode).toBeDefined(); + expect(funcNode?.name).toBe('process_data'); + expect(funcNode?.visibility).toBe('public'); + }); + + it('should extract struct declarations', () => { + const code = ` +pub struct User { + pub id: String, + pub name: String, + email: String, +} +`; + const result = extractFromSource('models.rs', code); + + const structNode = result.nodes.find((n) => n.kind === 'struct'); + expect(structNode).toBeDefined(); + expect(structNode?.name).toBe('User'); + }); + + it('should extract trait declarations', () => { + const code = ` +pub trait Repository { + fn find(&self, id: &str) -> Option; + fn save(&mut self, entity: Entity) -> Result<(), Error>; +} +`; + const result = extractFromSource('traits.rs', code); + + const traitNode = result.nodes.find((n) => n.kind === 'trait'); + expect(traitNode).toBeDefined(); + expect(traitNode?.name).toBe('Repository'); + }); + + it('should extract impl Trait for Type as implements edges', () => { + const code = ` +pub struct MyCache {} + +pub trait Cache { + fn get(&self, key: &str) -> Option; +} + +impl Cache for MyCache { + fn get(&self, key: &str) -> Option { + None + } +} +`; + const result = extractFromSource('cache.rs', code); + + // Should have an unresolved reference for implements + const implRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'implements' && r.referenceName === 'Cache' + ); + expect(implRef).toBeDefined(); + + // The struct MyCache should be the source + const myCacheNode = result.nodes.find((n) => n.name === 'MyCache' && n.kind === 'struct'); + expect(myCacheNode).toBeDefined(); + expect(implRef?.fromNodeId).toBe(myCacheNode?.id); + }); + + it('should extract trait supertraits as extends references', () => { + const code = ` +pub trait Display {} + +pub trait Error: Display { + fn description(&self) -> &str; +} +`; + const result = extractFromSource('error.rs', code); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' && r.referenceName === 'Display' + ); + expect(extendsRef).toBeDefined(); + + const errorTrait = result.nodes.find((n) => n.name === 'Error' && n.kind === 'trait'); + expect(errorTrait).toBeDefined(); + expect(extendsRef?.fromNodeId).toBe(errorTrait?.id); + }); + + it('should not create implements edges for plain impl blocks', () => { + const code = ` +pub struct Counter { + count: u32, +} + +impl Counter { + pub fn new() -> Counter { + Counter { count: 0 } + } + pub fn increment(&mut self) { + self.count += 1; + } +} +`; + const result = extractFromSource('counter.rs', code); + + // Should have no implements references (no trait involved) + const implRefs = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'implements' + ); + expect(implRefs).toHaveLength(0); + }); +}); + +describe('Java Extraction', () => { + it('should extract class declarations', () => { + const code = ` +public class UserService { + private final UserRepository repository; + + public UserService(UserRepository repository) { + this.repository = repository; + } + + public User getUser(String id) { + return repository.findById(id); + } +} +`; + const result = extractFromSource('UserService.java', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('UserService'); + expect(classNode?.visibility).toBe('public'); + }); + + it('should extract method declarations', () => { + const code = ` +public class Calculator { + public static int add(int a, int b) { + return a + b; + } +} +`; + const result = extractFromSource('Calculator.java', code); + + const methodNode = result.nodes.find((n) => n.kind === 'method' && n.name === 'add'); + expect(methodNode).toBeDefined(); + expect(methodNode?.isStatic).toBe(true); + }); +}); + +describe('C# Extraction', () => { + it('should extract class declarations', () => { + const code = ` +public class OrderService +{ + private readonly IOrderRepository _repository; + + public OrderService(IOrderRepository repository) + { + _repository = repository; + } + + public async Task GetOrderAsync(string id) + { + return await _repository.FindByIdAsync(id); + } +} +`; + const result = extractFromSource('OrderService.cs', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('OrderService'); + expect(classNode?.visibility).toBe('public'); + }); +}); + +describe('PHP Extraction', () => { + it('should extract class declarations', () => { + const code = `userService = $userService; + } + + public function show(string $id): User + { + return $this->userService->find($id); + } +} +`; + const result = extractFromSource('UserController.php', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('UserController'); + }); + + it('should extract class inheritance (extends) and interface implementation', () => { + const code = ` n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('ChildController'); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' + ); + expect(extendsRef).toBeDefined(); + expect(extendsRef?.referenceName).toBe('BaseController'); + + const implementsRefs = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'implements' + ); + expect(implementsRefs.length).toBe(2); + expect(implementsRefs.map((r) => r.referenceName)).toContain('Serializable'); + expect(implementsRefs.map((r) => r.referenceName)).toContain('JsonSerializable'); + }); +}); + +describe('Swift Extraction', () => { + it('should extract class declarations', () => { + const code = ` +public class NetworkManager { + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + public func fetchData(from url: URL) async throws -> Data { + let (data, _) = try await session.data(from: url) + return data + } +} +`; + const result = extractFromSource('NetworkManager.swift', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('NetworkManager'); + }); + + it('should extract function declarations', () => { + const code = ` +func calculateSum(_ numbers: [Int]) -> Int { + return numbers.reduce(0, +) +} + +public func formatCurrency(amount: Double) -> String { + return String(format: "$%.2f", amount) +} +`; + const result = extractFromSource('utils.swift', code); + + const functions = result.nodes.filter((n) => n.kind === 'function'); + expect(functions.length).toBeGreaterThanOrEqual(1); + }); + + it('should extract struct declarations', () => { + const code = ` +public struct User { + let id: UUID + var name: String + var email: String + + func displayName() -> String { + return name + } +} +`; + const result = extractFromSource('User.swift', code); + + const structNode = result.nodes.find((n) => n.kind === 'struct'); + expect(structNode).toBeDefined(); + expect(structNode?.name).toBe('User'); + }); + + it('should extract protocol declarations', () => { + const code = ` +public protocol Repository { + associatedtype Entity + + func find(id: String) async throws -> Entity? + func save(_ entity: Entity) async throws +} +`; + const result = extractFromSource('Repository.swift', code); + + const protocolNode = result.nodes.find((n) => n.kind === 'interface'); + expect(protocolNode).toBeDefined(); + expect(protocolNode?.name).toBe('Repository'); + }); + + it('should extract class inheritance and protocol conformance', () => { + const code = ` +class DataRequest: Request { + func validate() {} +} + +class UploadRequest: DataRequest, Sendable { + func upload() {} +} + +enum AFError: Error { + case invalidURL +} + +struct HTTPMethod: RawRepresentable { + let rawValue: String +} + +protocol UploadConvertible: URLRequestConvertible { + func asURLRequest() throws -> URLRequest +} +`; + const result = extractFromSource('Inheritance.swift', code); + + const extendsRefs = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'extends' + ); + + // DataRequest extends Request + expect(extendsRefs.find((r) => r.referenceName === 'Request')).toBeDefined(); + // UploadRequest extends DataRequest and Sendable + expect(extendsRefs.find((r) => r.referenceName === 'DataRequest')).toBeDefined(); + expect(extendsRefs.find((r) => r.referenceName === 'Sendable')).toBeDefined(); + // AFError extends Error + expect(extendsRefs.find((r) => r.referenceName === 'Error')).toBeDefined(); + // HTTPMethod extends RawRepresentable + expect(extendsRefs.find((r) => r.referenceName === 'RawRepresentable')).toBeDefined(); + // UploadConvertible extends URLRequestConvertible + expect(extendsRefs.find((r) => r.referenceName === 'URLRequestConvertible')).toBeDefined(); + }); +}); + +describe('Kotlin Extraction', () => { + it('should extract class declarations', () => { + const code = ` +class UserRepository(private val database: Database) { + fun findById(id: String): User? { + return database.query("SELECT * FROM users WHERE id = ?", id) + } + + suspend fun save(user: User) { + database.insert(user) + } +} +`; + const result = extractFromSource('UserRepository.kt', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('UserRepository'); + }); + + it('should extract function declarations', () => { + const code = ` +fun calculateTotal(items: List): Double { + return items.sumOf { it.price } +} + +suspend fun fetchUserData(userId: String): User { + return api.getUser(userId) +} +`; + const result = extractFromSource('utils.kt', code); + + const functions = result.nodes.filter((n) => n.kind === 'function'); + expect(functions.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect suspend functions as async', () => { + const code = ` +suspend fun loadData(): List { + delay(1000) + return listOf("a", "b", "c") +} +`; + const result = extractFromSource('loader.kt', code); + + const funcNode = result.nodes.find((n) => n.kind === 'function'); + expect(funcNode).toBeDefined(); + expect(funcNode?.isAsync).toBe(true); + }); + + it('should extract fun interface declarations', () => { + const code = ` +fun interface OnObjectRetainedListener { + fun onObjectRetained() +} +`; + const result = extractFromSource('listener.kt', code); + + const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); + expect(ifaceNode).toBeDefined(); + expect(ifaceNode?.name).toBe('OnObjectRetainedListener'); + + const methodNode = result.nodes.find((n) => n.kind === 'method'); + expect(methodNode).toBeDefined(); + expect(methodNode?.name).toBe('onObjectRetained'); + expect(methodNode?.qualifiedName).toBe('OnObjectRetainedListener::onObjectRetained'); + }); + + it('should extract complex fun interface with nested classes', () => { + const code = ` +fun interface EventListener { + fun onEvent(event: Event) + + sealed class Event { + class DumpingHeap : Event() + } +} +`; + const result = extractFromSource('events.kt', code); + + const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); + expect(ifaceNode).toBeDefined(); + expect(ifaceNode?.name).toBe('EventListener'); + + // Nested sealed class should still be extracted (as sibling due to grammar limitations) + const eventClass = result.nodes.find((n) => n.kind === 'class' && n.name === 'Event'); + expect(eventClass).toBeDefined(); + + const dumpingHeap = result.nodes.find((n) => n.kind === 'class' && n.name === 'DumpingHeap'); + expect(dumpingHeap).toBeDefined(); + }); + + it('should not affect regular function declarations', () => { + const code = ` +fun interface MyCallback { + fun invoke(value: Int) +} + +fun regularFunction(): String { + return "hello" +} +`; + const result = extractFromSource('mixed.kt', code); + + const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); + expect(ifaceNode).toBeDefined(); + expect(ifaceNode?.name).toBe('MyCallback'); + + const funcNode = result.nodes.find((n) => n.kind === 'function'); + expect(funcNode).toBeDefined(); + expect(funcNode?.name).toBe('regularFunction'); + }); + + it('should extract fun interface with annotation on method (Pattern 2b)', () => { + // When the SAM method has annotations like @Throws, tree-sitter produces a different + // misparse: function_declaration > ERROR("interface Name {") instead of + // function_declaration > user_type("interface"). This is the OkHttp Interceptor pattern. + const code = ` +import java.io.IOException + +fun interface Interceptor { + @Throws(IOException::class) + fun intercept(chain: Chain): Response +} +`; + const result = extractFromSource('interceptor.kt', code); + + const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); + expect(ifaceNode).toBeDefined(); + expect(ifaceNode?.name).toBe('Interceptor'); + }); + + it('should extract methods from interface with nested fun interface', () => { + // When an interface contains a nested `fun interface`, tree-sitter misparsed + // the parent body as ERROR. Methods inside should still be extracted. + const code = ` +interface WebSocket { + fun request(): Request + fun send(text: String): Boolean + fun cancel() + fun interface Factory { + fun newWebSocket(request: Request): WebSocket + } +} +`; + const result = extractFromSource('websocket.kt', code); + + const wsIface = result.nodes.find((n) => n.kind === 'interface' && n.name === 'WebSocket'); + expect(wsIface).toBeDefined(); + + const methods = result.nodes.filter((n) => n.kind === 'method' && n.qualifiedName?.startsWith('WebSocket::')); + const methodNames = methods.map((m) => m.name); + expect(methodNames).toContain('request'); + expect(methodNames).toContain('send'); + expect(methodNames).toContain('cancel'); + }); +}); + +describe('Dart Extraction', () => { + it('should extract class declarations', () => { + const code = ` +class UserService { + final Database _db; + + Future findById(String id) async { + return await _db.query(id); + } + + void _privateMethod() {} +} +`; + const result = extractFromSource('service.dart', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('UserService'); + expect(classNode?.visibility).toBe('public'); + + const methodNodes = result.nodes.filter((n) => n.kind === 'method'); + expect(methodNodes.length).toBeGreaterThanOrEqual(2); + + const findById = methodNodes.find((m) => m.name === 'findById'); + expect(findById).toBeDefined(); + expect(findById?.isAsync).toBe(true); + + const privateMethod = methodNodes.find((m) => m.name === '_privateMethod'); + expect(privateMethod).toBeDefined(); + expect(privateMethod?.visibility).toBe('private'); + }); + + it('should extract top-level function declarations', () => { + const code = ` +void topLevelFunction(String name) { + print(name); +} +`; + const result = extractFromSource('utils.dart', code); + + const funcNode = result.nodes.find((n) => n.kind === 'function'); + expect(funcNode).toBeDefined(); + expect(funcNode?.name).toBe('topLevelFunction'); + expect(funcNode?.language).toBe('dart'); + }); + + it('should extract enum declarations', () => { + const code = ` +enum Status { active, inactive, pending } +`; + const result = extractFromSource('models.dart', code); + + const enumNode = result.nodes.find((n) => n.kind === 'enum'); + expect(enumNode).toBeDefined(); + expect(enumNode?.name).toBe('Status'); + }); + + it('should extract mixin declarations', () => { + const code = ` +mixin LoggerMixin { + void log(String message) {} +} +`; + const result = extractFromSource('mixins.dart', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('LoggerMixin'); + + const methodNode = result.nodes.find((n) => n.kind === 'method'); + expect(methodNode).toBeDefined(); + expect(methodNode?.name).toBe('log'); + }); + + it('should extract extension declarations', () => { + const code = ` +extension StringExt on String { + bool get isBlank => trim().isEmpty; +} +`; + const result = extractFromSource('extensions.dart', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('StringExt'); + }); + + it('should detect static methods', () => { + const code = ` +class Utils { + static void doWork() {} +} +`; + const result = extractFromSource('utils.dart', code); + + const methodNode = result.nodes.find((n) => n.kind === 'method'); + expect(methodNode).toBeDefined(); + expect(methodNode?.name).toBe('doWork'); + expect(methodNode?.isStatic).toBe(true); + }); + + it('should detect async functions', () => { + const code = ` +Future fetchData() async { + return await http.get('/data'); +} +`; + const result = extractFromSource('api.dart', code); + + const funcNode = result.nodes.find((n) => n.kind === 'function'); + expect(funcNode).toBeDefined(); + expect(funcNode?.name).toBe('fetchData'); + expect(funcNode?.isAsync).toBe(true); + }); + + it('should detect private visibility via underscore convention', () => { + const code = ` +void _privateHelper() {} + +void publicFunction() {} +`; + const result = extractFromSource('helpers.dart', code); + + const functions = result.nodes.filter((n) => n.kind === 'function'); + const privateFunc = functions.find((f) => f.name === '_privateHelper'); + const publicFunc = functions.find((f) => f.name === 'publicFunction'); + + expect(privateFunc?.visibility).toBe('private'); + expect(publicFunc?.visibility).toBe('public'); + }); +}); + +describe('Import Extraction', () => { + describe('TypeScript/JavaScript imports', () => { + it('should extract default imports', () => { + const code = `import React from 'react';`; + const result = extractFromSource('app.tsx', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('react'); + expect(importNode?.signature).toBe("import React from 'react';"); + }); + + it('should extract named imports', () => { + const code = `import { Bug, Database } from '@phosphor-icons/react';`; + const result = extractFromSource('icons.tsx', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('@phosphor-icons/react'); + expect(importNode?.signature).toContain('Bug'); + expect(importNode?.signature).toContain('Database'); + }); + + it('should extract namespace imports', () => { + const code = `import * as Icons from '@phosphor-icons/react';`; + const result = extractFromSource('icons.tsx', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('@phosphor-icons/react'); + expect(importNode?.signature).toContain('* as Icons'); + }); + + it('should extract side-effect imports', () => { + const code = `import './styles.css';`; + const result = extractFromSource('app.tsx', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('./styles.css'); + }); + + it('should extract mixed imports (default + named)', () => { + const code = `import React, { useState, useEffect } from 'react';`; + const result = extractFromSource('app.tsx', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('react'); + expect(importNode?.signature).toContain('React'); + expect(importNode?.signature).toContain('useState'); + expect(importNode?.signature).toContain('useEffect'); + }); + + it('should extract multiple import statements', () => { + const code = ` +import React from 'react'; +import { Button } from './components'; +import './styles.css'; +`; + const result = extractFromSource('app.tsx', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('react'); + expect(names).toContain('./components'); + expect(names).toContain('./styles.css'); + }); + + it('should extract type imports', () => { + const code = `import type { FC, ReactNode } from 'react';`; + const result = extractFromSource('types.ts', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('react'); + expect(importNode?.signature).toContain('type'); + expect(importNode?.signature).toContain('FC'); + }); + + it('should extract aliased named imports', () => { + const code = `import { useState as useStateAlias } from 'react';`; + const result = extractFromSource('hooks.ts', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('react'); + expect(importNode?.signature).toContain('useState'); + expect(importNode?.signature).toContain('useStateAlias'); + }); + + it('should extract relative path imports', () => { + const code = `import { helper } from '../utils/helper';`; + const result = extractFromSource('components/Button.tsx', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('../utils/helper'); + expect(importNode?.signature).toContain('helper'); + }); + }); + + describe('Python imports', () => { + it('should extract simple import statement', () => { + const code = `import json`; + const result = extractFromSource('utils.py', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('json'); + }); + + it('should extract from import statement', () => { + const code = `from os import path`; + const result = extractFromSource('utils.py', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('os'); + expect(importNode?.signature).toContain('path'); + }); + + it('should extract multiple imports from same module', () => { + const code = `from typing import List, Dict, Optional`; + const result = extractFromSource('types.py', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('typing'); + expect(importNode?.signature).toContain('List'); + expect(importNode?.signature).toContain('Dict'); + }); + + it('should extract multiple import statements', () => { + const code = ` +import os +import sys +`; + const result = extractFromSource('main.py', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(2); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('os'); + expect(names).toContain('sys'); + }); + + it('should extract aliased import', () => { + const code = `import numpy as np`; + const result = extractFromSource('data.py', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('numpy'); + expect(importNode?.signature).toContain('as np'); + }); + + it('should extract relative import', () => { + const code = `from .utils import helper`; + const result = extractFromSource('module.py', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('.utils'); + expect(importNode?.signature).toContain('helper'); + }); + + it('should extract wildcard import', () => { + const code = `from typing import *`; + const result = extractFromSource('types.py', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('typing'); + expect(importNode?.signature).toContain('*'); + }); + }); + + describe('Rust imports', () => { + it('should extract simple use declaration', () => { + const code = `use std::io;`; + const result = extractFromSource('main.rs', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('std'); + expect(importNode?.signature).toBe('use std::io;'); + }); + + it('should extract scoped use list', () => { + const code = `use std::{ffi::OsStr, io, path::Path};`; + const result = extractFromSource('main.rs', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('std'); + expect(importNode?.signature).toContain('ffi::OsStr'); + expect(importNode?.signature).toContain('path::Path'); + }); + + it('should extract crate imports', () => { + const code = `use crate::error::Error;`; + const result = extractFromSource('lib.rs', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('crate'); + }); + + it('should extract super imports', () => { + const code = `use super::utils;`; + const result = extractFromSource('submod.rs', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('super'); + }); + + it('should extract external crate imports', () => { + const code = `use serde::{Serialize, Deserialize};`; + const result = extractFromSource('types.rs', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('serde'); + expect(importNode?.signature).toContain('Serialize'); + expect(importNode?.signature).toContain('Deserialize'); + }); + }); + + describe('Go imports', () => { + it('should extract single import', () => { + const code = ` +package main + +import "fmt" +`; + const result = extractFromSource('main.go', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('fmt'); + }); + + it('should extract grouped imports', () => { + const code = ` +package main + +import ( + "fmt" + "os" + "encoding/json" +) +`; + const result = extractFromSource('main.go', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('fmt'); + expect(names).toContain('os'); + expect(names).toContain('encoding/json'); + }); + + it('should extract aliased import', () => { + const code = ` +package main + +import f "fmt" +`; + const result = extractFromSource('main.go', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('fmt'); + expect(importNode?.signature).toContain('f'); + }); + + it('should extract dot import', () => { + const code = ` +package main + +import . "math" +`; + const result = extractFromSource('main.go', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('math'); + expect(importNode?.signature).toContain('.'); + }); + + it('should extract blank import', () => { + const code = ` +package main + +import _ "github.com/go-sql-driver/mysql" +`; + const result = extractFromSource('main.go', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('github.com/go-sql-driver/mysql'); + expect(importNode?.signature).toContain('_'); + }); + }); + + describe('Swift imports', () => { + it('should extract simple import', () => { + const code = `import Foundation`; + const result = extractFromSource('main.swift', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('Foundation'); + expect(importNode?.signature).toBe('import Foundation'); + }); + + it('should extract @testable import', () => { + const code = `@testable import Alamofire`; + const result = extractFromSource('Tests.swift', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('Alamofire'); + expect(importNode?.signature).toContain('@testable'); + }); + + it('should extract @preconcurrency import', () => { + const code = `@preconcurrency import Security`; + const result = extractFromSource('Auth.swift', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('Security'); + }); + + it('should extract multiple imports', () => { + const code = ` +import Foundation +import UIKit +import Alamofire +`; + const result = extractFromSource('App.swift', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('Foundation'); + expect(names).toContain('UIKit'); + expect(names).toContain('Alamofire'); + }); + }); + + describe('Kotlin imports', () => { + it('should extract simple import', () => { + const code = `import java.io.IOException`; + const result = extractFromSource('Main.kt', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('java.io.IOException'); + expect(importNode?.signature).toBe('import java.io.IOException'); + }); + + it('should extract aliased import', () => { + const code = `import okhttp3.Request.Builder as RequestBuilder`; + const result = extractFromSource('Utils.kt', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('okhttp3.Request.Builder'); + expect(importNode?.signature).toContain('as RequestBuilder'); + }); + + it('should extract wildcard import', () => { + const code = `import java.util.concurrent.TimeUnit.*`; + const result = extractFromSource('Time.kt', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('java.util.concurrent.TimeUnit'); + expect(importNode?.signature).toContain('.*'); + }); + + it('should extract multiple imports', () => { + const code = ` +import java.io.IOException +import kotlin.test.assertFailsWith +import okhttp3.OkHttpClient +`; + const result = extractFromSource('Test.kt', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('java.io.IOException'); + expect(names).toContain('kotlin.test.assertFailsWith'); + expect(names).toContain('okhttp3.OkHttpClient'); + }); + }); + + describe('Java imports', () => { + it('should extract simple import', () => { + const code = `import java.util.List;`; + const result = extractFromSource('Main.java', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('java.util.List'); + expect(importNode?.signature).toBe('import java.util.List;'); + }); + + it('should extract static import', () => { + const code = `import static java.util.Collections.emptyList;`; + const result = extractFromSource('Utils.java', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('java.util.Collections.emptyList'); + expect(importNode?.signature).toContain('static'); + }); + + it('should extract wildcard import', () => { + const code = `import java.util.*;`; + const result = extractFromSource('App.java', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('java.util'); + expect(importNode?.signature).toContain('.*'); + }); + + it('should extract nested class import', () => { + const code = `import java.util.Map.Entry;`; + const result = extractFromSource('MapUtil.java', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('java.util.Map.Entry'); + }); + + it('should extract multiple imports', () => { + const code = ` +import java.util.List; +import java.util.Map; +import java.io.IOException; +`; + const result = extractFromSource('Service.java', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('java.util.List'); + expect(names).toContain('java.util.Map'); + expect(names).toContain('java.io.IOException'); + }); + }); + + describe('C# imports', () => { + it('should extract simple using', () => { + const code = `using System;`; + const result = extractFromSource('Program.cs', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('System'); + expect(importNode?.signature).toBe('using System;'); + }); + + it('should extract qualified using', () => { + const code = `using System.Collections.Generic;`; + const result = extractFromSource('Utils.cs', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('System.Collections.Generic'); + }); + + it('should extract static using', () => { + const code = `using static System.Console;`; + const result = extractFromSource('App.cs', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('System.Console'); + expect(importNode?.signature).toContain('static'); + }); + + it('should extract alias using', () => { + const code = `using MyList = System.Collections.Generic.List;`; + const result = extractFromSource('Types.cs', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('System.Collections.Generic.List'); + expect(importNode?.signature).toContain('MyList ='); + }); + + it('should extract multiple usings', () => { + const code = ` +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +`; + const result = extractFromSource('Service.cs', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('System'); + expect(names).toContain('System.Threading.Tasks'); + expect(names).toContain('Microsoft.Extensions.DependencyInjection'); + }); + }); + + describe('PHP imports', () => { + it('should extract simple use', () => { + const code = ` n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('PHPUnit\\Framework\\TestCase'); + }); + + it('should extract aliased use', () => { + const code = ` n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('Mockery'); + expect(importNode?.signature).toContain('as m'); + }); + + it('should extract function use', () => { + const code = ` n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('Illuminate\\Support\\env'); + expect(importNode?.signature).toContain('function'); + }); + + it('should extract grouped use', () => { + const code = ` n.kind === 'import'); + expect(importNodes.length).toBe(2); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('Illuminate\\Database\\Model'); + expect(names).toContain('Illuminate\\Database\\Builder'); + }); + + it('should extract multiple uses', () => { + const code = ` n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('Illuminate\\Support\\Collection'); + expect(names).toContain('Illuminate\\Support\\Str'); + expect(names).toContain('Closure'); + }); + }); + + describe('Ruby imports', () => { + it('should extract require', () => { + const code = `require 'json'`; + const result = extractFromSource('app.rb', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('json'); + expect(importNode?.signature).toBe("require 'json'"); + }); + + it('should extract require with path', () => { + const code = `require 'active_support/core_ext/string'`; + const result = extractFromSource('config.rb', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('active_support/core_ext/string'); + }); + + it('should extract require_relative', () => { + const code = `require_relative '../test_helper'`; + const result = extractFromSource('test/my_test.rb', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('../test_helper'); + expect(importNode?.signature).toContain('require_relative'); + }); + + it('should not extract non-require calls', () => { + const code = `puts 'hello'`; + const result = extractFromSource('app.rb', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeUndefined(); + }); + + it('should extract multiple requires', () => { + const code = ` +require 'json' +require 'yaml' +require_relative 'helper' +`; + const result = extractFromSource('lib.rb', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('json'); + expect(names).toContain('yaml'); + expect(names).toContain('helper'); + }); + }); + + describe('Ruby modules', () => { + it('should extract module as module node with containment', () => { + const code = ` +module CachedCounting + def self.disable + @enabled = false + end + + def perform_increment!(key, count) + write_cache!(key, count) + end +end +`; + const result = extractFromSource('concerns/cached_counting.rb', code); + + const moduleNode = result.nodes.find((n) => n.kind === 'module' && n.name === 'CachedCounting'); + expect(moduleNode).toBeDefined(); + expect(moduleNode?.qualifiedName).toBe('CachedCounting'); + + // Methods inside module should have module-qualified names + const disableMethod = result.nodes.find((n) => n.name === 'disable' && n.kind === 'method'); + expect(disableMethod).toBeDefined(); + expect(disableMethod?.qualifiedName).toBe('CachedCounting::disable'); + + const incrementMethod = result.nodes.find((n) => n.name === 'perform_increment!' && n.kind === 'method'); + expect(incrementMethod).toBeDefined(); + expect(incrementMethod?.qualifiedName).toBe('CachedCounting::perform_increment!'); + + // Containment edge from module to methods + const containsEdges = result.edges.filter((e) => e.source === moduleNode?.id && e.kind === 'contains'); + expect(containsEdges.length).toBeGreaterThanOrEqual(2); + }); + + it('should handle nested modules with classes', () => { + const code = ` +module Discourse + module Auth + class AuthProvider + def authenticate(params) + validate(params) + end + end + end +end +`; + const result = extractFromSource('lib/auth.rb', code); + + const discourseModule = result.nodes.find((n) => n.kind === 'module' && n.name === 'Discourse'); + expect(discourseModule).toBeDefined(); + + const authModule = result.nodes.find((n) => n.kind === 'module' && n.name === 'Auth'); + expect(authModule).toBeDefined(); + expect(authModule?.qualifiedName).toBe('Discourse::Auth'); + + const authProvider = result.nodes.find((n) => n.kind === 'class' && n.name === 'AuthProvider'); + expect(authProvider).toBeDefined(); + expect(authProvider?.qualifiedName).toBe('Discourse::Auth::AuthProvider'); + + const authMethod = result.nodes.find((n) => n.name === 'authenticate'); + expect(authMethod).toBeDefined(); + expect(authMethod?.qualifiedName).toBe('Discourse::Auth::AuthProvider::authenticate'); + }); + }); + + describe('C/C++ imports', () => { + it('should extract system include', () => { + const code = `#include `; + const result = extractFromSource('main.cpp', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('iostream'); + expect(importNode?.signature).toBe('#include '); + }); + + it('should extract system include with path', () => { + const code = `#include `; + const result = extractFromSource('app.cpp', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('nlohmann/json.hpp'); + }); + + it('should extract local include', () => { + const code = `#include "myheader.h"`; + const result = extractFromSource('main.cpp', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('myheader.h'); + }); + + it('should extract C header', () => { + const code = `#include `; + const result = extractFromSource('main.c', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('stdio.h'); + }); + + it('should extract multiple includes', () => { + const code = ` +#include +#include +#include "config.h" +`; + const result = extractFromSource('app.cpp', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('iostream'); + expect(names).toContain('vector'); + expect(names).toContain('config.h'); + }); + }); + + describe('Dart imports', () => { + it('should extract dart: import', () => { + const code = `import 'dart:async';`; + const result = extractFromSource('main.dart', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('dart:async'); + expect(importNode?.signature).toBe("import 'dart:async';"); + }); + + it('should extract package import', () => { + const code = `import 'package:flutter/material.dart';`; + const result = extractFromSource('app.dart', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('package:flutter/material.dart'); + }); + + it('should extract aliased import', () => { + const code = `import 'package:http/http.dart' as http;`; + const result = extractFromSource('api.dart', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('package:http/http.dart'); + expect(importNode?.signature).toContain('as http'); + }); + + it('should extract multiple imports', () => { + const code = ` +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +`; + const result = extractFromSource('main.dart', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('dart:async'); + expect(names).toContain('dart:convert'); + expect(names).toContain('package:flutter/material.dart'); + }); + + it('should extract relative import', () => { + const code = `import '../utils/helpers.dart';`; + const result = extractFromSource('lib/main.dart', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('../utils/helpers.dart'); + }); + }); + + describe('Liquid imports', () => { + it('should extract render tag', () => { + const code = `{% render 'loading-spinner' %}`; + const result = extractFromSource('template.liquid', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('loading-spinner'); + expect(importNode?.signature).toContain('render'); + }); + + it('should extract section tag', () => { + const code = `{% section 'header' %}`; + const result = extractFromSource('layout/theme.liquid', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('header'); + expect(importNode?.signature).toContain('section'); + }); + + it('should extract include tag', () => { + const code = `{% include 'icon-cart' %}`; + const result = extractFromSource('snippets/header.liquid', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('icon-cart'); + expect(importNode?.signature).toContain('include'); + }); + + it('should extract render with whitespace control', () => { + const code = `{%- render 'price' -%}`; + const result = extractFromSource('snippets/product.liquid', code); + + const importNode = result.nodes.find((n) => n.kind === 'import'); + expect(importNode).toBeDefined(); + expect(importNode?.name).toBe('price'); + }); + + it('should extract multiple imports', () => { + const code = ` +{% section 'header' %} +{% render 'loading-spinner' %} +{% render 'cart-drawer' %} +`; + const result = extractFromSource('layout/theme.liquid', code); + + const importNodes = result.nodes.filter((n) => n.kind === 'import'); + expect(importNodes.length).toBe(3); + + const names = importNodes.map((n) => n.name); + expect(names).toContain('header'); + expect(names).toContain('loading-spinner'); + expect(names).toContain('cart-drawer'); + }); + }); +}); + +// ============================================================================= +// Pascal / Delphi Extraction +// ============================================================================= + +describe('Pascal / Delphi Extraction', () => { + describe('Language detection', () => { + it('should detect Pascal files', () => { + expect(detectLanguage('UAuth.pas')).toBe('pascal'); + expect(detectLanguage('App.dpr')).toBe('pascal'); + expect(detectLanguage('Package.dpk')).toBe('pascal'); + expect(detectLanguage('App.lpr')).toBe('pascal'); + expect(detectLanguage('MainForm.dfm')).toBe('pascal'); + expect(detectLanguage('MainForm.fmx')).toBe('pascal'); + }); + + it('should report Pascal as supported', () => { + expect(isLanguageSupported('pascal')).toBe(true); + expect(getSupportedLanguages()).toContain('pascal'); + }); + }); + + describe('Unit extraction', () => { + it('should extract unit as module', () => { + const code = `unit MyUnit;\ninterface\nimplementation\nend.`; + const result = extractFromSource('MyUnit.pas', code); + + const moduleNode = result.nodes.find((n) => n.kind === 'module'); + expect(moduleNode).toBeDefined(); + expect(moduleNode?.name).toBe('MyUnit'); + expect(moduleNode?.language).toBe('pascal'); + }); + + it('should extract program as module', () => { + const code = `program MyApp;\nbegin\nend.`; + const result = extractFromSource('MyApp.dpr', code); + + const moduleNode = result.nodes.find((n) => n.kind === 'module'); + expect(moduleNode).toBeDefined(); + expect(moduleNode?.name).toBe('MyApp'); + }); + + it('should fallback to filename when module name is empty', () => { + // Some .dpr templates use "program;" without a name + const code = `program;\nuses SysUtils;\nbegin\nend.`; + const result = extractFromSource('Console.dpr', code); + + const moduleNode = result.nodes.find((n) => n.kind === 'module'); + expect(moduleNode).toBeDefined(); + expect(moduleNode?.name).toBe('Console'); + }); + }); + + describe('Uses clause (imports)', () => { + it('should extract uses as individual imports', () => { + const code = `unit Test;\ninterface\nuses\n System.SysUtils,\n System.Classes;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const imports = result.nodes.filter((n) => n.kind === 'import'); + expect(imports.length).toBe(2); + expect(imports.map((n) => n.name)).toContain('System.SysUtils'); + expect(imports.map((n) => n.name)).toContain('System.Classes'); + }); + + it('should create unresolved references for imports', () => { + const code = `unit Test;\ninterface\nuses\n UAuth;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const importRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'imports' + ); + expect(importRef).toBeDefined(); + expect(importRef?.referenceName).toBe('UAuth'); + }); + }); + + describe('Class extraction', () => { + it('should extract class declarations', () => { + const code = `unit Test;\ninterface\ntype\n TMyClass = class\n public\n procedure DoSomething;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('TMyClass'); + }); + + it('should extract class with inheritance', () => { + const code = `unit Test;\ninterface\ntype\n TChild = class(TParent)\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' + ); + expect(extendsRef).toBeDefined(); + expect(extendsRef?.referenceName).toBe('TParent'); + }); + + it('should extract class with interface implementation', () => { + const code = `unit Test;\ninterface\ntype\n TService = class(TInterfacedObject, ILogger)\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' + ); + const implementsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'implements' + ); + expect(extendsRef?.referenceName).toBe('TInterfacedObject'); + expect(implementsRef?.referenceName).toBe('ILogger'); + }); + }); + + describe('Record extraction', () => { + it('should extract records as class nodes', () => { + const code = `unit Test;\ninterface\ntype\n TPoint = record\n X: Double;\n Y: Double;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode).toBeDefined(); + expect(classNode?.name).toBe('TPoint'); + + const fields = result.nodes.filter((n) => n.kind === 'field'); + expect(fields.length).toBe(2); + expect(fields.map((f) => f.name)).toContain('X'); + expect(fields.map((f) => f.name)).toContain('Y'); + }); + }); + + describe('Interface extraction', () => { + it('should extract interface declarations', () => { + const code = `unit Test;\ninterface\ntype\n ILogger = interface\n procedure Log(const AMsg: string);\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); + expect(ifaceNode).toBeDefined(); + expect(ifaceNode?.name).toBe('ILogger'); + }); + }); + + describe('Method extraction', () => { + it('should extract methods with visibility', () => { + const code = `unit Test;\ninterface\ntype\n TMyClass = class\n private\n FValue: Integer;\n public\n constructor Create;\n function GetValue: Integer;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const methods = result.nodes.filter((n) => n.kind === 'method'); + expect(methods.length).toBe(2); + + const createMethod = methods.find((m) => m.name === 'Create'); + expect(createMethod?.visibility).toBe('public'); + + const getValue = methods.find((m) => m.name === 'GetValue'); + expect(getValue?.visibility).toBe('public'); + + const fields = result.nodes.filter((n) => n.kind === 'field'); + const fValue = fields.find((f) => f.name === 'FValue'); + expect(fValue?.visibility).toBe('private'); + }); + + it('should detect static methods (class methods)', () => { + const code = `unit Test;\ninterface\ntype\n THelper = class\n public\n class function Create: THelper; static;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const methods = result.nodes.filter((n) => n.kind === 'method'); + const staticMethod = methods.find((m) => m.name === 'Create'); + expect(staticMethod?.isStatic).toBe(true); + }); + }); + + describe('Enum extraction', () => { + it('should extract enums with members', () => { + const code = `unit Test;\ninterface\ntype\n TColor = (clRed, clGreen, clBlue);\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const enumNode = result.nodes.find((n) => n.kind === 'enum'); + expect(enumNode).toBeDefined(); + expect(enumNode?.name).toBe('TColor'); + + const members = result.nodes.filter((n) => n.kind === 'enum_member'); + expect(members.length).toBe(3); + expect(members.map((m) => m.name)).toEqual(['clRed', 'clGreen', 'clBlue']); + }); + }); + + describe('Property extraction', () => { + it('should extract properties', () => { + const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n property Name: string read FName write FName;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const propNode = result.nodes.find((n) => n.kind === 'property'); + expect(propNode).toBeDefined(); + expect(propNode?.name).toBe('Name'); + expect(propNode?.visibility).toBe('public'); + }); + }); + + describe('Constant extraction', () => { + it('should extract constants', () => { + const code = `unit Test;\ninterface\nconst\n MAX_RETRIES = 3;\n APP_NAME = 'MyApp';\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const constants = result.nodes.filter((n) => n.kind === 'constant'); + expect(constants.length).toBe(2); + expect(constants.map((c) => c.name)).toContain('MAX_RETRIES'); + expect(constants.map((c) => c.name)).toContain('APP_NAME'); + }); + }); + + describe('Type alias extraction', () => { + it('should extract type aliases', () => { + const code = `unit Test;\ninterface\ntype\n TUserName = string;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const aliasNode = result.nodes.find((n) => n.kind === 'type_alias'); + expect(aliasNode).toBeDefined(); + expect(aliasNode?.name).toBe('TUserName'); + }); + }); + + describe('Call extraction', () => { + it('should extract calls from implementation bodies', () => { + const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n procedure DoWork;\n end;\nimplementation\nprocedure TObj.DoWork;\nbegin\n WriteLn('hello');\nend;\nend.`; + const result = extractFromSource('Test.pas', code); + + const callRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'calls' + ); + expect(callRef).toBeDefined(); + expect(callRef?.referenceName).toBe('WriteLn'); + }); + }); + + describe('Containment edges', () => { + it('should create contains edges for class members', () => { + const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n procedure Foo;\n end;\nimplementation\nend.`; + const result = extractFromSource('Test.pas', code); + + const classNode = result.nodes.find((n) => n.kind === 'class'); + const methodNode = result.nodes.find((n) => n.kind === 'method'); + expect(classNode).toBeDefined(); + expect(methodNode).toBeDefined(); + + const containsEdge = result.edges.find( + (e) => e.source === classNode?.id && e.target === methodNode?.id && e.kind === 'contains' + ); + expect(containsEdge).toBeDefined(); + }); + }); + + describe('Full fixture: UAuth.pas', () => { + const code = `unit UAuth; + +interface + +uses + System.SysUtils, + System.Classes; + +type + ITokenValidator = interface + ['{11111111-1111-1111-1111-111111111111}'] + function Validate(const AToken: string): Boolean; + end; + + TAuthService = class(TInterfacedObject, ITokenValidator) + private + FToken: string; + FLoginCount: Integer; + procedure IncLoginCount; + protected + function GetToken: string; + public + constructor Create; + destructor Destroy; override; + function Validate(const AToken: string): Boolean; + function Login(const AUser, APass: string): string; + property Token: string read GetToken; + property LoginCount: Integer read FLoginCount; + end; + +implementation + +constructor TAuthService.Create; +begin + inherited Create; + FToken := ''; + FLoginCount := 0; +end; + +destructor TAuthService.Destroy; +begin + FToken := ''; + inherited Destroy; +end; + +procedure TAuthService.IncLoginCount; +begin + Inc(FLoginCount); +end; + +function TAuthService.GetToken: string; +begin + Result := FToken; +end; + +function TAuthService.Validate(const AToken: string): Boolean; +begin + Result := AToken <> ''; +end; + +function TAuthService.Login(const AUser, APass: string): string; +begin + IncLoginCount; + if Validate(AUser + ':' + APass) then + begin + FToken := AUser; + Result := 'ok'; + end + else + Result := ''; +end; + +end.`; + + it('should extract all expected nodes', () => { + const result = extractFromSource('UAuth.pas', code); + + expect(result.errors).toHaveLength(0); + + // Module + const moduleNode = result.nodes.find((n) => n.kind === 'module'); + expect(moduleNode?.name).toBe('UAuth'); + + // Imports + const imports = result.nodes.filter((n) => n.kind === 'import'); + expect(imports.length).toBe(2); + + // Interface + const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); + expect(ifaceNode?.name).toBe('ITokenValidator'); + + // Class + const classNode = result.nodes.find((n) => n.kind === 'class'); + expect(classNode?.name).toBe('TAuthService'); + + // Methods + const methods = result.nodes.filter((n) => n.kind === 'method'); + expect(methods.length).toBeGreaterThanOrEqual(6); + expect(methods.map((m) => m.name)).toContain('Create'); + expect(methods.map((m) => m.name)).toContain('Destroy'); + expect(methods.map((m) => m.name)).toContain('Login'); + + // Fields + const fields = result.nodes.filter((n) => n.kind === 'field'); + expect(fields.length).toBe(2); + expect(fields.every((f) => f.visibility === 'private')).toBe(true); + + // Properties + const props = result.nodes.filter((n) => n.kind === 'property'); + expect(props.length).toBe(2); + expect(props.map((p) => p.name)).toContain('Token'); + expect(props.map((p) => p.name)).toContain('LoginCount'); + }); + + it('should extract inheritance and interface implementation', () => { + const result = extractFromSource('UAuth.pas', code); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' + ); + expect(extendsRef?.referenceName).toBe('TInterfacedObject'); + + const implementsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'implements' + ); + expect(implementsRef?.referenceName).toBe('ITokenValidator'); + }); + + it('should extract calls from implementation', () => { + const result = extractFromSource('UAuth.pas', code); + + const callRefs = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'calls' + ); + expect(callRefs.map((r) => r.referenceName)).toContain('Inc'); + expect(callRefs.map((r) => r.referenceName)).toContain('Validate'); + }); + }); + + describe('Full fixture: UTypes.pas', () => { + const code = `unit UTypes; + +interface + +uses + System.SysUtils; + +const + C_MAX_RETRIES = 3; + C_DEFAULT_NAME = 'Guest'; + +type + TUserRole = (urAdmin, urEditor, urViewer); + + TPoint2D = record + X: Double; + Y: Double; + end; + + TUserName = string; + + TUserInfo = class + public + type + TAddress = record + Street: string; + City: string; + Zip: string; + end; + private + FName: TUserName; + FRole: TUserRole; + FAddress: TAddress; + public + constructor Create(const AName: TUserName; ARole: TUserRole); + function GetDisplayName: string; + class function CreateAdmin(const AName: TUserName): TUserInfo; static; + property Name: TUserName read FName write FName; + property Role: TUserRole read FRole; + property Address: TAddress read FAddress write FAddress; + end; + +implementation + +constructor TUserInfo.Create(const AName: TUserName; ARole: TUserRole); +begin + FName := AName; + FRole := ARole; +end; + +function TUserInfo.GetDisplayName: string; +begin + if FRole = urAdmin then + Result := '[Admin] ' + FName + else + Result := FName; +end; + +class function TUserInfo.CreateAdmin(const AName: TUserName): TUserInfo; +begin + Result := TUserInfo.Create(AName, urAdmin); +end; + +end.`; + + it('should extract enums with members', () => { + const result = extractFromSource('UTypes.pas', code); + + const enumNode = result.nodes.find((n) => n.kind === 'enum'); + expect(enumNode?.name).toBe('TUserRole'); + + const members = result.nodes.filter((n) => n.kind === 'enum_member'); + expect(members.length).toBe(3); + expect(members.map((m) => m.name)).toEqual(['urAdmin', 'urEditor', 'urViewer']); + }); + + it('should extract constants', () => { + const result = extractFromSource('UTypes.pas', code); + + const constants = result.nodes.filter((n) => n.kind === 'constant'); + expect(constants.length).toBe(2); + expect(constants.map((c) => c.name)).toContain('C_MAX_RETRIES'); + expect(constants.map((c) => c.name)).toContain('C_DEFAULT_NAME'); + }); + + it('should extract type aliases', () => { + const result = extractFromSource('UTypes.pas', code); + + const aliases = result.nodes.filter((n) => n.kind === 'type_alias'); + expect(aliases.map((a) => a.name)).toContain('TUserName'); + }); + + it('should extract records as classes with fields', () => { + const result = extractFromSource('UTypes.pas', code); + + const classes = result.nodes.filter((n) => n.kind === 'class'); + expect(classes.map((c) => c.name)).toContain('TPoint2D'); + + // TPoint2D fields + const fields = result.nodes.filter((n) => n.kind === 'field'); + expect(fields.map((f) => f.name)).toContain('X'); + expect(fields.map((f) => f.name)).toContain('Y'); + }); + + it('should extract static class methods', () => { + const result = extractFromSource('UTypes.pas', code); + + const methods = result.nodes.filter((n) => n.kind === 'method'); + const staticMethod = methods.find((m) => m.name === 'CreateAdmin'); + expect(staticMethod).toBeDefined(); + expect(staticMethod?.isStatic).toBe(true); + }); + + it('should extract nested types', () => { + const result = extractFromSource('UTypes.pas', code); + + const classes = result.nodes.filter((n) => n.kind === 'class'); + expect(classes.map((c) => c.name)).toContain('TAddress'); + }); + }); +}); + +// ============================================================================= +// DFM/FMX Extraction +// ============================================================================= + +describe('DFM/FMX Extraction', () => { + it('should extract components from DFM', () => { + const code = `object Form1: TForm1 + Left = 0 + Top = 0 + Caption = 'My Form' + object Button1: TButton + Left = 10 + Top = 10 + Caption = 'Click Me' + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(2); + expect(components.map((c) => c.name)).toContain('Form1'); + expect(components.map((c) => c.name)).toContain('Button1'); + + const button = components.find((c) => c.name === 'Button1'); + expect(button?.signature).toBe('TButton'); + }); + + it('should extract nested component hierarchy', () => { + const code = `object Form1: TForm1 + object Panel1: TPanel + object Label1: TLabel + Caption = 'Hello' + end + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(3); + + // Check nesting: Panel1 contains Label1 + const panel = components.find((c) => c.name === 'Panel1'); + const label = components.find((c) => c.name === 'Label1'); + const containsEdge = result.edges.find( + (e) => e.source === panel?.id && e.target === label?.id && e.kind === 'contains' + ); + expect(containsEdge).toBeDefined(); + }); + + it('should extract event handler references', () => { + const code = `object Form1: TForm1 + OnCreate = FormCreate + OnDestroy = FormDestroy + object Button1: TButton + OnClick = Button1Click + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const refs = result.unresolvedReferences; + expect(refs.length).toBe(3); + expect(refs.map((r) => r.referenceName)).toContain('FormCreate'); + expect(refs.map((r) => r.referenceName)).toContain('FormDestroy'); + expect(refs.map((r) => r.referenceName)).toContain('Button1Click'); + expect(refs.every((r) => r.referenceKind === 'references')).toBe(true); + }); + + it('should handle multi-line properties', () => { + const code = `object Form1: TForm1 + SQL.Strings = ( + 'SELECT * FROM users' + 'WHERE active = 1') + object Button1: TButton + OnClick = Button1Click + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(2); + + const refs = result.unresolvedReferences; + expect(refs.length).toBe(1); + expect(refs[0]?.referenceName).toBe('Button1Click'); + }); + + it('should handle inherited keyword', () => { + const code = `inherited Form1: TForm1 + Caption = 'Inherited Form' + object Button1: TButton + OnClick = Button1Click + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(2); + expect(components.map((c) => c.name)).toContain('Form1'); + }); + + it('should handle item collection properties', () => { + const code = `object Form1: TForm1 + object StatusBar1: TStatusBar + Panels = < + item + Width = 200 + end + item + Width = 200 + end> + end +end`; + const result = extractFromSource('Form1.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(2); + }); + + describe('Full fixture: MainForm.dfm', () => { + const code = `object frmMain: TfrmMain + Left = 0 + Top = 0 + Caption = 'CodeGraph DFM Fixture' + ClientHeight = 480 + ClientWidth = 640 + OnCreate = FormCreate + OnDestroy = FormDestroy + object pnlTop: TPanel + Left = 0 + Top = 0 + Width = 640 + Height = 50 + object lblTitle: TLabel + Left = 16 + Top = 16 + Caption = 'Authentication Service' + end + object btnLogin: TButton + Left = 540 + Top = 12 + OnClick = btnLoginClick + end + end + object pnlContent: TPanel + Left = 0 + Top = 50 + object edtUsername: TEdit + Left = 16 + Top = 16 + OnChange = edtUsernameChange + end + object edtPassword: TEdit + Left = 16 + Top = 48 + OnKeyPress = edtPasswordKeyPress + end + object mmoLog: TMemo + Left = 16 + Top = 88 + end + end + object pnlStatus: TStatusBar + Left = 0 + Top = 440 + Panels = < + item + Width = 200 + end + item + Width = 200 + end> + end +end`; + + it('should extract all components', () => { + const result = extractFromSource('MainForm.dfm', code); + + const components = result.nodes.filter((n) => n.kind === 'component'); + expect(components.length).toBe(9); + expect(components.map((c) => c.name)).toEqual( + expect.arrayContaining([ + 'frmMain', 'pnlTop', 'lblTitle', 'btnLogin', + 'pnlContent', 'edtUsername', 'edtPassword', 'mmoLog', 'pnlStatus', + ]) + ); + }); + + it('should extract all event handlers', () => { + const result = extractFromSource('MainForm.dfm', code); + + const refs = result.unresolvedReferences; + expect(refs.length).toBe(5); + expect(refs.map((r) => r.referenceName)).toEqual( + expect.arrayContaining([ + 'FormCreate', 'FormDestroy', 'btnLoginClick', + 'edtUsernameChange', 'edtPasswordKeyPress', + ]) + ); + }); + }); +}); + +describe('Full Indexing', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should index a TypeScript file', async () => { + // Create test file + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync( + path.join(srcDir, 'utils.ts'), + ` +export function add(a: number, b: number): number { + return a + b; +} + +export function multiply(a: number, b: number): number { + return a * b; +} +` + ); + + // Initialize and index + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexAll(); + + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(1); + expect(result.nodesCreated).toBeGreaterThanOrEqual(2); + + // Check nodes were stored + const nodes = cg.getNodesInFile('src/utils.ts'); + expect(nodes.length).toBeGreaterThanOrEqual(2); + + const addFunc = nodes.find((n) => n.name === 'add'); + expect(addFunc).toBeDefined(); + expect(addFunc?.kind).toBe('function'); + + cg.close(); + }); + + it('should index multiple files', async () => { + // Create test files + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + + fs.writeFileSync( + path.join(srcDir, 'math.ts'), + `export function add(a: number, b: number) { return a + b; }` + ); + + fs.writeFileSync( + path.join(srcDir, 'string.ts'), + `export function capitalize(s: string) { return s.toUpperCase(); }` + ); + + // Initialize and index + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexAll(); + + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(2); + + const files = cg.getFiles(); + expect(files.length).toBe(2); + + cg.close(); + }); + + it('should track file hashes for incremental updates', async () => { + // Create initial file + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 1;`); + + // Initialize and index + const cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + + // Check file is tracked + const file = cg.getFile('src/main.ts'); + expect(file).toBeDefined(); + expect(file?.contentHash).toBeDefined(); + + // Modify file + fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 2;`); + + // Check for changes + const changes = cg.getChangedFiles(); + expect(changes.modified).toContain('src/main.ts'); + + cg.close(); + }); + + it('should sync and detect changes', async () => { + // Create initial file + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync( + path.join(srcDir, 'main.ts'), + `export function original() { return 1; }` + ); + + // Initialize and index + const cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + + const initialNodes = cg.getNodesInFile('src/main.ts'); + expect(initialNodes.some((n) => n.name === 'original')).toBe(true); + + // Modify file + fs.writeFileSync( + path.join(srcDir, 'main.ts'), + `export function updated() { return 2; }` + ); + + // Sync + const syncResult = await cg.sync(); + expect(syncResult.filesModified).toBe(1); + + // Check nodes were updated + const updatedNodes = cg.getNodesInFile('src/main.ts'); + expect(updatedNodes.some((n) => n.name === 'updated')).toBe(true); + expect(updatedNodes.some((n) => n.name === 'original')).toBe(false); + + cg.close(); + }); +}); + +describe('Path Normalization', () => { + it('should convert backslashes to forward slashes', () => { + expect(normalizePath('gui\\node_modules\\foo')).toBe('gui/node_modules/foo'); + expect(normalizePath('src\\components\\Button.tsx')).toBe('src/components/Button.tsx'); + }); + + it('should leave forward-slash paths unchanged', () => { + expect(normalizePath('src/components/Button.tsx')).toBe('src/components/Button.tsx'); + }); + + it('should handle empty string', () => { + expect(normalizePath('')).toBe(''); + }); +}); + +describe('Directory Exclusion', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should exclude directories listed in .gitignore', () => { + // Create structure: src/index.ts + node_modules/pkg/index.js, gitignore node_modules + const srcDir = path.join(tempDir, 'src'); + const nmDir = path.join(tempDir, 'node_modules', 'pkg'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(nmDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); + fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};'); + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n'); + + const files = scanDirectory(tempDir); + + expect(files).toContain('src/index.ts'); + expect(files.every((f) => !f.includes('node_modules'))).toBe(true); + }); + + it('should exclude nested node_modules via a root .gitignore', () => { + // A trailing-slash pattern with no leading slash matches at any depth. + const srcDir = path.join(tempDir, 'packages', 'app', 'src'); + const nmDir = path.join(tempDir, 'packages', 'app', 'node_modules', 'pkg'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(nmDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); + fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};'); + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n'); + + const files = scanDirectory(tempDir); + + expect(files).toContain('packages/app/src/index.ts'); + expect(files.every((f) => !f.includes('node_modules'))).toBe(true); + }); + + it('should apply a nested .gitignore only to its own subtree', () => { + const appSrc = path.join(tempDir, 'app', 'src'); + fs.mkdirSync(appSrc, { recursive: true }); + fs.writeFileSync(path.join(appSrc, 'keep.ts'), 'export const a = 1;'); + fs.writeFileSync(path.join(appSrc, 'skip.ts'), 'export const b = 2;'); + fs.writeFileSync(path.join(tempDir, 'app', '.gitignore'), 'src/skip.ts\n'); + // A sibling with the same name outside app/ must NOT be ignored. + const otherDir = path.join(tempDir, 'other', 'src'); + fs.mkdirSync(otherDir, { recursive: true }); + fs.writeFileSync(path.join(otherDir, 'skip.ts'), 'export const c = 3;'); + + const files = scanDirectory(tempDir); + + expect(files).toContain('app/src/keep.ts'); + expect(files).not.toContain('app/src/skip.ts'); + expect(files).toContain('other/src/skip.ts'); + }); + + it('should always skip .git directories', () => { + const srcDir = path.join(tempDir, 'src'); + const gitDir = path.join(tempDir, '.git', 'objects'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(gitDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); + fs.writeFileSync(path.join(gitDir, 'pack.ts'), 'export const y = 2;'); + + const files = scanDirectory(tempDir); + + expect(files).toContain('src/index.ts'); + expect(files.every((f) => !f.includes('.git'))).toBe(true); + }); + + it('should return forward-slash paths on all platforms', () => { + const srcDir = path.join(tempDir, 'src', 'components'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'Button.tsx'), 'export function Button() {}'); + + const files = scanDirectory(tempDir); + + expect(files.length).toBe(1); + expect(files[0]).toBe('src/components/Button.tsx'); + expect(files[0]).not.toContain('\\'); + }); +}); + +describe('Git Submodules', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should index files inside git submodules (issue #147)', async () => { + const { execFileSync } = await import('child_process'); + const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, stdio: 'pipe' }); + + // Build a separate "library" repo to use as a submodule source. + const libDir = path.join(tempDir, '_lib'); + fs.mkdirSync(libDir, { recursive: true }); + git(libDir, 'init', '-q'); + git(libDir, 'config', 'user.email', 'test@test.com'); + git(libDir, 'config', 'user.name', 'Test'); + fs.writeFileSync(path.join(libDir, 'lib.ts'), 'export const fromSubmodule = 1;'); + git(libDir, 'add', '-A'); + git(libDir, 'commit', '-q', '-m', 'lib init'); + + // Build the main repo and add the lib repo as a submodule. + const mainDir = path.join(tempDir, 'main'); + fs.mkdirSync(mainDir, { recursive: true }); + git(mainDir, 'init', '-q'); + git(mainDir, 'config', 'user.email', 'test@test.com'); + git(mainDir, 'config', 'user.name', 'Test'); + fs.writeFileSync(path.join(mainDir, 'app.ts'), 'export const app = 1;'); + git(mainDir, 'add', '-A'); + git(mainDir, 'commit', '-q', '-m', 'app init'); + // protocol.file.allow=always is required to add a local-path submodule on + // recent git versions (CVE-2022-39253 mitigation). + execFileSync( + 'git', + ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', libDir, 'libs/lib'], + { cwd: mainDir, stdio: 'pipe' } + ); + git(mainDir, 'commit', '-q', '-m', 'add submodule'); + + const files = scanDirectory(mainDir); + + expect(files).toContain('app.ts'); + expect(files).toContain('libs/lib/lib.ts'); + }); +}); + +describe('Nested non-submodule git repos', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should index files in embedded git repos run from a git super-repo (issue #193)', async () => { + const { execFileSync } = await import('child_process'); + const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, stdio: 'pipe' }); + + // Top-level workspace is itself a git repo, holding no source directly — + // the CMake "super-repo" layout from the issue. + const root = path.join(tempDir, 'root'); + fs.mkdirSync(path.join(root, 'coding'), { recursive: true }); + git(root, 'init', '-q'); + git(root, 'config', 'user.email', 'test@test.com'); + git(root, 'config', 'user.name', 'Test'); + fs.writeFileSync(path.join(root, 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.10)\n'); + + // Two independent clones living inside the workspace (NOT submodules): + // one with committed source, one with only untracked source. + const sub1 = path.join(root, 'sub_repo1', 'src'); + fs.mkdirSync(sub1, { recursive: true }); + git(path.join(root, 'sub_repo1'), 'init', '-q'); + git(path.join(root, 'sub_repo1'), 'config', 'user.email', 'test@test.com'); + git(path.join(root, 'sub_repo1'), 'config', 'user.name', 'Test'); + fs.writeFileSync(path.join(sub1, 'one.ts'), 'export const one = 1;'); + git(path.join(root, 'sub_repo1'), 'add', '-A'); + git(path.join(root, 'sub_repo1'), 'commit', '-q', '-m', 'sub1 init'); + + const sub2 = path.join(root, 'sub_repo2', 'src'); + fs.mkdirSync(sub2, { recursive: true }); + git(path.join(root, 'sub_repo2'), 'init', '-q'); + fs.writeFileSync(path.join(sub2, 'two.ts'), 'export const two = 2;'); + + const files = scanDirectory(root); + + // Both committed and untracked source from the nested repos must be found. + expect(files).toContain('sub_repo1/src/one.ts'); + expect(files).toContain('sub_repo2/src/two.ts'); + }); + + it('should respect each embedded repo\'s own .gitignore', async () => { + const { execFileSync } = await import('child_process'); + const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, stdio: 'pipe' }); + + const root = path.join(tempDir, 'root'); + fs.mkdirSync(root, { recursive: true }); + git(root, 'init', '-q'); + + const sub = path.join(root, 'sub_repo', 'src'); + fs.mkdirSync(sub, { recursive: true }); + git(path.join(root, 'sub_repo'), 'init', '-q'); + fs.writeFileSync(path.join(root, 'sub_repo', '.gitignore'), 'src/generated.ts\n'); + fs.writeFileSync(path.join(sub, 'real.ts'), 'export const real = 1;'); + fs.writeFileSync(path.join(sub, 'generated.ts'), 'export const generated = 1;'); + + const files = scanDirectory(root); + + expect(files).toContain('sub_repo/src/real.ts'); + expect(files).not.toContain('sub_repo/src/generated.ts'); + }); +}); + +// ============================================================================= +// Scala +// ============================================================================= + +describe('Scala Extraction', () => { + describe('Language detection', () => { + it('should detect Scala files', () => { + expect(detectLanguage('Main.scala')).toBe('scala'); + expect(detectLanguage('script.sc')).toBe('scala'); + expect(detectLanguage('src/UserService.scala')).toBe('scala'); + }); + + it('should report Scala as supported', () => { + expect(isLanguageSupported('scala')).toBe(true); + expect(getSupportedLanguages()).toContain('scala'); + }); + }); + + describe('Class extraction', () => { + it('should extract class definitions', () => { + const code = ` +class UserService(private val repo: UserRepository) { + def findUser(id: String): Option[String] = Some(id) +} +`; + const result = extractFromSource('UserService.scala', code); + const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'UserService'); + expect(cls).toBeDefined(); + expect(cls?.language).toBe('scala'); + }); + + it('should extract object definitions as class kind', () => { + const code = ` +object DatabaseConfig { + val url = "jdbc:postgresql://localhost/mydb" +} +`; + const result = extractFromSource('Config.scala', code); + const obj = result.nodes.find((n) => n.kind === 'class' && n.name === 'DatabaseConfig'); + expect(obj).toBeDefined(); + }); + + it('should extract trait definitions as trait kind', () => { + const code = ` +trait Repository[A] { + def findById(id: String): Option[A] + def save(entity: A): Unit +} +`; + const result = extractFromSource('Repository.scala', code); + const trait_ = result.nodes.find((n) => n.kind === 'trait' && n.name === 'Repository'); + expect(trait_).toBeDefined(); + }); + }); + + describe('Method and function extraction', () => { + it('should extract method definitions inside a class', () => { + const code = ` +class Calculator { + def add(a: Int, b: Int): Int = a + b + def divide(a: Double, b: Double): Double = a / b +} +`; + const result = extractFromSource('Calculator.scala', code); + const methods = result.nodes.filter((n) => n.kind === 'method'); + expect(methods.find((m) => m.name === 'add')).toBeDefined(); + expect(methods.find((m) => m.name === 'divide')).toBeDefined(); + }); + + it('should extract method signatures', () => { + const code = ` +class Greeter { + def greet(name: String): String = s"Hello, \${name}!" +} +`; + const result = extractFromSource('Greeter.scala', code); + const method = result.nodes.find((n) => n.name === 'greet'); + expect(method?.signature).toContain('name: String'); + expect(method?.signature).toContain('String'); + }); + + it('should extract top-level function definitions as functions', () => { + const code = ` +def factorial(n: Int): Int = if (n <= 1) 1 else n * factorial(n - 1) +def greet(name: String): String = s"Hello, \${name}!" +`; + const result = extractFromSource('utils.scala', code); + const fns = result.nodes.filter((n) => n.kind === 'function'); + expect(fns.find((f) => f.name === 'factorial')).toBeDefined(); + expect(fns.find((f) => f.name === 'greet')).toBeDefined(); + }); + }); + + describe('Val and var extraction', () => { + it('should extract val inside a class as field', () => { + const code = ` +class Config { + val timeout: Int = 30 + val host: String = "localhost" +} +`; + const result = extractFromSource('Config.scala', code); + const fields = result.nodes.filter((n) => n.kind === 'field'); + expect(fields.find((f) => f.name === 'timeout')).toBeDefined(); + expect(fields.find((f) => f.name === 'host')).toBeDefined(); + }); + + it('should extract var inside a class as field', () => { + const code = ` +class Counter { + var count: Int = 0 +} +`; + const result = extractFromSource('Counter.scala', code); + const field = result.nodes.find((n) => n.kind === 'field' && n.name === 'count'); + expect(field).toBeDefined(); + }); + + it('should extract top-level val as constant', () => { + const code = ` +val MaxConnections: Int = 100 +val DefaultTimeout = 30 +`; + const result = extractFromSource('constants.scala', code); + const consts = result.nodes.filter((n) => n.kind === 'constant'); + expect(consts.find((c) => c.name === 'MaxConnections')).toBeDefined(); + }); + + it('should extract top-level var as variable', () => { + const code = ` +var retries: Int = 3 +`; + const result = extractFromSource('state.scala', code); + const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'retries'); + expect(v).toBeDefined(); + }); + + it('should include type in val/var signature', () => { + const code = ` +class Service { + val timeout: Int = 30 +} +`; + const result = extractFromSource('Service.scala', code); + const field = result.nodes.find((n) => n.name === 'timeout'); + expect(field?.signature).toContain('timeout'); + expect(field?.signature).toContain('Int'); + }); + }); + + describe('Enum extraction', () => { + it('should extract enum definitions', () => { + const code = ` +enum Color: + case Red + case Green + case Blue +`; + const result = extractFromSource('Color.scala', code); + const enumNode = result.nodes.find((n) => n.kind === 'enum' && n.name === 'Color'); + expect(enumNode).toBeDefined(); + }); + + it('should extract enum cases as enum_member', () => { + const code = ` +enum Direction: + case North + case South + case East + case West +`; + const result = extractFromSource('Direction.scala', code); + const members = result.nodes.filter((n) => n.kind === 'enum_member'); + expect(members.find((m) => m.name === 'North')).toBeDefined(); + expect(members.find((m) => m.name === 'South')).toBeDefined(); + expect(members.length).toBeGreaterThanOrEqual(4); + }); + }); + + describe('Type alias extraction', () => { + it('should extract type aliases', () => { + const code = ` +type UserId = String +type UserMap = Map[String, String] +`; + const result = extractFromSource('types.scala', code); + const aliases = result.nodes.filter((n) => n.kind === 'type_alias'); + expect(aliases.find((a) => a.name === 'UserId')).toBeDefined(); + expect(aliases.find((a) => a.name === 'UserMap')).toBeDefined(); + }); + }); + + describe('Import extraction', () => { + it('should extract import declarations', () => { + const code = ` +import scala.collection.mutable.ListBuffer +import scala.concurrent.Future +`; + const result = extractFromSource('imports.scala', code); + const imports = result.nodes.filter((n) => n.kind === 'import'); + expect(imports.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('Visibility modifiers', () => { + it('should extract private visibility', () => { + const code = ` +class Service { + private val secret: String = "abc" + private def helper(): Unit = {} +} +`; + const result = extractFromSource('Service.scala', code); + const secretField = result.nodes.find((n) => n.name === 'secret'); + expect(secretField?.visibility).toBe('private'); + const helperMethod = result.nodes.find((n) => n.name === 'helper'); + expect(helperMethod?.visibility).toBe('private'); + }); + + it('should extract protected visibility', () => { + const code = ` +class Base { + protected def helperMethod(): Unit = {} +} +`; + const result = extractFromSource('Base.scala', code); + const method = result.nodes.find((n) => n.name === 'helperMethod'); + expect(method?.visibility).toBe('protected'); + }); + + it('should default to public visibility', () => { + const code = ` +class Greeter { + def hello(): Unit = {} +} +`; + const result = extractFromSource('Greeter.scala', code); + const method = result.nodes.find((n) => n.name === 'hello'); + expect(method?.visibility).toBe('public'); + }); + }); + + describe('Inheritance', () => { + it('should extract extends relationships', () => { + const code = ` +class AdminUser extends User { + def adminAction(): Unit = {} +} +`; + const result = extractFromSource('AdminUser.scala', code); + const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends'); + expect(extendsRefs.find((r) => r.referenceName === 'User')).toBeDefined(); + }); + }); + + describe('Call extraction', () => { + it('should extract function call expressions', () => { + const code = ` +def processData(): Unit = { + val result = computeResult() + println(result) +} +`; + const result = extractFromSource('processor.scala', code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + expect(calls.length).toBeGreaterThan(0); + }); + }); +}); + +describe('Vue Extraction', () => { + it('should detect Vue files', () => { + expect(detectLanguage('App.vue')).toBe('vue'); + expect(detectLanguage('components/Button.vue')).toBe('vue'); + expect(isLanguageSupported('vue')).toBe(true); + }); + + it('should extract component node from a Vue SFC', () => { + const code = ` + + +`; + const result = extractFromSource('HelloWorld.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('HelloWorld'); + expect(componentNode?.language).toBe('vue'); + expect(componentNode?.isExported).toBe(true); + }); + + it('should extract functions from +`; + const result = extractFromSource('Button.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('Button'); + + const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'handleClick'); + expect(funcNode).toBeDefined(); + expect(funcNode?.language).toBe('vue'); + }); + + it('should extract from +`; + const result = extractFromSource('Counter.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('Counter'); + + const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'increment'); + expect(funcNode).toBeDefined(); + expect(funcNode?.language).toBe('vue'); + + // All nodes should be marked as vue language + for (const node of result.nodes) { + expect(node.language).toBe('vue'); + } + }); + + it('should extract from both + + +`; + const result = extractFromSource('DualScript.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + + const greetFunc = result.nodes.find((n) => n.kind === 'function' && n.name === 'greet'); + expect(greetFunc).toBeDefined(); + }); + + it('should create component node for template-only Vue file', () => { + const code = ` +`; + const result = extractFromSource('Static.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('Static'); + expect(componentNode?.language).toBe('vue'); + + // Only the component node should exist (no script nodes) + expect(result.nodes.length).toBe(1); + }); + + it('should create containment edges from component to script nodes', () => { + const code = ` + + +`; + const result = extractFromSource('Contained.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + + // Should have containment edges from component to child nodes + const containEdges = result.edges.filter( + (e) => e.source === componentNode!.id && e.kind === 'contains' + ); + expect(containEdges.length).toBeGreaterThan(0); + }); +}); + +describe('Instantiates + Decorates edge extraction', () => { + it('emits an instantiates ref for `new Foo()`', () => { + const code = ` +class Foo {} +function bootstrap() { return new Foo(); } +`; + const result = extractFromSource('app.ts', code); + const ref = result.unresolvedReferences.find( + (r) => r.referenceKind === 'instantiates' && r.referenceName === 'Foo' + ); + expect(ref).toBeDefined(); + }); + + it('strips type-argument suffix from generic constructors', () => { + const code = ` +class Container { constructor(_: T) {} } +function go() { return new Container('x'); } +`; + const result = extractFromSource('app.ts', code); + const ref = result.unresolvedReferences.find( + (r) => r.referenceKind === 'instantiates' + ); + expect(ref).toBeDefined(); + // Container must be normalised to "Container" — otherwise + // resolution can never match the class node. + expect(ref!.referenceName).toBe('Container'); + }); + + it('keeps trailing identifier from qualified `new ns.Foo()`', () => { + const code = ` +const ns = { Foo: class {} }; +function go() { return new ns.Foo(); } +`; + const result = extractFromSource('app.ts', code); + const ref = result.unresolvedReferences.find( + (r) => r.referenceKind === 'instantiates' + ); + // We can't always resolve which Foo, but the name should be the + // simple identifier so name-matching has a chance. + expect(ref?.referenceName).toBe('Foo'); + }); + + it('emits a decorates ref for `@Foo class X {}`', () => { + const code = ` +function Foo(_arg: string) { return (cls: any) => cls; } +@Foo('x') +class X {} +`; + const result = extractFromSource('app.ts', code); + const decorClass = result.unresolvedReferences.find( + (r) => r.referenceKind === 'decorates' && r.referenceName === 'Foo' + ); + expect(decorClass).toBeDefined(); + }); + + it('does NOT attribute a prior class\'s decorator to the next class', () => { + // Regression: the sibling-walk must stop at the first non- + // decorator separator. `@A class Foo {} @B class Bar {}` must + // produce `decorates(Foo, A)` and `decorates(Bar, B)` — never + // `decorates(Bar, A)`. + const code = ` +function A(cls: any) { return cls; } +function B(cls: any) { return cls; } +@A +class Foo {} +@B +class Bar {} +`; + const result = extractFromSource('app.ts', code); + const decoratesEdges = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'decorates' + ); + // Exactly one decorates ref per decorated class, no cross-attribution. + const fromBar = decoratesEdges.filter((r) => + result.nodes.find((n) => n.id === r.fromNodeId && n.name === 'Bar') + ); + expect(fromBar.length).toBe(1); + expect(fromBar[0]!.referenceName).toBe('B'); + }); + + it('emits a decorates ref for `@Foo method() {}`', () => { + const code = ` +function Get(p: string) { return (t: any, k: string) => t; } +class Svc { + @Get('/x') method() { return 1; } +} +`; + const result = extractFromSource('app.ts', code); + const decorMethod = result.unresolvedReferences.find( + (r) => r.referenceKind === 'decorates' && r.referenceName === 'Get' + ); + expect(decorMethod).toBeDefined(); + // The decorated symbol must be `method`, not the constructor or class. + const decoratedNode = result.nodes.find((n) => n.id === decorMethod!.fromNodeId); + expect(decoratedNode?.name).toBe('method'); + }); +}); + +// ============================================================================= +// Lua +// ============================================================================= + +describe('Lua Extraction', () => { + describe('Language detection', () => { + it('should detect Lua files', () => { + expect(detectLanguage('init.lua')).toBe('lua'); + expect(detectLanguage('src/util.lua')).toBe('lua'); + }); + + it('should report Lua as supported', () => { + expect(isLanguageSupported('lua')).toBe(true); + expect(getSupportedLanguages()).toContain('lua'); + }); + }); + + describe('Function extraction', () => { + it('should extract global and local functions', () => { + const code = ` +function configure(opts) return opts end +local function helper(x) return x * 2 end +`; + const result = extractFromSource('init.lua', code); + const funcs = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(funcs).toContain('configure'); + expect(funcs).toContain('helper'); + const configure = result.nodes.find((n) => n.name === 'configure'); + expect(configure?.language).toBe('lua'); + expect(configure?.signature).toBe('(opts)'); + }); + + it('should split table/method functions into a receiver and method name', () => { + const code = ` +function M.connect(host, port) return host end +function M:send(data) return self end +`; + const result = extractFromSource('init.lua', code); + const methods = result.nodes.filter((n) => n.kind === 'method'); + const connect = methods.find((m) => m.name === 'connect'); + expect(connect?.qualifiedName).toBe('M::connect'); + const send = methods.find((m) => m.name === 'send'); + expect(send?.qualifiedName).toBe('M::send'); + }); + }); + + describe('Variable extraction', () => { + it('should extract local variable declarations', () => { + const code = ` +local M = {} +local count = 0 +`; + const result = extractFromSource('mod.lua', code); + const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name); + expect(vars).toContain('M'); + expect(vars).toContain('count'); + }); + }); + + describe('Import extraction (require)', () => { + it('should extract require() in local declarations and bare calls', () => { + const code = ` +local socket = require("socket") +local http = require "resty.http" +require("side.effect") +`; + const result = extractFromSource('net.lua', code); + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(imports).toContain('socket'); + expect(imports).toContain('resty.http'); + expect(imports).toContain('side.effect'); + + const ref = result.unresolvedReferences.find( + (r) => r.referenceKind === 'imports' && r.referenceName === 'socket' + ); + expect(ref).toBeDefined(); + }); + + // Regression: the tree-sitter-wasms Lua grammar (ABI 13) corrupts the shared + // WASM heap under web-tree-sitter 0.25, dropping nested calls/imports on every + // parse after the first. We vendor the ABI-15 grammar instead — this guards it + // by extracting several sources in sequence and asserting the LAST still works. + it('should keep extracting require across many sequential parses', () => { + let last; + for (let i = 0; i < 8; i++) { + last = extractFromSource(`f${i}.lua`, `local m = require("module.${i}")\nreturn m\n`); + } + const imports = last!.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(imports).toContain('module.7'); + }); + }); + + describe('Call extraction', () => { + it('should record intra-file calls as resolvable references', () => { + const code = ` +local function helper(x) return x end +local function run(y) return helper(y) end +`; + const result = extractFromSource('calls.lua', code); + const call = result.unresolvedReferences.find( + (r) => r.referenceKind === 'calls' && r.referenceName === 'helper' + ); + expect(call).toBeDefined(); + }); + }); +}); + +// ============================================================================= +// Luau (typed superset of Lua — https://luau.org) +// ============================================================================= + +describe('Luau Extraction', () => { + describe('Language detection', () => { + it('should detect Luau files', () => { + expect(detectLanguage('init.luau')).toBe('luau'); + expect(detectLanguage('src/Client.luau')).toBe('luau'); + }); + + it('should report Luau as supported', () => { + expect(isLanguageSupported('luau')).toBe(true); + expect(getSupportedLanguages()).toContain('luau'); + }); + }); + + describe('Type aliases', () => { + it('should extract `type` and `export type` definitions', () => { + const code = ` +export type Vector = { x: number, y: number } +type Handler = (msg: string) -> boolean +`; + const result = extractFromSource('types.luau', code); + const aliases = result.nodes.filter((n) => n.kind === 'type_alias'); + const vector = aliases.find((a) => a.name === 'Vector'); + expect(vector).toBeDefined(); + expect(vector?.isExported).toBe(true); + const handler = aliases.find((a) => a.name === 'Handler'); + expect(handler).toBeDefined(); + expect(handler?.isExported).toBe(false); + }); + }); + + describe('Typed functions and methods', () => { + it('should capture typed signatures and split methods by receiver', () => { + const code = ` +function configure(opts: { debug: boolean }): boolean + return opts.debug +end +function Client:fetch(path: string): Response + return path +end +`; + const result = extractFromSource('client.luau', code); + const configure = result.nodes.find((n) => n.kind === 'function' && n.name === 'configure'); + expect(configure?.language).toBe('luau'); + expect(configure?.signature).toBe('(opts: { debug: boolean }): boolean'); + const fetch = result.nodes.find((n) => n.kind === 'method' && n.name === 'fetch'); + expect(fetch?.qualifiedName).toBe('Client::fetch'); + }); + }); + + describe('Imports and variables', () => { + it('should extract string and Roblox instance-path require imports', () => { + const code = ` +local http = require("http") +local Signal = require(script.Parent.Signal) +local count = 0 +`; + const result = extractFromSource('mod.luau', code); + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(imports).toContain('http'); // string require + expect(imports).toContain('Signal'); // Roblox instance-path require + const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name); + expect(vars).toContain('count'); + }); + }); +}); diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts new file mode 100644 index 0000000..78ebfce --- /dev/null +++ b/__tests__/foundation.test.ts @@ -0,0 +1,305 @@ +/** + * Foundation Tests + * + * Tests for the CodeGraph foundation layer. + */ + +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'; +import { Node, Edge } from '../src/types'; +import { isInitialized, getCodeGraphDir, validateDirectory } from '../src/directory'; +import { DatabaseConnection, getDatabasePath } from '../src/db'; + +// Create a temporary directory for each test +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-')); +} + +// Clean up temporary directory +function cleanupTempDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe('CodeGraph Foundation', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + describe('Initialization', () => { + it('should initialize a new project', () => { + const cg = CodeGraph.initSync(tempDir); + + expect(CodeGraph.isInitialized(tempDir)).toBe(true); + expect(fs.existsSync(getCodeGraphDir(tempDir))).toBe(true); + expect(fs.existsSync(getDatabasePath(tempDir))).toBe(true); + + cg.close(); + }); + + it('should create .gitignore in .CodeGraph directory', () => { + const cg = CodeGraph.initSync(tempDir); + + const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore'); + expect(fs.existsSync(gitignorePath)).toBe(true); + + const content = fs.readFileSync(gitignorePath, 'utf-8'); + expect(content).toContain('*.db'); + + cg.close(); + }); + + it('should throw if already initialized', () => { + const cg = CodeGraph.initSync(tempDir); + cg.close(); + + expect(() => CodeGraph.initSync(tempDir)).toThrow(/already initialized/i); + }); + }); + + describe('Opening Projects', () => { + it('should open an existing project', () => { + // First initialize + const cg1 = CodeGraph.initSync(tempDir); + cg1.close(); + + // Then open + const cg2 = CodeGraph.openSync(tempDir); + expect(cg2.getProjectRoot()).toBe(path.resolve(tempDir)); + cg2.close(); + }); + + it('should throw if not initialized', () => { + expect(() => CodeGraph.openSync(tempDir)).toThrow(/not initialized/i); + }); + }); + + describe('Static Methods', () => { + it('isInitialized should return false for new directory', () => { + expect(CodeGraph.isInitialized(tempDir)).toBe(false); + }); + + it('isInitialized should return true after init', () => { + const cg = CodeGraph.initSync(tempDir); + expect(CodeGraph.isInitialized(tempDir)).toBe(true); + cg.close(); + }); + }); + + describe('Database', () => { + it('should create database with correct schema', () => { + const cg = CodeGraph.initSync(tempDir); + + // Check that we can get stats (requires tables to exist) + const stats = cg.getStats(); + expect(stats.nodeCount).toBe(0); + expect(stats.edgeCount).toBe(0); + expect(stats.fileCount).toBe(0); + + cg.close(); + }); + + it('should return correct database size', () => { + const cg = CodeGraph.initSync(tempDir); + const stats = cg.getStats(); + + // Database should have some size (at least the schema) + expect(stats.dbSizeBytes).toBeGreaterThan(0); + + cg.close(); + }); + + it('should support optimize operation', () => { + const cg = CodeGraph.initSync(tempDir); + + // Should not throw + expect(() => cg.optimize()).not.toThrow(); + + cg.close(); + }); + + it('should support clear operation', () => { + const cg = CodeGraph.initSync(tempDir); + + // Should not throw + expect(() => cg.clear()).not.toThrow(); + + const stats = cg.getStats(); + expect(stats.nodeCount).toBe(0); + + cg.close(); + }); + }); + + describe('Directory Management', () => { + it('should validate directory structure', () => { + const cg = CodeGraph.initSync(tempDir); + cg.close(); + + const validation = validateDirectory(tempDir); + expect(validation.valid).toBe(true); + expect(validation.errors).toHaveLength(0); + }); + + it('should detect invalid directory', () => { + const validation = validateDirectory(tempDir); + expect(validation.valid).toBe(false); + expect(validation.errors.length).toBeGreaterThan(0); + }); + }); + + describe('Uninitialize', () => { + it('should remove .CodeGraph directory', () => { + const cg = CodeGraph.initSync(tempDir); + + cg.uninitialize(); + + expect(fs.existsSync(getCodeGraphDir(tempDir))).toBe(false); + expect(CodeGraph.isInitialized(tempDir)).toBe(false); + }); + }); + + describe('Close/Destroy', () => { + it('should close database but keep .CodeGraph directory', () => { + const cg = CodeGraph.initSync(tempDir); + + cg.destroy(); // destroy is alias for close + + expect(fs.existsSync(getCodeGraphDir(tempDir))).toBe(true); + expect(CodeGraph.isInitialized(tempDir)).toBe(true); + }); + }); + + describe('Graph Query Methods', () => { + it('should throw "Node not found" for non-existent nodes', () => { + const cg = CodeGraph.initSync(tempDir); + + // getContext throws for non-existent nodes + expect(() => cg.getContext('non-existent')).toThrow(/not found/i); + + cg.close(); + }); + + it('should return empty results for non-existent nodes', () => { + const cg = CodeGraph.initSync(tempDir); + + // These methods return empty results instead of throwing + const traverseResult = cg.traverse('non-existent'); + expect(traverseResult.nodes.size).toBe(0); + + const callGraph = cg.getCallGraph('non-existent'); + expect(callGraph.nodes.size).toBe(0); + + const typeHierarchy = cg.getTypeHierarchy('non-existent'); + expect(typeHierarchy.nodes.size).toBe(0); + + const usages = cg.findUsages('non-existent'); + expect(usages.length).toBe(0); + + cg.close(); + }); + + }); +}); + +describe('Database Connection', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should initialize new database', () => { + const dbPath = path.join(tempDir, 'test.db'); + const db = DatabaseConnection.initialize(dbPath); + + expect(db.isOpen()).toBe(true); + expect(fs.existsSync(dbPath)).toBe(true); + + db.close(); + }); + + it('should get schema version', () => { + const dbPath = path.join(tempDir, 'test.db'); + const db = DatabaseConnection.initialize(dbPath); + + const version = db.getSchemaVersion(); + expect(version).not.toBeNull(); + expect(version?.version).toBe(4); + + db.close(); + }); + + it('should support transactions', () => { + const dbPath = path.join(tempDir, 'test.db'); + const db = DatabaseConnection.initialize(dbPath); + + const result = db.transaction(() => { + return 42; + }); + + expect(result).toBe(42); + + db.close(); + }); + + it('should throw when opening non-existent database', () => { + const dbPath = path.join(tempDir, 'nonexistent.db'); + + expect(() => DatabaseConnection.open(dbPath)).toThrow(/not found/i); + }); +}); + +describe('Query Builder', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(() => { + tempDir = createTempDir(); + cg = CodeGraph.initSync(tempDir); + }); + + afterEach(() => { + cg.close(); + cleanupTempDir(tempDir); + }); + + it('should return null for non-existent node', () => { + const node = cg.getNode('nonexistent'); + expect(node).toBeNull(); + }); + + it('should return empty array for nodes in non-existent file', () => { + const nodes = cg.getNodesInFile('nonexistent.ts'); + expect(nodes).toEqual([]); + }); + + it('should return empty array for edges from non-existent node', () => { + const edges = cg.getOutgoingEdges('nonexistent'); + expect(edges).toEqual([]); + }); + + it('should return null for non-existent file', () => { + const file = cg.getFile('nonexistent.ts'); + expect(file).toBeNull(); + }); + + it('should return empty array for files when none tracked', () => { + const files = cg.getFiles(); + expect(files).toEqual([]); + }); +}); diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts new file mode 100644 index 0000000..b64e8c6 --- /dev/null +++ b/__tests__/frameworks-integration.test.ts @@ -0,0 +1,59 @@ +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 { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('Django end-to-end framework extraction', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('creates a route->view edge from urls.py to view class', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-django-')); + fs.writeFileSync(path.join(tmpDir, 'manage.py'), '# marker\n'); + fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'django==4.2\n'); + fs.mkdirSync(path.join(tmpDir, 'users')); + fs.writeFileSync(path.join(tmpDir, 'users/__init__.py'), ''); + fs.writeFileSync( + path.join(tmpDir, 'users/views.py'), + 'class UserListView:\n def get(self, request): pass\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'users/urls.py'), + 'from django.urls import path\n' + + 'from users.views import UserListView\n' + + 'urlpatterns = [path("users/", UserListView.as_view(), name="user-list")]\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // Route node exists + const routes = cg.getNodesByKind('route'); + expect(routes.length).toBeGreaterThan(0); + const route = routes.find((n) => n.name === 'users/'); + expect(route).toBeDefined(); + + // View class exists + const classNodes = cg.getNodesByKind('class'); + const view = classNodes.find((n) => n.name === 'UserListView'); + expect(view).toBeDefined(); + + // Edge route -> view exists + const edges = cg.getOutgoingEdges(route!.id); + const toView = edges.find((e) => e.target === view!.id); + expect(toView).toBeDefined(); + expect(toView!.kind).toBe('references'); + + cg.close(); + }); +}); diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts new file mode 100644 index 0000000..a5e5c56 --- /dev/null +++ b/__tests__/frameworks.test.ts @@ -0,0 +1,1069 @@ +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'); + }); +}); + +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'); + }); +}); + +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(); + }); +}); + +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('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('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('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('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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +import { reactResolver } from '../src/resolution/frameworks/react'; +import { svelteResolver } from '../src/resolution/frameworks/svelte'; + +describe('reactResolver.extract (smoke)', () => { + it('returns { nodes, references } shape', () => { + const src = `}/>`; + const result = reactResolver.extract!('App.tsx', src); + expect(result).toHaveProperty('nodes'); + expect(result).toHaveProperty('references'); + expect(Array.isArray(result.nodes)).toBe(true); + expect(Array.isArray(result.references)).toBe(true); + }); +}); + +describe('svelteResolver.extract (smoke)', () => { + it('returns { nodes, references } shape', () => { + const result = svelteResolver.extract!('+page.svelte', ''); + expect(result).toHaveProperty('nodes'); + expect(result).toHaveProperty('references'); + }); +}); + +// 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(['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(['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__/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__/graph.test.ts b/__tests__/graph.test.ts new file mode 100644 index 0000000..7c771af --- /dev/null +++ b/__tests__/graph.test.ts @@ -0,0 +1,435 @@ +/** + * 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'; + +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); + }); + }); + + 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); + }); + }); + + 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', () => { + it('should get file dependencies', () => { + const deps = cg.getFileDependencies('src/main.ts'); + + expect(Array.isArray(deps)).toBe(true); + }); + + it('should get file dependents', () => { + const dependents = cg.getFileDependents('src/utils.ts'); + + expect(Array.isArray(dependents)).toBe(true); + }); + }); + + 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'); + }); + }); +}); diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts new file mode 100644 index 0000000..59e869e --- /dev/null +++ b/__tests__/installer-targets.test.ts @@ -0,0 +1,890 @@ +/** + * 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 } from '../src/installer'; +import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; +import { cleanupLegacyHooks } 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; + }, + }; +} + +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 after only config.toml exists — second pass is fully unchanged', () => { + const codex = getTarget('codex')!; + // First install creates both files. + codex.install('global', { autoAllow: false }); + // Delete the AGENTS.md to simulate partial state (user wiped one file). + const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md'); + expect(fs.existsSync(agentsMd)).toBe(true); + fs.unlinkSync(agentsMd); + // Reinstall — TOML stays unchanged, AGENTS.md is recreated. + const second = codex.install('global', { autoAllow: false }); + const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!; + const mdEntry = second.files.find((f) => f.path.endsWith('AGENTS.md'))!; + expect(tomlEntry.action).toBe('unchanged'); + expect(mdEntry.action).toBe('created'); + // Third install — both unchanged (full idempotency restored). + const third = codex.install('global', { autoAllow: false }); + for (const f of third.files) expect(f.action).toBe('unchanged'); + }); + + 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 AGENTS.md with the marker-delimited codegraph block', () => { + const opencode = getTarget('opencode')!; + opencode.install('global', { autoAllow: true }); + const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md'); + expect(fs.existsSync(agentsMd)).toBe(true); + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain(''); + expect(body).toContain(''); + expect(body).toContain('codegraph_callers'); + }); + + it('opencode: AGENTS.md install preserves pre-existing user content outside markers', () => { + 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'); + + 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).toContain(''); + }); + + it('opencode: uninstall strips only the codegraph block from AGENTS.md', () => { + 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'); + + opencode.install('global', { autoAllow: true }); + 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'); + expect(body).not.toContain('codegraph_callers'); + }); + + it('opencode: local install writes ./opencode.jsonc and ./AGENTS.md in cwd', () => { + 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); + }); + + 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'); + }); + + 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: 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__codegraph_search'); + }); + + 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(); + }); +}); + +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('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 — 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'); + + it('deletes the dedicated codegraph.mdc entirely (no orphaned frontmatter left behind)', () => { + cursor.install('local', { autoAllow: true }); + 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); + expect(cursor.detect('local').alreadyConfigured).toBe(false); + }); + + it('preserves user content added outside the codegraph markers (strips only our block)', () => { + cursor.install('local', { autoAllow: true }); + const withUserContent = + fs.readFileSync(rulesFile(), 'utf-8') + '\n## My own rule\nkeep me\n'; + fs.writeFileSync(rulesFile(), withUserContent); + + 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; +} diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts new file mode 100644 index 0000000..728ed7c --- /dev/null +++ b/__tests__/installer.test.ts @@ -0,0 +1,220 @@ +/** + * Installer Tests + * + * Tests for installer config-writer fixes: + * - readJsonFile error handling + * - writeClaudeMd section replacement + */ + +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, + writePermissions, + writeClaudeMd, + hasMcpConfig, + hasPermissions, + hasClaudeMdSection, +} 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'); + }); + }); + + describe('writeClaudeMd section replacement', () => { + it('should create new CLAUDE.md with markers', () => { + const result = writeClaudeMd('local'); + + expect(result.created).toBe(true); + const content = fs.readFileSync(path.join(tempDir, '.claude', 'CLAUDE.md'), 'utf-8'); + expect(content).toContain(''); + expect(content).toContain(''); + expect(content).toContain('## CodeGraph'); + }); + + it('should replace marked section on update', () => { + // First write + writeClaudeMd('local'); + + // Modify file to add custom content before and after + const claudeMdPath = path.join(tempDir, '.claude', 'CLAUDE.md'); + const original = fs.readFileSync(claudeMdPath, 'utf-8'); + const modified = '## My Custom Section\n\nCustom content\n\n' + original + '\n\n## Another Section\n\nMore content\n'; + fs.writeFileSync(claudeMdPath, modified); + + // Second write should leave the marked block as-is (byte-identical + // body, so result is `created:false, updated:false` — both flags + // are off but the surrounding custom content must survive). + writeClaudeMd('local'); + + const final = fs.readFileSync(claudeMdPath, 'utf-8'); + expect(final).toContain('## My Custom Section'); + expect(final).toContain('Custom content'); + expect(final).toContain('## Another Section'); + expect(final).toContain('More content'); + expect(final).toContain('## CodeGraph'); + }); + + it('should use atomic writes (no temp files left behind)', () => { + writeClaudeMd('local'); + + const claudeDir = path.join(tempDir, '.claude'); + const files = fs.readdirSync(claudeDir); + const tmpFiles = files.filter(f => f.includes('.tmp.')); + expect(tmpFiles).toHaveLength(0); + }); + + it('should not overwrite content after unmarked section with ### subsections', () => { + // Create a CLAUDE.md with an unmarked CodeGraph section that has ### subsections + // followed by another ## section + const claudeDir = path.join(tempDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const claudeMdPath = path.join(claudeDir, 'CLAUDE.md'); + fs.writeFileSync(claudeMdPath, [ + '## Pre-existing Section', + '', + 'Some content', + '', + '## CodeGraph', + '', + '### Subsection A', + '', + 'Old codegraph content', + '', + '### Subsection B', + '', + 'More old content', + '', + '## Important Section After', + '', + 'This content must not be overwritten!', + '', + ].join('\n')); + + const result = writeClaudeMd('local'); + expect(result.updated).toBe(true); + + const final = fs.readFileSync(claudeMdPath, 'utf-8'); + // The section after CodeGraph must be preserved + expect(final).toContain('## Important Section After'); + expect(final).toContain('This content must not be overwritten!'); + // Pre-existing section should also be preserved + expect(final).toContain('## Pre-existing Section'); + // New CodeGraph content should be present with markers + expect(final).toContain(''); + expect(final).toContain(''); + }); + + it('should replace unmarked section without subsections', () => { + const claudeDir = path.join(tempDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const claudeMdPath = path.join(claudeDir, 'CLAUDE.md'); + // Note: regex needs \n before ## CodeGraph, so prefix with another section + fs.writeFileSync(claudeMdPath, [ + '## Intro', + '', + 'Preamble', + '', + '## CodeGraph', + '', + 'Old simple content', + '', + '## Next Section', + '', + 'Must be preserved', + '', + ].join('\n')); + + writeClaudeMd('local'); + + const final = fs.readFileSync(claudeMdPath, 'utf-8'); + expect(final).toContain(''); + expect(final).toContain('## Next Section'); + expect(final).toContain('Must be preserved'); + expect(final).not.toContain('Old simple content'); + }); + }); +}); diff --git a/__tests__/integration/full-pipeline.test.ts b/__tests__/integration/full-pipeline.test.ts new file mode 100644 index 0000000..cb01aa5 --- /dev/null +++ b/__tests__/integration/full-pipeline.test.ts @@ -0,0 +1,244 @@ +/** + * 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); +}); 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..495d493 --- /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 task on codegraph_context', async () => { + const huge = 'b'.repeat(50_000); + const result = await handler.execute('codegraph_context', { task: 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__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts new file mode 100644 index 0000000..4a57eba --- /dev/null +++ b/__tests__/mcp-initialize.test.ts @@ -0,0 +1,149 @@ +/** + * 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'], + }) 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); +}); diff --git a/__tests__/mcp-ppid-watchdog.test.ts b/__tests__/mcp-ppid-watchdog.test.ts new file mode 100644 index 0000000..0e3dc18 --- /dev/null +++ b/__tests__/mcp-ppid-watchdog.test.ts @@ -0,0 +1,168 @@ +/** + * 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], + env: { ...process.env, CODEGRAPH_PPID_POLL_MS: '200' }, + 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-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__/node-sqlite-backend.test.ts b/__tests__/node-sqlite-backend.test.ts new file mode 100644 index 0000000..d1e630f --- /dev/null +++ b/__tests__/node-sqlite-backend.test.ts @@ -0,0 +1,71 @@ +/** + * node:sqlite backend (issue #238 follow-up). + * + * node:sqlite (Node's built-in real SQLite) is now the sole backend. This drives + * a real index + queries through it, so WAL, FTS5 search, and @named-param + * writes are all exercised end-to-end. + * + * Skipped on Node < 22.5 where node:sqlite doesn't exist. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src'; + +let nodeSqliteAvailable = false; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('node:sqlite'); + nodeSqliteAvailable = true; +} catch { + nodeSqliteAvailable = false; +} + +describe.skipIf(!nodeSqliteAvailable)('node:sqlite backend — real index + queries', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nodesqlite-')); + fs.writeFileSync(path.join(dir, 'a.ts'), 'export function helper(): number { return 1; }\n'); + fs.writeFileSync( + path.join(dir, 'b.ts'), + "import { helper } from './a';\nexport function main(): number { return helper(); }\n" + ); + cg = await CodeGraph.init(dir, { index: true }); + }); + + afterAll(() => { + cg?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('uses the node:sqlite backend', () => { + expect(cg.getBackend()).toBe('node-sqlite'); + }); + + it('runs in WAL mode — the whole reason it beats the wasm fallback', () => { + expect(cg.getJournalMode()).toBe('wal'); + }); + + it('indexed the project (write path: @named-param INSERTs via node:sqlite)', () => { + const stats = cg.getStats(); + expect(stats.fileCount).toBe(2); + expect(stats.nodeCount).toBeGreaterThan(0); + }); + + it('FTS5 search returns the indexed symbol (read path)', () => { + const results = cg.searchNodes('helper'); + const names = results.map(r => r.node.name); + expect(names).toContain('helper'); + }); + + it('graph traversal resolves the cross-file caller', () => { + const helper = cg.searchNodes('helper').find(r => r.node.name === 'helper'); + expect(helper).toBeTruthy(); + const callers = cg.getCallers(helper!.node.id); + expect(callers.map(c => c.node.name)).toContain('main'); + }); +}); diff --git a/__tests__/node-version-check.test.ts b/__tests__/node-version-check.test.ts new file mode 100644 index 0000000..fc455eb --- /dev/null +++ b/__tests__/node-version-check.test.ts @@ -0,0 +1,69 @@ +/** + * Pin the Node-25 block banner content. The banner replaced a soft + * `console.warn` because the warning was scrolling off-screen before + * the OOM crash 30 seconds later, generating duplicate bug reports + * (#54, #81, #140). The recipe and override env var below are + * load-bearing — if any of them get edited away, this test catches it. + */ + +import { describe, it, expect } from 'vitest'; +import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from '../src/bin/node-version-check'; + +describe('buildNode25BlockBanner', () => { + it('embeds the reported Node version in the header', () => { + expect(buildNode25BlockBanner('25.9.0')).toContain( + 'Unsupported Node.js version: 25.9.0' + ); + }); + + it('names the V8 turboshaft WASM root cause and the OOM symptom', () => { + const banner = buildNode25BlockBanner('25.7.0'); + expect(banner).toContain('V8 WASM JIT'); + expect(banner).toContain('turboshaft'); + expect(banner).toContain('Fatal process out of memory: Zone'); + }); + + it('points users to Node 22 LTS via nvm and Homebrew', () => { + const banner = buildNode25BlockBanner('25.7.0'); + expect(banner).toContain('Node.js 22 LTS'); + expect(banner).toContain('nvm install 22'); + expect(banner).toContain('brew install node@22'); + }); + + it('documents the CODEGRAPH_ALLOW_UNSAFE_NODE override', () => { + const banner = buildNode25BlockBanner('25.7.0'); + expect(banner).toContain('CODEGRAPH_ALLOW_UNSAFE_NODE=1'); + }); + + it('links to issue #81 for the root-cause writeup', () => { + expect(buildNode25BlockBanner('25.7.0')).toContain( + 'github.com/colbymchenry/codegraph/issues/81' + ); + }); +}); + +describe('buildNodeTooOldBanner', () => { + it('embeds the reported Node version in the header', () => { + expect(buildNodeTooOldBanner('18.20.0')).toContain( + 'Unsupported Node.js version: 18.20.0' + ); + }); + + it('states the supported floor matching MIN_NODE_MAJOR', () => { + expect(MIN_NODE_MAJOR).toBe(20); + expect(buildNodeTooOldBanner('18.0.0')).toContain( + `requires Node.js ${MIN_NODE_MAJOR} or newer` + ); + }); + + it('points users to Node 22 LTS via nvm and Homebrew', () => { + const banner = buildNodeTooOldBanner('16.0.0'); + expect(banner).toContain('Node.js 22 LTS'); + expect(banner).toContain('nvm install 22'); + expect(banner).toContain('brew install node@22'); + }); + + it('documents the CODEGRAPH_ALLOW_UNSAFE_NODE override', () => { + expect(buildNodeTooOldBanner('18.0.0')).toContain('CODEGRAPH_ALLOW_UNSAFE_NODE=1'); + }); +}); diff --git a/__tests__/npm-shim.test.ts b/__tests__/npm-shim.test.ts new file mode 100644 index 0000000..16e7050 --- /dev/null +++ b/__tests__/npm-shim.test.ts @@ -0,0 +1,208 @@ +/** + * npm thin-installer launcher (`scripts/npm-shim.js`) tests. + * + * The shim runs on the user's own Node, locates the per-platform optionalDependency + * bundle, and — when a registry mirror failed to deliver it (issue #303) — falls + * back to downloading the bundle from GitHub Releases. These tests exercise that + * shim as a real subprocess from a temp "main package" dir (its own package.json + * + node_modules), so resolution and version lookup behave hermetically. + * + * The download/checksum paths run against a local self-signed HTTPS server via + * CODEGRAPH_DOWNLOAD_BASE — no real network, no published release needed. The + * shim is launched with async `spawn` (not spawnSync), so the test's event loop + * stays free to serve those requests. + * + * POSIX only: the fake bundle launcher is a shell script and extraction uses the + * system `tar`. Skipped on Windows (where the shim's exec path differs anyway). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, execSync } from 'child_process'; +import * as https from 'https'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import type { AddressInfo } from 'net'; + +const SHIM_SRC = path.join(__dirname, '..', 'scripts', 'npm-shim.js'); +const target = `${process.platform}-${process.arch}`; +const asset = `codegraph-${target}.tar.gz`; +const isWindows = process.platform === 'win32'; + +function hasOpenssl(): boolean { + try { execSync('openssl version', { stdio: 'ignore' }); return true; } catch { return false; } +} +const CAN_NET = !isWindows && hasOpenssl(); + +function mkTmp(label: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `cg-shim-${label}-`)); +} + +// A temp dir standing in for the installed @colbymchenry/codegraph main package. +function makePkg(version = '9.9.9-test'): string { + const dir = mkTmp('pkg'); + fs.copyFileSync(SHIM_SRC, path.join(dir, 'npm-shim.js')); + fs.writeFileSync(path.join(dir, 'package.json'), + JSON.stringify({ name: '@colbymchenry/codegraph', version }) + '\n'); + return dir; +} + +// A fake bundle launcher that prints a marker + its args, so we can prove the +// shim found and exec'd it (and passed args through). +function writeLauncher(binDir: string): void { + fs.mkdirSync(binDir, { recursive: true }); + const p = path.join(binDir, 'codegraph'); + fs.writeFileSync(p, '#!/bin/sh\necho "FAKE_BUNDLE_RAN args:$*"\n'); + fs.chmodSync(p, 0o755); +} + +// Launch the shim with async spawn so the in-process HTTPS server can respond +// while it runs (spawnSync would block this event loop and deadlock). +function runShim(pkgDir: string, args: string[], env: Record) { + return new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => { + const child = spawn(process.execPath, [path.join(pkgDir, 'npm-shim.js'), ...args], { + env: { ...process.env, ...env }, + }); + let stdout = '', stderr = ''; + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + child.on('close', (status) => resolve({ status, stdout, stderr })); + }); +} + +describe.skipIf(isWindows)('npm-shim launcher', () => { + it('runs the installed optional-dependency bundle without any download', async () => { + const pkg = makePkg(); + const platformPkg = path.join(pkg, 'node_modules', '@colbymchenry', `codegraph-${target}`); + writeLauncher(path.join(platformPkg, 'bin')); + fs.writeFileSync(path.join(platformPkg, 'package.json'), + JSON.stringify({ name: `@colbymchenry/codegraph-${target}`, version: '9.9.9-test' }) + '\n'); + const cache = mkTmp('cache'); + const r = await runShim(pkg, ['--probe-abc'], { CODEGRAPH_INSTALL_DIR: cache }); + + expect(r.status).toBe(0); + expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); + expect(r.stdout).toContain('--probe-abc'); // args passed through + expect(r.stderr).not.toContain('downloading'); // never reached the fallback + expect(fs.existsSync(path.join(cache, 'bundles'))).toBe(false); + }); + + it('uses an already-cached bundle even when downloads are disabled', async () => { + const pkg = makePkg('1.2.3-cached'); + const cache = mkTmp('cache'); + writeLauncher(path.join(cache, 'bundles', `${target}-1.2.3-cached`, 'bin')); + const r = await runShim(pkg, ['--probe-xyz'], { + CODEGRAPH_INSTALL_DIR: cache, + CODEGRAPH_NO_DOWNLOAD: '1', + }); + + expect(r.status).toBe(0); + expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); + expect(r.stdout).toContain('--probe-xyz'); + expect(r.stderr).toBe(''); + }); + + it('prints actionable guidance and exits 1 when disabled with no bundle', async () => { + const pkg = makePkg(); + const r = await runShim(pkg, ['--version'], { + CODEGRAPH_INSTALL_DIR: mkTmp('cache'), + CODEGRAPH_NO_DOWNLOAD: '1', + }); + + expect(r.status).toBe(1); + expect(r.stderr).toContain(`no prebuilt bundle for ${target}`); + expect(r.stderr).toContain(`@colbymchenry/codegraph-${target}`); + expect(r.stderr).toContain('--registry=https://registry.npmjs.org'); + expect(r.stderr).toContain('install.sh'); + }); +}); + +describe.skipIf(!CAN_NET)('npm-shim download fallback (local HTTPS)', () => { + let server: https.Server; + let port = 0; + let fixtureBytes: Buffer; + let fixtureSha: string; + let sumsBody: string | null = null; // per-test: SHA256SUMS contents, or null for 404 + + beforeAll(async () => { + // Self-signed cert for the mock release host. + const cdir = mkTmp('tls'); + const keyP = path.join(cdir, 'key.pem'); + const certP = path.join(cdir, 'cert.pem'); + execSync( + `openssl req -x509 -newkey rsa:2048 -nodes -keyout ${keyP} -out ${certP} -days 1 -subj "/CN=localhost"`, + { stdio: 'ignore' }, + ); + + // Build a fake bundle archive (codegraph-/bin/codegraph), like a real release asset. + const work = mkTmp('fixture'); + writeLauncher(path.join(work, `codegraph-${target}`, 'bin')); + const archive = path.join(work, asset); + execSync(`tar -czf ${JSON.stringify(archive)} -C ${JSON.stringify(work)} codegraph-${target}`); + fixtureBytes = fs.readFileSync(archive); + fixtureSha = crypto.createHash('sha256').update(fixtureBytes).digest('hex'); + + server = https.createServer({ key: fs.readFileSync(keyP), cert: fs.readFileSync(certP) }, (req, res) => { + const url = req.url || ''; + if (url.endsWith(`/${asset}`)) { + res.writeHead(200); res.end(fixtureBytes); + } else if (url.endsWith('/SHA256SUMS')) { + if (sumsBody === null) { res.writeHead(404); res.end('not found'); } + else { res.writeHead(200); res.end(sumsBody); } + } else { + res.writeHead(404); res.end('not found'); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as AddressInfo).port; + }, 30000); + + afterAll(() => { server?.close(); }); + + function netEnv(cache: string): Record { + return { + CODEGRAPH_INSTALL_DIR: cache, + CODEGRAPH_DOWNLOAD_BASE: `https://127.0.0.1:${port}`, + NODE_TLS_REJECT_UNAUTHORIZED: '0', + }; + } + + it('downloads, verifies the checksum, extracts, and execs the bundle', async () => { + sumsBody = `${fixtureSha} ${asset}\n`; + const pkg = makePkg('5.0.0-net'); + const cache = mkTmp('cache'); + const r = await runShim(pkg, ['--probe-net'], netEnv(cache)); + + expect(r.stderr).toContain('downloading'); + expect(r.stderr).toContain('checksum verified'); + expect(r.status).toBe(0); + expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); + expect(r.stdout).toContain('--probe-net'); + expect(fs.existsSync(path.join(cache, 'bundles', `${target}-5.0.0-net`, 'bin', 'codegraph'))).toBe(true); + }, 20000); + + it('aborts (exit 1) on a checksum mismatch and caches nothing', async () => { + sumsBody = `${'0'.repeat(64)} ${asset}\n`; + const pkg = makePkg('5.0.0-bad'); + const cache = mkTmp('cache'); + const r = await runShim(pkg, ['--version'], netEnv(cache)); + + expect(r.status).toBe(1); + expect(r.stderr).toContain('checksum mismatch'); + expect(r.stdout).not.toContain('FAKE_BUNDLE_RAN'); // never exec'd a tampered bundle + expect(fs.existsSync(path.join(cache, 'bundles', `${target}-5.0.0-bad`))).toBe(false); + }, 20000); + + it('proceeds when no SHA256SUMS is published (older releases)', async () => { + sumsBody = null; // 404 + const pkg = makePkg('5.0.0-nosums'); + const cache = mkTmp('cache'); + const r = await runShim(pkg, ['--version'], netEnv(cache)); + + expect(r.status).toBe(0); + expect(r.stderr).toContain('downloading'); + expect(r.stderr).not.toContain('checksum verified'); // skipped, not failed + expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); + }, 20000); +}); diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts new file mode 100644 index 0000000..6741e90 --- /dev/null +++ b/__tests__/pr19-improvements.test.ts @@ -0,0 +1,719 @@ +/** + * PR #19 Improvement Tests + * + * Tests for changes ported from PR #15 and #16: + * - Lazy grammar loading + * - Arrow function extraction (body traversal) + * - Graph traversal 'both' direction fix + * - Best-candidate resolution picking + * - Schema v2 migration (filePath/language on unresolved_refs) + * - Batch insert for unresolved refs + * - SQLite performance pragmas + * - MCP symbol disambiguation and output truncation + * - CLI uninit command + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { extractFromSource } from '../src/extraction'; +import { + getParser, + isLanguageSupported, + getSupportedLanguages, + clearParserCache, + getUnavailableGrammarErrors, + initGrammars, + loadAllGrammars, +} from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +// Create a temporary directory for each test +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pr19-test-')); +} + +// Clean up temporary directory +function cleanupTempDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// Check if the node:sqlite backend is available (Node >= 22.5) +function hasSqliteBindings(): boolean { + try { + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(':memory:'); + db.close(); + return true; + } catch { + return false; + } +} + +const HAS_SQLITE = hasSqliteBindings(); + +// ============================================================================= +// Lazy Grammar Loading +// ============================================================================= + +describe('Lazy Grammar Loading', () => { + afterEach(() => { + clearParserCache(); + }); + + it('should load grammars lazily on first use', () => { + // Clear cache to force fresh load + clearParserCache(); + + // TypeScript should be loadable + const parser = getParser('typescript'); + expect(parser).not.toBeNull(); + }); + + it('should cache loaded grammars', () => { + clearParserCache(); + + const parser1 = getParser('typescript'); + const parser2 = getParser('typescript'); + + // Same reference from cache + expect(parser1).toBe(parser2); + }); + + it('should return null for unknown language', () => { + const parser = getParser('unknown'); + expect(parser).toBeNull(); + }); + + it('should handle unavailable grammars gracefully', () => { + // 'unknown' is not a valid grammar, should not crash + expect(isLanguageSupported('unknown')).toBe(false); + }); + + it('should report liquid as supported (custom extractor)', () => { + expect(isLanguageSupported('liquid')).toBe(true); + }); + + it('should include liquid in supported languages', () => { + const supported = getSupportedLanguages(); + expect(supported).toContain('liquid'); + }); + + it('should return unavailable grammar errors as a record', () => { + clearParserCache(); + const errors = getUnavailableGrammarErrors(); + // Should be a plain object (may or may not have entries depending on platform) + expect(typeof errors).toBe('object'); + }); + + it('should support multiple languages independently', () => { + clearParserCache(); + + // Load two different languages - one failing shouldn't affect the other + const tsParser = getParser('typescript'); + const pyParser = getParser('python'); + + expect(tsParser).not.toBeNull(); + expect(pyParser).not.toBeNull(); + expect(tsParser).not.toBe(pyParser); + }); + + it('should clear all caches on clearParserCache', () => { + // Load a grammar + getParser('typescript'); + + // Clear + clearParserCache(); + + // Errors should be cleared too + const errors = getUnavailableGrammarErrors(); + expect(Object.keys(errors)).toHaveLength(0); + }); +}); + +// ============================================================================= +// Arrow Function Extraction - Body Traversal +// ============================================================================= + +describe('Arrow Function Body Traversal', () => { + it('should extract unresolved references from arrow function bodies', () => { + const code = ` +export const useAuth = () => { + const user = getUser(); + const token = generateToken(user); + return { user, token }; +}; +`; + const result = extractFromSource('hooks.ts', code); + + // The arrow function should be extracted + const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'useAuth'); + expect(funcNode).toBeDefined(); + + // Calls inside the body should be captured as unresolved references + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + const callNames = calls.map((c) => c.referenceName); + expect(callNames).toContain('getUser'); + expect(callNames).toContain('generateToken'); + }); + + it('should extract unresolved references from function expression bodies', () => { + const code = ` +export const processData = function(input: string): string { + const cleaned = sanitize(input); + return transform(cleaned); +}; +`; + const result = extractFromSource('utils.ts', code); + + const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'processData'); + expect(funcNode).toBeDefined(); + + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + const callNames = calls.map((c) => c.referenceName); + expect(callNames).toContain('sanitize'); + expect(callNames).toContain('transform'); + }); + + it('should not create duplicate nodes for arrow functions', () => { + const code = ` +export const handler = () => { + doSomething(); +}; +`; + const result = extractFromSource('handler.ts', code); + + // Should be exactly 1 function node, 0 variable nodes for 'handler' + const funcNodes = result.nodes.filter((n) => n.name === 'handler' && n.kind === 'function'); + const varNodes = result.nodes.filter((n) => n.name === 'handler' && n.kind === 'variable'); + expect(funcNodes).toHaveLength(1); + expect(varNodes).toHaveLength(0); + }); + + it('should extract nested calls in arrow functions in JavaScript', () => { + const code = ` +export const fetchData = async () => { + const response = await fetchAPI('/data'); + return parseResponse(response); +}; +`; + const result = extractFromSource('api.js', code); + + const funcNode = result.nodes.find((n) => n.name === 'fetchData'); + expect(funcNode).toBeDefined(); + expect(funcNode?.kind).toBe('function'); + + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + const callNames = calls.map((c) => c.referenceName); + expect(callNames).toContain('fetchAPI'); + expect(callNames).toContain('parseResponse'); + }); +}); + +// ============================================================================= +// Graph Traversal 'both' Direction Fix +// (requires better-sqlite3 - will use CodeGraph integration) +// ============================================================================= + +describe('Graph Traversal Both Direction', () => { + let testDir: string; + + beforeEach(() => { + testDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(testDir); + }); + + it.skipIf(!HAS_SQLITE)('should traverse both directions from a node', async () => { + const CodeGraph = (await import('../src/index')).default; + + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + // A -> B -> C (A calls B, B calls C) + fs.writeFileSync(path.join(srcDir, 'a.ts'), ` +import { funcB } from './b'; +export function funcA(): void { funcB(); } +`); + fs.writeFileSync(path.join(srcDir, 'b.ts'), ` +import { funcC } from './c'; +export function funcB(): void { funcC(); } +`); + fs.writeFileSync(path.join(srcDir, 'c.ts'), ` +export function funcC(): void { console.log('c'); } +`); + + const cg = CodeGraph.initSync(testDir, { + config: { include: ['src/**/*.ts'], exclude: [] }, + }); + + await cg.indexAll(); + cg.resolveReferences(); + + const functions = cg.getNodesByKind('function'); + const funcB = functions.find((n) => n.name === 'funcB'); + + if (!funcB) { + cg.destroy(); + return; + } + + // Traverse 'both' from B - should find A (incoming caller) and C (outgoing callee) + const subgraph = cg.traverse(funcB.id, { + maxDepth: 1, + direction: 'both', + }); + + // B itself + at least one neighbor in each direction + expect(subgraph.nodes.size).toBeGreaterThanOrEqual(2); + expect(subgraph.nodes.has(funcB.id)).toBe(true); + + cg.destroy(); + }); +}); + +// ============================================================================= +// Best-Candidate Resolution +// ============================================================================= + +describe('Best-Candidate Resolution', () => { + it.skipIf(!HAS_SQLITE)('should be testable via the resolution module types', async () => { + const { ReferenceResolver } = await import('../src/resolution'); + expect(typeof ReferenceResolver.prototype.resolveOne).toBe('function'); + }); +}); + +// ============================================================================= +// Schema v2 Migration +// ============================================================================= + +describe('Schema v2 Migration', () => { + it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => { + const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations'); + expect(CURRENT_SCHEMA_VERSION).toBe(4); + }); + + it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => { + const { getPendingMigrations } = await import('../src/db/migrations'); + expect(typeof getPendingMigrations).toBe('function'); + }); +}); + +// ============================================================================= +// Database Layer: Batch Insert, getAllNodes, Pragmas +// ============================================================================= + +describe('Database Layer Improvements', () => { + let testDir: string; + + beforeEach(() => { + testDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(testDir); + }); + + it.skipIf(!HAS_SQLITE)('should support batch insert of unresolved refs', async () => { + const { DatabaseConnection } = await import('../src/db'); + const { QueryBuilder } = await import('../src/db/queries'); + + const dbPath = path.join(testDir, 'codegraph.db'); + const db = DatabaseConnection.initialize(dbPath); + const queries = new QueryBuilder(db.getDb()); + + // Insert a node first (needed as foreign key) + queries.insertNode({ + id: 'func:test:1', + kind: 'function', + name: 'testFunc', + qualifiedName: 'test::testFunc', + filePath: 'test.ts', + language: 'typescript', + startLine: 1, + endLine: 5, + startColumn: 0, + endColumn: 1, + updatedAt: Date.now(), + }); + + // Batch insert unresolved refs with filePath and language + queries.insertUnresolvedRefsBatch([ + { + fromNodeId: 'func:test:1', + referenceName: 'helperA', + referenceKind: 'calls', + line: 2, + column: 4, + filePath: 'test.ts', + language: 'typescript', + }, + { + fromNodeId: 'func:test:1', + referenceName: 'helperB', + referenceKind: 'calls', + line: 3, + column: 4, + filePath: 'test.ts', + language: 'typescript', + }, + ]); + + const refs = queries.getUnresolvedReferences(); + expect(refs).toHaveLength(2); + expect(refs.map((r) => r.referenceName).sort()).toEqual(['helperA', 'helperB']); + + // Verify filePath and language are persisted + expect(refs[0]?.filePath).toBe('test.ts'); + expect(refs[0]?.language).toBe('typescript'); + + db.close(); + }); + + it.skipIf(!HAS_SQLITE)('should support getAllNodes', async () => { + const { DatabaseConnection } = await import('../src/db'); + const { QueryBuilder } = await import('../src/db/queries'); + + const dbPath = path.join(testDir, 'codegraph.db'); + const db = DatabaseConnection.initialize(dbPath); + const queries = new QueryBuilder(db.getDb()); + + // Insert some nodes + for (let i = 0; i < 3; i++) { + queries.insertNode({ + id: `func:test:${i}`, + kind: 'function', + name: `func${i}`, + qualifiedName: `test::func${i}`, + filePath: 'test.ts', + language: 'typescript', + startLine: i * 10 + 1, + endLine: i * 10 + 5, + startColumn: 0, + endColumn: 1, + updatedAt: Date.now(), + }); + } + + const allNodes = queries.getAllNodes(); + expect(allNodes).toHaveLength(3); + expect(allNodes.map((n) => n.name).sort()).toEqual(['func0', 'func1', 'func2']); + + db.close(); + }); + + it.skipIf(!HAS_SQLITE)('should set performance pragmas on initialization', async () => { + const { DatabaseConnection } = await import('../src/db'); + + const dbPath = path.join(testDir, 'codegraph.db'); + const db = DatabaseConnection.initialize(dbPath); + const rawDb = db.getDb(); + + // Check pragmas were set + const synchronous = rawDb.pragma('synchronous', { simple: true }); + expect(synchronous).toBe(1); // NORMAL = 1 + + const cacheSize = rawDb.pragma('cache_size', { simple: true }) as number; + expect(cacheSize).toBe(-64000); + + const tempStore = rawDb.pragma('temp_store', { simple: true }); + expect(tempStore).toBe(2); // MEMORY = 2 + + const mmapSize = rawDb.pragma('mmap_size', { simple: true }) as number; + expect(mmapSize).toBe(268435456); // 256 MB + + db.close(); + }); + + it.skipIf(!HAS_SQLITE)('should handle empty batch insert gracefully', async () => { + const { DatabaseConnection } = await import('../src/db'); + const { QueryBuilder } = await import('../src/db/queries'); + + const dbPath = path.join(testDir, 'codegraph.db'); + const db = DatabaseConnection.initialize(dbPath); + const queries = new QueryBuilder(db.getDb()); + + // Should not throw on empty array + expect(() => queries.insertUnresolvedRefsBatch([])).not.toThrow(); + + db.close(); + }); +}); + +// ============================================================================= +// Resolution Warm Caches +// ============================================================================= + +describe('Resolution Warm Caches', () => { + let testDir: string; + + beforeEach(() => { + testDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(testDir); + }); + + it.skipIf(!HAS_SQLITE)('should warm caches and use them for lookups', async () => { + const CodeGraph = (await import('../src/index')).default; + + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + fs.writeFileSync(path.join(srcDir, 'a.ts'), ` +export function myFunc(): void {} +export function otherFunc(): void { myFunc(); } +`); + + const cg = CodeGraph.initSync(testDir, { + config: { include: ['src/**/*.ts'], exclude: [] }, + }); + + await cg.indexAll(); + + // resolveReferences internally calls warmCaches + const result = cg.resolveReferences(); + + // Should complete without error + expect(result.stats.total).toBeGreaterThanOrEqual(0); + + cg.destroy(); + }); +}); + +// ============================================================================= +// MCP Tool Improvements +// ============================================================================= + +describe('MCP Tool Improvements', () => { + it.skipIf(!HAS_SQLITE)('should export ToolHandler class', async () => { + const { ToolHandler } = await import('../src/mcp/tools'); + expect(typeof ToolHandler).toBe('function'); + }); + + it.skipIf(!HAS_SQLITE)('should have findSymbol and truncateOutput as private methods', async () => { + const { ToolHandler } = await import('../src/mcp/tools'); + const proto = ToolHandler.prototype; + expect(typeof (proto as any).findSymbol).toBe('function'); + expect(typeof (proto as any).truncateOutput).toBe('function'); + }); + + it.skipIf(!HAS_SQLITE)('should truncate output exceeding MAX_OUTPUT_LENGTH', async () => { + const { ToolHandler } = await import('../src/mcp/tools'); + + // Access private method for testing + const handler = Object.create(ToolHandler.prototype); + const truncate = (handler as any).truncateOutput.bind(handler); + + // Short text should not be truncated + const short = 'Hello world'; + expect(truncate(short)).toBe(short); + + // Long text should be truncated + const long = 'x'.repeat(20000); + const result = truncate(long); + expect(result.length).toBeLessThan(long.length); + expect(result).toContain('... (output truncated)'); + }); + + it.skipIf(!HAS_SQLITE)('should truncate at a clean line boundary', async () => { + const { ToolHandler } = await import('../src/mcp/tools'); + + const handler = Object.create(ToolHandler.prototype); + const truncate = (handler as any).truncateOutput.bind(handler); + + // Build text with newlines exceeding the limit + const lines: string[] = []; + for (let i = 0; i < 500; i++) { + lines.push(`Line ${i}: ${'a'.repeat(50)}`); + } + const text = lines.join('\n'); + + const result = truncate(text); + // Should end with truncation notice after a newline boundary + expect(result).toContain('... (output truncated)'); + // Should not cut mid-line (the char before truncation notice should be \n) + const beforeTruncation = result.split('\n\n... (output truncated)')[0]!; + expect(beforeTruncation.endsWith('\n') || !beforeTruncation.includes('\0')).toBe(true); + }); + + describe('findSymbol disambiguation', () => { + it.skipIf(!HAS_SQLITE)('should prefer exact name matches', async () => { + const { ToolHandler } = await import('../src/mcp/tools'); + const CodeGraph = (await import('../src/index')).default; + + const tmpDir = createTempDir(); + const srcDir = path.join(tmpDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + fs.writeFileSync(path.join(srcDir, 'a.ts'), ` +export function getValue(): number { return 1; } +export function getValueFromCache(): number { return 2; } +`); + + const cg = CodeGraph.initSync(tmpDir, { + config: { include: ['src/**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + + const handler = new ToolHandler(cg); + const findSymbol = (handler as any).findSymbol.bind(handler); + + const match = findSymbol(cg, 'getValue'); + expect(match).not.toBeNull(); + expect(match.node.name).toBe('getValue'); + // Should not have a disambiguation note for single exact match + expect(match.note).toBe(''); + + handler.closeAll(); + cg.destroy(); + cleanupTempDir(tmpDir); + }); + + it.skipIf(!HAS_SQLITE)('should note when multiple symbols share the same name', async () => { + const { ToolHandler } = await import('../src/mcp/tools'); + const CodeGraph = (await import('../src/index')).default; + + const tmpDir = createTempDir(); + const srcDir = path.join(tmpDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + // Two files with the same function name + fs.writeFileSync(path.join(srcDir, 'a.ts'), ` +export function handle(): void {} +`); + fs.writeFileSync(path.join(srcDir, 'b.ts'), ` +export function handle(): void {} +`); + + const cg = CodeGraph.initSync(tmpDir, { + config: { include: ['src/**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + + const handler = new ToolHandler(cg); + const findSymbol = (handler as any).findSymbol.bind(handler); + + const match = findSymbol(cg, 'handle'); + expect(match).not.toBeNull(); + expect(match.node.name).toBe('handle'); + // Should have a disambiguation note + expect(match.note).toContain('2 symbols named "handle"'); + + handler.closeAll(); + cg.destroy(); + cleanupTempDir(tmpDir); + }); + + it.skipIf(!HAS_SQLITE)('should return null when symbol is not found', async () => { + const { ToolHandler } = await import('../src/mcp/tools'); + const CodeGraph = (await import('../src/index')).default; + + const tmpDir = createTempDir(); + const srcDir = path.join(tmpDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'a.ts'), `export function foo(): void {}`); + + const cg = CodeGraph.initSync(tmpDir, { + config: { include: ['src/**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + + const handler = new ToolHandler(cg); + const findSymbol = (handler as any).findSymbol.bind(handler); + + const match = findSymbol(cg, 'nonExistentSymbol'); + expect(match).toBeNull(); + + handler.closeAll(); + cg.destroy(); + cleanupTempDir(tmpDir); + }); + }); +}); + +// ============================================================================= +// CLI uninit Command +// ============================================================================= + +describe('CLI uninit', () => { + let testDir: string; + + beforeEach(() => { + testDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(testDir); + }); + + it.skipIf(!HAS_SQLITE)('should uninitialize a project via CodeGraph.uninitialize()', async () => { + const CodeGraph = (await import('../src/index')).default; + + // Initialize + const cg = CodeGraph.initSync(testDir); + expect(CodeGraph.isInitialized(testDir)).toBe(true); + + // Uninitialize + cg.uninitialize(); + + // .codegraph directory should be removed + expect(CodeGraph.isInitialized(testDir)).toBe(false); + }); +}); + +// ============================================================================= +// Tree-sitter Version Pinning +// ============================================================================= + +describe('Tree-sitter WASM Setup', () => { + it('should use web-tree-sitter and tree-sitter-wasms in dependencies', () => { + const pkgPath = path.join(__dirname, '..', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + + expect(pkg.dependencies['web-tree-sitter']).toBeDefined(); + expect(pkg.dependencies['tree-sitter-wasms']).toBeDefined(); + }); + + it('should not have native tree-sitter in dependencies', () => { + const pkgPath = path.join(__dirname, '..', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + + expect(pkg.dependencies['tree-sitter']).toBeUndefined(); + expect(pkg.overrides).toBeUndefined(); + }); +}); + +// ============================================================================= +// Embedder Float32Array Fix +// ============================================================================= + +describe('Float32Array Fix', () => { + it('should correctly convert typed arrays (regression check)', () => { + // Simulates the fix: Float32Array.from(Array.from(arr)) vs new Float32Array(arr.length) + const source = new Float64Array([1.5, 2.5, 3.5, 4.5]); + + // The OLD buggy approach: + const buggy = new Float32Array(source.length); + // buggy is all zeros! + expect(buggy[0]).toBe(0); + expect(buggy[1]).toBe(0); + + // The NEW fixed approach: + const fixed = Float32Array.from(Array.from(source)); + expect(fixed[0]).toBeCloseTo(1.5); + expect(fixed[1]).toBeCloseTo(2.5); + expect(fixed[2]).toBeCloseTo(3.5); + expect(fixed[3]).toBeCloseTo(4.5); + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts new file mode 100644 index 0000000..1ca3a3f --- /dev/null +++ b/__tests__/resolution.test.ts @@ -0,0 +1,849 @@ +/** + * Resolution Module Tests + * + * Tests for Phase 3: Reference Resolution + */ + +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'; +import { Node, UnresolvedReference } from '../src/types'; +import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution'; +import { matchReference } from '../src/resolution/name-matcher'; +import { resolveImportPath, extractImportMappings } from '../src/resolution/import-resolver'; +import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks'; +import { QueryBuilder } from '../src/db/queries'; +import { DatabaseConnection } from '../src/db'; + +describe('Resolution Module', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(() => { + // Create temp directory + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-resolution-test-')); + }); + + afterEach(() => { + // Clean up + if (cg) { + cg.destroy(); + } else if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } + }); + + describe('Name Matcher', () => { + it('should match exact name references', () => { + // Create a mock context + const mockNodes: Node[] = [ + { + id: 'func:test.ts:myFunction:10', + kind: 'function', + name: 'myFunction', + qualifiedName: 'test.ts::myFunction', + filePath: 'test.ts', + language: 'typescript', + startLine: 10, + endLine: 20, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }, + ]; + + const context: ResolutionContext = { + getNodesInFile: () => mockNodes, + getNodesByName: (name) => mockNodes.filter((n) => n.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => ['test.ts'], + }; + + const ref = { + fromNodeId: 'caller:main.ts:caller:5', + referenceName: 'myFunction', + referenceKind: 'calls' as const, + line: 5, + column: 10, + filePath: 'main.ts', + language: 'typescript' as const, + }; + + const result = matchReference(ref, context); + + expect(result).not.toBeNull(); + expect(result?.targetNodeId).toBe('func:test.ts:myFunction:10'); + expect(result?.resolvedBy).toBe('exact-match'); + }); + + it('should prefer same-module candidates over cross-module matches', () => { + // Simulates a Python monorepo where multiple apps define navigate() + const candidateA: Node = { + id: 'func:apps/app_a/src/server.py:navigate:10', + kind: 'function', + name: 'navigate', + qualifiedName: 'apps/app_a/src/server.py::navigate', + filePath: 'apps/app_a/src/server.py', + language: 'python', + startLine: 10, + endLine: 20, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + + const candidateB: Node = { + id: 'func:apps/app_b/src/server.py:navigate:15', + kind: 'function', + name: 'navigate', + qualifiedName: 'apps/app_b/src/server.py::navigate', + filePath: 'apps/app_b/src/server.py', + language: 'python', + startLine: 15, + endLine: 25, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: (name) => name === 'navigate' ? [candidateA, candidateB] : [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + // Reference from app_a should resolve to app_a's navigate, not app_b's + const ref = { + fromNodeId: 'func:apps/app_a/src/handler.py:handler:5', + referenceName: 'navigate', + referenceKind: 'calls' as const, + line: 5, + column: 10, + filePath: 'apps/app_a/src/handler.py', + language: 'python' as const, + }; + + const result = matchReference(ref, context); + + expect(result).not.toBeNull(); + expect(result?.targetNodeId).toBe('func:apps/app_a/src/server.py:navigate:10'); + expect(result?.resolvedBy).toBe('exact-match'); + }); + + it('should lower confidence for cross-module exact matches', () => { + // Only one candidate but in a completely different module + const candidates: Node[] = [ + { + id: 'func:apps/app_b/src/server.py:navigate:10', + kind: 'function', + name: 'navigate', + qualifiedName: 'apps/app_b/src/server.py::navigate', + filePath: 'apps/app_b/src/server.py', + language: 'python', + startLine: 10, + endLine: 20, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }, + { + id: 'func:apps/app_c/src/server.py:navigate:10', + kind: 'function', + name: 'navigate', + qualifiedName: 'apps/app_c/src/server.py::navigate', + filePath: 'apps/app_c/src/server.py', + language: 'python', + startLine: 10, + endLine: 20, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }, + ]; + + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: (name) => name === 'navigate' ? candidates : [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + // Reference from app_a — neither candidate is in the same module + const ref = { + fromNodeId: 'func:apps/app_a/src/handler.py:handler:5', + referenceName: 'navigate', + referenceKind: 'calls' as const, + line: 5, + column: 10, + filePath: 'apps/app_a/src/handler.py', + language: 'python' as const, + }; + + const result = matchReference(ref, context); + + // Should still resolve but with low confidence + expect(result).not.toBeNull(); + expect(result?.confidence).toBeLessThanOrEqual(0.4); + }); + + it('should match qualified name references', () => { + const mockClassNode: Node = { + id: 'class:user.ts:User:5', + kind: 'class', + name: 'User', + qualifiedName: 'user.ts::User', + filePath: 'user.ts', + language: 'typescript', + startLine: 5, + endLine: 30, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + + const mockMethodNode: Node = { + id: 'method:user.ts:User.save:15', + kind: 'method', + name: 'save', + qualifiedName: 'user.ts::User::save', + filePath: 'user.ts', + language: 'typescript', + startLine: 15, + endLine: 25, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + + const context: ResolutionContext = { + getNodesInFile: (fp) => fp === 'user.ts' ? [mockClassNode, mockMethodNode] : [], + getNodesByName: (name) => { + if (name === 'User') return [mockClassNode]; + if (name === 'save') return [mockMethodNode]; + return []; + }, + getNodesByQualifiedName: (qn) => { + if (qn === 'user.ts::User::save') return [mockMethodNode]; + return []; + }, + getNodesByKind: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => ['user.ts'], + }; + + const ref = { + fromNodeId: 'caller:main.ts:main:5', + referenceName: 'User.save', + referenceKind: 'calls' as const, + line: 5, + column: 10, + filePath: 'main.ts', + language: 'typescript' as const, + }; + + const result = matchReference(ref, context); + + expect(result).not.toBeNull(); + expect(result?.targetNodeId).toBe('method:user.ts:User.save:15'); + }); + }); + + describe('Import Resolver', () => { + it('should resolve relative import paths', () => { + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p) => p === 'src/components/utils.ts' || p === 'src/components/utils/index.ts', + readFile: () => null, + getProjectRoot: () => '', + getAllFiles: () => ['src/components/utils.ts', 'src/components/utils/index.ts'], + }; + + const result = resolveImportPath( + './utils', + 'src/components/Button.ts', + 'typescript', + context + ); + + expect(result).toBe('src/components/utils.ts'); + }); + + it('should resolve parent directory imports', () => { + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p) => p === 'src/helpers.ts' || p === 'src/helpers/index.ts', + readFile: () => null, + getProjectRoot: () => '', + getAllFiles: () => ['src/helpers.ts', 'src/helpers/index.ts'], + }; + + const result = resolveImportPath( + '../helpers', + 'src/components/Button.ts', + 'typescript', + context + ); + + expect(result).toBe('src/helpers.ts'); + }); + + it('should extract JS/TS import mappings', () => { + const content = ` +import { foo } from './foo'; +import bar from '../bar'; +import * as utils from './utils'; +import { baz, qux } from './baz'; +`; + + const mappings = extractImportMappings( + 'src/index.ts', + content, + 'typescript' + ); + + expect(mappings.length).toBeGreaterThan(0); + expect(mappings.some((m) => m.localName === 'foo')).toBe(true); + expect(mappings.some((m) => m.localName === 'bar')).toBe(true); + }); + + it('should extract Python import mappings', () => { + const content = ` +from utils import helper +from .models import User +import os +from ..services import auth_service +`; + + const mappings = extractImportMappings( + 'src/main.py', + content, + 'python' + ); + + expect(mappings.length).toBeGreaterThan(0); + expect(mappings.some((m) => m.localName === 'helper')).toBe(true); + expect(mappings.some((m) => m.localName === 'User')).toBe(true); + }); + }); + + describe('Framework Detection', () => { + it('should detect React framework', () => { + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: (p) => { + if (p === 'package.json') { + return JSON.stringify({ + dependencies: { react: '^18.0.0' }, + }); + } + return null; + }, + getProjectRoot: () => '/test', + getAllFiles: () => ['package.json', 'src/App.tsx'], + }; + + const frameworks = detectFrameworks(context); + expect(frameworks.some((f) => f.name === 'react')).toBe(true); + }); + + it('should detect Express framework', () => { + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: (p) => { + if (p === 'package.json') { + return JSON.stringify({ + dependencies: { express: '^4.18.0' }, + }); + } + return null; + }, + getProjectRoot: () => '/test', + getAllFiles: () => ['package.json', 'src/app.js'], + }; + + const frameworks = detectFrameworks(context); + expect(frameworks.some((f) => f.name === 'express')).toBe(true); + }); + + it('should detect Laravel framework', () => { + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p) => p === 'artisan', + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => ['artisan', 'app/Http/Kernel.php'], + }; + + const frameworks = detectFrameworks(context); + expect(frameworks.some((f) => f.name === 'laravel')).toBe(true); + }); + + it('should return all framework resolvers', () => { + const resolvers = getAllFrameworkResolvers(); + expect(resolvers.length).toBeGreaterThan(0); + expect(resolvers.some((r) => r.name === 'react')).toBe(true); + expect(resolvers.some((r) => r.name === 'express')).toBe(true); + expect(resolvers.some((r) => r.name === 'laravel')).toBe(true); + }); + }); + + describe('React Framework Resolver', () => { + it('should resolve React component references', () => { + const mockNodes: Node[] = [ + { + id: 'component:src/Button.tsx:Button:5', + kind: 'component', + name: 'Button', + qualifiedName: 'src/Button.tsx::Button', + filePath: 'src/Button.tsx', + language: 'tsx', + startLine: 5, + endLine: 20, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }, + ]; + + const context: ResolutionContext = { + getNodesInFile: (fp) => (fp === 'src/Button.tsx' ? mockNodes : []), + getNodesByName: () => mockNodes, + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: (p) => { + if (p === 'package.json') { + return JSON.stringify({ dependencies: { react: '^18.0.0' } }); + } + return null; + }, + getProjectRoot: () => '/test', + getAllFiles: () => ['package.json', 'src/Button.tsx', 'src/App.tsx'], + }; + + const frameworks = detectFrameworks(context); + const reactResolver = frameworks.find((f) => f.name === 'react'); + expect(reactResolver).toBeDefined(); + + const ref = { + fromNodeId: 'component:src/App.tsx:App:1', + referenceName: 'Button', + referenceKind: 'renders' as const, + line: 10, + column: 5, + filePath: 'src/App.tsx', + language: 'typescript' as const, + }; + + const result = reactResolver!.resolve(ref, context); + expect(result).not.toBeNull(); + expect(result?.targetNodeId).toBe('component:src/Button.tsx:Button:5'); + }); + + it('should resolve custom hook references', () => { + const mockNodes: Node[] = [ + { + id: 'hook:src/hooks/useAuth.ts:useAuth:1', + kind: 'function', + name: 'useAuth', + qualifiedName: 'src/hooks/useAuth.ts::useAuth', + filePath: 'src/hooks/useAuth.ts', + language: 'typescript', + startLine: 1, + endLine: 20, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }, + ]; + + const context: ResolutionContext = { + getNodesInFile: (fp) => (fp.includes('useAuth') ? mockNodes : []), + getNodesByName: () => mockNodes, + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: (p) => { + if (p === 'package.json') { + return JSON.stringify({ dependencies: { react: '^18.0.0' } }); + } + return null; + }, + getProjectRoot: () => '/test', + getAllFiles: () => ['package.json', 'src/hooks/useAuth.ts'], + }; + + const frameworks = detectFrameworks(context); + const reactResolver = frameworks.find((f) => f.name === 'react'); + + const ref = { + fromNodeId: 'component:src/App.tsx:App:1', + referenceName: 'useAuth', + referenceKind: 'calls' as const, + line: 5, + column: 10, + filePath: 'src/App.tsx', + language: 'typescript' as const, + }; + + const result = reactResolver!.resolve(ref, context); + expect(result).not.toBeNull(); + expect(result?.targetNodeId).toBe('hook:src/hooks/useAuth.ts:useAuth:1'); + }); + }); + + describe('Integration Tests', () => { + it('should create resolver from CodeGraph instance', async () => { + // Create a simple TypeScript project + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', dependencies: { react: '^18.0.0' } }) + ); + + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + + // Create utility file + fs.writeFileSync( + path.join(srcDir, 'utils.ts'), + `export function formatDate(date: Date): string { + return date.toISOString(); +} + +export function parseDate(str: string): Date { + return new Date(str); +}` + ); + + // Create main file that uses utils + fs.writeFileSync( + path.join(srcDir, 'main.ts'), + `import { formatDate, parseDate } from './utils'; + +function processDate(input: string): string { + const date = parseDate(input); + return formatDate(date); +}` + ); + + // Initialize and index + cg = await CodeGraph.init(tempDir, { index: true }); + + // Check that resolver detected React framework + const frameworks = cg.getDetectedFrameworks(); + expect(frameworks).toContain('react'); + + // Get stats to verify indexing worked + const stats = cg.getStats(); + expect(stats.fileCount).toBe(2); + expect(stats.nodeCount).toBeGreaterThan(0); + }); + + it('should resolve references after indexing', async () => { + // Create a project with references + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + fs.writeFileSync( + path.join(srcDir, 'helper.ts'), + `export function helperFunction(): void { + console.log('helper'); +}` + ); + + fs.writeFileSync( + path.join(srcDir, 'main.ts'), + `import { helperFunction } from './helper'; + +function main(): void { + helperFunction(); +}` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + // Run reference resolution + const result = cg.resolveReferences(); + + // Should have attempted resolution + expect(result.stats.total).toBeGreaterThanOrEqual(0); + }); + + it('promotes calls→instantiates when target resolves to a class (Python)', async () => { + // Python has no `new` keyword — `Foo()` is the standard + // instantiation syntax. Extraction can't tell that apart from + // a function call without symbol info, so it emits a `calls` + // ref. Resolution promotes it to `instantiates` once the + // target is known to be a class. + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + fs.writeFileSync( + path.join(srcDir, 'app.py'), + `class UserService: + def __init__(self): + self.db = None + +def bootstrap(): + return UserService() +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const bootstrap = cg + .getNodesByKind('function') + .find((n) => n.name === 'bootstrap'); + expect(bootstrap).toBeDefined(); + + const outgoing = cg.getOutgoingEdges(bootstrap!.id); + const instantiates = outgoing.find((e) => e.kind === 'instantiates'); + expect(instantiates).toBeDefined(); + // Same edge must NOT also appear as a `calls` edge — promotion + // replaces the kind, doesn't duplicate. + const callsToUserService = outgoing.filter( + (e) => e.kind === 'calls' && e.target === instantiates!.target + ); + expect(callsToUserService).toHaveLength(0); + }); + }); + + describe('Name Matcher: kind bias for new ref kinds', () => { + const baseContext = (candidates: Node[]): ResolutionContext => ({ + getNodesInFile: () => [], + getNodesByName: (name) => candidates.filter((c) => c.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }); + + it('prefers a class candidate over a function for `instantiates` refs', () => { + // A class and a function share a name across the codebase. + // Without the kind bias, the function (which gets the +25 `calls` + // bonus historically applied to all candidates of that kind) would + // win. Now the instantiates branch reverses it. + const fn: Node = { + id: 'func:utils.ts:Logger:5', kind: 'function', name: 'Logger', + qualifiedName: 'utils.ts::Logger', filePath: 'utils.ts', language: 'typescript', + startLine: 5, endLine: 7, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const cls: Node = { + id: 'class:logger.ts:Logger:10', kind: 'class', name: 'Logger', + qualifiedName: 'logger.ts::Logger', filePath: 'logger.ts', language: 'typescript', + startLine: 10, endLine: 30, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + + const ref = { + fromNodeId: 'func:main.ts:bootstrap:1', + referenceName: 'Logger', + referenceKind: 'instantiates' as const, + line: 5, column: 0, filePath: 'main.ts', language: 'typescript' as const, + }; + + const result = matchReference(ref, baseContext([fn, cls])); + expect(result?.targetNodeId).toBe('class:logger.ts:Logger:10'); + }); + + it('prefers a function candidate over a non-function for `decorates` refs', () => { + const variable: Node = { + id: 'var:config.ts:Inject:5', kind: 'variable', name: 'Inject', + qualifiedName: 'config.ts::Inject', filePath: 'config.ts', language: 'typescript', + startLine: 5, endLine: 5, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const decorator: Node = { + id: 'func:di.ts:Inject:10', kind: 'function', name: 'Inject', + qualifiedName: 'di.ts::Inject', filePath: 'di.ts', language: 'typescript', + startLine: 10, endLine: 20, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + + const ref = { + fromNodeId: 'class:svc.ts:UserService:1', + referenceName: 'Inject', + referenceKind: 'decorates' as const, + line: 5, column: 0, filePath: 'svc.ts', language: 'typescript' as const, + }; + + const result = matchReference(ref, baseContext([variable, decorator])); + expect(result?.targetNodeId).toBe('func:di.ts:Inject:10'); + }); + }); + + describe('tsconfig path aliases', () => { + it('resolves an aliased import to the alias-mapped file (not a same-named file elsewhere)', async () => { + // Two same-named exports in different directories. Without alias + // resolution, name-matcher would pick whichever it finds first; + // with alias resolution, the import path uniquely picks one. + fs.mkdirSync(path.join(tempDir, 'src/utils'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'src/legacy'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/utils/format.ts'), + `export function pickMe(): number { return 1; }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/legacy/format.ts'), + `export function pickMe(): number { return 99; }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main.ts'), + `import { pickMe } from '@utils/format';\nexport function go(): number { return pickMe(); }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + baseUrl: './src', + paths: { '@utils/*': ['utils/*'] }, + }, + }) + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + // The two pickMe nodes live in different files. The aliased + // import should attach the call edge to the @utils-mapped one, + // not the legacy duplicate. + const all = cg.getNodesByKind('function').filter((n) => n.name === 'pickMe'); + const utilsNode = all.find((n) => n.filePath === 'src/utils/format.ts'); + const legacyNode = all.find((n) => n.filePath === 'src/legacy/format.ts'); + expect(utilsNode).toBeDefined(); + expect(legacyNode).toBeDefined(); + + const utilsCallers = cg.getCallers(utilsNode!.id); + const legacyCallers = cg.getCallers(legacyNode!.id); + expect(utilsCallers.length).toBeGreaterThan(0); + expect(utilsCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); + // The legacy node should NOT have a caller from src/main.ts — + // the alias correctly picked the utils version. + expect(legacyCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(false); + }); + + it('falls back gracefully when tsconfig is absent', async () => { + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/a.ts'), + `export function aFn(): void {}\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/b.ts'), + `import { aFn } from './a';\nexport function bFn(): void { aFn(); }\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + // No tsconfig present — index should still complete and the + // relative-import-based call edge should be created. + const aFn = cg.getNodesByKind('function').find((n) => n.name === 'aFn'); + expect(aFn).toBeDefined(); + const callers = cg.getCallers(aFn!.id); + expect(callers.some((c) => c.node.filePath === 'src/b.ts')).toBe(true); + }); + }); + + describe('re-export chain following', () => { + it('chases a 3-hop barrel chain (wildcard → named → declaration)', async () => { + // main.ts → all.ts (wildcard) → index.ts (named) → auth.ts (declaration). + // Without chain following, `signIn` resolves to nothing because + // none of the barrel files declare it directly. + fs.mkdirSync(path.join(tempDir, 'src/services'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/services/auth.ts'), + `export function signIn(): void {}\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/services/index.ts'), + `export { signIn } from './auth';\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/all.ts'), + `export * from './services/index';\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main.ts'), + `import { signIn } from './all';\nexport function go(): void { signIn(); }\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const signInNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'signIn' && n.filePath === 'src/services/auth.ts'); + expect(signInNode).toBeDefined(); + const callers = cg.getCallers(signInNode!.id); + expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); + }); + + it('follows a renamed named re-export (export { foo as bar } from ...)', async () => { + // The chase has to look up `foo` in the upstream module even + // though the importer asked for `bar` — exercises the rename + // branch of findExportedSymbol. + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/auth.ts'), + `export function signIn(): void {}\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/index.ts'), + `export { signIn as login } from './auth';\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main.ts'), + `import { login } from './index';\nexport function go(): void { login(); }\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const signInNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'signIn' && n.filePath === 'src/auth.ts'); + expect(signInNode).toBeDefined(); + const callers = cg.getCallers(signInNode!.id); + expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); + }); + }); +}); diff --git a/__tests__/search-query-parser.test.ts b/__tests__/search-query-parser.test.ts new file mode 100644 index 0000000..8a7767d --- /dev/null +++ b/__tests__/search-query-parser.test.ts @@ -0,0 +1,142 @@ +/** + * Unit tests for the field-qualified query parser and bounded + * edit distance — the two algorithms behind `kind:`/`lang:`/`path:`/ + * `name:` filtering and the fuzzy typo fallback. + */ + +import { describe, it, expect } from 'vitest'; +import { parseQuery, boundedEditDistance } from '../src/search/query-parser'; + +describe('parseQuery', () => { + it('returns plain text for a query with no field prefixes', () => { + const r = parseQuery('authenticate user'); + expect(r.text).toBe('authenticate user'); + expect(r.kinds).toEqual([]); + expect(r.languages).toEqual([]); + expect(r.pathFilters).toEqual([]); + expect(r.nameFilters).toEqual([]); + }); + + it('extracts kind: filter and removes it from text', () => { + const r = parseQuery('kind:function auth'); + expect(r.kinds).toEqual(['function']); + expect(r.text).toBe('auth'); + }); + + it('extracts lang: and language: as the same filter family', () => { + const a = parseQuery('lang:typescript foo'); + const b = parseQuery('language:typescript foo'); + expect(a.languages).toEqual(['typescript']); + expect(b.languages).toEqual(['typescript']); + }); + + it('handles multiple kind: filters as an OR set', () => { + const r = parseQuery('kind:function kind:method auth'); + expect(r.kinds.sort()).toEqual(['function', 'method']); + }); + + it('extracts path: and name: as substring filters (kept verbatim)', () => { + const r = parseQuery('path:src/api name:Handler'); + expect(r.pathFilters).toEqual(['src/api']); + expect(r.nameFilters).toEqual(['Handler']); + }); + + it('preserves quoted spans as a single token (whitespace in path:)', () => { + const r = parseQuery('path:"my dir/file" foo'); + expect(r.pathFilters).toEqual(['my dir/file']); + expect(r.text).toBe('foo'); + }); + + it('passes URL-like tokens through to text (does not match http: as a field)', () => { + const r = parseQuery('http://example.com'); + expect(r.text).toBe('http://example.com'); + expect(r.kinds).toEqual([]); + }); + + it('passes empty-value tokens through as text (kind: → "kind:")', () => { + const r = parseQuery('kind: foo'); + expect(r.kinds).toEqual([]); + // The trailing-colon token comes back as plain text + expect(r.text.includes('kind:')).toBe(true); + }); + + it('passes unknown field prefixes through as text (TODO: keeps the colon)', () => { + const r = parseQuery('TODO: needs review'); + expect(r.text).toBe('TODO: needs review'); + expect(r.kinds).toEqual([]); + }); + + it('rejects unknown values for kind: (passes the whole token to text)', () => { + const r = parseQuery('kind:invalid foo'); + // Invalid kind value falls back to text + expect(r.kinds).toEqual([]); + expect(r.text).toContain('kind:invalid'); + }); + + it('handles all-filters-no-text query', () => { + const r = parseQuery('kind:function lang:typescript'); + expect(r.kinds).toEqual(['function']); + expect(r.languages).toEqual(['typescript']); + expect(r.text).toBe(''); + }); + + it('survives empty input', () => { + const r = parseQuery(''); + expect(r.text).toBe(''); + expect(r.kinds).toEqual([]); + }); + + it('survives a very long input (no allocation explosion)', () => { + const huge = 'foo '.repeat(5000); // 20k chars + const r = parseQuery(huge); + expect(r.text.length).toBeGreaterThan(0); + }); +}); + +describe('boundedEditDistance', () => { + it('returns 0 for identical strings', () => { + expect(boundedEditDistance('user', 'user', 2)).toBe(0); + }); + + it('returns 1 for a single substitution', () => { + expect(boundedEditDistance('user', 'usar', 2)).toBe(1); + }); + + it('returns 1 for a single insertion', () => { + expect(boundedEditDistance('user', 'users', 2)).toBe(1); + }); + + it('returns 1 for a single deletion', () => { + expect(boundedEditDistance('users', 'user', 2)).toBe(1); + }); + + it('returns 2 for a transposition (two edits in basic Levenshtein)', () => { + // 'aple' vs 'palp' would be 2; pick a clearer pair. + // 'foo' vs 'fou': substitution + insertion = 2 if different lengths. + expect(boundedEditDistance('confg', 'configX', 2)).toBe(2); + }); + + it('returns maxDist+1 when distance clearly exceeds budget', () => { + expect(boundedEditDistance('foo', 'completely-different', 2)).toBe(3); + }); + + it('respects length-difference shortcut', () => { + // |len(a) - len(b)| > maxDist must immediately be over budget + expect(boundedEditDistance('a', 'aaaaaaa', 2)).toBe(3); + }); + + it('handles empty inputs', () => { + expect(boundedEditDistance('', '', 2)).toBe(0); + expect(boundedEditDistance('a', '', 2)).toBe(1); + expect(boundedEditDistance('', 'abc', 2)).toBe(3); + }); + + it('is case-sensitive — caller must lowercase if case-insensitive match wanted', () => { + expect(boundedEditDistance('Foo', 'foo', 2)).toBe(1); + }); + + it('early-exits when row min exceeds budget (correctness, not just perf)', () => { + // 'aaaaa' vs 'bbbbb': distance is 5, well over budget 2 + expect(boundedEditDistance('aaaaa', 'bbbbb', 2)).toBe(3); + }); +}); diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts new file mode 100644 index 0000000..75ac843 --- /dev/null +++ b/__tests__/security.test.ts @@ -0,0 +1,644 @@ +/** + * Security Tests + * + * Tests for P0/P1 security fixes: + * - FileLock (cross-process locking) + * - Path traversal prevention + * - MCP input validation + * - Atomic writes + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { FileLock, validateProjectPath } from '../src/utils'; +import CodeGraph from '../src/index'; +import { ToolHandler, tools } from '../src/mcp/tools'; +import { scanDirectory, isSourceFile } from '../src/extraction'; +import { DatabaseConnection, getDatabasePath } from '../src/db'; +import { QueryBuilder } from '../src/db/queries'; + +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-security-test-')); +} + +function cleanupTempDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe('FileLock', () => { + let tempDir: string; + let lockPath: string; + + beforeEach(() => { + tempDir = createTempDir(); + lockPath = path.join(tempDir, 'test.lock'); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should acquire and release a lock', () => { + const lock = new FileLock(lockPath); + lock.acquire(); + + expect(fs.existsSync(lockPath)).toBe(true); + const content = fs.readFileSync(lockPath, 'utf-8').trim(); + expect(parseInt(content, 10)).toBe(process.pid); + + lock.release(); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('should prevent double acquisition within same process', () => { + const lock1 = new FileLock(lockPath); + const lock2 = new FileLock(lockPath); + + lock1.acquire(); + + // Second lock should fail because our PID is alive + expect(() => lock2.acquire()).toThrow(/locked by another process/); + + lock1.release(); + }); + + it('should detect and remove stale locks from dead processes', () => { + // Write a lock file with a PID that doesn't exist + // PID 99999999 is extremely unlikely to be a real process + fs.writeFileSync(lockPath, '99999999'); + + const lock = new FileLock(lockPath); + // Should succeed because the PID is dead + expect(() => lock.acquire()).not.toThrow(); + + lock.release(); + }); + + it('should execute function with withLock', () => { + const lock = new FileLock(lockPath); + + const result = lock.withLock(() => { + expect(fs.existsSync(lockPath)).toBe(true); + return 42; + }); + + expect(result).toBe(42); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('should release lock even if function throws', () => { + const lock = new FileLock(lockPath); + + expect(() => { + lock.withLock(() => { + throw new Error('test error'); + }); + }).toThrow('test error'); + + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('should execute async function with withLockAsync', async () => { + const lock = new FileLock(lockPath); + + const result = await lock.withLockAsync(async () => { + expect(fs.existsSync(lockPath)).toBe(true); + return 'async-result'; + }); + + expect(result).toBe('async-result'); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('should release lock even if async function throws', async () => { + const lock = new FileLock(lockPath); + + await expect( + lock.withLockAsync(async () => { + throw new Error('async error'); + }) + ).rejects.toThrow('async error'); + + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('release should be idempotent', () => { + const lock = new FileLock(lockPath); + lock.acquire(); + lock.release(); + // Second release should not throw + expect(() => lock.release()).not.toThrow(); + }); +}); + +describe('Path Traversal Prevention', () => { + let testDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + testDir = createTempDir(); + + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir); + + fs.writeFileSync( + path.join(srcDir, 'hello.ts'), + `export function hello(): string { return "hi"; }\n` + ); + + cg = CodeGraph.initSync(testDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + }); + + afterEach(() => { + if (cg) cg.close(); + cleanupTempDir(testDir); + }); + + it('should read code for valid nodes within project', async () => { + const nodes = cg.getNodesByKind('function'); + const hello = nodes.find((n) => n.name === 'hello'); + expect(hello).toBeDefined(); + + const code = await cg.getCode(hello!.id); + expect(code).toContain('hello'); + }); + + it('should return null for non-existent node', async () => { + const code = await cg.getCode('does-not-exist'); + expect(code).toBeNull(); + }); +}); + +describe('validateProjectPath — sensitive directory blocking', () => { + // POSIX-only: on Windows '/etc' resolves to C:\etc (non-existent), not a + // sensitive dir — the Windows case is covered by the win32-gated test below. + it.runIf(process.platform !== 'win32')('blocks POSIX system directories (exact match)', () => { + expect(validateProjectPath('/')).toMatch(/sensitive system directory/i); + expect(validateProjectPath('/etc')).toMatch(/sensitive system directory/i); + }); + + it('allows a normal, existing directory', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-validate-')); + try { + expect(validateProjectPath(dir)).toBeNull(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + // SENSITIVE_PATHS stores the Windows entries lowercase and validateProjectPath + // matches via resolved.toLowerCase(), so 'C:\\Windows' and 'c:\\windows' are + // both blocked. path.resolve is platform-specific, so this only runs on Windows. + it.runIf(process.platform === 'win32')( + 'blocks Windows system directories regardless of case', + () => { + expect(validateProjectPath('C:\\Windows')).toMatch(/sensitive system directory/i); + expect(validateProjectPath('c:\\windows')).toMatch(/sensitive system directory/i); + expect(validateProjectPath('C:\\WINDOWS\\System32')).toMatch(/sensitive system directory/i); + } + ); +}); + +describe('MCP Input Validation', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + testDir = createTempDir(); + + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir); + + fs.writeFileSync( + path.join(srcDir, 'example.ts'), + `export function exampleFunc(): void {}\nexport class ExampleClass {}\n` + ); + + cg = CodeGraph.initSync(testDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterEach(() => { + if (cg) cg.close(); + cleanupTempDir(testDir); + }); + + it('should reject non-string query in codegraph_search', async () => { + const result = await handler.execute('codegraph_search', { query: null }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('non-empty string'); + }); + + it('should reject empty string query in codegraph_search', async () => { + const result = await handler.execute('codegraph_search', { query: '' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('non-empty string'); + }); + + it('should accept valid query in codegraph_search', async () => { + const result = await handler.execute('codegraph_search', { query: 'example' }); + expect(result.isError).toBeFalsy(); + }); + + it('should clamp limit to valid range in codegraph_search', async () => { + // Extremely large limit should still work (clamped to 100) + const result = await handler.execute('codegraph_search', { query: 'example', limit: 999999 }); + expect(result.isError).toBeFalsy(); + }); + + it('should reject non-string symbol in codegraph_callers', async () => { + const result = await handler.execute('codegraph_callers', { symbol: 123 }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('non-empty string'); + }); + + it('should reject non-string task in codegraph_context', async () => { + const result = await handler.execute('codegraph_context', { task: undefined }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('non-empty string'); + }); + + it('should truncate oversized codegraph_context output', async () => { + const oversizedContext = Array.from({ length: 400 }, (_, i) => `line-${i} ${'x'.repeat(80)}`).join('\n'); + const fakeCg = { + buildContext: async () => oversizedContext, + }; + const fakeHandler = new ToolHandler(fakeCg as unknown as CodeGraph); + + const result = await fakeHandler.execute('codegraph_context', { task: 'find example' }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text.length).toBeLessThan(oversizedContext.length); + expect(result.content[0].text).toContain('... (output truncated)'); + }); + + it('should reject non-string symbol in codegraph_impact', async () => { + const result = await handler.execute('codegraph_impact', { symbol: [] }); + expect(result.isError).toBe(true); + }); + + it('should reject non-string symbol in codegraph_node', async () => { + const result = await handler.execute('codegraph_node', { symbol: false }); + expect(result.isError).toBe(true); + }); + + it('should reject non-string symbol in codegraph_callees', async () => { + const result = await handler.execute('codegraph_callees', { symbol: {} }); + expect(result.isError).toBe(true); + }); + + it('should handle NaN limit gracefully', async () => { + const result = await handler.execute('codegraph_search', { query: 'example', limit: 'abc' }); + expect(result.isError).toBeFalsy(); + }); + + it('should handle negative limit gracefully', async () => { + const result = await handler.execute('codegraph_search', { query: 'example', limit: -5 }); + expect(result.isError).toBeFalsy(); + }); + + // #230: getCodeGraph must reject a sensitive system directory passed as + // projectPath before opening it. The error surfaces through execute()'s + // catch as an isError result. /etc is sensitive on POSIX; C:\Windows on + // Windows (path.resolve is platform-specific, so each case is gated). + it.runIf(process.platform !== 'win32')( + 'rejects a sensitive POSIX projectPath (/etc) via the MCP handler', + async () => { + const result = await handler.execute('codegraph_search', { + query: 'example', + projectPath: '/etc', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/sensitive system directory/i); + } + ); + + it.runIf(process.platform === 'win32')( + 'rejects a sensitive Windows projectPath (C:\\Windows) via the MCP handler', + async () => { + const result = await handler.execute('codegraph_search', { + query: 'example', + projectPath: 'C:\\Windows', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/sensitive system directory/i); + } + ); +}); + +describe('Atomic Writes', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should not leave temp files on success', () => { + // We test this indirectly through the config-writer module + // by checking that no .tmp files remain after writing + const configDir = path.join(tempDir, '.claude'); + fs.mkdirSync(configDir, { recursive: true }); + + const testFile = path.join(configDir, 'test.json'); + // Simulate what atomicWriteFileSync does + const tmpPath = testFile + '.tmp.' + process.pid; + fs.writeFileSync(tmpPath, '{"test": true}'); + fs.renameSync(tmpPath, testFile); + + expect(fs.existsSync(testFile)).toBe(true); + expect(fs.existsSync(tmpPath)).toBe(false); + + const content = JSON.parse(fs.readFileSync(testFile, 'utf-8')); + expect(content.test).toBe(true); + }); +}); + +describe('Source file detection (isSourceFile)', () => { + it('selects files by supported extension', () => { + expect(isSourceFile('src/index.ts')).toBe(true); + expect(isSourceFile('src/deep/nested/file.ts')).toBe(true); + expect(isSourceFile('src/component.tsx')).toBe(true); + expect(isSourceFile('lib/util.js')).toBe(true); + expect(isSourceFile('src/main.py')).toBe(true); + }); + + it('rejects unsupported extensions and extensionless files', () => { + expect(isSourceFile('src/component.css')).toBe(false); + expect(isSourceFile('README.md')).toBe(false); + expect(isSourceFile('Makefile')).toBe(false); + expect(isSourceFile('.gitignore')).toBe(false); + }); + + it('matches regardless of leading dot directories', () => { + expect(isSourceFile('.hidden/index.ts')).toBe(true); + }); +}); + +describe('JSON.parse Error Boundaries in DB', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should not crash when node has malformed JSON in decorators column', () => { + const dbPath = path.join(tempDir, 'test.db'); + const db = DatabaseConnection.initialize(dbPath); + const queries = new QueryBuilder(db.getDb()); + + // Insert a node with malformed JSON in the decorators column + db.getDb().prepare(` + INSERT INTO nodes (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, decorators, is_exported, is_async, is_static, is_abstract, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + 'test-node-1', 'function', 'myFunc', 'myFunc', 'test.ts', 'typescript', + 1, 5, 0, 0, + '{not valid json!!!}', // malformed decorators + 0, 0, 0, 0, Date.now() + ); + + // Should not throw - should return node with undefined decorators + const node = queries.getNodeById('test-node-1'); + expect(node).not.toBeNull(); + expect(node!.name).toBe('myFunc'); + expect(node!.decorators).toBeUndefined(); + + db.close(); + }); + + it('should not crash when edge has malformed JSON in metadata column', () => { + const dbPath = path.join(tempDir, 'test.db'); + const db = DatabaseConnection.initialize(dbPath); + const queries = new QueryBuilder(db.getDb()); + + // Insert two nodes first + const insertNode = db.getDb().prepare(` + INSERT INTO nodes (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, is_exported, is_async, is_static, is_abstract, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + insertNode.run('node-a', 'function', 'funcA', 'funcA', 'a.ts', 'typescript', 1, 5, 0, 0, 0, 0, 0, 0, Date.now()); + insertNode.run('node-b', 'function', 'funcB', 'funcB', 'b.ts', 'typescript', 1, 5, 0, 0, 0, 0, 0, 0, Date.now()); + + // Insert edge with malformed metadata + db.getDb().prepare(` + INSERT INTO edges (source, target, kind, metadata) + VALUES (?, ?, ?, ?) + `).run('node-a', 'node-b', 'calls', 'broken json {{{'); + + // Should not throw - should return edge with undefined metadata + const edges = queries.getOutgoingEdges('node-a'); + expect(edges.length).toBe(1); + expect(edges[0].source).toBe('node-a'); + expect(edges[0].target).toBe('node-b'); + expect(edges[0].metadata).toBeUndefined(); + + db.close(); + }); + + it('should not crash when file record has malformed JSON in errors column', () => { + const dbPath = path.join(tempDir, 'test.db'); + const db = DatabaseConnection.initialize(dbPath); + const queries = new QueryBuilder(db.getDb()); + + // Insert a file with malformed errors JSON + db.getDb().prepare(` + INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run('test.ts', 'abc123', 'typescript', 100, Date.now(), Date.now(), 5, 'not-an-array'); + + // Should not throw - should return file with undefined errors + const file = queries.getFileByPath('test.ts'); + expect(file).not.toBeNull(); + expect(file!.path).toBe('test.ts'); + expect(file!.errors).toBeUndefined(); + + db.close(); + }); +}); + +describe('Symlink Cycle Detection', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should handle symlink cycle without infinite loop', () => { + // Create directory structure with a symlink cycle + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;\n'); + + // Create a symlink from src/loop -> tempDir (parent directory) + try { + fs.symlinkSync(tempDir, path.join(srcDir, 'loop'), 'dir'); + } catch { + // Skip test if symlinks not supported (e.g., Windows without admin) + return; + } + + + // This should complete without hanging + const files = scanDirectory(tempDir); + + // Should find the real file but not loop infinitely + expect(files).toContain('src/index.ts'); + // Should not find duplicates via the symlink path + const indexFiles = files.filter(f => f.endsWith('index.ts')); + expect(indexFiles.length).toBe(1); + }); + + it('should follow valid symlinks to directories', () => { + // Create source directory with a file + const realDir = path.join(tempDir, 'real'); + fs.mkdirSync(realDir); + fs.writeFileSync(path.join(realDir, 'hello.ts'), 'export function hello() {}\n'); + + // Create a symlink to realDir + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + try { + fs.symlinkSync(realDir, path.join(srcDir, 'linked'), 'dir'); + } catch { + return; + } + + + const files = scanDirectory(tempDir); + + // Should find files from both the real dir and via the symlink + // But deduplicate since they resolve to the same real path + expect(files.some(f => f.includes('hello.ts'))).toBe(true); + }); + + it('should skip broken symlinks gracefully', () => { + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync(path.join(srcDir, 'valid.ts'), 'export const y = 2;\n'); + + try { + fs.symlinkSync('/nonexistent/path', path.join(srcDir, 'broken'), 'dir'); + } catch { + return; + } + + + // Should not throw + const files = scanDirectory(tempDir); + expect(files).toContain('src/valid.ts'); + }); +}); + +describe('Session marker symlink resistance', () => { + // The marker write lives in src/mcp/tools.ts behind handleContext. We exercise + // it end-to-end via ToolHandler.execute so the test exercises the same code + // path Claude Code drives. The session id is per-test so other parallel test + // runs can't collide with the marker file we plant a symlink at. + const SESSION_ID = `cg-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const crypto = require('crypto') as typeof import('crypto'); + const hash = crypto.createHash('md5').update(SESSION_ID).digest('hex').slice(0, 16); + const markerPath = path.join(os.tmpdir(), `codegraph-consulted-${hash}`); + + let projectDir: string; + let victimDir: string; + let victimFile: string; + + beforeEach(async () => { + projectDir = createTempDir(); + victimDir = createTempDir(); + victimFile = path.join(victimDir, 'private.txt'); + fs.writeFileSync(victimFile, 'SECRET-DO-NOT-OVERWRITE\n'); + if (fs.existsSync(markerPath)) fs.unlinkSync(markerPath); + + // A real .codegraph/ has to exist for handleContext to get past the + // "not initialized" guard — index a tiny fixture so the call reaches the + // marker write step rather than short-circuiting on missing project state. + fs.writeFileSync(path.join(projectDir, 'a.ts'), 'export const x = 1;\n'); + const cg = await CodeGraph.init(projectDir); + await cg.indexAll(); + cg.close(); + }); + + afterEach(() => { + if (fs.existsSync(markerPath)) fs.unlinkSync(markerPath); + cleanupTempDir(projectDir); + cleanupTempDir(victimDir); + }); + + it('does not follow a pre-planted symlink at the marker path', async () => { + // Skip on platforms where the user can't create symlinks (Windows without + // dev mode + admin). The CWE-59 risk we're guarding against doesn't apply + // when symlinks aren't creatable, so the skip is correct, not a gap. + try { + fs.symlinkSync(victimFile, markerPath); + } catch { + return; + } + + const cg = await CodeGraph.open(projectDir); + const handler = new ToolHandler(cg); + process.env.CLAUDE_SESSION_ID = SESSION_ID; + try { + await handler.execute('codegraph_context', { task: 'find x' }); + } finally { + delete process.env.CLAUDE_SESSION_ID; + cg.close(); + } + + // The victim file's contents must be untouched — the old writeFileSync + // path would have followed the symlink and written an ISO timestamp here. + expect(fs.readFileSync(victimFile, 'utf8')).toBe('SECRET-DO-NOT-OVERWRITE\n'); + + // And the marker path itself must still be the symlink we planted — + // no fallback path that quietly unlinked + recreated it (which would + // also work, but is a behavior we don't want to silently rely on). + expect(fs.lstatSync(markerPath).isSymbolicLink()).toBe(true); + }); + + it('writes the marker file with 0o600 perms on a clean path', async () => { + // No symlink planted — happy path. Verifies the new openSync(mode: 0o600) + // call is what actually lands on disk (regression guard for the perm + // tightening that came with the O_NOFOLLOW fix). + const cg = await CodeGraph.open(projectDir); + const handler = new ToolHandler(cg); + process.env.CLAUDE_SESSION_ID = SESSION_ID; + try { + await handler.execute('codegraph_context', { task: 'find x' }); + } finally { + delete process.env.CLAUDE_SESSION_ID; + cg.close(); + } + + expect(fs.existsSync(markerPath)).toBe(true); + // chmod's low 9 bits — strip the file-type bits for a clean compare. + // Windows can't enforce 0o600 in the POSIX sense; skip the assertion + // there since the underlying OS will normalize the mode anyway. + if (process.platform !== 'win32') { + const mode = fs.statSync(markerPath).mode & 0o777; + expect(mode).toBe(0o600); + } + }); +}); diff --git a/__tests__/sqlite-backend.test.ts b/__tests__/sqlite-backend.test.ts new file mode 100644 index 0000000..0815551 --- /dev/null +++ b/__tests__/sqlite-backend.test.ts @@ -0,0 +1,44 @@ +/** + * SQLite backend reporting. + * + * node:sqlite (Node's built-in real SQLite) is the sole backend. Pin that + * DatabaseConnection / CodeGraph report it and come up in WAL. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { DatabaseConnection } from '../src/db'; +import { CodeGraph } from '../src'; + +describe('DatabaseConnection — backend reporting', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-backend-')); + }); + + afterEach(() => { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reports the node-sqlite backend in WAL for an initialized DB', () => { + const conn = DatabaseConnection.initialize(path.join(dir, 'test.db')); + expect(conn.getBackend()).toBe('node-sqlite'); + expect(conn.getJournalMode()).toBe('wal'); + conn.close(); + }); + + it('CodeGraph.getBackend() delegates to the underlying DatabaseConnection', async () => { + fs.writeFileSync(path.join(dir, 'x.ts'), `export function x(): void {}\n`); + const cg = await CodeGraph.init(dir, { index: true }); + try { + expect(cg.getBackend()).toBe('node-sqlite'); + } finally { + cg.destroy(); + } + }); +}); diff --git a/__tests__/strip-comments.test.ts b/__tests__/strip-comments.test.ts new file mode 100644 index 0000000..ef2ec05 --- /dev/null +++ b/__tests__/strip-comments.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { stripCommentsForRegex } from '../src/resolution/strip-comments'; + +describe('stripCommentsForRegex', () => { + it('python: strips line comments', () => { + const src = "x = 1 # path('/fake/', View)\nreal = 2"; + const out = stripCommentsForRegex(src, 'python'); + expect(out).not.toMatch(/path\('\/fake\//); + expect(out).toMatch(/real = 2/); + }); + + it('python: strips triple-quoted docstrings', () => { + const src = `""" +path('/in-docstring/', View) +""" +real = 1 +`; + const out = stripCommentsForRegex(src, 'python'); + expect(out).not.toMatch(/in-docstring/); + expect(out).toMatch(/real = 1/); + }); + + it('python: keeps # inside strings', () => { + const src = `path('#/fragment/', View)\n`; + const out = stripCommentsForRegex(src, 'python'); + expect(out).toContain("'#/fragment/'"); + }); + + it('python: handles triple-single-quoted docstrings', () => { + const src = `'''\npath('/fake/')\n'''\nreal = 1\n`; + const out = stripCommentsForRegex(src, 'python'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/real = 1/); + }); + + it('typescript: strips //, /* */', () => { + const src = + "// app.get('/fake', x)\n/* app.get('/also-fake', y) */\napp.get('/real', z)"; + const out = stripCommentsForRegex(src, 'typescript'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/'\/real'/); + }); + + it('typescript: keeps // inside strings', () => { + const src = `const url = "https://example.com/path";\n`; + const out = stripCommentsForRegex(src, 'typescript'); + expect(out).toContain('https://example.com/path'); + }); + + it('php: strips //, #, and /* */', () => { + const src = + "// Route::get('/a', X::class)\n# Route::get('/b', Y::class)\n/* Route::get('/c', Z::class) */\nReal::go();"; + const out = stripCommentsForRegex(src, 'php'); + expect(out).not.toMatch(/'\/a'/); + expect(out).not.toMatch(/'\/b'/); + expect(out).not.toMatch(/'\/c'/); + expect(out).toContain('Real::go();'); + }); + + it('ruby: strips =begin/=end', () => { + const src = + "=begin\nget '/fake', to: 'x#y'\n=end\nget '/real', to: 'a#b'\n"; + const out = stripCommentsForRegex(src, 'ruby'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/'\/real'/); + }); + + it('ruby: strips # comments', () => { + const src = "# get '/fake', to: 'x#y'\nget '/real', to: 'a#b'\n"; + const out = stripCommentsForRegex(src, 'ruby'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/'\/real'/); + }); + + it('rust: handles nested block comments', () => { + const src = + '/* outer /* inner */ still in outer */ .route("/real", get(h))'; + const out = stripCommentsForRegex(src, 'rust'); + expect(out).not.toMatch(/inner/); + expect(out).toMatch(/\/real/); + }); + + it('go: keeps backtick raw strings intact, strips // comments', () => { + const src = '// r.GET("/fake", h)\nr.GET(`/real`, h2)\n'; + const out = stripCommentsForRegex(src, 'go'); + expect(out).not.toMatch(/fake/); + // backtick raw string contents preserved + expect(out).toMatch(/`\/real`/); + }); + + it('go: strips block comments containing route-shaped text', () => { + const src = '/* r.GET("/fake", h) */\nr.GET("/real", h2)\n'; + const out = stripCommentsForRegex(src, 'go'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/"\/real"/); + }); + + it('java: strips // and /* */ comments', () => { + const src = + '// @GetMapping("/fake")\n/* @PostMapping("/also-fake") */\n@GetMapping("/real")\n'; + const out = stripCommentsForRegex(src, 'java'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/"\/real"/); + }); + + it('csharp: strips // and /* */ comments', () => { + const src = + '// [HttpGet("/fake")]\n/* [HttpPost("/also-fake")] */\n[HttpGet("/real")]\n'; + const out = stripCommentsForRegex(src, 'csharp'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/"\/real"/); + }); + + it('swift: strips // and /* */ comments', () => { + const src = + '// app.get("fake", use: x)\n/* app.get("also-fake", use: y) */\napp.get("real", use: z)\n'; + const out = stripCommentsForRegex(src, 'swift'); + expect(out).not.toMatch(/fake/); + expect(out).toMatch(/"real"/); + }); + + it('preserves line numbers (newlines retained)', () => { + const src = "line1\n# comment with path('/fake/')\nline3"; + const out = stripCommentsForRegex(src, 'python'); + expect(out.split('\n').length).toBe(3); + expect(out.split('\n')[2]).toBe('line3'); + }); + + it('preserves overall length so source offsets stay valid', () => { + const src = "x = 1 # path('/fake/', View)\nreal = 2"; + const out = stripCommentsForRegex(src, 'python'); + expect(out.length).toBe(src.length); + }); +}); diff --git a/__tests__/symbol-lookup.test.ts b/__tests__/symbol-lookup.test.ts new file mode 100644 index 0000000..86dda6c --- /dev/null +++ b/__tests__/symbol-lookup.test.ts @@ -0,0 +1,194 @@ +/** + * Module-qualified symbol lookup (`stage_apply::run`, `Session.request`, + * `configurator/stage_apply`). + * + * Pinned because the lookup vocabulary is what makes codegraph useful + * in workspaces with same-named symbols across modules — Rust + * sub-pipelines, Python `__init__.py` packages, Java packages, etc. + * See #173 for the original report: a `run` function in + * `src/configurator/stage_apply.rs` was indexed but `stage_apply::run` + * returned "not found" because (a) FTS strips colons to nothing, + * leaving a useless query, and (b) `matchesSymbol` only understood + * `.`-style qualifiers. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +function hasSqliteBindings(): boolean { + try { + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(':memory:'); + db.close(); + return true; + } catch { + return false; + } +} +const HAS_SQLITE = hasSqliteBindings(); + +function tmpRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-symbol-lookup-')); +} + +function rmTree(dir: string): void { + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); +} + +async function buildRustWorkspace(): Promise { + const root = tmpRoot(); + const cfgDir = path.join(root, 'src', 'configurator'); + fs.mkdirSync(cfgDir, { recursive: true }); + fs.writeFileSync( + path.join(root, 'Cargo.toml'), + `[package]\nname = "fixture"\nversion = "0.1.0"\nedition = "2021"\n[lib]\npath = "src/lib.rs"\n` + ); + fs.writeFileSync(path.join(root, 'src', 'lib.rs'), `pub mod configurator;\npub mod scheduler;\n`); + fs.writeFileSync( + path.join(cfgDir, 'mod.rs'), + `pub mod stage_apply;\npub mod stage_detect;\n` + ); + fs.writeFileSync( + path.join(cfgDir, 'stage_apply.rs'), + `pub async fn run() -> Result<(), ()> {\n render_and_write();\n Ok(())\n}\n\nfn render_and_write() {}\n` + ); + fs.writeFileSync( + path.join(cfgDir, 'stage_detect.rs'), + `pub async fn run() -> Result<(), ()> { Ok(()) }\n` + ); + fs.writeFileSync( + path.join(root, 'src', 'scheduler.rs'), + `pub fn run_due_tasks() -> Result<(), ()> { Ok(()) }\n` + ); + return root; +} + +describe.skipIf(!HAS_SQLITE)('matchesSymbol — module-qualified lookups (#173)', () => { + let projectRoot: string; + let cg: any; + let handler: any; + let findSymbol: (cg: any, s: string) => { node: any; note: string } | null; + let findAllSymbols: (cg: any, s: string) => { nodes: any[]; note: string }; + + beforeEach(async () => { + projectRoot = await buildRustWorkspace(); + const CodeGraph = (await import('../src/index')).default; + const { ToolHandler } = await import('../src/mcp/tools'); + cg = CodeGraph.initSync(projectRoot, { + config: { include: ['**/*.rs'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + findSymbol = (handler as any).findSymbol.bind(handler); + findAllSymbols = (handler as any).findAllSymbols.bind(handler); + }); + + afterEach(() => { + handler?.closeAll(); + cg?.destroy(); + rmTree(projectRoot); + }); + + it('resolves `stage_apply::run` to the run in stage_apply.rs (not stage_detect.rs)', () => { + const match = findSymbol(cg, 'stage_apply::run'); + expect(match).not.toBeNull(); + expect(match!.node.name).toBe('run'); + expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); + }); + + it('rejects `stage_apply::run` for the same-named function in a different module', () => { + const all = findAllSymbols(cg, 'stage_apply::run'); + // All returned nodes must be in stage_apply.rs — never in stage_detect.rs + for (const node of all.nodes) { + expect(node.filePath).toMatch(/stage_apply\.rs$/); + } + expect(all.nodes.length).toBeGreaterThan(0); + }); + + it('resolves `configurator::stage_apply::run` (multi-level qualifier)', () => { + const match = findSymbol(cg, 'configurator::stage_apply::run'); + expect(match).not.toBeNull(); + expect(match!.node.name).toBe('run'); + expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); + }); + + it('resolves `crate::configurator::stage_apply::run` (Rust path prefix stripped)', () => { + const match = findSymbol(cg, 'crate::configurator::stage_apply::run'); + expect(match).not.toBeNull(); + expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); + }); + + it('resolves `configurator/stage_apply` (slash qualifier)', () => { + const match = findSymbol(cg, 'configurator/stage_apply/run'); + expect(match).not.toBeNull(); + expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); + }); + + it('does not silently collide bare `run` with `run_due_tasks`', () => { + const match = findSymbol(cg, 'run'); + expect(match).not.toBeNull(); + // Whatever it picks, it must be an exact-name match, not a partial. + expect(match!.node.name).toBe('run'); + }); + + it('aggregates all bare-name `run` matches across modules', () => { + const all = findAllSymbols(cg, 'run'); + const names = all.nodes.map((n: any) => n.name); + expect(names.every((n: string) => n === 'run')).toBe(true); + expect(all.nodes.length).toBeGreaterThanOrEqual(2); // stage_apply + stage_detect + // The note should call out the ambiguity. + expect(all.note).toMatch(/Aggregated|symbols named "run"/); + }); + + it('still returns null for genuinely unknown qualified lookups', () => { + const match = findSymbol(cg, 'stage_apply::nonexistent_fn'); + expect(match).toBeNull(); + }); +}); + +describe.skipIf(!HAS_SQLITE)('matchesSymbol — dotted lookups (regression for #173 fix)', () => { + let projectRoot: string; + let cg: any; + let handler: any; + let findSymbol: (cg: any, s: string) => { node: any; note: string } | null; + + beforeEach(async () => { + projectRoot = tmpRoot(); + const src = path.join(projectRoot, 'src'); + fs.mkdirSync(src, { recursive: true }); + fs.writeFileSync( + path.join(src, 'session.ts'), + `export class Session {\n request(): void {}\n}\nexport function request(): void {}\n` + ); + + const CodeGraph = (await import('../src/index')).default; + const { ToolHandler } = await import('../src/mcp/tools'); + cg = CodeGraph.initSync(projectRoot, { + config: { include: ['src/**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + findSymbol = (handler as any).findSymbol.bind(handler); + }); + + afterEach(() => { + handler?.closeAll(); + cg?.destroy(); + rmTree(projectRoot); + }); + + it('`Session.request` resolves to the method, not the bare function', () => { + const match = findSymbol(cg, 'Session.request'); + expect(match).not.toBeNull(); + expect(match!.node.kind).toBe('method'); + expect(match!.node.qualifiedName).toContain('Session::request'); + }); +}); diff --git a/__tests__/sync.test.ts b/__tests__/sync.test.ts new file mode 100644 index 0000000..708a92a --- /dev/null +++ b/__tests__/sync.test.ts @@ -0,0 +1,306 @@ +/** + * Sync Module Tests + * + * Tests for sync functionality (incremental updates). + * Note: Git hooks functionality has been removed in favor of codegraph's + * Claude Code hooks integration. + */ + +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'; + +describe('Sync Module', () => { + describe('Sync Functionality', () => { + let testDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sync-func-')); + + // Create initial source files + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync( + path.join(srcDir, 'index.ts'), + `export function hello() { return 'world'; }` + ); + + // Initialize and index + cg = CodeGraph.initSync(testDir, { + config: { + include: ['**/*.ts'], + exclude: [], + }, + }); + await cg.indexAll(); + }); + + afterEach(() => { + if (cg) { + cg.destroy(); + } + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('getChangedFiles()', () => { + it('should detect added files', () => { + // Add a new file + fs.writeFileSync( + path.join(testDir, 'src', 'new.ts'), + `export function newFunc() { return 42; }` + ); + + const changes = cg.getChangedFiles(); + + expect(changes.added).toContain('src/new.ts'); + expect(changes.modified).toHaveLength(0); + expect(changes.removed).toHaveLength(0); + }); + + it('should detect modified files', () => { + // Modify existing file + fs.writeFileSync( + path.join(testDir, 'src', 'index.ts'), + `export function hello() { return 'modified'; }` + ); + + const changes = cg.getChangedFiles(); + + expect(changes.added).toHaveLength(0); + expect(changes.modified).toContain('src/index.ts'); + expect(changes.removed).toHaveLength(0); + }); + + it('should detect removed files', () => { + // Remove file + fs.unlinkSync(path.join(testDir, 'src', 'index.ts')); + + const changes = cg.getChangedFiles(); + + expect(changes.added).toHaveLength(0); + expect(changes.modified).toHaveLength(0); + expect(changes.removed).toContain('src/index.ts'); + }); + }); + + describe('sync()', () => { + it('should reindex added files', async () => { + // Add a new file + fs.writeFileSync( + path.join(testDir, 'src', 'new.ts'), + `export function newFunc() { return 42; }` + ); + + const result = await cg.sync(); + + expect(result.filesAdded).toBe(1); + expect(result.filesModified).toBe(0); + expect(result.filesRemoved).toBe(0); + + // Verify new function is in the graph + const nodes = cg.searchNodes('newFunc'); + expect(nodes.length).toBeGreaterThan(0); + }); + + it('should reindex modified files', async () => { + // Modify existing file + fs.writeFileSync( + path.join(testDir, 'src', 'index.ts'), + `export function goodbye() { return 'farewell'; }` + ); + + const result = await cg.sync(); + + expect(result.filesModified).toBe(1); + + // Verify new function is in the graph + const nodes = cg.searchNodes('goodbye'); + expect(nodes.length).toBeGreaterThan(0); + + // Verify old function is gone + const oldNodes = cg.searchNodes('hello'); + expect(oldNodes.length).toBe(0); + }); + + it('should remove nodes from deleted files', async () => { + // Remove file + fs.unlinkSync(path.join(testDir, 'src', 'index.ts')); + + const result = await cg.sync(); + + expect(result.filesRemoved).toBe(1); + + // Verify function is gone + const nodes = cg.searchNodes('hello'); + expect(nodes.length).toBe(0); + }); + + it('should report no changes when nothing changed', async () => { + const result = await cg.sync(); + + expect(result.filesAdded).toBe(0); + expect(result.filesModified).toBe(0); + expect(result.filesRemoved).toBe(0); + expect(result.filesChecked).toBeGreaterThan(0); + }); + }); + }); + + describe('Git-based sync', () => { + let testDir: string; + let cg: CodeGraph; + + function git(...args: string[]) { + execFileSync('git', args, { cwd: testDir, stdio: 'pipe' }); + } + + beforeEach(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-git-sync-')); + + // Initialize a git repo with an initial commit + git('init'); + git('config', 'user.email', 'test@test.com'); + git('config', 'user.name', 'Test'); + + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync( + path.join(srcDir, 'index.ts'), + `export function hello() { return 'world'; }` + ); + + git('add', '-A'); + git('commit', '-m', 'initial'); + + // Initialize CodeGraph and index + cg = CodeGraph.initSync(testDir, { + config: { + include: ['**/*.ts'], + exclude: [], + }, + }); + await cg.indexAll(); + }); + + afterEach(() => { + if (cg) { + cg.destroy(); + } + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should detect modified files via git', async () => { + fs.writeFileSync( + path.join(testDir, 'src', 'index.ts'), + `export function hello() { return 'modified'; }` + ); + + const result = await cg.sync(); + + expect(result.filesModified).toBe(1); + expect(result.changedFilePaths).toContain('src/index.ts'); + }); + + it('should detect new untracked files via git', async () => { + fs.writeFileSync( + path.join(testDir, 'src', 'new.ts'), + `export function newFunc() { return 42; }` + ); + + const result = await cg.sync(); + + expect(result.filesAdded).toBe(1); + expect(result.changedFilePaths).toContain('src/new.ts'); + + // Verify the function was indexed + const nodes = cg.searchNodes('newFunc'); + expect(nodes.length).toBeGreaterThan(0); + }); + + it('should stop reporting untracked files once they are indexed (issue #206)', async () => { + // Untracked files stay `??` in git status even after codegraph indexes + // them. Change detection must compare them against the DB by hash, not + // report every untracked file as "added" on every sync/status. + fs.writeFileSync( + path.join(testDir, 'src', 'new.ts'), + `export function newFunc() { return 42; }` + ); + + // First sync indexes the untracked file. + const first = await cg.sync(); + expect(first.filesAdded).toBe(1); + + // The file is still untracked in git, but now lives in the DB. + expect(cg.searchNodes('newFunc').length).toBeGreaterThan(0); + + // status must not keep flagging it as a pending addition... + const changes = cg.getChangedFiles(); + expect(changes.added).not.toContain('src/new.ts'); + expect(changes.modified).not.toContain('src/new.ts'); + + // ...and a second sync must be a no-op for it. + const second = await cg.sync(); + expect(second.filesAdded).toBe(0); + expect(second.filesModified).toBe(0); + }); + + it('should re-index an untracked file when its contents change', async () => { + const filePath = path.join(testDir, 'src', 'new.ts'); + fs.writeFileSync(filePath, `export function newFunc() { return 42; }`); + await cg.sync(); + + // Modify the still-untracked file. + fs.writeFileSync(filePath, `export function renamedFunc() { return 7; }`); + + const changes = cg.getChangedFiles(); + expect(changes.modified).toContain('src/new.ts'); + + const result = await cg.sync(); + expect(result.filesModified).toBe(1); + expect(cg.searchNodes('renamedFunc').length).toBeGreaterThan(0); + expect(cg.searchNodes('newFunc').length).toBe(0); + }); + + it('should detect deleted files via git', async () => { + fs.unlinkSync(path.join(testDir, 'src', 'index.ts')); + + const result = await cg.sync(); + + expect(result.filesRemoved).toBe(1); + + // Verify function is gone + const nodes = cg.searchNodes('hello'); + expect(nodes.length).toBe(0); + }); + + it('should skip files with unsupported extensions', async () => { + // A .txt file has no supported grammar, so sync must not index it. + fs.writeFileSync( + path.join(testDir, 'src', 'notes.txt'), + `just some notes` + ); + + const result = await cg.sync(); + + expect(result.filesAdded).toBe(0); + expect(result.filesModified).toBe(0); + }); + + it('should report no changes on clean working tree', async () => { + const result = await cg.sync(); + + expect(result.filesAdded).toBe(0); + expect(result.filesModified).toBe(0); + expect(result.filesRemoved).toBe(0); + expect(result.changedFilePaths).toBeUndefined(); + }); + }); +}); diff --git a/__tests__/wasm-runtime-flags.test.ts b/__tests__/wasm-runtime-flags.test.ts new file mode 100644 index 0000000..a4dae8b --- /dev/null +++ b/__tests__/wasm-runtime-flags.test.ts @@ -0,0 +1,87 @@ +/** + * WASM runtime flags — the workaround for the V8 turboshaft WASM Zone OOM + * (`Fatal process out of memory: Zone`) that crashed `codegraph index` on large + * polyglot repos under Node >= 22. See issues #293 and #298. + * + * The crash was reproduced with the real indexer on the bundled Node 24 runtime; + * empirically only `--liftoff-only` prevents it (`--no-wasm-tier-up` / + * `--no-wasm-dynamic-tiering` do not), and the flag must be on node's command + * line — `setFlagsFromString`, worker `execArgv`, and `NODE_OPTIONS` all fail. + * These tests pin that contract so it can't silently regress. + */ +import { describe, it, expect } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + WASM_RUNTIME_FLAGS, + processHasWasmRuntimeFlags, + buildRelaunchArgv, +} from '../src/extraction/wasm-runtime-flags'; + +describe('WASM_RUNTIME_FLAGS', () => { + it('pins --liftoff-only (the only flag shown to stop the turboshaft Zone OOM)', () => { + // On Node 24, --no-wasm-tier-up and --no-wasm-dynamic-tiering both still + // crash; only --liftoff-only forces grammars onto the Liftoff baseline and + // off the optimizing tier. Pin it so it can't be swapped for an ineffective + // flag. + expect(WASM_RUNTIME_FLAGS).toContain('--liftoff-only'); + }); + + it('every flag is a real, accepted flag on the running Node/V8 runtime', () => { + // node rejects unknown CLI flags at startup, so a renamed/removed flag would + // break the bundled launcher and make the relaunch guard a silent no-op. + // Prove each flag actually launches node here. + const res = spawnSync( + process.execPath, + [...WASM_RUNTIME_FLAGS, '-e', 'process.exit(0)'], + { encoding: 'utf8' } + ); + expect(res.status, `node rejected ${WASM_RUNTIME_FLAGS.join(' ')}:\n${res.stderr}`).toBe(0); + }); +}); + +describe('processHasWasmRuntimeFlags', () => { + it('is true only when every required flag is present', () => { + expect(processHasWasmRuntimeFlags(['--liftoff-only'])).toBe(true); + expect(processHasWasmRuntimeFlags(['--liftoff-only', '--enable-source-maps'])).toBe(true); + }); + + it('is false when the flags are absent', () => { + expect(processHasWasmRuntimeFlags([])).toBe(false); + expect(processHasWasmRuntimeFlags(['--max-old-space-size=4096'])).toBe(false); + }); +}); + +describe('buildRelaunchArgv', () => { + it('places the wasm flags first, then the script and its args', () => { + expect(buildRelaunchArgv('/x/codegraph.js', ['index', '/repo'], [])).toEqual([ + '--liftoff-only', + '/x/codegraph.js', + 'index', + '/repo', + ]); + }); + + it('preserves other existing node flags without duplicating ours', () => { + expect( + buildRelaunchArgv('/x/codegraph.js', ['status'], ['--liftoff-only', '--enable-source-maps']) + ).toEqual(['--liftoff-only', '--enable-source-maps', '/x/codegraph.js', 'status']); + }); + + it('produces an argv that actually launches node WITH the flag applied', () => { + // End-to-end proof of the delivery mechanism without needing the crash: + // run the constructed argv and confirm the child sees the flag in execArgv. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-relaunch-')); + try { + const harness = path.join(dir, 'harness.cjs'); + fs.writeFileSync(harness, 'process.stdout.write(JSON.stringify(process.execArgv));'); + const res = spawnSync(process.execPath, buildRelaunchArgv(harness, []), { encoding: 'utf8' }); + expect(res.status, res.stderr).toBe(0); + expect(JSON.parse(res.stdout)).toContain('--liftoff-only'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/watch-policy.test.ts b/__tests__/watch-policy.test.ts new file mode 100644 index 0000000..5cb92ce --- /dev/null +++ b/__tests__/watch-policy.test.ts @@ -0,0 +1,82 @@ +/** + * Watch Policy Tests + * + * Covers the decision of whether the live file watcher runs, including the + * WSL2 /mnt auto-detect and the env-var escape hatches (issue #199), plus + * that FileWatcher.start() honors the decision. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { watchDisabledReason } from '../src/sync/watch-policy'; +import { FileWatcher } from '../src/sync/watcher'; + +describe('watchDisabledReason', () => { + it('returns a reason when CODEGRAPH_NO_WATCH=1', () => { + const reason = watchDisabledReason('/home/me/project', { + env: { CODEGRAPH_NO_WATCH: '1' }, + isWsl: false, + }); + expect(reason).toBeTruthy(); + expect(reason).toMatch(/CODEGRAPH_NO_WATCH/); + }); + + it('auto-disables on a WSL2 /mnt drive', () => { + const reason = watchDisabledReason('/mnt/d/code/project', { env: {}, isWsl: true }); + expect(reason).toBeTruthy(); + expect(reason).toMatch(/mnt/); + }); + + it('does NOT disable on a native WSL home path', () => { + expect(watchDisabledReason('/home/me/project', { env: {}, isWsl: true })).toBeNull(); + }); + + it('does NOT disable on /mnt when not running under WSL', () => { + // A real Linux box may legitimately have a fast /mnt mount. + expect(watchDisabledReason('/mnt/d/code/project', { env: {}, isWsl: false })).toBeNull(); + }); + + it('does NOT treat /mnt/wsl (fast Linux mount) as a Windows drive', () => { + expect(watchDisabledReason('/mnt/wsl/project', { env: {}, isWsl: true })).toBeNull(); + }); + + it('CODEGRAPH_FORCE_WATCH=1 overrides WSL auto-detect', () => { + const reason = watchDisabledReason('/mnt/d/code/project', { + env: { CODEGRAPH_FORCE_WATCH: '1' }, + isWsl: true, + }); + expect(reason).toBeNull(); + }); + + it('CODEGRAPH_NO_WATCH wins over CODEGRAPH_FORCE_WATCH', () => { + const reason = watchDisabledReason('/home/me/project', { + env: { CODEGRAPH_NO_WATCH: '1', CODEGRAPH_FORCE_WATCH: '1' }, + isWsl: false, + }); + expect(reason).toBeTruthy(); + }); +}); + +describe('FileWatcher honors the watch policy', () => { + let testDir: string; + + afterEach(() => { + delete process.env.CODEGRAPH_NO_WATCH; + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('does not start when CODEGRAPH_NO_WATCH=1', () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-nowatch-')); + process.env.CODEGRAPH_NO_WATCH = '1'; + + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); + const watcher = new FileWatcher(testDir, syncFn); + + expect(watcher.start()).toBe(false); + expect(watcher.isActive()).toBe(false); + }); +}); diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts new file mode 100644 index 0000000..fde5f59 --- /dev/null +++ b/__tests__/watcher.test.ts @@ -0,0 +1,278 @@ +/** + * FileWatcher Tests + * + * Tests for the file watcher that auto-syncs on changes. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { FileWatcher } from '../src/sync/watcher'; +import CodeGraph from '../src/index'; + +/** + * Helper to wait for a condition with timeout + */ +function waitFor( + condition: () => boolean, + timeoutMs = 10000, + intervalMs = 100 +): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const check = () => { + if (condition()) return resolve(); + if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out')); + setTimeout(check, intervalMs); + }; + check(); + }); +} + +describe('FileWatcher', () => { + let testDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watcher-')); + // Create a source file so the directory isn't empty + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); + }); + + afterEach(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('start/stop lifecycle', () => { + it('should start and stop without errors', () => { + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); + const watcher = new FileWatcher(testDir, syncFn); + + const started = watcher.start(); + expect(started).toBe(true); + expect(watcher.isActive()).toBe(true); + + watcher.stop(); + expect(watcher.isActive()).toBe(false); + }); + + it('should be idempotent on double start', () => { + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); + const watcher = new FileWatcher(testDir, syncFn); + + expect(watcher.start()).toBe(true); + expect(watcher.start()).toBe(true); // Should not throw + expect(watcher.isActive()).toBe(true); + + watcher.stop(); + }); + + it('should be idempotent on double stop', () => { + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); + const watcher = new FileWatcher(testDir, syncFn); + + watcher.start(); + watcher.stop(); + watcher.stop(); // Should not throw + expect(watcher.isActive()).toBe(false); + }); + }); + + describe('debounced sync', () => { + it('should trigger sync after file change', async () => { + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 }); + const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 }); + + watcher.start(); + + // Create a new file + fs.writeFileSync(path.join(testDir, 'src', 'new.ts'), 'export const y = 2;'); + + // Wait for debounced sync to fire + await waitFor(() => syncFn.mock.calls.length > 0, 5000); + expect(syncFn).toHaveBeenCalled(); + + watcher.stop(); + }); + + it('should debounce rapid changes into a single sync', async () => { + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 }); + const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 500 }); + + watcher.start(); + + // Rapid-fire changes + for (let i = 0; i < 5; i++) { + fs.writeFileSync( + path.join(testDir, 'src', `file${i}.ts`), + `export const v${i} = ${i};` + ); + await new Promise((r) => setTimeout(r, 50)); + } + + // Wait for the single debounced sync + await waitFor(() => syncFn.mock.calls.length > 0, 5000); + + // Should have been called once (debounced), not 5 times + expect(syncFn.mock.calls.length).toBe(1); + + watcher.stop(); + }); + }); + + describe('filtering', () => { + it('should ignore files not matching include patterns', async () => { + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); + const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 }); + + watcher.start(); + + // Let watcher settle — fs.watch may fire residual events from beforeEach + await new Promise((r) => setTimeout(r, 400)); + syncFn.mockClear(); + + // Create a file that doesn't match include patterns + fs.writeFileSync(path.join(testDir, 'src', 'readme.md'), '# Hello'); + + // Wait a bit longer than debounce — sync should NOT trigger + await new Promise((r) => setTimeout(r, 500)); + expect(syncFn).not.toHaveBeenCalled(); + + watcher.stop(); + }); + + it('should ignore .codegraph directory changes', async () => { + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); + const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 }); + + watcher.start(); + + // Let watcher settle — fs.watch may fire residual events from beforeEach + await new Promise((r) => setTimeout(r, 400)); + syncFn.mockClear(); + + // Simulate a .codegraph directory change + const cgDir = path.join(testDir, '.codegraph'); + fs.mkdirSync(cgDir, { recursive: true }); + fs.writeFileSync(path.join(cgDir, 'db.sqlite'), 'fake'); + + // Wait — sync should NOT trigger + await new Promise((r) => setTimeout(r, 500)); + expect(syncFn).not.toHaveBeenCalled(); + + watcher.stop(); + }); + }); + + describe('callbacks', () => { + it('should call onSyncComplete after successful sync', async () => { + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 2, durationMs: 50 }); + const onSyncComplete = vi.fn(); + const watcher = new FileWatcher(testDir, syncFn, { + debounceMs: 200, + onSyncComplete, + }); + + watcher.start(); + + fs.writeFileSync(path.join(testDir, 'src', 'test.ts'), 'export const z = 3;'); + + await waitFor(() => onSyncComplete.mock.calls.length > 0, 5000); + expect(onSyncComplete).toHaveBeenCalledWith({ filesChanged: 2, durationMs: 50 }); + + watcher.stop(); + }); + + it('should call onSyncError when sync throws', async () => { + const syncFn = vi.fn().mockRejectedValue(new Error('sync failed')); + const onSyncError = vi.fn(); + const watcher = new FileWatcher(testDir, syncFn, { + debounceMs: 200, + onSyncError, + }); + + watcher.start(); + + fs.writeFileSync(path.join(testDir, 'src', 'test.ts'), 'export const z = 3;'); + + await waitFor(() => onSyncError.mock.calls.length > 0, 5000); + expect(onSyncError).toHaveBeenCalled(); + expect(onSyncError.mock.calls[0]![0]).toBeInstanceOf(Error); + + watcher.stop(); + }); + }); + + describe('CodeGraph integration', () => { + let cg: CodeGraph; + + afterEach(() => { + if (cg) cg.close(); + }); + + it('should watch and unwatch via CodeGraph API', async () => { + cg = CodeGraph.initSync(testDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + + expect(cg.isWatching()).toBe(false); + + const started = cg.watch({ debounceMs: 200 }); + expect(started).toBe(true); + expect(cg.isWatching()).toBe(true); + + cg.unwatch(); + expect(cg.isWatching()).toBe(false); + }); + + it('should stop watching on close', async () => { + cg = CodeGraph.initSync(testDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + + cg.watch({ debounceMs: 200 }); + expect(cg.isWatching()).toBe(true); + + cg.close(); + // After close, isWatching should be false + // (we can't call isWatching after close since DB is closed, + // but we verify no errors are thrown) + }); + + it('should auto-sync when files change while watching', async () => { + cg = CodeGraph.initSync(testDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + + const initialStats = cg.getStats(); + const initialNodes = initialStats.nodeCount; + + cg.watch({ debounceMs: 300 }); + + // Add a new file with a function + fs.writeFileSync( + path.join(testDir, 'src', 'added.ts'), + 'export function added() { return 42; }' + ); + + // Wait for auto-sync to pick it up + await waitFor(() => { + const stats = cg.getStats(); + return stats.nodeCount > initialNodes; + }, 10000); + + // The new function should be in the graph + const results = cg.searchNodes('added'); + expect(results.length).toBeGreaterThan(0); + + cg.unwatch(); + }); + }); +}); 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 ` 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 ,