Initial commit: codegraph (local-first code intelligence library + MCP server)

This commit is contained in:
xiaowei
2026-05-24 17:16:20 +08:00
commit 4a9833e67b
176 changed files with 52092 additions and 0 deletions
+219
View File
@@ -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 <language> 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 <lang> # 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 <lang> path/to/valid-sample.<ext>
```
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-<lang> # often ships a prebuilt *.wasm
# or build one: npx tree-sitter build --wasm (needs Docker/emscripten)
cp <the>.wasm src/extraction/wasm/tree-sitter-<lang>.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 <lang> path/to/sample.<ext>
# vendored grammar: pass the wasm path instead of the token
node scripts/add-lang/dump-ast.mjs src/extraction/wasm/tree-sitter-<lang>.wasm sample.<ext>
```
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 `'<lang>',` to the `LANGUAGES` const (before `'unknown'`);
- add `'**/*.<ext>',` 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`: `<lang>: 'tree-sitter-<lang>.wasm',`
- `EXTENSION_MAP`: each file extension → `'<lang>'` (e.g. `'.lua': 'lua',`)
- `getLanguageDisplayName`: `<lang>: '<Display Name>',`
- **vendored only**: add `<lang>` to the
`(lang === 'pascal' || lang === 'scala' || …)` wasm-path branch.
3. **`src/extraction/languages/<lang>.ts`** — new file exporting
`export const <lang>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 { <lang>Extractor } from
'./<lang>';` and add `<lang>: <lang>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 === '<lang>')` 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 <sample-repo> && codegraph init -i )
node scripts/add-lang/verify-extraction.mjs <sample-repo> <lang>
```
`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 `<lang>.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('<Lang> 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
`<lang>`-dominant, one per size tier:
```bash
gh search repos --language=<lang> --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 `<lang>` but mostly another
language. Write one cross-file architecture **question** per repo (the kind that
needs tracing across files). Add a `"<Language>"` 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 <lang> <name> <url> "<question>" 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 `<Lang>` to the "19+ Languages" feature bullet, and add a
row to the **Supported Languages** table:
`| <Lang> | \`.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 **<Lang>** (`.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.
+74
View File
@@ -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 <VERSION> <repo-name> <repo-url> "<question>" <MODE>
```
**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`).
+73
View File
@@ -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?" }
]
}
+38
View File
@@ -0,0 +1,38 @@
---
description: CodeGraph MCP usage guide — when to use which tool
alwaysApply: true
---
<!-- CODEGRAPH_START -->
## 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?"*
<!-- CODEGRAPH_END -->
+121
View File
@@ -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<version>) 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 (## [<version>], 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
+55
View File
@@ -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/
+74
View File
@@ -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-<target>/
node | node.exe # official Node runtime for <target>
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-<target>` 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.
+597
View File
@@ -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 <platform>` 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 <platform>`.
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=<url>` 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 `<num>\t<code>`, 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=<csv|auto|all|none>`, `--location=<global|local>`, `--yes`,
`--no-permissions`, `--print-config <id>`.
- `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
+166
View File
@@ -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 <user>@<guest_ip> "..."`. 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/<n>/head` lag: `git fetch <fork-url> <branch>` 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 (<one-line summary>)"
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.
+21
View File
@@ -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.
+541
View File
@@ -0,0 +1,541 @@
<div align="center">
# 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)
</div>
## 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
```
<sub>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.</sub>
### Initialize Projects
```bash
cd your-project
codegraph init -i
```
<div align="center">
![1_C_VYnhpys0UHrOuOgpgoyw](https://github.com/user-attachments/assets/f168182f-4d9a-44e0-94d7-08d018cc8a3a)
</div>
### Uninstall
Changed your mind? One command removes CodeGraph from every agent it configured:
```bash
codegraph uninstall
```
<sub>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.</sub>
---
## 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.
<details>
<summary><strong>Full benchmark details</strong></summary>
**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.
</details>
---
## 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 <id>` | 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.
<details>
<summary><strong>Manual Setup (Alternative)</strong></summary>
**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"
]
}
}
```
</details>
<details>
<summary><strong>Global Instructions Reference</strong></summary>
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?"
```
</details>
---
## 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> # Search symbols (--kind, --limit, --json)
codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json)
codegraph context <task> # Build context for AI (--format, --max-nodes)
codegraph callers <symbol> # Find what calls a function/method (--limit, --json)
codegraph callees <symbol> # Find what a function/method calls (--limit, --json)
codegraph impact <symbol> # 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 <n>` | Max dependency traversal depth | `5` |
| `-f, --filter <glob>` | 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
<a href="https://www.star-history.com/?repos=colbymchenry%2Fcodegraph&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=colbymchenry/codegraph&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=colbymchenry/codegraph&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=colbymchenry/codegraph&type=date&legend=top-left" />
</picture>
</a>
## License
MIT
---
<div align="center">
**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)
</div>
+152
View File
@@ -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<string, unknown>)[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();
}
});
});
+374
View File
@@ -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<PaymentResult> {
// 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<PaymentResult> {
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');
}
});
});
});
+161
View File
@@ -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();
});
});
+518
View File
@@ -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> = {},
): 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',
'<?php\n',
);
// Module files go through hook detection, not route extraction
expect(nodes).toHaveLength(0);
});
it('returns empty result for files with no valid routes', () => {
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 = `<?php
/**
* Implements hook_form_alter().
*/
function mymodule_form_alter(&$form, $form_state, $form_id) {
// ...
}
`;
const { references } = drupalResolver.extract!(
'web/modules/custom/mymodule/mymodule.module',
src,
);
const hookRef = references.find(
(r) => r.referenceName === 'hook_form_alter',
);
expect(hookRef).toBeDefined();
expect(hookRef!.referenceKind).toBe('references');
});
it('detects hook implementation via name pattern (Strategy B)', () => {
const src = `<?php
function mymodule_views_data() {
return [];
}
`;
const { references } = drupalResolver.extract!(
'web/modules/custom/mymodule/mymodule.module',
src,
);
const hookRef = references.find(
(r) => 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 = `<?php
function other_module_helper() {}
`;
const { references } = drupalResolver.extract!(
'web/modules/custom/mymodule/mymodule.module',
src,
);
expect(references).toHaveLength(0);
});
it('detects hooks in .install files', () => {
const src = `<?php
/**
* Implements hook_schema().
*/
function mymodule_schema() {
return [];
}
`;
const { references } = drupalResolver.extract!(
'web/modules/custom/mymodule/mymodule.install',
src,
);
const hookRef = references.find((r) => r.referenceName === 'hook_schema');
expect(hookRef).toBeDefined();
});
it('detects hooks in .theme files', () => {
const src = `<?php
/**
* Implements hook_preprocess_node().
*/
function mytheme_preprocess_node(&$variables) {}
`;
const { references } = drupalResolver.extract!(
'web/themes/custom/mytheme/mytheme.theme',
src,
);
const hookRef = references.find(
(r) => 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 = `<?php
/**
* Implements hook_form_alter().
*/
function mymodule_form_alter(&$form, $form_state, $form_id) {}
`;
const { references } = drupalResolver.extract!(
'web/modules/custom/mymodule/mymodule.module',
src,
);
const hookRefs = references.filter(
(r) => 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'),
[
'<?php',
'namespace Drupal\\my_module\\Controller;',
'use Drupal\\Core\\Controller\\ControllerBase;',
'class HelloController extends ControllerBase {',
' public function build() {',
" return ['#markup' => '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();
});
});
+123
View File
@@ -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<string, unknown>),
});
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<string, unknown>),
});
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);
});
+82
View File
@@ -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<string, { name: string }>; edges: unknown[]; roots: string[] },
latencyMs: number
): EvalResult {
const expectedLower = new Set(expectedSymbols.map((s) => s.toLowerCase()));
const nodeNames = new Set<string>();
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,
};
}
+93
View File
@@ -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 },
},
];
+37
View File
@@ -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<string, unknown>;
}
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[];
}
+234
View File
@@ -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 `<digits>\t<code>`.
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);
});
});
File diff suppressed because it is too large Load Diff
+305
View File
@@ -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([]);
});
});
+59
View File
@@ -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();
});
});
File diff suppressed because it is too large Load Diff
+129
View File
@@ -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);
});
});
+170
View File
@@ -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<string, string | undefined>, fn: () => void): void {
const saved: Record<string, string | undefined> = {};
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);
});
});
+435
View File
@@ -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');
});
});
});
+890
View File
@@ -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<string, any> = { 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('<!-- CODEGRAPH_START -->');
expect(body).toContain('<!-- CODEGRAPH_END -->');
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('<!-- CODEGRAPH_START -->');
});
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, any>): 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<string, any> {
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;
}
+220
View File
@@ -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('<!-- CODEGRAPH_START -->');
expect(content).toContain('<!-- CODEGRAPH_END -->');
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('<!-- CODEGRAPH_START -->');
expect(final).toContain('<!-- CODEGRAPH_END -->');
});
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('<!-- CODEGRAPH_START -->');
expect(final).toContain('## Next Section');
expect(final).toContain('Must be preserved');
expect(final).not.toContain('Old simple content');
});
});
});
+244
View File
@@ -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);
});
+96
View File
@@ -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<string, number>(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<string, number>(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<string, number>(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<string, string | null>(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<string, number>(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<string, number>(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);
});
});
@@ -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/);
});
});
+53
View File
@@ -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);
});
});
+149
View File
@@ -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<T>(
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);
});
+168
View File
@@ -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<boolean> {
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') : '<no stderr captured>';
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);
});
+180
View File
@@ -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<Record<string, any>> {
const messages: Array<Record<string, any>> = [];
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<Record<string, any>>,
predicate: (m: Record<string, any>) => boolean,
timeoutMs: number,
): Promise<Record<string, any>> {
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);
});
+71
View File
@@ -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');
});
});
+69
View File
@@ -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');
});
});
+208
View File
@@ -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<string, string>) {
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-<target>/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<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as AddressInfo).port;
}, 30000);
afterAll(() => { server?.close(); });
function netEnv(cache: string): Record<string, string> {
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);
});
+719
View File
@@ -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);
});
});
+849
View File
@@ -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);
});
});
});
+142
View File
@@ -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);
});
});
+644
View File
@@ -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);
}
});
});
+44
View File
@@ -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();
}
});
});
+134
View File
@@ -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);
});
});
+194
View File
@@ -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<string> {
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');
});
});
+306
View File
@@ -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();
});
});
});
+87
View File
@@ -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 });
}
});
});
+82
View File
@@ -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);
});
});
+278
View File
@@ -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<void> {
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();
});
});
});
+558
View File
@@ -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 <codebase_path>/.codegraph
node dist/bin/codegraph.js init -iv <codebase_path>
```
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 <codebase_path>/.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 <codebase_path>/.codegraph/codegraph.db \
"SELECT kind, COUNT(*) FROM edges GROUP BY kind ORDER BY COUNT(*) DESC;"
# Check node kind distribution
sqlite3 <codebase_path>/.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('<codebase_path>');
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('<codebase_path>');
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('<codebase_path>');
// 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('<codebase_path>');
// Pick a core class or interface that many things depend on
const results = cg.searchNodes('<CoreClass>', { 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('<codebase_path>');
// 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 <codebase_path>/.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 <codebase_path>/.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('<codebase_path>');
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/<lang>.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/<lang>.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/<lang>.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/<lang>.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 `<anonymous>` | `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 `<script>` blocks, missing calls in template markup | `src/extraction/svelte-extractor.ts: extractTemplateCalls` scans `{expression}` blocks in template for call patterns |
| Svelte `$state`/`$derived` rune calls creating noise | Runes are compiler builtins, not real function calls | `src/extraction/svelte-extractor.ts` filters `SVELTE_RUNES` set from unresolved references |
| Object literal getters/setters extracted as standalone functions | `method_definition` inside `object` literals treated same as class methods | `src/extraction/tree-sitter.ts: extractMethod` skips `method_definition` nodes whose parent is `object`/`object_expression` |
| JavaScript `class extends` produces zero inheritance edges | JS tree-sitter uses `class_heritage → identifier` (bare), not `class_heritage → extends_clause → identifier` like TypeScript | `src/extraction/tree-sitter.ts: extractInheritance` — handle bare `identifier`/`type_identifier` children when parent is `class_heritage` |
| PHP traits extracted as classes | `trait_declaration` in `classTypes` but `extractClass` hardcodes `class` kind | `src/extraction/languages/php.ts: classifyClassNode` returns `'trait'` for `trait_declaration`; `src/extraction/tree-sitter-types.ts` adds `'trait'` to return type |
| PHP class properties missing (0 field nodes) | `extractField` looks for `variable_declarator` children; PHP uses `property_element > variable_name > name` | `src/extraction/tree-sitter.ts: extractField` — handle `property_element` children with `variable_name > name` path |
| PHP class constants skipped inside classes | `variableTypes` check has `!isInsideClassLikeNode()` guard, so `const_declaration` inside classes falls through | `src/extraction/languages/php.ts: visitNode` hook catches `const_declaration`, extracts `const_element > name` as `constant` kind |
| PHP `use TraitName` inside classes invisible | `use_declaration` nodes in class body not processed for edges | `src/extraction/languages/php.ts: visitNode` hook extracts trait names from `use_declaration` and creates `implements` unresolved references |
## After Fixing Issues
```bash
npm run build
rm -rf <codebase_path>/.codegraph
node dist/bin/codegraph.js init -iv <codebase_path>
# Re-run the failing tests from above
```
Always run the full test suite before marking a language as verified:
```bash
npm test
```
## Adding `getReceiverType`
**Only needed for languages where methods are top-level or outside their owner type in the AST.** If the language nests methods inside class/struct bodies (Python, Java, TypeScript, C#), the qualified name already includes the parent — verify with the sanity check before adding anything.
### 1. Add the hook to the language extractor
In `src/extraction/languages/<lang>.ts`, add `getReceiverType` to the extractor object:
```typescript
getReceiverType: (node, source) => {
// Extract the owner type name from the method's AST node.
// Return the type name string, or undefined if not applicable.
//
// The core extractMethod() in tree-sitter.ts will use this to set:
// qualifiedName = `${filePath}::${receiverType}::${methodName}`
},
```
### 2. Reference: Go implementation
```typescript
// src/extraction/languages/go.ts
getReceiverType: (node, source) => {
const receiver = getChildByField(node, 'receiver');
if (!receiver) return undefined;
const text = getNodeText(receiver, source);
const match = text.match(/\*?\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
return match?.[1];
},
```
### 3. Where it's consumed
`src/extraction/tree-sitter.ts` in `extractMethod()`:
```typescript
const receiverType = this.extractor.getReceiverType?.(node, this.source);
if (receiverType) {
extraProps.qualifiedName = `${this.filePath}::${receiverType}::${name}`;
}
```
## Key Files
| File | Role |
|------|------|
| `src/extraction/languages/<lang>.ts` | Language extractor — node types, call types, `getReceiverType` |
| `src/extraction/tree-sitter.ts` | Core extraction — `extractMethod()`, `extractCall()`, `extractInheritance()` |
| `src/extraction/tree-sitter-types.ts` | `LanguageExtractor` interface definition |
| `src/search/query-utils.ts` | `STOP_WORDS`, `extractSearchTerms`, `scorePathRelevance` |
| `src/db/queries.ts` | `searchNodesFTS` (BM25), `findNodesByExactName` (co-location boost) |
| `src/context/index.ts` | `findRelevantContext` — hybrid search + graph traversal |
| `src/mcp/tools.ts` | MCP tool handlers — `codegraph_explore` implementation |
## Language Status
### Verified
- [x] **Go** — `getReceiverType` extracts receiver from `func (sl *Type) method()`
- [x] **Swift** — NOT needed. Tree-sitter nests methods inside class/extension bodies
- [x] **Java** — NOT needed. Methods nested in class body. Verified against Guava
- [x] **Python** — NOT needed. Methods nested in class body. Verified against Flask
- [x] **Rust** — `getReceiverType` walks up to parent `impl_item` to extract type name. Also adds `contains` edges from struct to impl methods. Verified against Deno
- [x] **C** — NOT needed. No methods in C. Strong function/struct/enum extraction with excellent call edge density. Verified against Redis
- [x] **C++** — NOT needed for header-only libs. `isMisparsedFunction` hook filters macro-caused misparse artifacts (e.g. `NLOHMANN_JSON_NAMESPACE_BEGIN`). `visitFunctionBody` now extracts structural nodes (classes/structs/enums) inside macro-confused "function" bodies. Content-based `.h` detection (`looksLikeCpp` in `grammars.ts`) promotes C++ headers to `cpp` language so classes in `.h` files are extracted. Verified against nlohmann/json and gRPC. Note: out-of-class `Type::method()` definitions would need `getReceiverType` but are uncommon in header-only codebases.
- [x] **C#** — NOT needed. Methods nested in class body. Added `base_list` handling in `extractInheritance` for C#'s `: Parent, IInterface` syntax. Added `propertyTypes` support for C# `property_declaration` nodes. Fixed `extractField` to handle C#'s nested `variable_declaration > variable_declarator` structure. Verified against Jellyfin
- [x] **Ruby** — NOT needed for `getReceiverType`. Methods nested in class body. Added `visitNode` hook to extract Ruby `module` nodes (concerns, namespaces) with proper containment and qualified names. Methods inside modules get `Module::method` qualified names. Also wired up the `ExtractorContext` with `pushScope`/`popScope` for language hooks. Verified against Discourse
- [x] **TypeScript** — NOT needed for `getReceiverType`. Methods nested in class body. Added `abstract_class_declaration` to `classTypes` so abstract classes are properly extracted. Fixed single-expression arrow function extraction (`const fn = () => expr` was silently dropped because `extractName` picked up the body identifier instead of returning `<anonymous>` for parent name resolution). Verified against Grafana
- [x] **Dart** — NOT needed for `getReceiverType`. Methods nested in class body. Added bare call extraction for selector-based method calls (e.g. `object.method()`). Verified against Flutter
- [x] **Kotlin** — `getReceiverType` extracts receiver from extension functions `fun Type.method()`. Added `classifyClassNode` to distinguish interfaces/enums from classes (all use `class_declaration` AST node). Added `resolveBody` hook since Kotlin's tree-sitter grammar doesn't use field names. Added `navigation_expression` handling for method call extraction. Added `object_declaration` via `extraClassNodeTypes`. Added `delegation_specifier` handling in `extractInheritance` for Kotlin's `: Parent, Interface` syntax. Also fixed `extractInterface` to visit body children (interface methods were not being extracted). Added `visitNode` hook to handle `fun interface` (SAM) declarations — tree-sitter-kotlin doesn't support this Kotlin 1.4+ syntax, producing ERROR or function_declaration misparse; the hook detects both patterns and extracts the interface. Verified against Koin, LeakCanary
- [x] **Svelte** — Custom `SvelteExtractor` delegates `<script>` blocks to TS/JS parser; creates `component` nodes for each `.svelte` file. Added template expression call extraction: scans `{expression}` blocks in markup for function calls (e.g. `class={cn(...)}`), creating call edges from component to callees — increased Svelte call edges from 29 to 387. Filtered Svelte 5 rune calls (`$state`, `$props`, `$derived`, `$effect`, `$bindable`). Also fixed: destructured `$props()` patterns (e.g. `let { x, y } = $props()`) no longer extracted as ugly multi-line variable names (skip `object_pattern`/`array_pattern` in `extractVariable`). Object literal getter/setter methods no longer extracted as standalone functions. Verified against shadcn-svelte
- [x] **PHP** — NOT needed for `getReceiverType`. Methods nested in class body. Added `classifyClassNode` to distinguish traits from classes (`trait_declaration` → `trait` kind). Added `'trait'` to `classifyClassNode` return type in `tree-sitter-types.ts` and handling in visitor. Fixed PHP property extraction: `extractField` now handles `property_element > variable_name > name` AST structure (added 4,366 field nodes). Added `visitNode` hook for class constants (`const_declaration` inside classes was skipped by `variableTypes` guard) and trait `use` declarations (`use HasFactory, SoftDeletes;` creates `implements` edges — increased from 636 to 1,514). Verified against Laravel
### Needs Verification
(none currently)
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
# CodeGraph standalone installer for Windows (PowerShell).
#
# Downloads a self-contained bundle (a vendored Node runtime + the app) from
# GitHub Releases. No Node.js, no build tools required.
#
# irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex
#
# Re-run to upgrade. To uninstall: remove $env:LOCALAPPDATA\codegraph and drop
# its \current\bin entry from your user PATH.
#
# Environment:
# CODEGRAPH_VERSION release tag to install (default: latest)
# CODEGRAPH_INSTALL_DIR install location (default: %LOCALAPPDATA%\codegraph)
$ErrorActionPreference = 'Stop'
$repo = 'colbymchenry/codegraph'
$installDir = if ($env:CODEGRAPH_INSTALL_DIR) { $env:CODEGRAPH_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'codegraph' }
# 1. Detect architecture -> target matching the release archives.
$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') { 'arm64' } else { 'x64' }
$target = "win32-$arch"
# 2. Resolve the version (latest release unless pinned).
$version = $env:CODEGRAPH_VERSION
if (-not $version) {
$version = (Invoke-RestMethod "https://api.github.com/repos/$repo/releases/latest").tag_name
}
if (-not $version) { throw "codegraph: could not resolve latest version; set CODEGRAPH_VERSION." }
# 3. Download + extract the bundle into a stable 'current' dir (overwritten on upgrade).
$url = "https://github.com/$repo/releases/download/$version/codegraph-$target.zip"
Write-Host "Installing CodeGraph $version ($target)..."
$tmp = Join-Path $env:TEMP ("cg-" + [guid]::NewGuid().ToString())
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
$zip = Join-Path $tmp 'cg.zip'
Invoke-WebRequest -Uri $url -OutFile $zip
$dest = Join-Path $installDir 'current'
if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
New-Item -ItemType Directory -Force -Path $dest | Out-Null
Expand-Archive -Path $zip -DestinationPath $dest -Force
# Archives contain a top-level codegraph-<target>\ dir; flatten it.
$inner = Join-Path $dest "codegraph-$target"
if (Test-Path $inner) {
Get-ChildItem -Force $inner | Move-Item -Destination $dest -Force
Remove-Item -Recurse -Force $inner
}
Remove-Item -Recurse -Force $tmp
# 4. Put the launcher dir on the user's PATH.
$binDir = Join-Path $dest 'bin'
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (($userPath -split ';') -notcontains $binDir) {
[Environment]::SetEnvironmentVariable('Path', "$binDir;$userPath", 'User')
Write-Host "Added $binDir to your PATH (restart your terminal to pick it up)."
}
Write-Host "Installed to $dest"
Write-Host "Run: codegraph --help"
Executable
+95
View File
@@ -0,0 +1,95 @@
#!/bin/sh
#
# CodeGraph standalone installer.
#
# Downloads a self-contained bundle (a vendored Node runtime + the app) from
# GitHub Releases. No Node.js, no build tools, no npm required — ideal for a
# fresh Linux VPS over SSH.
#
# curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
#
# Upgrade: re-run the same command.
# Uninstall: curl -fsSL .../install.sh | sh -s -- --uninstall
#
# Environment:
# CODEGRAPH_VERSION release tag to install (default: latest)
# CODEGRAPH_INSTALL_DIR bundle location (default: ~/.codegraph)
# CODEGRAPH_BIN_DIR symlink location (default: ~/.local/bin)
set -eu
REPO="colbymchenry/codegraph"
INSTALL_DIR="${CODEGRAPH_INSTALL_DIR:-$HOME/.codegraph}"
BIN_DIR="${CODEGRAPH_BIN_DIR:-$HOME/.local/bin}"
if [ "${1:-}" = "--uninstall" ]; then
rm -f "$BIN_DIR/codegraph"
rm -rf "$INSTALL_DIR"
echo "CodeGraph uninstalled (removed $INSTALL_DIR and $BIN_DIR/codegraph)."
exit 0
fi
# 1. Detect platform → target triple matching the release archives.
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Darwin) os="darwin" ;;
Linux) os="linux" ;;
*) echo "codegraph: unsupported OS '$os'." >&2; exit 1 ;;
esac
case "$arch" in
arm64|aarch64) arch="arm64" ;;
x86_64|amd64) arch="x64" ;;
*) echo "codegraph: unsupported architecture '$arch'." >&2; exit 1 ;;
esac
target="${os}-${arch}"
# 2. Resolve the version (latest release unless pinned).
#
# Resolve "latest" from the releases/latest *web* redirect, not the GitHub API:
# the unauthenticated API is rate-limited to 60 requests/hour per IP and returns
# 403 once exhausted — routine on shared/cloud hosts and CI (issue #325). The
# redirect (github.com/<repo>/releases/latest -> .../releases/tag/vX.Y.Z) has no
# such limit. Fall back to the API if the redirect can't be read.
version="${CODEGRAPH_VERSION:-}"
if [ -z "$version" ]; then
version="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest" \
| sed -n 's#.*/releases/tag/##p')"
fi
if [ -z "$version" ]; then
version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \
| sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1)"
fi
[ -n "$version" ] || { echo "codegraph: could not resolve latest version; set CODEGRAPH_VERSION (e.g. CODEGRAPH_VERSION=v0.9.4)." >&2; exit 1; }
# Release tags are vX.Y.Z; accept a bare X.Y.Z in CODEGRAPH_VERSION too.
case "$version" in v*) ;; *) version="v$version" ;; esac
# 3. Download + extract the bundle.
url="https://github.com/$REPO/releases/download/$version/codegraph-${target}.tar.gz"
echo "Installing CodeGraph $version ($target)..."
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl -fsSL "$url" -o "$tmp/cg.tar.gz" || { echo "codegraph: download failed: $url" >&2; exit 1; }
dest="$INSTALL_DIR/versions/$version"
rm -rf "$dest"
mkdir -p "$dest"
# Archives contain a top-level codegraph-<target>/ dir; strip it.
tar -xzf "$tmp/cg.tar.gz" -C "$dest" --strip-components=1
# 4. Symlink the launcher onto PATH and mark the current version.
mkdir -p "$BIN_DIR"
ln -sf "$dest/bin/codegraph" "$BIN_DIR/codegraph"
ln -sfn "$dest" "$INSTALL_DIR/current"
echo "Installed to $dest"
echo "Linked $BIN_DIR/codegraph"
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*)
echo ""
echo "$BIN_DIR is not on your PATH. Add it:"
echo " export PATH=\"$BIN_DIR:\$PATH\""
;;
esac
echo ""
echo "Done. Run: codegraph --help"
+1609
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@colbymchenry/codegraph",
"version": "0.9.4",
"description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"codegraph": "./dist/bin/codegraph.js"
},
"files": [
"dist",
"scripts",
"README.md"
],
"scripts": {
"build": "tsc && npm run copy-assets && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"",
"preuninstall": "node dist/bin/uninstall.js",
"copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"",
"dev": "tsc --watch",
"cli": "npm run build && node dist/bin/codegraph.js",
"test": "vitest run",
"test:watch": "vitest",
"test:eval": "vitest run __tests__/evaluation/",
"eval": "npm run build && npx tsx __tests__/evaluation/runner.ts",
"clean": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true})\""
},
"keywords": [
"code-intelligence",
"knowledge-graph",
"static-analysis"
],
"author": "",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^1.3.0",
"commander": "^14.0.2",
"fast-string-width": "^3.0.2",
"fast-wrap-ansi": "^0.2.0",
"ignore": "^7.0.5",
"jsonc-parser": "^3.3.1",
"picomatch": "^4.0.3",
"sisteransi": "^1.0.5",
"tree-sitter-wasms": "^0.1.11",
"web-tree-sitter": "^0.25.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^20.19.30",
"@types/picomatch": "^4.0.2",
"typescript": "^5.0.0",
"vitest": "^2.1.9"
},
"engines": {
"node": ">=20.0.0 <25.0.0"
}
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Add-lang benchmark for ONE repo:
# clone -> wipe+index (with the codegraph on PATH) -> verify extraction ->
# with/without retrieval A/B (reuses scripts/agent-eval/run-all.sh).
#
# Assumes the codegraph dev build is already built + linked on PATH — the skill
# runs `npm run build && ./scripts/local-install.sh` ONCE before looping repos.
# The A/B is skipped if extraction fails its critical checks (don't burn $ on a
# broken extractor); set FORCE_AB=1 to run it anyway.
#
# Usage: bench.sh <lang> <repo-name> <repo-url> "<question>" [headless|tmux|all]
# Env: CORPUS corpus dir (default /tmp/codegraph-corpus, shared with agent-eval)
set -uo pipefail
LANG_TOKEN="${1:?usage: bench.sh <lang> <repo-name> <repo-url> \"<question>\" [mode]}"
NAME="${2:?repo-name required}"
URL="${3:?repo-url required}"
Q="${4:?question required}"
MODE="${5:-headless}"
HARNESS="$(cd "$(dirname "$0")" && pwd)"
AGENT_EVAL="$(cd "$HARNESS/../agent-eval" && pwd)"
CORPUS="${CORPUS:-/tmp/codegraph-corpus}"
REPO="$CORPUS/$NAME"
command -v codegraph >/dev/null || { echo "no codegraph on PATH (build + ./scripts/local-install.sh first)"; exit 1; }
echo "==================== add-lang bench: $NAME ($LANG_TOKEN) ===================="
echo "codegraph: $(command -v codegraph) -> $(codegraph --version 2>/dev/null || echo '?')"
# 1. Ensure the repo (shallow clone, reuse if present).
mkdir -p "$CORPUS"
if [ -d "$REPO/.git" ]; then
echo "→ reusing checkout: $REPO"
else
echo "→ cloning $URL"
git clone --depth 1 "$URL" "$REPO" || { echo "git clone failed"; exit 1; }
fi
# 2. Wipe + index with the binary under test.
echo "→ wiping .codegraph and indexing"
rm -rf "$REPO/.codegraph"
( cd "$REPO" && codegraph init -i ) || { echo "indexing failed"; exit 1; }
# 3. Verify extraction (cheap guard before the paid A/B).
echo "→ verifying extraction"
node "$HARNESS/verify-extraction.mjs" "$REPO" "$LANG_TOKEN"
VERIFY=$?
# 4. Retrieval A/B (skipped if extraction is broken, unless FORCE_AB=1).
if [ "$VERIFY" -ne 0 ] && [ "${FORCE_AB:-0}" != "1" ]; then
echo "→ SKIPPING A/B — extraction failed critical checks (set FORCE_AB=1 to override)"
else
echo "→ retrieval A/B (mode=$MODE)"
bash "$AGENT_EVAL/run-all.sh" "$REPO" "$Q" "$MODE"
fi
echo "==================== bench complete: $NAME (verify exit=$VERIFY) ===================="
# Exit reflects extraction: 0 = pass/warn, 1 = critical fail, 2 = couldn't read status.
exit "$VERIFY"
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
// Verify a tree-sitter grammar wasm is HEALTHY under the project's web-tree-sitter
// runtime BEFORE writing an extractor. Prints the ABI version and parses a valid
// sample many times in a multi-grammar context, to catch heap-corruption bugs
// that silently drop nodes on every parse after the first.
//
// Why this exists: the tree-sitter-wasms Lua grammar is ABI 13 and corrupts the
// shared WASM heap under web-tree-sitter 0.25 — Lua extraction degraded on every
// file after the first (nested calls/imports vanished). The fix was to vendor the
// upstream ABI-15 wasm. Run this on any new grammar first; if it FAILs, vendor a
// newer build instead of using the tree-sitter-wasms one.
//
// Usage: node scripts/add-lang/check-grammar.mjs <lang|wasm-path> <valid-sample> [iterations]
// Exit: 0 healthy, 1 corruption / parse errors, 2 could not run.
// NOTE: the sample must be SYNTACTICALLY VALID — a broken sample fails for the
// wrong reason.
import { readFileSync, existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { Parser, Language } from 'web-tree-sitter';
const require = createRequire(import.meta.url);
const fail = (code, msg) => { console.error(`[check-grammar] ${msg}`); process.exit(code); };
const [token, sample, iterArg] = process.argv.slice(2);
if (!token || !sample) fail(2, 'usage: check-grammar.mjs <lang|wasm-path> <valid-sample> [iterations]');
if (!existsSync(sample)) fail(2, `sample not found: ${sample}`);
const iters = iterArg ? parseInt(iterArg, 10) : 20;
const SPECIAL = { csharp: 'c_sharp', 'c#': 'c_sharp' };
function resolveWasm(t) {
if (t.endsWith('.wasm')) return existsSync(t) ? t : fail(2, `wasm not found: ${t}`);
const base = SPECIAL[t.toLowerCase()] ?? t.toLowerCase();
try { return require.resolve(`tree-sitter-wasms/out/tree-sitter-${base}.wasm`); } catch { /* try vendored */ }
const vendored = `src/extraction/wasm/tree-sitter-${base}.wasm`;
if (existsSync(vendored)) return vendored;
return fail(2, `no grammar for "${t}" — not in tree-sitter-wasms and not vendored`);
}
const wasmPath = resolveWasm(token);
const source = readFileSync(sample, 'utf8');
try { await Parser.init(); }
catch { await Parser.init({ locateFile: () => require.resolve('web-tree-sitter/tree-sitter.wasm') }); }
// Load a second, known-good grammar — the corruption surfaces under the
// multi-grammar runtime that real indexing uses, not a single grammar in isolation.
try { await Language.load(require.resolve('tree-sitter-wasms/out/tree-sitter-python.wasm')); } catch { /* ok */ }
let language;
try { language = await Language.load(wasmPath); }
catch (e) { fail(2, `failed to load ${wasmPath}: ${e.message}`); }
const parser = new Parser();
parser.setLanguage(language);
let ok = 0, err = 0;
for (let i = 0; i < iters; i++) {
const tree = parser.parse(source);
if (tree.rootNode.hasError) err++; else ok++;
}
console.log(`grammar: ${wasmPath.split('/').pop()}`);
console.log(` ABI version: ${language.abiVersion}`);
console.log(` parses: ${ok} clean / ${err} with errors (of ${iters})`);
if (err > 0) {
console.log(
`RESULT: FAIL — ${err}/${iters} parses produced ERROR trees on a valid sample. ` +
`This grammar corrupts under web-tree-sitter; vendor a newer (ABI 14/15) wasm ` +
`(see SKILL.md "Find a grammar"). Confirm your sample is syntactically valid first.`
);
process.exit(1);
}
console.log('RESULT: PASS — grammar parses cleanly and reuses safely.');
process.exit(0);
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
// Dump the tree-sitter AST for a sample file so you can write a LanguageExtractor
// mapping. Loads a grammar .wasm directly via web-tree-sitter (the same runtime
// codegraph uses) — you do NOT need to register the language first.
//
// Usage:
// node scripts/add-lang/dump-ast.mjs <lang|wasm-path> <sample-file> [--depth=N] [--full]
// Examples:
// node scripts/add-lang/dump-ast.mjs lua sample.lua
// node scripts/add-lang/dump-ast.mjs src/extraction/wasm/tree-sitter-zig.wasm a.zig --depth=4
//
// Output: an indented AST (named nodes, with field names) followed by a
// node-type FREQUENCY table. The frequency table is the payoff — it tells you
// which node types to map to functionTypes / classTypes / importTypes / etc.
import { readFileSync, existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { Parser, Language } from 'web-tree-sitter';
const require = createRequire(import.meta.url);
const fail = (msg) => { console.error(`[dump-ast] ${msg}`); process.exit(1); };
const argv = process.argv.slice(2);
const positional = argv.filter((a) => !a.startsWith('--'));
const [langOrWasm, sampleFile] = positional;
const depthFlag = argv.find((a) => a.startsWith('--depth='));
const showAll = argv.includes('--full'); // also print anonymous (token) nodes
const maxDepth = depthFlag ? parseInt(depthFlag.split('=')[1], 10) : (showAll ? Infinity : 8);
if (!langOrWasm || !sampleFile) {
fail('usage: dump-ast.mjs <lang|wasm-path> <sample-file> [--depth=N] [--full]');
}
if (!existsSync(sampleFile)) fail(`sample file not found: ${sampleFile}`);
// Language tokens whose tree-sitter-wasms filename differs from the token.
const WASM_SPECIAL = { csharp: 'c_sharp', 'c#': 'c_sharp' };
function resolveWasm(token) {
if (token.endsWith('.wasm')) {
if (!existsSync(token)) fail(`wasm not found: ${token}`);
return token;
}
const base = WASM_SPECIAL[token.toLowerCase()] ?? token.toLowerCase();
try {
return require.resolve(`tree-sitter-wasms/out/tree-sitter-${base}.wasm`);
} catch {
/* not in tree-sitter-wasms — try a vendored copy */
}
const vendored = `src/extraction/wasm/tree-sitter-${base}.wasm`;
if (existsSync(vendored)) return vendored;
fail(
`no grammar for "${token}" — not in tree-sitter-wasms and not vendored at ` +
`${vendored}. Pass an explicit .wasm path, or vendor one (see SKILL.md "Find a grammar").`
);
}
const wasmPath = resolveWasm(langOrWasm);
const source = readFileSync(sampleFile, 'utf8');
try {
await Parser.init();
} catch {
await Parser.init({ locateFile: () => require.resolve('web-tree-sitter/tree-sitter.wasm') });
}
let language;
try {
language = await Language.load(wasmPath);
} catch (e) {
fail(`failed to load grammar ${wasmPath}: ${e.message}`);
}
const parser = new Parser();
parser.setLanguage(language);
const tree = parser.parse(source);
const freq = new Map();
const snippet = (node) => {
const t = node.text.replace(/\s+/g, ' ').trim();
return t.length > 48 ? `${t.slice(0, 48)}…` : t;
};
function walk(node, depth, fieldName) {
if (node.isNamed) freq.set(node.type, (freq.get(node.type) || 0) + 1);
if ((node.isNamed || showAll) && depth <= maxDepth) {
const field = fieldName ? `${fieldName}: ` : '';
const leaf = node.childCount === 0 ? ` "${snippet(node)}"` : '';
console.log(`${' '.repeat(depth)}${field}${node.type} @${node.startPosition.row + 1}:${node.startPosition.column}${leaf}`);
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walk(child, depth + 1, node.fieldNameForChild(i));
}
}
console.log(`\n# AST for ${sampleFile} (grammar: ${wasmPath.split('/').pop()})\n`);
walk(tree.rootNode, 0, null);
console.log('\n# Node-type frequency (named nodes) — map the relevant ones in your extractor:\n');
[...freq.entries()]
.sort((a, b) => b[1] - a[1])
.forEach(([type, n]) => console.log(` ${String(n).padStart(5)} ${type}`));
console.log();
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
// Sanity-check that codegraph extracted REAL symbols (not just file/import nodes)
// from a repo for a given language. Exits non-zero on a critical failure so it
// can drive a write-extractor -> build -> re-check loop.
//
// Usage: node scripts/add-lang/verify-extraction.mjs <repo-path> <lang>
// Reads `codegraph status <repo> --json` using whatever codegraph is on PATH,
// so it reflects the binary that built the index.
//
// Exit codes: 0 = pass or soft-warn, 1 = critical fail, 2 = could not run.
import { execFileSync } from 'node:child_process';
const [repo, lang] = process.argv.slice(2);
if (!repo || !lang) {
console.error('usage: verify-extraction.mjs <repo-path> <lang>');
process.exit(2);
}
let status;
try {
const out = execFileSync('codegraph', ['status', repo, '--json'], { encoding: 'utf8' });
status = JSON.parse(out);
} catch (e) {
console.error(`[verify] could not read codegraph status for ${repo}: ${e.message}`);
process.exit(2);
}
// Kinds that prove the extractor mapped AST node types (everything except
// 'file' and 'import', which codegraph creates structurally for any language).
const SYMBOL_KINDS = new Set([
'module', 'class', 'struct', 'interface', 'trait', 'protocol', 'function',
'method', 'property', 'field', 'variable', 'constant', 'enum', 'enum_member',
'type_alias', 'namespace', 'route', 'component',
]);
const byKind = status.nodesByKind || {};
const langs = status.languages || [];
const files = status.fileCount || 0;
const edges = status.edgeCount || 0;
const symbolKinds = Object.keys(byKind).filter((k) => SYMBOL_KINDS.has(k));
const symbolCount = symbolKinds.reduce((s, k) => s + byKind[k], 0);
const checks = [];
const add = (severity, ok, label, detail) => checks.push({ severity, ok, label, detail });
add('critical', status.initialized === true, 'index initialized', `initialized=${status.initialized}`);
add('critical', langs.includes(lang), `language "${lang}" detected`, `languages=[${langs.join(', ')}]`);
add('critical', symbolCount > 0, 'structural symbols extracted', `${symbolCount} symbols (${symbolKinds.join(', ') || 'NONE — only file/import nodes!'})`);
add('soft', symbolCount >= files, 'symbol density >= 1/file', `${symbolCount} symbols across ${files} files`);
add('soft', edges > files, 'edges resolved', `${edges} edges across ${files} files`);
console.log(`\n# Extraction check — ${repo} (lang=${lang}, backend=${status.backend})`);
console.log(` files=${files} nodes=${status.nodeCount} edges=${edges}`);
console.log(` nodesByKind: ${JSON.stringify(byKind)}\n`);
for (const c of checks) console.log(` ${c.ok ? '✓' : '✗'} ${c.label} — ${c.detail}`);
const critical = checks.filter((c) => !c.ok && c.severity === 'critical');
const soft = checks.filter((c) => !c.ok && c.severity === 'soft');
console.log();
if (critical.length) {
console.log(`RESULT: FAIL (${critical.length} critical) — extractor or grammar wiring is broken. Re-run dump-ast.mjs and fix the node-type mappings.`);
process.exit(1);
}
if (soft.length) {
console.log(`RESULT: WARN (${soft.length} soft) — extraction works but looks thin; inspect the counts above.`);
process.exit(0);
}
console.log('RESULT: PASS — extraction looks healthy.');
process.exit(0);
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# One-shot CodeGraph quality audit:
# set version -> ensure corpus repo -> wipe+reindex with that version ->
# run with/without A/B -> restore the local dev link.
#
# Usage: audit.sh <version> <repo-name> <repo-url> "<question>" [headless|all]
# <version> "local" (build + npm link this repo) | "latest" | a version (e.g. 0.7.10)
# <repo-name> dir name under the corpus dir
# <repo-url> git URL (cloned --depth 1 when the repo dir is missing)
# [mode] headless (default) | all (also the interactive tmux arms)
# Env: CORPUS corpus dir (default: /tmp/codegraph-corpus)
set -uo pipefail
VERSION="${1:?usage: audit.sh <version> <repo-name> <repo-url> \"<question>\" [mode]}"
NAME="${2:?repo-name required}"
URL="${3:?repo-url required}"
Q="${4:?question required}"
MODE="${5:-headless}"
HARNESS="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$HARNESS/../.." && pwd)" # codegraph repo root
CORPUS="${CORPUS:-/tmp/codegraph-corpus}"
REPO="$CORPUS/$NAME"
PKG="@colbymchenry/codegraph"
echo "==================== CodeGraph audit ===================="
echo "version=$VERSION repo=$NAME mode=$MODE corpus=$CORPUS"
echo
# 1. Set the codegraph version under test (mutates the global install).
if [ "$VERSION" = local ]; then
echo "→ [1/4] building + linking local dev build (local-install.sh)"
( cd "$REPO_ROOT" && ./scripts/local-install.sh ) || { echo "local-install.sh failed"; exit 1; }
else
echo "→ [1/4] installing $PKG@$VERSION globally"
npm install -g "$PKG@$VERSION" || { echo "npm install -g $PKG@$VERSION failed"; exit 1; }
fi
ACTUAL="$(codegraph --version 2>/dev/null || echo '?')"
echo " codegraph on PATH: $(command -v codegraph) -> $ACTUAL"
# 2. Ensure the corpus repo exists (clone shallow if missing, reuse if present).
mkdir -p "$CORPUS"
if [ -d "$REPO/.git" ]; then
echo "→ [2/4] reusing existing checkout: $REPO"
else
echo "→ [2/4] cloning $URL"
git clone --depth 1 "$URL" "$REPO" || { echo "git clone failed"; exit 1; }
fi
# 3. Wipe + re-index with THIS version (the index must be built by the same
# binary that serves it — different versions extract differently).
echo "→ [3/4] wiping .codegraph and re-indexing with $ACTUAL"
rm -rf "$REPO/.codegraph"
( cd "$REPO" && codegraph init -i ) || { echo "indexing failed"; exit 1; }
# 4. Run the with/without A/B.
echo "→ [4/4] running A/B harness (mode=$MODE)"
bash "$HARNESS/run-all.sh" "$REPO" "$Q" "$MODE"
# Restore the dev link (the normal working state in this repo).
echo
echo "→ restoring local dev link (local-install.sh)"
if ( cd "$REPO_ROOT" && ./scripts/local-install.sh >/dev/null 2>&1 ); then
echo " global codegraph restored to dev build"
else
echo " WARN: restore failed — run ./scripts/local-install.sh manually"
fi
echo "==================== audit complete ===================="
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Drive an INTERACTIVE Claude Code session in tmux, send a prompt, wait for the
# agent to finish, then print the tool-call breakdown from the session logs.
#
# Why interactive (not `claude -p`): headless print-mode picks the
# general-purpose subagent, while real interactive sessions delegate to the
# Explore subagent (or drive codegraph from the main thread). Only the
# interactive TUI reproduces the behavior users actually see. (Idle-detection
# technique borrowed from devpit's WaitForIdle.)
#
# Usage: itrun.sh <repo-path> <label> "<prompt>"
# Output dir: $AGENT_EVAL_OUT (default /tmp/agent-eval)
# Requires: tmux 3.0+, a logged-in `claude` CLI, codegraph MCP configured.
set -uo pipefail
REPO="$1"; LABEL="$2"; PROMPT="$3"
SESSION="cgt_${LABEL}"
OUT_DIR="${AGENT_EVAL_OUT:-/tmp/agent-eval}"; mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/itrun-${LABEL}.txt"
HERE="$(cd "$(dirname "$0")" && pwd)"
cap() { tmux capture-pane -p -t "$SESSION" -S -40; }
tmux kill-session -t "$SESSION" 2>/dev/null
# Wide pane so the TUI doesn't hard-wrap tool lines.
tmux new-session -d -s "$SESSION" -x 230 -y 60
tmux send-keys -t "$SESSION" "cd $REPO && claude --dangerously-skip-permissions ${CLAUDE_EXTRA_ARGS:-}" Enter
# Wait for the ❯ prompt (claude drew its UI), up to 60s. NOTE: ❯ appears on the
# welcome screen seconds before the input actually accepts keystrokes, so this is
# necessary but NOT sufficient — the type-and-verify loop below is what proves
# the input is live.
ready=0
for _ in $(seq 1 120); do
cap | grep -q "❯" && { ready=1; break; }
sleep 0.5
done
[ "$ready" = 1 ] || { echo "claude never drew its UI"; cap; tmux kill-session -t "$SESSION" 2>/dev/null; exit 1; }
# Accept the per-folder "Is this a project you trust?" dialog if it shows (first
# time claude opens a given repo). Option 1 ("Yes, I trust this folder") is
# pre-selected, so Enter accepts. This dialog also contains ❯, so it must be
# cleared before the type-and-verify loop or keystrokes land on the menu.
for _ in $(seq 1 20); do
cap | grep -q "trust this folder" || break
tmux send-keys -t "$SESSION" Enter
sleep 1
done
# Type-and-verify: send the prompt, confirm a distinctive chunk of it actually
# landed in the input box, retry if it didn't (handles the early-❯ race where
# the welcome screen shows the prompt glyph but MCP init is still eating keys).
needle="${PROMPT:0:24}"
typed=0
for _ in $(seq 1 30); do
tmux send-keys -l -t "$SESSION" "$PROMPT"
sleep 1
if cap | grep -Fq "$needle"; then typed=1; break; fi
# Clear whatever partial text may have landed, then retry.
tmux send-keys -t "$SESSION" C-u
sleep 1
done
[ "$typed" = 1 ] || { echo "prompt never landed in the input box"; cap; tmux kill-session -t "$SESSION" 2>/dev/null; exit 1; }
sleep 0.5
tmux send-keys -t "$SESSION" Enter
# Busy signals. The robust one is the spinner's elapsed-time-in-parens, which
# EVERY working state shows — both the pre-stream thinking phase
# "(8s · thinking with max effort)" and the streaming phase
# "(24s · ↑ 2.5k tokens · …)", and it survives the 32s→"1m 3s" rollover. We OR
# in the token arrows, "esc to interrupt", and "Initializing" as belt-and-braces
# (some TUI versions/states show one but not the others).
BUSY_RE='esc to interrupt|↓ [0-9]|↑ [0-9]|Initializing|\(([0-9]+m )?[0-9]+s ·'
# Wait for work to START (busy indicator appears), up to 60s. If it never starts,
# fail loudly rather than silently reporting an empty run.
started=0
for _ in $(seq 1 120); do
cap | grep -qE "$BUSY_RE" && { started=1; break; }
sleep 0.5
done
[ "$started" = 1 ] || { echo "agent never started working"; cap; tmux kill-session -t "$SESSION" 2>/dev/null; exit 1; }
# Poll for idle: not busy AND ❯ present, for 10 consecutive polls (~5s) to ride
# out mid-conversation thinking gaps that briefly drop the spinner. Up to ~15min.
consec=0
for _ in $(seq 1 1800); do
pane=$(cap)
if echo "$pane" | grep -qE "$BUSY_RE"; then
consec=0
elif echo "$pane" | grep -q "❯"; then
consec=$((consec+1)); [ "$consec" -ge 10 ] && break
else
consec=0
fi
sleep 0.5
done
sleep 1
tmux capture-pane -p -t "$SESSION" -S - > "$OUT"
echo "captured $(wc -l < "$OUT") lines -> $OUT"
grep -oE "Done \([^)]*\)" "$OUT" | tail -1
grep -oE "[0-9.]+k?/[0-9.]+M" "$OUT" | tail -1 | sed 's/^/Context /'
tmux kill-session -t "$SESSION" 2>/dev/null
# Clean tool breakdown from the session logs (main + subagents).
node "$HERE/parse-session.mjs" "$REPO" 2>/dev/null || true
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
// Parse a Claude Code stream-json run log: tool-call sequence + token usage.
import { readFileSync } from 'fs';
const file = process.argv[2];
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
const toolCalls = [];
let result = null;
let initTools = null;
for (const line of lines) {
let ev;
try { ev = JSON.parse(line); } catch { continue; }
if (ev.type === 'system' && ev.subtype === 'init') {
initTools = (ev.tools || []).filter(t => /codegraph/.test(t));
}
if (ev.type === 'assistant' && ev.message?.content) {
for (const block of ev.message.content) {
if (block.type === 'tool_use') {
let detail = '';
if (block.name === 'Task') detail = ` [subagent_type=${block.input?.subagent_type ?? '?'}] ${(block.input?.description ?? '').slice(0,40)}`;
else if (/codegraph/.test(block.name)) detail = ` ${JSON.stringify(block.input?.query ?? block.input?.task ?? block.input?.symbol ?? '').slice(0,60)}`;
else if (block.name === 'Bash') detail = ` ${(block.input?.command ?? '').slice(0,50)}`;
else if (block.name === 'Read') detail = ` ${(block.input?.file_path ?? '').split('/').slice(-1)[0]}`;
toolCalls.push(`${block.name}${detail}`);
}
}
}
if (ev.type === 'result') result = ev;
}
console.log(`\n=== ${file.split('/').pop()} ===`);
console.log(`codegraph tools exposed: ${initTools ? initTools.length : '?'}`);
console.log(`\nTool calls (${toolCalls.length}):`);
const counts = {};
for (const tc of toolCalls) { const n = tc.split(' ')[0]; counts[n] = (counts[n]||0)+1; }
console.log(' by type:', JSON.stringify(counts));
toolCalls.forEach((tc, i) => console.log(` ${i+1}. ${tc}`));
if (result) {
const u = result.usage || {};
const totalIn = (u.input_tokens||0) + (u.cache_read_input_tokens||0) + (u.cache_creation_input_tokens||0);
console.log(`\nResult: ${result.subtype} | duration ${(result.duration_ms/1000).toFixed(0)}s | turns ${result.num_turns}`);
console.log(` tokens: in=${totalIn} out=${u.output_tokens||0} | cost $${(result.total_cost_usd||0).toFixed(3)}`);
}
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
// Parse the newest Claude Code session log for a project + its subagent logs,
// and report the tool-call breakdown (main + subagents). Works for interactive
// runs (driven via itrun.sh) — Claude Code writes full transcripts to
// ~/.claude/projects/<escaped-cwd>/<session>.jsonl with subagents/ alongside.
import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
const projectArg = process.argv[2];
if (!projectArg) { console.error('usage: parse-session.mjs <project-dir>'); process.exit(1); }
// Claude Code escapes the (real) cwd by replacing every "/" with "-".
const real = realpathSync(projectArg);
const escaped = real.replace(/\//g, '-');
const projDir = join(homedir(), '.claude', 'projects', escaped);
if (!existsSync(projDir)) { console.error('no session logs at', projDir); process.exit(1); }
// Newest top-level session .jsonl
const sessions = readdirSync(projDir)
.filter(f => f.endsWith('.jsonl'))
.map(f => ({ f, m: statSync(join(projDir, f)).mtimeMs }))
.sort((a, b) => b.m - a.m);
if (sessions.length === 0) { console.error('no .jsonl sessions in', projDir); process.exit(1); }
const sessionId = sessions[0].f.replace('.jsonl', '');
function tally(file) {
const counts = {};
for (const line of readFileSync(file, 'utf8').split('\n')) {
if (!line) continue;
let ev; try { ev = JSON.parse(line); } catch { continue; }
const content = ev.message?.content;
if (!Array.isArray(content)) continue;
for (const b of content) {
if (b.type === 'tool_use') counts[b.name] = (counts[b.name] || 0) + 1;
}
}
return counts;
}
// Sum token usage from a transcript. The TUI's "Done (…Xk tokens…)" line only
// covers a subagent's throughput; this works for main-thread runs too and is
// consistent across both paths. `gen` = output, `fresh` = uncached input
// (input + cache_creation), `cached` = cache reads (≈free), `total` = all.
function sumTokens(file) {
const t = { gen: 0, fresh: 0, cached: 0 };
for (const line of readFileSync(file, 'utf8').split('\n')) {
if (!line) continue;
let ev; try { ev = JSON.parse(line); } catch { continue; }
const u = ev.message?.usage;
if (!u) continue;
t.gen += u.output_tokens || 0;
t.fresh += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
t.cached += u.cache_read_input_tokens || 0;
}
return t;
}
const mainCounts = tally(join(projDir, sessionId + '.jsonl'));
// Subagent transcripts live under <session>/subagents/*.jsonl
const subDir = join(projDir, sessionId, 'subagents');
const subCounts = {};
let subAgentFiles = 0;
if (existsSync(subDir)) {
for (const f of readdirSync(subDir).filter(f => f.endsWith('.jsonl'))) {
subAgentFiles++;
const c = tally(join(subDir, f));
for (const [k, v] of Object.entries(c)) subCounts[k] = (subCounts[k] || 0) + v;
}
}
const fmt = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1])
.map(([k, v]) => ` ${String(v).padStart(3)} ${k}`).join('\n') || ' (none)';
console.log(`session: ${sessionId}`);
console.log(`\nMAIN thread tools:\n${fmt(mainCounts)}`);
console.log(`\nSUBAGENT tools (${subAgentFiles} subagent transcript${subAgentFiles === 1 ? '' : 's'}):\n${fmt(subCounts)}`);
const explore = subCounts['mcp__codegraph__codegraph_explore'] || mainCounts['mcp__codegraph__codegraph_explore'] || 0;
const reads = (subCounts['Read'] || 0) + (mainCounts['Read'] || 0);
const greps = (subCounts['Grep'] || 0) + (mainCounts['Grep'] || 0) + (subCounts['Bash'] || 0) + (mainCounts['Bash'] || 0);
console.log(`\nVERDICT: codegraph_explore used ${explore}x | Read ${reads} | Grep/Bash ${greps}`);
// Token totals (main + subagents), consistent across main-thread and subagent runs.
const tok = { gen: 0, fresh: 0, cached: 0 };
const addTok = (t) => { tok.gen += t.gen; tok.fresh += t.fresh; tok.cached += t.cached; };
addTok(sumTokens(join(projDir, sessionId + '.jsonl')));
if (existsSync(subDir)) {
for (const f of readdirSync(subDir).filter(f => f.endsWith('.jsonl'))) addTok(sumTokens(join(subDir, f)));
}
const k = (n) => (n / 1000).toFixed(1) + 'k';
console.log(`TOKENS: gen ${k(tok.gen)} | fresh-in ${k(tok.fresh)} | cached-in ${k(tok.cached)} | billable≈ ${k(tok.gen + tok.fresh)}`);
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Headless Claude Code run against a repo with codegraph MCP, capturing the
# full stream-json so we can see tool calls + token usage. Complements the
# interactive itrun.sh: headless gives a clean per-tool breakdown + exact
# tokens/cost, but defaults to the general-purpose subagent (not Explore).
# To force the Explore path, ask for it in the prompt.
#
# Usage: run-agent.sh <repo-path> <label> "<prompt>"
# Env: AGENT_EVAL_OUT (default /tmp/agent-eval), CG_BIN (codegraph dist binary)
set -uo pipefail
REPO="$1"; LABEL="$2"; PROMPT="$3"
CG_BIN="${CG_BIN:-$(command -v codegraph || echo /usr/local/bin/codegraph)}"
OUT_DIR="${AGENT_EVAL_OUT:-/tmp/agent-eval}"; mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/run-${LABEL}.jsonl"
MCP_CONFIG=$(cat <<JSON
{"mcpServers":{"codegraph":{"command":"${CG_BIN}","args":["serve","--mcp","--path","${REPO}"]}}}
JSON
)
echo "→ running [$LABEL] in $REPO"
cd "$REPO" || exit 1
claude -p "$PROMPT" \
--output-format stream-json --verbose \
--permission-mode bypassPermissions \
--model opus \
--max-budget-usd 2 \
--strict-mcp-config --mcp-config "$MCP_CONFIG" \
> "$OUT" 2>"$OUT_DIR/run-${LABEL}.err"
echo "exit: $? | wrote $OUT ($(wc -l < "$OUT") lines)"
node "$(cd "$(dirname "$0")" && pwd)/parse-run.mjs" "$OUT" 2>/dev/null || true
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# With/without A/B (and optional interactive) eval for a codegraph version on a
# repo. Codegraph is the ONLY variable: both arms launch claude with
# --strict-mcp-config — with = codegraph-only MCP (pointed at $CG_BIN),
# without = empty MCP. Built-in Read/Grep/Bash stay available in both arms.
#
# Usage: run-all.sh <repo-path> "<question>" [headless|tmux|all]
# Env: CG_BIN codegraph binary (default: command -v codegraph)
# AGENT_EVAL_OUT output dir (default: /tmp/agent-eval)
set -uo pipefail
REPO="${1:?usage: run-all.sh <repo-path> \"<question>\" [headless|tmux|all]}"
Q="${2:?question required}"
MODE="${3:-headless}"
CG_BIN="${CG_BIN:-$(command -v codegraph)}"
OUT="${AGENT_EVAL_OUT:-/tmp/agent-eval}"
HARNESS="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$OUT"
[ -n "$CG_BIN" ] || { echo "no codegraph binary on PATH (set CG_BIN)"; exit 1; }
[ -d "$REPO/.codegraph" ] || { echo "no .codegraph index at $REPO — index it first"; exit 1; }
case "$MODE" in headless|tmux|all) ;; *) echo "mode must be headless|tmux|all (got '$MODE')"; exit 1;; esac
# MCP config files (path form avoids inline-JSON quoting through tmux).
cat > "$OUT/mcp-codegraph.json" <<JSON
{"mcpServers":{"codegraph":{"command":"$CG_BIN","args":["serve","--mcp","--path","$REPO"]}}}
JSON
echo '{"mcpServers":{}}' > "$OUT/mcp-empty.json"
echo "###### codegraph: $CG_BIN"
echo "###### repo: $REPO"
echo "###### question: $Q"
echo
# Headless arm: claude -p with stream-json -> exact tool sequence + tokens/cost.
headless() {
local label="$1" cfg="$2"
echo "############################## HEADLESS [$label] ##############################"
( cd "$REPO" && claude -p "$Q" \
--output-format stream-json --verbose \
--permission-mode bypassPermissions \
--model opus \
--max-budget-usd 4 \
--strict-mcp-config --mcp-config "$cfg" \
> "$OUT/run-$label.jsonl" 2>"$OUT/run-$label.err" )
echo "exit $? -> $OUT/run-$label.jsonl ($(wc -l < "$OUT/run-$label.jsonl" | tr -d ' ') lines)"
tail -2 "$OUT/run-$label.err" 2>/dev/null
node "$HARNESS/parse-run.mjs" "$OUT/run-$label.jsonl" 2>&1 || true
echo
}
if [ "$MODE" = headless ] || [ "$MODE" = all ]; then
headless "headless-with" "$OUT/mcp-codegraph.json"
headless "headless-without" "$OUT/mcp-empty.json"
fi
if [ "$MODE" = tmux ] || [ "$MODE" = all ]; then
echo "############################## INTERACTIVE [with] ##############################"
CLAUDE_EXTRA_ARGS="--model opus --strict-mcp-config --mcp-config $OUT/mcp-codegraph.json" \
bash "$HARNESS/itrun.sh" "$REPO" "int-with" "$Q" 2>&1 || echo "[itrun WITH failed]"
echo
echo "############################## INTERACTIVE [without] ##############################"
CLAUDE_EXTRA_ARGS="--model opus --strict-mcp-config --mcp-config $OUT/mcp-empty.json" \
bash "$HARNESS/itrun.sh" "$REPO" "int-without" "$Q" 2>&1 || echo "[itrun WITHOUT failed]"
echo
fi
echo "############################## RUN-ALL COMPLETE ##############################"
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
#
# Build a self-contained CodeGraph bundle: an official Node runtime + the
# compiled app + its production deps, so CodeGraph runs with NO system Node and
# NO native build — node:sqlite is built into the bundled Node. One archive per
# platform.
#
# Because dropping better-sqlite3 left zero native addons, the recipe is pure
# file-packaging (download the target's Node, copy the app, archive) — so any
# platform's bundle can be built on any OS. No cross-compile, no native runners.
#
# Usage:
# scripts/build-bundle.sh <target> [node-version]
# target: darwin-arm64 | darwin-x64 | linux-x64 | linux-arm64
# | win32-x64 | win32-arm64
# node-version: e.g. v24.16.0 (default below; pin for reproducible builds)
#
# Output:
# unix: release/codegraph-<target>.tar.gz (launcher: bin/codegraph)
# windows: release/codegraph-<target>.zip (launcher: bin/codegraph.cmd)
set -euo pipefail
TARGET="${1:?usage: build-bundle.sh <target> [node-version]}"
NODE_VERSION="${2:-v24.16.0}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/release"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
ARCH="${TARGET##*-}" # x64 | arm64
OSFAM="${TARGET%-*}" # darwin | linux | win32
echo "[bundle] target=${TARGET} node=${NODE_VERSION}"
# 1. Download + extract the official Node runtime for the target platform.
if [ "$OSFAM" = "win32" ]; then
NODE_DIST="node-${NODE_VERSION}-win-${ARCH}"
NODE_URL="https://nodejs.org/dist/${NODE_VERSION}/${NODE_DIST}.zip"
echo "[bundle] downloading ${NODE_URL}"
curl -fsSL "$NODE_URL" -o "$WORK/node.zip"
if command -v unzip >/dev/null 2>&1; then
unzip -q "$WORK/node.zip" -d "$WORK"
else
tar -xf "$WORK/node.zip" -C "$WORK" # bsdtar can read zip
fi
NODE_BIN="$WORK/${NODE_DIST}/node.exe"
else
NODE_DIST="node-${NODE_VERSION}-${TARGET}"
NODE_URL="https://nodejs.org/dist/${NODE_VERSION}/${NODE_DIST}.tar.gz"
echo "[bundle] downloading ${NODE_URL}"
curl -fsSL "$NODE_URL" -o "$WORK/node.tar.gz"
tar -xzf "$WORK/node.tar.gz" -C "$WORK"
NODE_BIN="$WORK/${NODE_DIST}/bin/node"
fi
[ -f "$NODE_BIN" ] || { echo "[bundle] error: node binary not found ($NODE_BIN)" >&2; exit 1; }
# 2. Build the app (compiled JS + copied wasm/schema assets).
echo "[bundle] building app"
( cd "$ROOT" && npm run build >/dev/null )
# 3. Stage: app + production-only deps (pure JS/wasm → portable across platforms).
STAGE="$WORK/codegraph-${TARGET}"
mkdir -p "$STAGE/lib" "$STAGE/bin"
cp -R "$ROOT/dist" "$STAGE/lib/dist"
cp "$ROOT/package.json" "$ROOT/package-lock.json" "$STAGE/lib/"
echo "[bundle] installing production dependencies"
( cd "$STAGE/lib" && npm ci --omit=dev --ignore-scripts >/dev/null 2>&1 )
rm -f "$STAGE/lib/package-lock.json"
# 4. Vendored Node + launcher (the launcher uses the bundled Node by relative
# path, so no system Node is ever needed).
#
# `--liftoff-only`: keep tree-sitter's large WASM grammars on V8's Liftoff
# baseline compiler so they never reach the turboshaft optimizing tier, whose
# per-compilation Zone arena OOMs the whole process (`Fatal process out of
# memory: Zone`) on Node >= 22 — even with tens of GB free. The flag is read at
# V8 engine init so it must be on node's command line; the parse worker inherits
# it. See issues #293/#298 and src/extraction/wasm-runtime-flags.ts. (The CLI
# also self-relaunches with this flag when launched without it, so non-bundled
# runs are covered too; passing it here avoids that extra spawn.)
if [ "$OSFAM" = "win32" ]; then
cp "$NODE_BIN" "$STAGE/node.exe"
printf '@"%%~dp0..\\node.exe" --liftoff-only "%%~dp0..\\lib\\dist\\bin\\codegraph.js" %%*\r\n' \
> "$STAGE/bin/codegraph.cmd"
else
cp "$NODE_BIN" "$STAGE/node"
cat > "$STAGE/bin/codegraph" <<'LAUNCH'
#!/bin/sh
# Resolve symlinks (e.g. the ~/.local/bin/codegraph link install.sh creates) so
# we find the real bundle dir, not the symlink's location.
SELF="$0"
while [ -L "$SELF" ]; do
target="$(readlink "$SELF")"
case "$target" in
/*) SELF="$target" ;;
*) SELF="$(dirname "$SELF")/$target" ;;
esac
done
DIR="$(cd "$(dirname "$SELF")/.." && pwd)"
# --liftoff-only: avoid the V8 turboshaft WASM Zone OOM (issues #293/#298).
exec "$DIR/node" --liftoff-only "$DIR/lib/dist/bin/codegraph.js" "$@"
LAUNCH
chmod +x "$STAGE/bin/codegraph"
fi
# 5. Archive (.zip for Windows, .tar.gz otherwise).
mkdir -p "$OUT"
if [ "$OSFAM" = "win32" ]; then
ARCHIVE="$OUT/codegraph-${TARGET}.zip"
rm -f "$ARCHIVE"
( cd "$WORK" && zip -rqX "$ARCHIVE" "codegraph-${TARGET}" )
else
ARCHIVE="$OUT/codegraph-${TARGET}.tar.gz"
# --no-xattrs: don't embed macOS xattrs that make GNU tar warn on Linux.
tar --no-xattrs -czf "$ARCHIVE" -C "$WORK" "codegraph-${TARGET}"
fi
echo "[bundle] wrote ${ARCHIVE} ($(du -h "$ARCHIVE" | cut -f1))"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Extract a release-notes block from CHANGELOG.md for a given version
* (or unwrap text supplied on stdin), then join hard-wrapped paragraphs.
*
* Why: GitHub renders release-note Markdown with GFM hard breaks, so
* every `\n` becomes `<br>`. The CHANGELOG is hard-wrapped at ~75
* chars for readable diffs, which then renders as awkward visible
* line breaks on the release page. This script joins indented
* continuation lines into a single line per bullet so the GFM
* renderer produces clean paragraphs.
*
* Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats
* newlines as spaces there).
*
* Usage:
* extract-release-notes.mjs <version> # read CHANGELOG.md
* extract-release-notes.mjs --stdin # read from stdin (any text)
*/
import { readFileSync } from 'fs';
const arg = process.argv[2];
if (!arg) {
console.error('usage: extract-release-notes.mjs <version> | --stdin');
process.exit(1);
}
let block;
if (arg === '--stdin') {
block = readFileSync(0, 'utf8').replace(/\r\n?/g, '\n').split('\n');
} else {
const version = arg;
const escaped = version.replace(/\./g, '\\.');
const headerRe = new RegExp(`^## \\[${escaped}\\]`);
const anyHeaderRe = /^## \[/;
const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n');
const start = lines.findIndex((l) => headerRe.test(l));
if (start === -1) {
console.error(`no '## [${version}]' entry found in CHANGELOG.md`);
process.exit(1);
}
const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l));
block = lines.slice(start, after === -1 ? lines.length : after);
}
// Track a stack of `{ indent: number }` frames so a continuation line
// can attach to the right ancestor. Handles the post-nested-list
// continuation pattern:
//
// - top-level
// - nested
// back to top-level <- 2-space indent, joins the top-level bullet
const out = [];
let buf = '';
let stack = [];
function flushBuf() {
if (buf !== '') {
out.push(buf);
buf = '';
}
}
function leadingSpaces(s) {
const m = s.match(/^(\s*)/);
return m ? m[1].length : 0;
}
// Bullets: `-`, `*`, `digit.` only. `+` is intentionally excluded — the
// CHANGELOG uses literal `+` inline (`config + instructions`) and we
// don't want to misread those as nested bullets.
const listItemRe = /^(\s*)([-*]|\d+\.)\s+/;
const fenceRe = /^\s*```/;
let inFence = false;
for (const line of block) {
// Fenced code blocks: pass through verbatim, no joining.
if (fenceRe.test(line)) {
flushBuf();
stack = [];
out.push(line);
inFence = !inFence;
continue;
}
if (inFence) {
out.push(line);
continue;
}
if (/^\s*$/.test(line)) {
flushBuf();
out.push('');
continue;
}
if (/^#/.test(line)) {
flushBuf();
stack = [];
out.push(line);
continue;
}
const itemMatch = line.match(listItemRe);
if (itemMatch) {
flushBuf();
const indent = itemMatch[1].length;
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
stack.pop();
}
stack.push({ indent });
buf = line;
continue;
}
if (/^\s/.test(line)) {
const indent = leadingSpaces(line);
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
flushBuf();
stack.pop();
}
const trimmed = line.replace(/^\s+/, '');
buf = buf === '' ? trimmed : `${buf} ${trimmed}`;
continue;
}
flushBuf();
stack = [];
out.push(line);
}
flushBuf();
process.stdout.write(out.join('\n'));
if (!out[out.length - 1]?.endsWith('\n')) process.stdout.write('\n');
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Build the current branch and link it as the global `codegraph` for
# hands-on testing. Replaces any existing global install for as long
# as the symlink is in place.
#
# Usage:
# ./scripts/local-install.sh # build + link
# ./scripts/local-install.sh --undo # unlink + restore the published version
set -euo pipefail
cd "$(dirname "$0")/.."
PKG=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "${1:-}" = "--undo" ]; then
echo "→ unlinking ${PKG}"
npm unlink -g "${PKG}" >/dev/null 2>&1 || true
echo "→ reinstalling published ${PKG}"
npm install -g "${PKG}"
echo "done: global codegraph -> $(command -v codegraph)"
exit 0
fi
echo "→ building ${PKG} ${VERSION} (${BRANCH})"
npm run build
echo "→ linking globally"
npm link
LINKED=$(command -v codegraph || echo "(not on PATH)")
echo
echo "✓ global codegraph now points to this branch"
echo " binary: ${LINKED}"
echo " branch: ${BRANCH}"
echo " version: ${VERSION}"
echo
echo "To restore the published version:"
echo " ./scripts/local-install.sh --undo"
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
'use strict';
//
// npm thin-installer launcher for CodeGraph.
//
// The heavy artifact (a vendored Node runtime + the app) ships as a per-platform
// optionalDependency: @colbymchenry/codegraph-<platform>-<arch>. npm installs
// only the one matching the host, via each package's `os`/`cpu` fields (the
// esbuild pattern). This shim — run by the user's OWN Node — locates that bundle
// and execs its launcher, so the real work always runs on the bundled Node 24
// (with node:sqlite), regardless of the user's Node version. The user's Node is
// only ever a launcher; even an ancient version can run this file.
//
// Self-heal (issue #303): some registries — notably the npmmirror/cnpm mirrors,
// and some corporate proxies — don't reliably mirror the per-platform
// optionalDependencies. npm treats an unfetchable optional dep as success and
// silently skips it, so the bundle goes missing and every command fails. When
// the installed bundle can't be resolved, this shim falls back to downloading
// the matching bundle straight from GitHub Releases — the very archive
// install.sh uses — into a cache dir, then runs that. Knobs:
// CODEGRAPH_NO_DOWNLOAD=1 disable the network fallback (print guidance)
// CODEGRAPH_INSTALL_DIR=DIR cache location (default: ~/.codegraph)
// CODEGRAPH_DOWNLOAD_BASE=URL release-download base (for mirrors/air-gapped)
//
// Wired up at release time as the main package's `bin`:
// "bin": { "codegraph": "npm-shim.js" }
// with the platform packages listed in `optionalDependencies`.
var childProcess = require('child_process');
var fs = require('fs');
var os = require('os');
var path = require('path');
var target = process.platform + '-' + process.arch; // e.g. darwin-arm64, linux-x64
var pkg = '@colbymchenry/codegraph-' + target;
var isWindows = process.platform === 'win32';
var REPO = 'colbymchenry/codegraph';
main().catch(function (e) {
process.stderr.write('codegraph: ' + (e && e.message ? e.message : String(e)) + '\n');
process.exit(1);
});
async function main() {
// Happy path: the npm-installed optional dependency. Fall back to a download
// when the registry didn't deliver it.
var resolved = resolveInstalledBundle() || (await selfHealBundle());
var res = childProcess.spawnSync(resolved.command, resolved.args, { stdio: 'inherit' });
if (res.error) {
process.stderr.write('codegraph: ' + res.error.message + '\n');
process.exit(1);
}
process.exit(res.status === null ? 1 : res.status);
}
// Resolve the launcher from the installed per-platform optionalDependency.
// Returns {command, args} or null if the package isn't installed.
function resolveInstalledBundle() {
try {
if (isWindows) {
// Modern Node refuses to spawn the bundle's .cmd directly (EINVAL, the
// CVE-2024-27980 hardening on Node 24), so invoke the bundled node.exe
// against the app entry point and pass --liftoff-only here.
var nodeExe = require.resolve(pkg + '/node.exe');
var entry = require.resolve(pkg + '/lib/dist/bin/codegraph.js');
return { command: nodeExe, args: liftoff(entry) };
}
return { command: require.resolve(pkg + '/bin/codegraph'), args: process.argv.slice(2) };
} catch (e) {
return null;
}
}
// Locate the launcher inside an extracted GitHub bundle directory (same
// node/lib/bin layout as the npm platform package). Returns {command, args} or
// null when the directory doesn't hold a usable bundle yet.
function launcherIn(dir) {
if (isWindows) {
var nodeExe = path.join(dir, 'node.exe');
var entry = path.join(dir, 'lib', 'dist', 'bin', 'codegraph.js');
if (fs.existsSync(nodeExe) && fs.existsSync(entry)) {
return { command: nodeExe, args: liftoff(entry) };
}
} else {
var launcher = path.join(dir, 'bin', 'codegraph');
if (fs.existsSync(launcher)) return { command: launcher, args: process.argv.slice(2) };
}
return null;
}
// --liftoff-only keeps tree-sitter's WASM grammars off V8's turboshaft tier to
// avoid the Zone OOM on Node >= 22 (issues #293/#298). The unix bin/codegraph
// launcher already passes it; on Windows we invoke node.exe directly so add it.
function liftoff(entry) {
return ['--liftoff-only', entry].concat(process.argv.slice(2));
}
// Download + cache the platform bundle from GitHub Releases. Returns
// {command, args}; exits the process with guidance if it can't.
async function selfHealBundle() {
var version = readVersion();
var bundlesDir = path.join(process.env.CODEGRAPH_INSTALL_DIR || path.join(os.homedir(), '.codegraph'), 'bundles');
var dest = path.join(bundlesDir, target + '-' + version);
// Already downloaded by a previous run? Use it even when downloads are
// disabled — CODEGRAPH_NO_DOWNLOAD blocks fetching, not a cached bundle.
var cached = launcherIn(dest);
if (cached) return cached;
if (process.env.CODEGRAPH_NO_DOWNLOAD) {
fail('the network fallback is disabled (CODEGRAPH_NO_DOWNLOAD is set).');
}
var asset = 'codegraph-' + target + (isWindows ? '.zip' : '.tar.gz');
var base = process.env.CODEGRAPH_DOWNLOAD_BASE || ('https://github.com/' + REPO + '/releases/download');
var url = base + '/v' + version + '/' + asset;
process.stderr.write(
'codegraph: platform bundle missing (registry did not provide ' + pkg + ').\n' +
'codegraph: downloading ' + asset + ' from GitHub Releases (' + version + ')...\n'
);
// Stage inside bundlesDir so the final rename is on the same filesystem (atomic,
// no EXDEV across tmpfs). Strip the archive's top-level codegraph-<target>/ dir.
fs.mkdirSync(bundlesDir, { recursive: true });
var stage = fs.mkdtempSync(path.join(bundlesDir, '.dl-'));
try {
var archivePath = path.join(stage, asset);
await download(url, archivePath, 6);
await verifyChecksum(archivePath, asset, base, version);
var extracted = path.join(stage, 'bundle');
fs.mkdirSync(extracted);
extract(archivePath, extracted);
var raced = launcherIn(dest); // another process may have finished meanwhile
if (raced) { rmrf(stage); return raced; }
try {
fs.renameSync(extracted, dest);
} catch (e) {
var other = launcherIn(dest); // lost the race but theirs is valid
if (other) { rmrf(stage); return other; }
throw e;
}
} catch (e) {
rmrf(stage);
fail('download failed (' + e.message + ').\n URL: ' + url);
}
rmrf(stage);
var ready = launcherIn(dest);
if (!ready) fail('downloaded bundle is missing its launcher under ' + dest + '.');
process.stderr.write('codegraph: bundle ready.\n');
return ready;
}
function readVersion() {
try {
return require(path.join(__dirname, 'package.json')).version;
} catch (e) {
fail('could not read this package\'s version to locate a matching release.');
}
}
// GET with manual redirect following (GitHub release URLs redirect to a CDN).
function download(url, dest, redirectsLeft) {
return new Promise(function (resolve, reject) {
var https = require('https');
// timeout is an idle/inactivity timeout — it won't kill a slow-but-progressing
// download, only a stalled connection (so a blocked mirror fails fast with
// guidance instead of hanging the user's command forever).
var req = https.get(url, { headers: { 'User-Agent': 'codegraph-npm-shim' }, timeout: 30000 }, function (res) {
var status = res.statusCode;
if (status >= 300 && status < 400 && res.headers.location) {
res.resume();
if (redirectsLeft <= 0) { reject(new Error('too many redirects')); return; }
download(new URL(res.headers.location, url).toString(), dest, redirectsLeft - 1).then(resolve, reject);
return;
}
if (status !== 200) { res.resume(); reject(new Error('HTTP ' + status)); return; }
var file = fs.createWriteStream(dest);
res.on('error', reject);
res.pipe(file);
file.on('error', reject);
file.on('finish', function () { file.close(function () { resolve(); }); });
});
req.on('timeout', function () { req.destroy(new Error('connection timed out')); });
req.on('error', reject);
});
}
// Best-effort integrity check. When the release publishes a SHA256SUMS file, the
// downloaded archive MUST match its listed hash or we abort. When that file is
// absent (older releases) or simply unreachable, we proceed — the archive still
// arrived from GitHub over TLS. So tampering/corruption is caught, while a
// missing checksum never breaks an install.
async function verifyChecksum(archivePath, asset, base, version) {
var sumsPath = archivePath + '.SHA256SUMS';
try {
await download(base + '/v' + version + '/SHA256SUMS', sumsPath, 6);
} catch (e) {
return; // not published / unreachable → skip
}
var expected = null;
var lines = fs.readFileSync(sumsPath, 'utf8').split('\n');
for (var i = 0; i < lines.length; i++) {
var m = lines[i].trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
if (m && path.basename(m[2].trim()) === asset) { expected = m[1].toLowerCase(); break; }
}
if (!expected) return; // asset not listed → nothing to check
var actual = require('crypto').createHash('sha256').update(fs.readFileSync(archivePath)).digest('hex');
if (actual !== expected) {
throw new Error('checksum mismatch for ' + asset +
' (expected ' + expected.slice(0, 12) + '…, got ' + actual.slice(0, 12) + '…)');
}
process.stderr.write('codegraph: checksum verified.\n');
}
// Extract via the system tar — present on macOS, Linux, and Windows 10+
// (bsdtar reads .zip too). No third-party dependency in the shim.
function extract(archive, destDir) {
var args = isWindows
? ['-xf', archive, '-C', destDir, '--strip-components=1']
: ['-xzf', archive, '-C', destDir, '--strip-components=1'];
var res = childProcess.spawnSync('tar', args, { stdio: 'ignore' });
if (res.error) throw new Error('tar unavailable: ' + res.error.message);
if (res.status !== 0) throw new Error('tar exited ' + res.status);
}
function rmrf(p) {
try { fs.rmSync(p, { recursive: true, force: true }); } catch (e) { /* best effort */ }
}
function fail(reason) {
process.stderr.write(
'codegraph: no prebuilt bundle for ' + target + '.\n' +
(reason ? 'codegraph: ' + reason + '\n' : '') +
'Expected the optional package ' + pkg + ' to be installed.\n' +
'A registry mirror (e.g. npmmirror/cnpm) that did not mirror the per-platform\n' +
'package is the usual cause. Fixes:\n' +
' - install from the official registry:\n' +
' npm i -g @colbymchenry/codegraph --registry=https://registry.npmjs.org\n' +
' - or use the standalone installer (no Node required):\n' +
' curl -fsSL https://raw.githubusercontent.com/' + REPO + '/main/install.sh | sh\n'
);
process.exit(1);
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Assemble the npm thin-installer packages from built bundles (esbuild pattern).
#
# Produces, under release/npm/:
# codegraph-<target>/ one per built bundle — the vendored Node + app, tagged
# with os/cpu so npm installs only the matching one.
# main/ the @colbymchenry/codegraph shim package: a tiny bin
# that execs the matching platform bundle, with every
# platform package in optionalDependencies.
#
# The release pipeline then `npm publish`es each dir. This does NOT touch the
# repo's package.json — the dev/from-source path keeps working; the *published*
# main package's shape is generated here.
#
# Prereq: run build-bundle.sh for each target first (release/codegraph-*.tar.gz).
# Usage: scripts/pack-npm.sh [version] (default: version from package.json)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-$(node -p "require('$ROOT/package.json').version")}"
SCOPE="@colbymchenry"
REL="$ROOT/release"
NPM="$REL/npm"
rm -rf "$NPM"
mkdir -p "$NPM/main"
shopt -s nullglob
archives=("$REL"/codegraph-*.tar.gz "$REL"/codegraph-*.zip)
[ ${#archives[@]} -gt 0 ] || { echo "[pack-npm] no bundles in $REL — run build-bundle.sh first" >&2; exit 1; }
targets=()
for archive in "${archives[@]}"; do
fname="$(basename "$archive")"
case "$fname" in
*.tar.gz) base="${fname%.tar.gz}" ;; # codegraph-<target>
*.zip) base="${fname%.zip}" ;;
esac
target="${base#codegraph-}" # <target>, e.g. darwin-arm64 / win32-x64
os="${target%-*}" # darwin | linux | win32
arch="${target##*-}" # arm64 | x64
pkgdir="$NPM/$base"
mkdir -p "$pkgdir"
case "$fname" in
*.zip)
tmpx="$(mktemp -d)"
unzip -q "$archive" -d "$tmpx"
mv "$tmpx/codegraph-${target}"/* "$pkgdir"/
rm -rf "$tmpx"
nodefile="node.exe"
;;
*)
tar -xzf "$archive" -C "$pkgdir" --strip-components=1
nodefile="node"
;;
esac
VERSION="$VERSION" SCOPE="$SCOPE" TARGET="$target" OSV="$os" ARCHV="$arch" NODEFILE="$nodefile" \
node -e '
const fs=require("fs");
fs.writeFileSync(process.argv[1], JSON.stringify({
name: `${process.env.SCOPE}/codegraph-${process.env.TARGET}`,
version: process.env.VERSION,
description: `CodeGraph self-contained bundle for ${process.env.TARGET}`,
os: [process.env.OSV], cpu: [process.env.ARCHV],
files: [process.env.NODEFILE, "lib", "bin"],
license: "MIT"
}, null, 2) + "\n");
' "$pkgdir/package.json"
targets+=("$target")
echo "[pack-npm] ${SCOPE}/codegraph-${target}@${VERSION}"
done
# Main shim package.
cp "$ROOT/scripts/npm-shim.js" "$NPM/main/npm-shim.js"
[ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$NPM/main/README.md"
VERSION="$VERSION" SCOPE="$SCOPE" TARGETS="${targets[*]}" \
node -e '
const fs=require("fs");
const opt={};
for (const t of process.env.TARGETS.split(/\s+/).filter(Boolean))
opt[`${process.env.SCOPE}/codegraph-${t}`]=process.env.VERSION;
fs.writeFileSync(process.argv[1], JSON.stringify({
name: `${process.env.SCOPE}/codegraph`,
version: process.env.VERSION,
description: "Local-first code intelligence for AI agents (MCP). Self-contained — bundles its own runtime.",
bin: { codegraph: "npm-shim.js" },
optionalDependencies: opt,
files: ["npm-shim.js","README.md"],
license: "MIT"
}, null, 2) + "\n");
' "$NPM/main/package.json"
echo "[pack-npm] ${SCOPE}/codegraph@${VERSION} (${#targets[@]} platform packages in optionalDependencies)"
echo "[pack-npm] output: $NPM"
+1710
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
/**
* Node.js version compatibility check.
*
* Node 25.x has a V8 turboshaft WASM JIT Zone allocator bug that
* reliably crashes CodeGraph with `Fatal process out of memory: Zone`
* during tree-sitter grammar compilation. This module owns the
* user-facing banner shown before exit. Kept side-effect-free so it's
* safe to import from tests without triggering CLI bootstrap.
*/
/**
* Build the bordered banner shown when CodeGraph detects an
* unsupported Node.js major version (currently 25+). Pinned via unit
* test so the recovery commands and override instructions can't be
* silently stripped by future edits.
*
* Uses ASCII glyphs to stay readable on Windows OEM-codepage consoles
* (see ../ui/glyphs.ts for the rationale).
*/
export function buildNode25BlockBanner(nodeVersion: string): string {
const sep = '-'.repeat(72);
return [
sep,
`[CodeGraph] Unsupported Node.js version: ${nodeVersion}`,
sep,
'Node.js 25.x has a V8 WASM JIT (turboshaft) Zone allocator bug that',
'crashes with `Fatal process out of memory: Zone` when CodeGraph',
'compiles tree-sitter grammars. CodeGraph WILL crash on this Node',
'version mid-indexing. See https://github.com/colbymchenry/codegraph/issues/81',
'',
'Fix: install Node.js 22 LTS:',
' nvm install 22 && nvm use 22 # nvm',
' brew install node@22 && brew link --overwrite --force node@22 # Homebrew',
'',
'To override (NOT recommended - you will likely OOM):',
' CODEGRAPH_ALLOW_UNSAFE_NODE=1 codegraph ...',
sep,
].join('\n');
}
/**
* Lowest supported Node.js major version. Matches the `engines` floor in
* package.json. Below this, CodeGraph relies on language features / native APIs
* that aren't present, and the combination is untested. `engines` alone only
* *warns* on install (unless the user set `engine-strict`), so the CLI bootstrap
* also hard-blocks here to actually enforce the floor.
*/
export const MIN_NODE_MAJOR = 20;
/**
* Build the bordered banner shown when CodeGraph detects a Node.js major below
* {@link MIN_NODE_MAJOR}. Pinned via unit test so the recovery commands and the
* override env var can't be silently stripped by future edits.
*
* Uses ASCII glyphs to stay readable on Windows OEM-codepage consoles
* (see ../ui/glyphs.ts for the rationale).
*/
export function buildNodeTooOldBanner(nodeVersion: string): string {
const sep = '-'.repeat(72);
return [
sep,
`[CodeGraph] Unsupported Node.js version: ${nodeVersion}`,
sep,
`CodeGraph requires Node.js ${MIN_NODE_MAJOR} or newer. Older versions lack`,
'language features and native APIs CodeGraph depends on, and are not',
'tested or supported.',
'',
'Fix: install Node.js 22 LTS:',
' nvm install 22 && nvm use 22 # nvm',
' brew install node@22 && brew link --overwrite --force node@22 # Homebrew',
'',
'To override (NOT recommended - unsupported):',
' CODEGRAPH_ALLOW_UNSAFE_NODE=1 codegraph ...',
sep,
].join('\n');
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* CodeGraph preuninstall cleanup script
*
* Runs automatically when `npm uninstall -g @colbymchenry/codegraph`
* is called. Loops over every known agent target's `uninstall(loc)`
* for the global location only — local-location entries live inside
* project working trees and aren't ours to nuke at npm-uninstall
* time.
*
* This script must never throw — a failed cleanup must not block
* uninstall.
*/
try {
// Lazy require so any module-level error in the registry can't
// bubble out and abort the npm uninstall.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ALL_TARGETS } = require('../installer/targets/registry') as
typeof import('../installer/targets/registry');
for (const target of ALL_TARGETS) {
if (!target.supportsLocation('global')) continue;
try {
target.uninstall('global');
} catch {
// Each target is independently safe-to-skip; per-target failure
// must not stop the loop.
}
}
} catch {
// If the registry itself can't be loaded (e.g. partial install),
// we silently skip cleanup. Uninstall still completes.
}
+271
View File
@@ -0,0 +1,271 @@
/**
* Context Formatter
*
* Formats TaskContext as markdown or JSON for consumption by Claude.
*/
import { Node, Edge, TaskContext, Subgraph } from '../types';
/**
* Format context as markdown
*
* Creates a compact markdown document optimized for Claude with minimal context usage:
* - Brief summary
* - Entry points with locations
* - Code blocks only for key symbols
*/
export function formatContextAsMarkdown(context: TaskContext): string {
const lines: string[] = [];
// Header with query
lines.push('## Code Context\n');
lines.push(`**Query:** ${context.query}\n`);
// Entry points - compact format
if (context.entryPoints.length > 0) {
lines.push('### Entry Points\n');
for (const node of context.entryPoints) {
const location = node.startLine ? `:${node.startLine}` : '';
lines.push(`- **${node.name}** (${node.kind}) - ${node.filePath}${location}`);
if (node.signature) {
lines.push(` \`${node.signature}\``);
}
}
lines.push('');
}
// Related symbols - compact list (skip verbose structure tree)
const otherSymbols = Array.from(context.subgraph.nodes.values())
.filter(n => !context.entryPoints.some(e => e.id === n.id))
.slice(0, 10); // Limit to 10 related symbols
if (otherSymbols.length > 0) {
lines.push('### Related Symbols\n');
const byFile = new Map<string, Node[]>();
for (const node of otherSymbols) {
const existing = byFile.get(node.filePath) || [];
existing.push(node);
byFile.set(node.filePath, existing);
}
for (const [file, nodes] of byFile) {
const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
lines.push(`- ${file}: ${nodeList}`);
}
lines.push('');
}
// Code blocks - only for key entry points
if (context.codeBlocks.length > 0) {
lines.push('### Code\n');
for (const block of context.codeBlocks) {
const nodeName = block.node?.name ?? 'Unknown';
lines.push(`#### ${nodeName} (${block.filePath}:${block.startLine})\n`);
lines.push('```' + block.language);
lines.push(block.content);
lines.push('```\n');
}
}
return lines.join('\n');
}
/**
* Format context as JSON
*
* Returns a structured JSON representation suitable for programmatic use.
*/
export function formatContextAsJson(context: TaskContext): string {
// Convert Map to array for JSON serialization
const serializable = {
query: context.query,
summary: context.summary,
entryPoints: context.entryPoints.map(serializeNode),
nodes: Array.from(context.subgraph.nodes.values()).map(serializeNode),
edges: context.subgraph.edges.map(serializeEdge),
codeBlocks: context.codeBlocks.map((block) => ({
filePath: block.filePath,
startLine: block.startLine,
endLine: block.endLine,
language: block.language,
content: block.content,
nodeName: block.node?.name,
nodeKind: block.node?.kind,
})),
relatedFiles: context.relatedFiles,
stats: context.stats,
};
return JSON.stringify(serializable, null, 2);
}
/**
* Format a subgraph as an ASCII tree structure
*/
export function formatSubgraphTree(subgraph: Subgraph, entryPoints: Node[]): string {
const lines: string[] = [];
const printed = new Set<string>();
// Build adjacency list for outgoing edges
const outgoing = new Map<string, Edge[]>();
for (const edge of subgraph.edges) {
const existing = outgoing.get(edge.source) ?? [];
existing.push(edge);
outgoing.set(edge.source, existing);
}
// Print each entry point as a tree root
for (const entry of entryPoints) {
formatNodeTree(entry, subgraph, outgoing, printed, lines, 0, '');
lines.push(''); // Blank line between trees
}
// Print any remaining nodes not reached from entry points
const remaining: Node[] = [];
for (const node of subgraph.nodes.values()) {
if (!printed.has(node.id)) {
remaining.push(node);
}
}
if (remaining.length > 0 && remaining.length <= 10) {
lines.push('Other relevant symbols:');
for (const node of remaining) {
const location = node.startLine ? `:${node.startLine}` : '';
lines.push(` ${node.kind}: ${node.name} (${node.filePath}${location})`);
}
} else if (remaining.length > 10) {
lines.push(`... and ${remaining.length} more related symbols`);
}
return lines.join('\n').trim();
}
/**
* Format a single node and its relationships
*/
function formatNodeTree(
node: Node,
subgraph: Subgraph,
outgoing: Map<string, Edge[]>,
printed: Set<string>,
lines: string[],
depth: number,
prefix: string
): void {
if (printed.has(node.id)) {
return;
}
printed.add(node.id);
// Node header
const location = node.startLine ? `:${node.startLine}` : '';
const signature = node.signature ? ` - ${truncate(node.signature, 50)}` : '';
lines.push(`${prefix}${node.kind}: ${node.name} (${node.filePath}${location})${signature}`);
// Outgoing edges
const edges = outgoing.get(node.id) ?? [];
const significantEdges = edges.filter((e) =>
['calls', 'extends', 'implements', 'imports', 'references'].includes(e.kind)
);
// Group by kind
const edgesByKind = new Map<string, Edge[]>();
for (const edge of significantEdges) {
const existing = edgesByKind.get(edge.kind) ?? [];
existing.push(edge);
edgesByKind.set(edge.kind, existing);
}
// Print edges grouped by kind
const newPrefix = prefix + ' ';
for (const [kind, kindEdges] of edgesByKind) {
if (kindEdges.length > 3) {
// Summarize if too many
const names = kindEdges
.slice(0, 3)
.map((e) => {
const target = subgraph.nodes.get(e.target);
return target?.name ?? 'unknown';
})
.join(', ');
lines.push(`${newPrefix}├── ${kind}: ${names} and ${kindEdges.length - 3} more`);
} else {
for (let i = 0; i < kindEdges.length; i++) {
const edge = kindEdges[i]!;
const target = subgraph.nodes.get(edge.target);
const targetName = target?.name ?? 'unknown';
const connector = i === kindEdges.length - 1 ? '└──' : '├──';
lines.push(`${newPrefix}${connector} ${kind} → ${targetName}`);
}
}
}
// Recurse for directly connected nodes (limited depth)
if (depth < 1) {
for (const edge of significantEdges.slice(0, 3)) {
const target = subgraph.nodes.get(edge.target);
if (target && !printed.has(target.id)) {
formatNodeTree(target, subgraph, outgoing, printed, lines, depth + 1, newPrefix);
}
}
}
}
/**
* Serialize a node for JSON output
*/
function serializeNode(node: Node): Record<string, unknown> {
return {
id: node.id,
kind: node.kind,
name: node.name,
qualifiedName: node.qualifiedName,
filePath: node.filePath,
language: node.language,
startLine: node.startLine,
endLine: node.endLine,
signature: node.signature,
docstring: node.docstring,
visibility: node.visibility,
isExported: node.isExported,
isAsync: node.isAsync,
isStatic: node.isStatic,
};
}
/**
* Serialize an edge for JSON output
*/
function serializeEdge(edge: Edge): Record<string, unknown> {
return {
source: edge.source,
target: edge.target,
kind: edge.kind,
line: edge.line,
column: edge.column,
};
}
/**
* Truncate a string with ellipsis
*/
function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) {
return str;
}
return str.slice(0, maxLength - 3) + '...';
}
/**
* Format bytes as human-readable string
*/
export function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} bytes`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
} else {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}
+1134
View File
File diff suppressed because it is too large Load Diff
+244
View File
@@ -0,0 +1,244 @@
/**
* Database Layer
*
* Handles SQLite database initialization and connection management.
*/
import { SqliteDatabase, SqliteBackend, createDatabase } from './sqlite-adapter';
import * as fs from 'fs';
import * as path from 'path';
import { SchemaVersion } from '../types';
import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations';
export { SqliteDatabase, SqliteBackend } from './sqlite-adapter';
/**
* Apply connection-level PRAGMAs. Shared by `initialize` and `open` so the two
* paths can't drift.
*
* `busy_timeout` is set FIRST, before any pragma that might touch the database
* file (notably `journal_mode`). If another process holds a write lock at open
* time, the later pragmas — and the connection's first query — then wait out
* the lock instead of throwing "database is locked" immediately. See issue #238.
*
* The 5s window (was 120s) rides out a normal incremental sync; the old
* 2-minute wait presented as a frozen, hung agent. With WAL, reads never block
* on a writer, so this timeout only governs cross-process write contention
* (e.g. the git-hook `codegraph sync` running while the MCP server writes).
*/
function configureConnection(db: SqliteDatabase): void {
db.pragma('busy_timeout = 5000'); // MUST be first — see above
db.pragma('foreign_keys = ON');
db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform
db.pragma('synchronous = NORMAL'); // safe with WAL mode
db.pragma('cache_size = -64000'); // 64 MB page cache
db.pragma('temp_store = MEMORY'); // temp tables in memory
db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O
}
/**
* Database connection wrapper with lifecycle management
*/
export class DatabaseConnection {
private db: SqliteDatabase;
private dbPath: string;
private backend: SqliteBackend;
private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend) {
this.db = db;
this.dbPath = dbPath;
this.backend = backend;
}
/**
* Initialize a new database at the given path
*/
static initialize(dbPath: string): DatabaseConnection {
// Ensure parent directory exists
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Create and configure database
const { db, backend } = createDatabase(dbPath);
configureConnection(db);
// Run schema initialization
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
// Record current schema version so migrations aren't re-applied on open
const currentVersion = getCurrentVersion(db);
if (currentVersion < CURRENT_SCHEMA_VERSION) {
db.prepare(
'INSERT OR IGNORE INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)'
).run(CURRENT_SCHEMA_VERSION, Date.now(), 'Initial schema includes all migrations');
}
return new DatabaseConnection(db, dbPath, backend);
}
/**
* Open an existing database
*/
static open(dbPath: string): DatabaseConnection {
if (!fs.existsSync(dbPath)) {
throw new Error(`Database not found: ${dbPath}`);
}
const { db, backend } = createDatabase(dbPath);
configureConnection(db);
// Check and run migrations if needed
const conn = new DatabaseConnection(db, dbPath, backend);
const currentVersion = getCurrentVersion(db);
if (currentVersion < CURRENT_SCHEMA_VERSION) {
runMigrations(db, currentVersion);
}
return conn;
}
/**
* Get the underlying database instance
*/
getDb(): SqliteDatabase {
return this.db;
}
/**
* Get the SQLite backend serving this connection. Per-instance so
* MCP cross-project queries report the right backend even when
* multiple project DBs are open in the same process.
*/
getBackend(): SqliteBackend {
return this.backend;
}
/**
* Get database file path
*/
getPath(): string {
return this.dbPath;
}
/**
* The journal mode actually in effect (e.g. 'wal', 'delete').
*
* SQLite silently keeps the prior mode if WAL can't be enabled — e.g. on
* filesystems without shared-memory support (some network/virtualized mounts,
* WSL2 /mnt), and always on the wasm backend. So the effective mode can differ
* from what `configureConnection` requested. Surfaced in `codegraph status` so
* a "database is locked" report is triageable: 'wal' ⇒ readers never block on a
* writer; anything else ⇒ they can. See issue #238.
*/
getJournalMode(): string {
const raw = this.db.pragma('journal_mode');
const row = Array.isArray(raw) ? raw[0] : raw;
const mode = row && typeof row === 'object'
? (row as Record<string, unknown>).journal_mode
: row;
return String(mode ?? '').toLowerCase();
}
/**
* Get current schema version
*/
getSchemaVersion(): SchemaVersion | null {
const row = this.db
.prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version DESC LIMIT 1')
.get() as { version: number; applied_at: number; description: string | null } | undefined;
if (!row) return null;
return {
version: row.version,
appliedAt: row.applied_at,
description: row.description ?? undefined,
};
}
/**
* Execute a function within a transaction
*/
transaction<T>(fn: () => T): T {
return this.db.transaction(fn)();
}
/**
* Get database file size in bytes
*/
getSize(): number {
const stats = fs.statSync(this.dbPath);
return stats.size;
}
/**
* Optimize database (vacuum and analyze)
*/
optimize(): void {
this.db.exec('VACUUM');
this.db.exec('ANALYZE');
}
/**
* Lightweight, non-blocking maintenance to run after bulk writes
* (indexAll, sync). Two operations:
*
* - `PRAGMA optimize` — incremental ANALYZE; SQLite only re-analyzes
* tables whose row counts changed materially since the last
* ANALYZE. Without it, the query planner has no statistics on the
* freshly-bulk-loaded tables and can pick suboptimal indexes.
*
* - `PRAGMA wal_checkpoint(PASSIVE)` — fold pending WAL pages back
* into the main database file so the WAL file doesn't grow
* unboundedly between automatic checkpoints (auto-fires at 1000
* pages by default; large indexAll runs blow past that).
*
* Both operations are silently swallowed on failure — they're a
* best-effort optimization, never load-bearing for correctness.
*/
runMaintenance(): void {
try {
this.db.exec('PRAGMA optimize');
} catch {
// ignore
}
try {
this.db.exec('PRAGMA wal_checkpoint(PASSIVE)');
} catch {
// ignore (e.g., not in WAL mode)
}
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
/**
* Check if the database connection is open
*/
isOpen(): boolean {
return this.db.open;
}
}
/**
* Default database filename
*/
export const DATABASE_FILENAME = 'codegraph.db';
/**
* Get the default database path for a project
*/
export function getDatabasePath(projectRoot: string): string {
return path.join(projectRoot, '.codegraph', DATABASE_FILENAME);
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Database Migrations
*
* Schema versioning and migration support.
*/
import { SqliteDatabase } from './sqlite-adapter';
/**
* Current schema version
*/
export const CURRENT_SCHEMA_VERSION = 4;
/**
* Migration definition
*/
interface Migration {
version: number;
description: string;
up: (db: SqliteDatabase) => void;
}
/**
* All migrations in order
*
* Note: Version 1 is the initial schema, handled by schema.sql
* Future migrations go here.
*/
const migrations: Migration[] = [
{
version: 2,
description: 'Add project metadata, provenance tracking, and unresolved ref context',
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS project_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
ALTER TABLE unresolved_refs ADD COLUMN file_path TEXT NOT NULL DEFAULT '';
ALTER TABLE unresolved_refs ADD COLUMN language TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE edges ADD COLUMN provenance TEXT DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_unresolved_file_path ON unresolved_refs(file_path);
CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance);
`);
},
},
{
version: 3,
description: 'Add lower(name) expression index for memory-efficient case-insensitive lookups',
up: (db) => {
db.exec(`
CREATE INDEX IF NOT EXISTS idx_nodes_lower_name ON nodes(lower(name));
`);
},
},
{
version: 4,
description:
'Drop redundant idx_edges_source / idx_edges_target (covered by source_kind / target_kind composites)',
up: (db) => {
db.exec(`
DROP INDEX IF EXISTS idx_edges_source;
DROP INDEX IF EXISTS idx_edges_target;
`);
},
},
];
/**
* Get the current schema version from the database
*/
export function getCurrentVersion(db: SqliteDatabase): number {
try {
const row = db
.prepare('SELECT MAX(version) as version FROM schema_versions')
.get() as { version: number | null } | undefined;
return row?.version ?? 0;
} catch {
// Table doesn't exist yet
return 0;
}
}
/**
* Record a migration as applied
*/
function recordMigration(db: SqliteDatabase, version: number, description: string): void {
db.prepare(
'INSERT INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)'
).run(version, Date.now(), description);
}
/**
* Run all pending migrations
*/
export function runMigrations(db: SqliteDatabase, fromVersion: number): void {
const pending = migrations.filter((m) => m.version > fromVersion);
if (pending.length === 0) {
return;
}
// Sort by version
pending.sort((a, b) => a.version - b.version);
// Run each migration in a transaction
for (const migration of pending) {
db.transaction(() => {
migration.up(db);
recordMigration(db, migration.version, migration.description);
})();
}
}
/**
* Check if the database needs migration
*/
export function needsMigration(db: SqliteDatabase): boolean {
const current = getCurrentVersion(db);
return current < CURRENT_SCHEMA_VERSION;
}
/**
* Get list of pending migrations
*/
export function getPendingMigrations(db: SqliteDatabase): Migration[] {
const current = getCurrentVersion(db);
return migrations
.filter((m) => m.version > current)
.sort((a, b) => a.version - b.version);
}
/**
* Get migration history from database
*/
export function getMigrationHistory(
db: SqliteDatabase
): Array<{ version: number; appliedAt: number; description: string | null }> {
const rows = db
.prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version')
.all() as Array<{ version: number; applied_at: number; description: string | null }>;
return rows.map((row) => ({
version: row.version,
appliedAt: row.applied_at,
description: row.description,
}));
}
+1509
View File
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
-- CodeGraph SQLite Schema
-- Version 1
-- Schema version tracking
CREATE TABLE IF NOT EXISTS schema_versions (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL,
description TEXT
);
-- Insert initial version
INSERT INTO schema_versions (version, applied_at, description)
VALUES (1, strftime('%s', 'now') * 1000, 'Initial schema');
-- =============================================================================
-- Core Tables
-- =============================================================================
-- Nodes: Code symbols (functions, classes, variables, etc.)
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
qualified_name TEXT NOT NULL,
file_path TEXT NOT NULL,
language TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
start_column INTEGER NOT NULL,
end_column INTEGER NOT NULL,
docstring TEXT,
signature TEXT,
visibility TEXT,
is_exported INTEGER DEFAULT 0,
is_async INTEGER DEFAULT 0,
is_static INTEGER DEFAULT 0,
is_abstract INTEGER DEFAULT 0,
decorators TEXT, -- JSON array
type_parameters TEXT, -- JSON array
updated_at INTEGER NOT NULL
);
-- Edges: Relationships between nodes
CREATE TABLE IF NOT EXISTS edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
target TEXT NOT NULL,
kind TEXT NOT NULL,
metadata TEXT, -- JSON object
line INTEGER,
col INTEGER,
provenance TEXT DEFAULT NULL,
FOREIGN KEY (source) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE
);
-- Files: Tracked source files
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
language TEXT NOT NULL,
size INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
indexed_at INTEGER NOT NULL,
node_count INTEGER DEFAULT 0,
errors TEXT -- JSON array
);
-- Unresolved References: References that need resolution after full indexing
CREATE TABLE IF NOT EXISTS unresolved_refs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_node_id TEXT NOT NULL,
reference_name TEXT NOT NULL,
reference_kind TEXT NOT NULL,
line INTEGER NOT NULL,
col INTEGER NOT NULL,
candidates TEXT, -- JSON array
file_path TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT 'unknown',
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
-- =============================================================================
-- Indexes for Query Performance
-- =============================================================================
-- Node indexes
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name ON nodes(qualified_name);
CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path);
CREATE INDEX IF NOT EXISTS idx_nodes_language ON nodes(language);
CREATE INDEX IF NOT EXISTS idx_nodes_file_line ON nodes(file_path, start_line);
CREATE INDEX IF NOT EXISTS idx_nodes_lower_name ON nodes(lower(name));
-- Full-text search index on node names, docstrings, and signatures
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
id,
name,
qualified_name,
docstring,
signature,
content='nodes',
content_rowid='rowid'
);
-- Triggers to keep FTS index in sync
CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature)
VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature);
END;
CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature)
VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature);
END;
CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature)
VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature);
INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature)
VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature);
END;
-- Edge indexes.
-- idx_edges_source / idx_edges_target are intentionally omitted —
-- the (source, kind) and (target, kind) composites below cover the
-- corresponding source-only / target-only lookups via SQLite's
-- left-prefix scan, so the narrow indexes are dead weight on writes.
-- Migration v4 drops them on existing databases.
CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source, kind);
CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target, kind);
-- File indexes
CREATE INDEX IF NOT EXISTS idx_files_language ON files(language);
CREATE INDEX IF NOT EXISTS idx_files_modified_at ON files(modified_at);
-- Unresolved refs indexes
CREATE INDEX IF NOT EXISTS idx_unresolved_from_node ON unresolved_refs(from_node_id);
CREATE INDEX IF NOT EXISTS idx_unresolved_name ON unresolved_refs(reference_name);
CREATE INDEX IF NOT EXISTS idx_unresolved_file_path ON unresolved_refs(file_path);
CREATE INDEX IF NOT EXISTS idx_unresolved_from_name ON unresolved_refs(from_node_id, reference_name);
CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance);
-- Project metadata for version/provenance tracking
CREATE TABLE IF NOT EXISTS project_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
+139
View File
@@ -0,0 +1,139 @@
/**
* SQLite Adapter
*
* Thin wrapper over Node's built-in `node:sqlite` (`DatabaseSync`), exposed
* through a small better-sqlite3-shaped interface so the rest of the codebase
* is storage-agnostic.
*
* CodeGraph ships with a bundled Node runtime, so `node:sqlite` (real SQLite,
* with WAL + FTS5) is always available — there is no native build step and no
* wasm fallback. When run from source instead, it requires Node >= 22.5.
*/
export interface SqliteStatement {
run(...params: any[]): { changes: number; lastInsertRowid: number | bigint };
get(...params: any[]): any;
all(...params: any[]): any[];
}
export interface SqliteDatabase {
prepare(sql: string): SqliteStatement;
exec(sql: string): void;
pragma(str: string, options?: { simple?: boolean }): any;
transaction<T>(fn: (...args: any[]) => T): (...args: any[]) => T;
close(): void;
readonly open: boolean;
}
/**
* The active SQLite backend. Only one now (`node:sqlite`); kept as a named type
* so `codegraph status` and the per-instance reporting have a stable shape.
*/
export type SqliteBackend = 'node-sqlite';
/**
* Wraps Node's built-in `node:sqlite` (`DatabaseSync`) to match the
* better-sqlite3 interface the rest of the code expects.
*
* node:sqlite is real SQLite compiled into Node, so it supports WAL, FTS5,
* mmap, and `@named` params natively — the only shims needed are the
* better-sqlite3 conveniences node:sqlite omits: a `.pragma()` helper, a
* `.transaction()` helper, and `open` (node:sqlite exposes `isOpen`).
*/
class NodeSqliteAdapter implements SqliteDatabase {
private _db: any;
constructor(dbPath: string) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseSync } = require('node:sqlite');
this._db = new DatabaseSync(dbPath);
}
get open(): boolean {
return this._db.isOpen;
}
prepare(sql: string): SqliteStatement {
// node:sqlite matches better-sqlite3's calling convention (variadic
// positional args, or a single object for @named params), so params forward
// through unchanged.
const stmt = this._db.prepare(sql);
return {
run(...params: any[]) {
const r = stmt.run(...params);
return {
changes: Number(r?.changes ?? 0),
lastInsertRowid: r?.lastInsertRowid ?? 0,
};
},
get(...params: any[]) {
return stmt.get(...params);
},
all(...params: any[]) {
return stmt.all(...params);
},
};
}
exec(sql: string): void {
this._db.exec(sql);
}
pragma(str: string, options?: { simple?: boolean }): any {
const trimmed = str.trim();
// Write pragma ("key = value"): node:sqlite is real SQLite, so every pragma
// (WAL, mmap, synchronous, …) applies as-is.
if (trimmed.includes('=')) {
this._db.exec(`PRAGMA ${trimmed}`);
return;
}
// Read pragma. Default: the row object (e.g. { journal_mode: 'wal' }).
// `{ simple: true }` returns just the single column value, like better-sqlite3.
const row = this._db.prepare(`PRAGMA ${trimmed}`).get();
if (options?.simple) {
return row && typeof row === 'object' ? Object.values(row)[0] : row;
}
return row;
}
transaction<T>(fn: (...args: any[]) => T): (...args: any[]) => T {
return (...args: any[]) => {
this._db.exec('BEGIN');
try {
const result = fn(...args);
this._db.exec('COMMIT');
return result;
} catch (error) {
this._db.exec('ROLLBACK');
throw error;
}
};
}
close(): void {
// node:sqlite's DatabaseSync.close() throws if already closed; make it
// idempotent to match better-sqlite3 (callers may close more than once).
if (this._db.isOpen) this._db.close();
}
}
/**
* Create a database connection backed by `node:sqlite`.
*
* Returns the active backend alongside the db so each `DatabaseConnection` can
* report it per-instance — MCP can open multiple project DBs in one process, so
* a process-global would race.
*/
export function createDatabase(dbPath: string): { db: SqliteDatabase; backend: SqliteBackend } {
try {
return { db: new NodeSqliteAdapter(dbPath), backend: 'node-sqlite' };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
throw new Error(
'Failed to open SQLite via the built-in node:sqlite module.\n' +
'CodeGraph requires node:sqlite (Node.js 22.5+). Install the self-contained\n' +
'CodeGraph release (it bundles a compatible Node), or run on Node 22.5+.\n' +
`Underlying error: ${msg}`
);
}
}
+260
View File
@@ -0,0 +1,260 @@
/**
* Directory Management
*
* Manages the .codegraph/ directory structure for CodeGraph data.
*/
import * as fs from 'fs';
import * as path from 'path';
/**
* CodeGraph directory name
*/
export const CODEGRAPH_DIR = '.codegraph';
/**
* Get the .codegraph directory path for a project
*/
export function getCodeGraphDir(projectRoot: string): string {
return path.join(projectRoot, CODEGRAPH_DIR);
}
/**
* Check if a project has been initialized with CodeGraph
* Requires both .codegraph/ directory AND codegraph.db to exist
*/
export function isInitialized(projectRoot: string): boolean {
const codegraphDir = getCodeGraphDir(projectRoot);
if (!fs.existsSync(codegraphDir) || !fs.statSync(codegraphDir).isDirectory()) {
return false;
}
// Must have codegraph.db, not just .codegraph folder
const dbPath = path.join(codegraphDir, 'codegraph.db');
return fs.existsSync(dbPath);
}
/**
* Find the nearest parent directory containing .codegraph/
*
* Walks up from the given path to find a CodeGraph-initialized project,
* similar to how git finds .git/ directories.
*
* @param startPath - Directory to start searching from
* @returns The project root containing .codegraph/, or null if not found
*/
export function findNearestCodeGraphRoot(startPath: string): string | null {
let current = path.resolve(startPath);
const root = path.parse(current).root;
while (current !== root) {
if (isInitialized(current)) {
return current;
}
const parent = path.dirname(current);
if (parent === current) break; // Reached filesystem root
current = parent;
}
// Check root as well
if (isInitialized(current)) {
return current;
}
return null;
}
/**
* Create the .codegraph directory structure
* Note: Only throws if codegraph.db already exists, not just if .codegraph/ exists.
*/
export function createDirectory(projectRoot: string): void {
const codegraphDir = getCodeGraphDir(projectRoot);
const dbPath = path.join(codegraphDir, 'codegraph.db');
// Only throw if CodeGraph is actually initialized (db exists)
// .codegraph/ folder alone is fine
if (fs.existsSync(dbPath)) {
throw new Error(`CodeGraph already initialized in ${projectRoot}`);
}
// Create main directory (if it doesn't exist)
fs.mkdirSync(codegraphDir, { recursive: true });
// Create .gitignore inside .codegraph (if it doesn't exist)
const gitignorePath = path.join(codegraphDir, '.gitignore');
if (!fs.existsSync(gitignorePath)) {
const gitignoreContent = `# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
`;
fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8');
}
}
/**
* Remove the .codegraph directory
*/
export function removeDirectory(projectRoot: string): void {
const codegraphDir = getCodeGraphDir(projectRoot);
if (!fs.existsSync(codegraphDir)) {
return;
}
// Verify .codegraph is a real directory, not a symlink pointing elsewhere
const lstat = fs.lstatSync(codegraphDir);
if (lstat.isSymbolicLink()) {
// Only remove the symlink itself, never follow it for recursive delete
fs.unlinkSync(codegraphDir);
return;
}
if (!lstat.isDirectory()) {
// Not a directory - remove the single file
fs.unlinkSync(codegraphDir);
return;
}
// Recursively remove directory
fs.rmSync(codegraphDir, { recursive: true, force: true });
}
/**
* Get all files in the .codegraph directory
*/
export function listDirectoryContents(projectRoot: string): string[] {
const codegraphDir = getCodeGraphDir(projectRoot);
if (!fs.existsSync(codegraphDir)) {
return [];
}
const files: string[] = [];
function walkDir(dir: string, prefix: string = ''): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
// Skip symlinks to prevent following links outside .codegraph
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
walkDir(path.join(dir, entry.name), relativePath);
} else {
files.push(relativePath);
}
}
}
walkDir(codegraphDir);
return files;
}
/**
* Get the total size of the .codegraph directory in bytes
*/
export function getDirectorySize(projectRoot: string): number {
const codegraphDir = getCodeGraphDir(projectRoot);
if (!fs.existsSync(codegraphDir)) {
return 0;
}
let totalSize = 0;
function walkDir(dir: string): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
// Skip symlinks to prevent following links outside .codegraph
if (entry.isSymbolicLink()) {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else {
const stats = fs.statSync(fullPath);
totalSize += stats.size;
}
}
}
walkDir(codegraphDir);
return totalSize;
}
/**
* Ensure a subdirectory exists within .codegraph
*/
export function ensureSubdirectory(projectRoot: string, subdirName: string): string {
if (subdirName.includes('..') || subdirName.includes(path.sep) || subdirName.includes('/')) {
throw new Error(`Invalid subdirectory name: ${subdirName}`);
}
const subdirPath = path.join(getCodeGraphDir(projectRoot), subdirName);
if (!fs.existsSync(subdirPath)) {
fs.mkdirSync(subdirPath, { recursive: true });
}
return subdirPath;
}
/**
* Check if the .codegraph directory has valid structure
*/
export function validateDirectory(projectRoot: string): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
const codegraphDir = getCodeGraphDir(projectRoot);
if (!fs.existsSync(codegraphDir)) {
errors.push('CodeGraph directory does not exist');
return { valid: false, errors };
}
if (!fs.statSync(codegraphDir).isDirectory()) {
errors.push('.codegraph exists but is not a directory');
return { valid: false, errors };
}
// Auto-repair missing .gitignore (non-critical file)
const gitignorePath = path.join(codegraphDir, '.gitignore');
if (!fs.existsSync(gitignorePath)) {
try {
const gitignoreContent = `# CodeGraph data files\n# These are local to each machine and should not be committed\n\n# Database\n*.db\n*.db-wal\n*.db-shm\n\n# Cache\ncache/\n\n# Logs\n*.log\n\n# Hook markers\n.dirty\n`;
fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8');
} catch {
// Non-fatal: warn but don't block
errors.push('.gitignore missing in .codegraph directory and could not be created');
}
}
return {
valid: errors.length === 0,
errors,
};
}
+240
View File
@@ -0,0 +1,240 @@
/**
* CodeGraph Error Classes
*
* Custom error types for better error handling and debugging.
*
* @module errors
*
* @example
* ```typescript
* import { FileError, ParseError, setLogger, silentLogger } from 'codegraph';
*
* // Catch specific error types
* try {
* await cg.indexAll();
* } catch (error) {
* if (error instanceof FileError) {
* console.log(`File error at ${error.filePath}: ${error.message}`);
* } else if (error instanceof ParseError) {
* console.log(`Parse error at ${error.filePath}:${error.line}`);
* }
* }
*
* // Disable logging for tests
* setLogger(silentLogger);
* ```
*/
/**
* Base error class for all CodeGraph errors.
*
* All CodeGraph-specific errors extend this class, allowing you to catch
* all CodeGraph errors with a single catch block.
*
* @example
* ```typescript
* try {
* await cg.indexAll();
* } catch (error) {
* if (error instanceof CodeGraphError) {
* console.log(`CodeGraph error [${error.code}]: ${error.message}`);
* }
* }
* ```
*/
export class CodeGraphError extends Error {
/** Error code for categorization (e.g., 'FILE_ERROR', 'PARSE_ERROR') */
readonly code: string;
/** Additional context about the error */
readonly context?: Record<string, unknown>;
constructor(message: string, code: string, context?: Record<string, unknown>) {
super(message);
this.name = 'CodeGraphError';
this.code = code;
this.context = context;
// Maintain proper stack trace for V8
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
/**
* Error reading or accessing files
*/
export class FileError extends CodeGraphError {
readonly filePath: string;
constructor(message: string, filePath: string, cause?: Error) {
super(message, 'FILE_ERROR', { filePath, cause: cause?.message });
this.name = 'FileError';
this.filePath = filePath;
if (cause) {
this.cause = cause;
}
}
}
/**
* Error parsing source code
*/
export class ParseError extends CodeGraphError {
readonly filePath: string;
readonly line?: number;
readonly column?: number;
constructor(
message: string,
filePath: string,
options?: { line?: number; column?: number; cause?: Error }
) {
super(message, 'PARSE_ERROR', {
filePath,
line: options?.line,
column: options?.column,
cause: options?.cause?.message,
});
this.name = 'ParseError';
this.filePath = filePath;
this.line = options?.line;
this.column = options?.column;
if (options?.cause) {
this.cause = options.cause;
}
}
}
/**
* Error with database operations
*/
export class DatabaseError extends CodeGraphError {
readonly operation: string;
constructor(message: string, operation: string, cause?: Error) {
super(message, 'DATABASE_ERROR', { operation, cause: cause?.message });
this.name = 'DatabaseError';
this.operation = operation;
if (cause) {
this.cause = cause;
}
}
}
/**
* Error with search operations
*/
export class SearchError extends CodeGraphError {
readonly query: string;
constructor(message: string, query: string, cause?: Error) {
super(message, 'SEARCH_ERROR', { query, cause: cause?.message });
this.name = 'SearchError';
this.query = query;
if (cause) {
this.cause = cause;
}
}
}
/**
* Error with vector/embedding operations
*/
export class VectorError extends CodeGraphError {
constructor(message: string, operation: string, cause?: Error) {
super(message, 'VECTOR_ERROR', { operation, cause: cause?.message });
this.name = 'VectorError';
if (cause) {
this.cause = cause;
}
}
}
/**
* Error with configuration
*/
export class ConfigError extends CodeGraphError {
constructor(message: string, details?: Record<string, unknown>) {
super(message, 'CONFIG_ERROR', details);
this.name = 'ConfigError';
}
}
/**
* Simple logger for CodeGraph operations
*
* By default, logs to console.warn for warnings and console.error for errors.
* Can be configured to use custom logging.
*/
export interface Logger {
debug(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
}
/**
* Default console-based logger
*/
export const defaultLogger: Logger = {
debug(message: string, context?: Record<string, unknown>): void {
if (process.env.CODEGRAPH_DEBUG) {
console.debug(`[CodeGraph] ${message}`, context ?? '');
}
},
warn(message: string, context?: Record<string, unknown>): void {
console.warn(`[CodeGraph] ${message}`, context ?? '');
},
error(message: string, context?: Record<string, unknown>): void {
console.error(`[CodeGraph] ${message}`, context ?? '');
},
};
/**
* Silent logger (no output) - useful for tests
*/
export const silentLogger: Logger = {
debug(): void {},
warn(): void {},
error(): void {},
};
/**
* Current logger instance (can be replaced)
*/
let currentLogger: Logger = defaultLogger;
/**
* Set the global logger
*/
export function setLogger(logger: Logger): void {
currentLogger = logger;
}
/**
* Get the current logger
*/
export function getLogger(): Logger {
return currentLogger;
}
/**
* Log a debug message
*/
export function logDebug(message: string, context?: Record<string, unknown>): void {
currentLogger.debug(message, context);
}
/**
* Log a warning message
*/
export function logWarn(message: string, context?: Record<string, unknown>): void {
currentLogger.warn(message, context);
}
/**
* Log an error message
*/
export function logError(message: string, context?: Record<string, unknown>): void {
currentLogger.error(message, context);
}
+159
View File
@@ -0,0 +1,159 @@
import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
import { generateNodeId } from './tree-sitter-helpers';
/**
* Custom extractor for Delphi DFM/FMX form files.
*
* DFM/FMX files describe the visual component hierarchy and event handler
* bindings. They use a simple text format (object/end blocks) that we parse
* with regex — no tree-sitter grammar exists for this format.
*
* Extracted information:
* - Components as NodeKind `component`
* - Nesting as EdgeKind `contains`
* - Event handlers (OnClick = MethodName) as UnresolvedReference → EdgeKind `references`
*/
export class DfmExtractor {
private filePath: string;
private source: string;
private nodes: Node[] = [];
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];
constructor(filePath: string, source: string) {
this.filePath = filePath;
this.source = source;
}
/**
* Extract components and event handler references from DFM/FMX source
*/
extract(): ExtractionResult {
const startTime = Date.now();
try {
const fileNode = this.createFileNode();
this.parseComponents(fileNode.id);
} catch (error) {
this.errors.push({
message: `DFM extraction error: ${error instanceof Error ? error.message : String(error)}`,
severity: 'error',
code: 'parse_error',
});
}
return {
nodes: this.nodes,
edges: this.edges,
unresolvedReferences: this.unresolvedReferences,
errors: this.errors,
durationMs: Date.now() - startTime,
};
}
/** Create a file node for the DFM form file */
private createFileNode(): Node {
const lines = this.source.split('\n');
const id = generateNodeId(this.filePath, 'file', this.filePath, 1);
const fileNode: Node = {
id,
kind: 'file',
name: this.filePath.split('/').pop() || this.filePath,
qualifiedName: this.filePath,
filePath: this.filePath,
language: 'pascal',
startLine: 1,
endLine: lines.length,
startColumn: 0,
endColumn: lines[lines.length - 1]?.length || 0,
updatedAt: Date.now(),
};
this.nodes.push(fileNode);
return fileNode;
}
/** Parse object/end blocks and extract components + event handlers */
private parseComponents(fileNodeId: string): void {
const lines = this.source.split('\n');
const stack: string[] = [fileNodeId];
const objectPattern = /^\s*(object|inherited|inline)\s+(\w+)\s*:\s*(\w+)/;
const eventPattern = /^\s*(On\w+)\s*=\s*(\w+)\s*$/;
const endPattern = /^\s*end\s*$/;
const multiLineStart = /=\s*\(\s*$/;
const multiLineItemStart = /=\s*<\s*$/;
let inMultiLine = false;
let multiLineEndChar = ')';
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const lineNum = i + 1;
// Skip multi-line properties
if (inMultiLine) {
if (line.trimEnd().endsWith(multiLineEndChar)) inMultiLine = false;
continue;
}
if (multiLineStart.test(line)) {
inMultiLine = true;
multiLineEndChar = ')';
continue;
}
if (multiLineItemStart.test(line)) {
inMultiLine = true;
multiLineEndChar = '>';
continue;
}
// Component declaration
const objMatch = line.match(objectPattern);
if (objMatch) {
const [, , name, typeName] = objMatch;
const nodeId = generateNodeId(this.filePath, 'component', name!, lineNum);
this.nodes.push({
id: nodeId,
kind: 'component',
name: name!,
qualifiedName: `${this.filePath}#${name}`,
filePath: this.filePath,
language: 'pascal',
startLine: lineNum,
endLine: lineNum,
startColumn: 0,
endColumn: line.length,
signature: typeName,
updatedAt: Date.now(),
});
this.edges.push({
source: stack[stack.length - 1]!,
target: nodeId,
kind: 'contains',
});
stack.push(nodeId);
continue;
}
// Event handler
const eventMatch = line.match(eventPattern);
if (eventMatch) {
const [, , methodName] = eventMatch;
this.unresolvedReferences.push({
fromNodeId: stack[stack.length - 1]!,
referenceName: methodName!,
referenceKind: 'references',
line: lineNum,
column: 0,
});
continue;
}
// Block end
if (endPattern.test(line)) {
if (stack.length > 1) stack.pop();
}
}
}
}
+333
View File
@@ -0,0 +1,333 @@
/**
* Grammar Loading and Caching
*
* Uses web-tree-sitter (WASM) for universal cross-platform support.
* Grammars are loaded lazily — only languages actually present in the project
* are compiled, keeping V8 WASM memory pressure low on large codebases.
*/
import * as path from 'path';
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
import { Language } from '../types';
export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'yaml' | 'twig' | 'unknown'>;
/**
* WASM filename map — maps each language to its .wasm grammar file
* in the tree-sitter-wasms package.
*/
const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
typescript: 'tree-sitter-typescript.wasm',
tsx: 'tree-sitter-tsx.wasm',
javascript: 'tree-sitter-javascript.wasm',
jsx: 'tree-sitter-javascript.wasm',
python: 'tree-sitter-python.wasm',
go: 'tree-sitter-go.wasm',
rust: 'tree-sitter-rust.wasm',
java: 'tree-sitter-java.wasm',
c: 'tree-sitter-c.wasm',
cpp: 'tree-sitter-cpp.wasm',
csharp: 'tree-sitter-c_sharp.wasm',
php: 'tree-sitter-php.wasm',
ruby: 'tree-sitter-ruby.wasm',
swift: 'tree-sitter-swift.wasm',
kotlin: 'tree-sitter-kotlin.wasm',
dart: 'tree-sitter-dart.wasm',
pascal: 'tree-sitter-pascal.wasm',
scala: 'tree-sitter-scala.wasm',
lua: 'tree-sitter-lua.wasm',
luau: 'tree-sitter-luau.wasm',
};
/**
* File extension to Language mapping
*/
export const EXTENSION_MAP: Record<string, Language> = {
'.ts': 'typescript',
'.tsx': 'tsx',
'.js': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
'.jsx': 'jsx',
'.py': 'python',
'.pyw': 'python',
'.go': 'go',
'.rs': 'rust',
'.java': 'java',
'.c': 'c',
'.h': 'c', // Could also be C++, defaulting to C
'.cpp': 'cpp',
'.cc': 'cpp',
'.cxx': 'cpp',
'.hpp': 'cpp',
'.hxx': 'cpp',
'.cs': 'csharp',
'.php': 'php',
// Drupal-specific PHP file extensions
'.module': 'php',
'.install': 'php',
'.theme': 'php',
'.inc': 'php',
// YAML (used for Drupal routing files; no symbol extraction, file-level tracking only)
'.yml': 'yaml',
'.yaml': 'yaml',
// Twig templates (file-level tracking only, no symbol extraction)
'.twig': 'twig',
'.rb': 'ruby',
'.rake': 'ruby',
'.swift': 'swift',
'.kt': 'kotlin',
'.kts': 'kotlin',
'.dart': 'dart',
'.liquid': 'liquid',
'.svelte': 'svelte',
'.vue': 'vue',
'.pas': 'pascal',
'.dpr': 'pascal',
'.dpk': 'pascal',
'.lpr': 'pascal',
'.dfm': 'pascal',
'.fmx': 'pascal',
'.scala': 'scala',
'.sc': 'scala',
'.lua': 'lua',
'.luau': 'luau',
};
/**
* Whether a file is one CodeGraph can parse, based purely on its extension.
* This is the single source of truth for "should we index this file" — derived
* from EXTENSION_MAP so parser support and indexing selection never drift.
*/
export function isSourceFile(filePath: string): boolean {
const dot = filePath.lastIndexOf('.');
if (dot < 0) return false;
return filePath.slice(dot).toLowerCase() in EXTENSION_MAP;
}
/**
* Caches for loaded grammars and parsers
*/
const parserCache = new Map<Language, Parser>();
const languageCache = new Map<Language, WasmLanguage>();
const unavailableGrammarErrors = new Map<Language, string>();
let parserInitialized = false;
/**
* Initialize the tree-sitter WASM runtime. Must be called before loading grammars.
* Does NOT load any grammar WASM files — use loadGrammarsForLanguages() for that.
* Idempotent — safe to call multiple times.
*/
export async function initGrammars(): Promise<void> {
if (parserInitialized) return;
await Parser.init();
parserInitialized = true;
}
/**
* Load grammar WASM files for specific languages only.
* Skips languages that are already loaded or have no WASM grammar.
* Must be called after initGrammars().
*/
export async function loadGrammarsForLanguages(languages: Language[]): Promise<void> {
if (!parserInitialized) {
await initGrammars();
}
// Deduplicate and filter to languages that have WASM grammars and aren't already loaded
const toLoad = [...new Set(languages)].filter(
(lang): lang is GrammarLanguage =>
lang in WASM_GRAMMAR_FILES &&
!languageCache.has(lang) &&
!unavailableGrammarErrors.has(lang)
);
// Load grammars sequentially to avoid web-tree-sitter WASM race condition on Node 20+
// See: https://github.com/tree-sitter/tree-sitter/issues/2338
for (const lang of toLoad) {
const wasmFile = WASM_GRAMMAR_FILES[lang];
try {
// Some grammars ship their own WASMs (not in tree-sitter-wasms, or the
// tree-sitter-wasms build is too old). Lua: tree-sitter-wasms ships an
// ABI-13 build that corrupts the shared WASM heap under web-tree-sitter
// 0.25 (drops nested calls/imports on every file after the first); we
// vendor the upstream ABI-15 wasm instead.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
languageCache.set(lang, language);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CodeGraph] Failed to load ${lang} grammar — parsing will be unavailable: ${message}`);
unavailableGrammarErrors.set(lang, message);
}
}
}
/**
* Load ALL grammar WASM files. Convenience function for tests and
* backward compatibility. Prefer loadGrammarsForLanguages() in production.
*/
export async function loadAllGrammars(): Promise<void> {
const allLanguages = Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[];
await loadGrammarsForLanguages(allLanguages);
}
/**
* Check if grammars have been initialized
*/
export function isGrammarsInitialized(): boolean {
return parserInitialized;
}
/**
* Get a parser for the specified language.
* Returns synchronously from pre-loaded cache.
*/
export function getParser(language: Language): Parser | null {
if (parserCache.has(language)) {
return parserCache.get(language)!;
}
const lang = languageCache.get(language);
if (!lang) {
return null;
}
const parser = new Parser();
parser.setLanguage(lang);
parserCache.set(language, parser);
return parser;
}
/**
* Detect language from file extension
*/
export function detectLanguage(filePath: string, source?: string): Language {
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
const lang = EXTENSION_MAP[ext] || 'unknown';
// .h files could be C or C++ — check source content for C++ features
if (lang === 'c' && ext === '.h' && source) {
if (looksLikeCpp(source)) return 'cpp';
}
return lang;
}
/**
* Heuristic: does a .h file contain C++ constructs?
* Checks the first ~8KB for patterns that are unique to C++ and never valid C.
*/
function looksLikeCpp(source: string): boolean {
const sample = source.substring(0, 8192);
return /\bnamespace\b|\bclass\s+\w+\s*[:{]|\btemplate\s*<|\b(?:public|private|protected)\s*:|\bvirtual\b|\busing\s+(?:namespace\b|\w+\s*=)/.test(sample);
}
/**
* Check if a language is supported (has a grammar defined).
* Returns true if the grammar exists, even if not yet loaded.
*/
export function isLanguageSupported(language: Language): boolean {
if (language === 'svelte') return true; // custom extractor (script block delegation)
if (language === 'vue') return true; // custom extractor (script block delegation)
if (language === 'liquid') return true; // custom regex extractor
if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver
if (language === 'twig') return true; // file-level tracking only
if (language === 'unknown') return false;
return language in WASM_GRAMMAR_FILES;
}
/**
* Check if a grammar has been loaded and is ready for parsing.
*/
export function isGrammarLoaded(language: Language): boolean {
if (language === 'svelte' || language === 'vue' || language === 'liquid') return true;
if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed
return languageCache.has(language);
}
/**
* Get all supported languages (those with grammar definitions).
*/
export function getSupportedLanguages(): Language[] {
return [...(Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[]), 'svelte', 'vue', 'liquid'];
}
/**
* Reset the cached parser for a language to reclaim WASM heap memory.
* The tree-sitter WASM runtime accumulates fragmented memory over thousands
* of parses. Deleting and recreating the Parser instance forces the WASM
* heap to reset, preventing "memory access out of bounds" crashes in
* large repos.
*/
export function resetParser(language: Language): void {
const old = parserCache.get(language);
if (old) {
old.delete();
parserCache.delete(language);
}
}
/**
* Clear parser/grammar caches (useful for testing)
*/
export function clearParserCache(): void {
for (const parser of parserCache.values()) {
parser.delete();
}
parserCache.clear();
// Note: languageCache is NOT cleared — WASM languages persist.
// To fully re-init, set parserInitialized = false and call initGrammars() again.
unavailableGrammarErrors.clear();
}
/**
* Report grammars that failed to load.
*/
export function getUnavailableGrammarErrors(): Partial<Record<Language, string>> {
const out: Partial<Record<Language, string>> = {};
for (const [language, message] of unavailableGrammarErrors.entries()) {
out[language] = message;
}
return out;
}
/**
* Get language display name
*/
export function getLanguageDisplayName(language: Language): string {
const names: Record<Language, string> = {
typescript: 'TypeScript',
javascript: 'JavaScript',
tsx: 'TypeScript (TSX)',
jsx: 'JavaScript (JSX)',
python: 'Python',
go: 'Go',
rust: 'Rust',
java: 'Java',
c: 'C',
cpp: 'C++',
csharp: 'C#',
php: 'PHP',
ruby: 'Ruby',
swift: 'Swift',
kotlin: 'Kotlin',
dart: 'Dart',
svelte: 'Svelte',
vue: 'Vue',
liquid: 'Liquid',
pascal: 'Pascal / Delphi',
scala: 'Scala',
lua: 'Lua',
luau: 'Luau',
yaml: 'YAML',
twig: 'Twig',
unknown: 'Unknown',
};
return names[language] || language;
}
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getChildByField, getNodeText } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const cExtractor: LanguageExtractor = {
functionTypes: ['function_definition'],
classTypes: [],
methodTypes: [],
interfaceTypes: [],
structTypes: ['struct_specifier'],
enumTypes: ['enum_specifier'],
enumMemberTypes: ['enumerator'],
typeAliasTypes: ['type_definition'], // typedef
importTypes: ['preproc_include'],
callTypes: ['call_expression'],
variableTypes: ['declaration'],
nameField: 'declarator',
bodyField: 'body',
paramsField: 'parameters',
resolveTypeAliasKind: (node, _source) => {
// C typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;`
// The inner enum_specifier/struct_specifier is anonymous, but we want the typedef name
// to become the enum/struct node name.
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum';
if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct';
}
return undefined;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
// C includes: #include <stdio.h>, #include "myheader.h"
const systemLib = node.namedChildren.find((c: SyntaxNode) => c.type === 'system_lib_string');
if (systemLib) {
return { moduleName: getNodeText(systemLib, source).replace(/^<|>$/g, ''), signature: importText };
}
const stringLiteral = node.namedChildren.find((c: SyntaxNode) => c.type === 'string_literal');
if (stringLiteral) {
const stringContent = stringLiteral.namedChildren.find((c: SyntaxNode) => c.type === 'string_content');
if (stringContent) {
return { moduleName: getNodeText(stringContent, source), signature: importText };
}
}
return null;
},
};
export const cppExtractor: LanguageExtractor = {
functionTypes: ['function_definition'],
classTypes: ['class_specifier'],
methodTypes: ['function_definition'],
interfaceTypes: [],
structTypes: ['struct_specifier'],
enumTypes: ['enum_specifier'],
enumMemberTypes: ['enumerator'],
typeAliasTypes: ['type_definition', 'alias_declaration'], // typedef and using
importTypes: ['preproc_include'],
callTypes: ['call_expression'],
variableTypes: ['declaration'],
nameField: 'declarator',
bodyField: 'body',
paramsField: 'parameters',
getVisibility: (node) => {
// Check for access specifier in parent
const parent = node.parent;
if (parent) {
for (let i = 0; i < parent.childCount; i++) {
const child = parent.child(i);
if (child?.type === 'access_specifier') {
const text = child.text;
if (text.includes('public')) return 'public';
if (text.includes('private')) return 'private';
if (text.includes('protected')) return 'protected';
}
}
}
return undefined;
},
resolveTypeAliasKind: (node, _source) => {
// C++ typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;`
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum';
if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct';
}
return undefined;
},
isMisparsedFunction: (name) => {
// C++ macros like NLOHMANN_JSON_NAMESPACE_BEGIN cause tree-sitter to misparse
// namespace blocks as function_definitions (e.g. name = "namespace detail").
// Also filter C++ keywords that tree-sitter occasionally misinterprets as
// function/method names (e.g. switch statements inside macro-confused scopes).
if (name.startsWith('namespace')) return true;
const cppKeywords = ['switch', 'if', 'for', 'while', 'do', 'case', 'return'];
return cppKeywords.includes(name);
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
// C++ includes: #include <iostream>, #include "myheader.h"
const systemLib = node.namedChildren.find((c: SyntaxNode) => c.type === 'system_lib_string');
if (systemLib) {
return { moduleName: getNodeText(systemLib, source).replace(/^<|>$/g, ''), signature: importText };
}
const stringLiteral = node.namedChildren.find((c: SyntaxNode) => c.type === 'string_literal');
if (stringLiteral) {
const stringContent = stringLiteral.namedChildren.find((c: SyntaxNode) => c.type === 'string_content');
if (stringContent) {
return { moduleName: getNodeText(stringContent, source), signature: importText };
}
}
return null;
},
};
+67
View File
@@ -0,0 +1,67 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const csharpExtractor: LanguageExtractor = {
functionTypes: [],
classTypes: ['class_declaration'],
methodTypes: ['method_declaration', 'constructor_declaration'],
interfaceTypes: ['interface_declaration'],
structTypes: ['struct_declaration'],
enumTypes: ['enum_declaration'],
enumMemberTypes: ['enum_member_declaration'],
typeAliasTypes: [],
importTypes: ['using_directive'],
callTypes: ['invocation_expression'],
variableTypes: ['local_declaration_statement'],
fieldTypes: ['field_declaration'],
propertyTypes: ['property_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameter_list',
getVisibility: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifier') {
const text = child.text;
if (text === 'public') return 'public';
if (text === 'private') return 'private';
if (text === 'protected') return 'protected';
if (text === 'internal') return 'internal';
}
}
return 'private'; // C# defaults to private
},
isStatic: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifier' && child.text === 'static') {
return true;
}
}
return false;
},
isAsync: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifier' && child.text === 'async') {
return true;
}
}
return false;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
// C# using directives: using System, using System.Collections.Generic, using static X, using Alias = X
const qualifiedName = node.namedChildren.find((c: SyntaxNode) => c.type === 'qualified_name');
if (qualifiedName) {
return { moduleName: getNodeText(qualifiedName, source), signature: importText };
}
// Simple namespace like "using System;" - get the first identifier
const identifier = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
if (identifier) {
return { moduleName: getNodeText(identifier, source), signature: importText };
}
return null;
},
};
+195
View File
@@ -0,0 +1,195 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const dartExtractor: LanguageExtractor = {
functionTypes: ['function_signature'],
classTypes: ['class_definition'],
methodTypes: ['method_signature'],
interfaceTypes: [],
structTypes: [],
enumTypes: ['enum_declaration'],
enumMemberTypes: ['enum_constant'],
typeAliasTypes: ['type_alias'],
importTypes: ['import_or_export'],
callTypes: [], // Dart calls use identifier+selector, handled via extractBareCall
variableTypes: [],
extraClassNodeTypes: ['mixin_declaration', 'extension_declaration'],
resolveBody: (node, bodyField) => {
// Dart: function_body is a next sibling of function_signature/method_signature
if (node.type === 'function_signature' || node.type === 'method_signature') {
const next = node.nextNamedSibling;
if (next?.type === 'function_body') return next;
return null;
}
// For class/mixin/extension: try standard field, then class_body/extension_body
const standard = node.childForFieldName(bodyField);
if (standard) return standard;
return node.namedChildren.find((c: SyntaxNode) =>
c.type === 'class_body' || c.type === 'extension_body'
) || null;
},
nameField: 'name',
bodyField: 'body', // class_definition uses 'body' field
paramsField: 'formal_parameter_list',
returnField: 'type',
getSignature: (node, source) => {
// For function_signature: extract params + return type
// For method_signature: delegate to inner function_signature
let sig = node;
if (node.type === 'method_signature') {
const inner = node.namedChildren.find((c: SyntaxNode) =>
c.type === 'function_signature' || c.type === 'getter_signature' || c.type === 'setter_signature'
);
if (inner) sig = inner;
}
const params = sig.namedChildren.find((c: SyntaxNode) => c.type === 'formal_parameter_list');
const retType = sig.namedChildren.find((c: SyntaxNode) =>
c.type === 'type_identifier' || c.type === 'void_type'
);
if (!params && !retType) return undefined;
let result = '';
if (retType) result += getNodeText(retType, source) + ' ';
if (params) result += getNodeText(params, source);
return result.trim() || undefined;
},
getVisibility: (node) => {
// Dart convention: _ prefix means private, otherwise public
let nameNode: SyntaxNode | null = null;
if (node.type === 'method_signature') {
const inner = node.namedChildren.find((c: SyntaxNode) =>
c.type === 'function_signature' || c.type === 'getter_signature' || c.type === 'setter_signature'
);
if (inner) nameNode = inner.namedChildren.find((c: SyntaxNode) => c.type === 'identifier') || null;
} else {
nameNode = node.childForFieldName('name');
}
if (nameNode && nameNode.text.startsWith('_')) return 'private';
return 'public';
},
isAsync: (node) => {
// In Dart, 'async' is on the function_body (next sibling), not the signature
const nextSibling = node.nextNamedSibling;
if (nextSibling?.type === 'function_body') {
for (let i = 0; i < nextSibling.childCount; i++) {
const child = nextSibling.child(i);
if (child?.type === 'async') return true;
}
}
return false;
},
isStatic: (node) => {
// For method_signature, check for 'static' child
if (node.type === 'method_signature') {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'static') return true;
}
}
return false;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
let moduleName = '';
// Dart imports: import 'dart:async'; import 'package:foo/bar.dart' as bar;
const libraryImport = node.namedChildren.find((c: SyntaxNode) => c.type === 'library_import');
if (libraryImport) {
const importSpec = libraryImport.namedChildren.find((c: SyntaxNode) => c.type === 'import_specification');
if (importSpec) {
const configurableUri = importSpec.namedChildren.find((c: SyntaxNode) => c.type === 'configurable_uri');
if (configurableUri) {
const uri = configurableUri.namedChildren.find((c: SyntaxNode) => c.type === 'uri');
if (uri) {
const stringLiteral = uri.namedChildren.find((c: SyntaxNode) => c.type === 'string_literal');
if (stringLiteral) {
moduleName = getNodeText(stringLiteral, source).replace(/['"]/g, '');
}
}
}
}
}
// Also handle exports: export 'src/foo.dart';
if (!moduleName) {
const libraryExport = node.namedChildren.find((c: SyntaxNode) => c.type === 'library_export');
if (libraryExport) {
const configurableUri = libraryExport.namedChildren.find((c: SyntaxNode) => c.type === 'configurable_uri');
if (configurableUri) {
const uri = configurableUri.namedChildren.find((c: SyntaxNode) => c.type === 'uri');
if (uri) {
const stringLiteral = uri.namedChildren.find((c: SyntaxNode) => c.type === 'string_literal');
if (stringLiteral) {
moduleName = getNodeText(stringLiteral, source).replace(/['"]/g, '');
}
}
}
}
}
if (moduleName) {
return { moduleName, signature: importText };
}
return null;
},
extractBareCall: (node, _source) => {
// Dart calls are: identifier + selector(argument_part), not a dedicated call node.
// Match on selector nodes that contain argument_part.
if (node.type === 'selector') {
const hasArgPart = node.namedChildren.some((c: SyntaxNode) => c.type === 'argument_part');
if (!hasArgPart) return undefined;
const prev = node.previousNamedSibling;
if (!prev) return undefined;
// Simple function/constructor call: prev is identifier (e.g., runApp(...), MyWidget(...))
if (prev.type === 'identifier') {
return prev.text;
}
// Method call: prev is selector with accessor (e.g., obj.method(...), Navigator.push(...))
if (prev.type === 'selector') {
const accessor = prev.namedChildren.find((c: SyntaxNode) =>
c.type === 'unconditional_assignable_selector' || c.type === 'conditional_assignable_selector'
);
if (accessor) {
const methodId = accessor.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
if (methodId) {
// Include receiver for first call in chain (receiver is a direct identifier)
const accessorPrev = prev.previousNamedSibling;
if (accessorPrev?.type === 'identifier') {
return accessorPrev.text + '.' + methodId.text;
}
return methodId.text;
}
}
}
// super.method() / this.method(): prev is bare unconditional_assignable_selector
if (prev.type === 'unconditional_assignable_selector' || prev.type === 'conditional_assignable_selector') {
const methodId = prev.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
if (methodId) return methodId.text;
}
return undefined;
}
// new MyWidget() — explicit constructor call
if (node.type === 'new_expression') {
const typeId = node.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier');
if (typeId) return typeId.text;
return undefined;
}
// const EdgeInsets.all(8.0) — const constructor call
if (node.type === 'const_object_expression') {
const typeId = node.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier');
const nameId = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
if (typeId && nameId) return typeId.text + '.' + nameId.text;
if (typeId) return typeId.text;
return undefined;
}
return undefined;
},
};
+51
View File
@@ -0,0 +1,51 @@
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const goExtractor: LanguageExtractor = {
functionTypes: ['function_declaration'],
classTypes: [], // Go doesn't have classes
methodTypes: ['method_declaration'],
interfaceTypes: [], // Handled via type_spec → resolveTypeAliasKind
structTypes: [], // Handled via type_spec → resolveTypeAliasKind
enumTypes: [],
typeAliasTypes: ['type_spec'], // Go type declarations
importTypes: ['import_declaration'],
callTypes: ['call_expression'],
variableTypes: ['var_declaration', 'short_var_declaration', 'const_declaration'],
methodsAreTopLevel: true,
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'result',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const result = getChildByField(node, 'result');
if (!params) return undefined;
let sig = getNodeText(params, source);
if (result) {
sig += ' ' + getNodeText(result, source);
}
return sig;
},
resolveTypeAliasKind: (node, _source) => {
// Go type_spec: `type Foo struct { ... }` or `type Bar interface { ... }`
// The inner type is in the 'type' field of the type_spec node
const typeChild = getChildByField(node, 'type');
if (!typeChild) return undefined;
if (typeChild.type === 'struct_type') return 'struct';
if (typeChild.type === 'interface_type') return 'interface';
return undefined;
},
getReceiverType: (node, source) => {
// Go method_declaration has a "receiver" field: func (sl *scrapeLoop) run(...)
// The receiver is a parameter_list containing a parameter_declaration
// with a type that may be a pointer_type (*scrapeLoop) or plain type (scrapeLoop)
const receiver = getChildByField(node, 'receiver');
if (!receiver) return undefined;
// Find the type identifier inside the receiver
const text = getNodeText(receiver, source);
// Extract type name from patterns like "(sl *Type)", "(sl Type)", "(*Type)", "(Type)"
const match = text.match(/\*?\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
return match?.[1];
},
};
+50
View File
@@ -0,0 +1,50 @@
/**
* Per-language extraction configurations.
*
* Each file exports a LanguageExtractor config object.
* This barrel builds the EXTRACTORS map consumed by TreeSitterExtractor.
*/
import { Language } from '../../types';
import type { LanguageExtractor } from '../tree-sitter-types';
import { typescriptExtractor } from './typescript';
import { javascriptExtractor } from './javascript';
import { pythonExtractor } from './python';
import { goExtractor } from './go';
import { rustExtractor } from './rust';
import { javaExtractor } from './java';
import { cExtractor, cppExtractor } from './c-cpp';
import { csharpExtractor } from './csharp';
import { phpExtractor } from './php';
import { rubyExtractor } from './ruby';
import { swiftExtractor } from './swift';
import { kotlinExtractor } from './kotlin';
import { dartExtractor } from './dart';
import { pascalExtractor } from './pascal';
import { scalaExtractor } from './scala';
import { luaExtractor } from './lua';
import { luauExtractor } from './luau';
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
tsx: typescriptExtractor,
javascript: javascriptExtractor,
jsx: javascriptExtractor,
python: pythonExtractor,
go: goExtractor,
rust: rustExtractor,
java: javaExtractor,
c: cExtractor,
cpp: cppExtractor,
csharp: csharpExtractor,
php: phpExtractor,
ruby: rubyExtractor,
swift: swiftExtractor,
kotlin: kotlinExtractor,
dart: dartExtractor,
pascal: pascalExtractor,
scala: scalaExtractor,
lua: luaExtractor,
luau: luauExtractor,
};
+59
View File
@@ -0,0 +1,59 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const javaExtractor: LanguageExtractor = {
functionTypes: [],
classTypes: ['class_declaration'],
methodTypes: ['method_declaration', 'constructor_declaration'],
interfaceTypes: ['interface_declaration'],
structTypes: [],
enumTypes: ['enum_declaration'],
enumMemberTypes: ['enum_constant'],
typeAliasTypes: [],
importTypes: ['import_declaration'],
callTypes: ['method_invocation'],
variableTypes: ['local_variable_declaration'],
fieldTypes: ['field_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'type',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const returnType = getChildByField(node, 'type');
if (!params) return undefined;
const paramsText = getNodeText(params, source);
return returnType ? getNodeText(returnType, source) + ' ' + paramsText : paramsText;
},
getVisibility: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers') {
const text = child.text;
if (text.includes('public')) return 'public';
if (text.includes('private')) return 'private';
if (text.includes('protected')) return 'protected';
}
}
return undefined;
},
isStatic: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers' && child.text.includes('static')) {
return true;
}
}
return false;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
const scopedId = node.namedChildren.find((c: SyntaxNode) => c.type === 'scoped_identifier');
if (scopedId) {
const moduleName = source.substring(scopedId.startIndex, scopedId.endIndex);
return { moduleName, signature: importText };
}
return null;
},
};
+84
View File
@@ -0,0 +1,84 @@
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const javascriptExtractor: LanguageExtractor = {
functionTypes: ['function_declaration', 'arrow_function', 'function_expression'],
classTypes: ['class_declaration'],
methodTypes: ['method_definition', 'field_definition'],
interfaceTypes: [],
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: ['import_statement'],
callTypes: ['call_expression'],
variableTypes: ['lexical_declaration', 'variable_declaration'],
nameField: 'name',
bodyField: 'body',
resolveBody: (node, bodyField) => {
// field_definition (arrow function class fields) nest the body inside
// an arrow_function or function_expression child:
// field_definition → arrow_function → body (statement_block)
// Also handles wrapper patterns like: field = throttle((e) => { ... })
// field_definition → call_expression → arguments → arrow_function → body
if (node.type === 'field_definition') {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'arrow_function' || child.type === 'function_expression') {
return getChildByField(child, bodyField);
}
if (child.type === 'call_expression') {
const args = getChildByField(child, 'arguments');
if (args) {
for (let j = 0; j < args.namedChildCount; j++) {
const arg = args.namedChild(j);
if (arg && (arg.type === 'arrow_function' || arg.type === 'function_expression')) {
return getChildByField(arg, bodyField);
}
}
}
}
}
}
return null;
},
paramsField: 'parameters',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
return params ? getNodeText(params, source) : undefined;
},
isExported: (node, _source) => {
let current = node.parent;
while (current) {
if (current.type === 'export_statement') return true;
current = current.parent;
}
return false;
},
isAsync: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'async') return true;
}
return false;
},
isConst: (node) => {
if (node.type === 'lexical_declaration') {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'const') return true;
}
}
return false;
},
extractImport: (node, source) => {
const sourceField = node.childForFieldName('source');
if (sourceField) {
const moduleName = source.substring(sourceField.startIndex, sourceField.endIndex).replace(/['"]/g, '');
if (moduleName) {
return { moduleName, signature: source.substring(node.startIndex, node.endIndex).trim() };
}
}
return null;
},
};
+238
View File
@@ -0,0 +1,238 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
/** Check if a node matches the `fun interface` misparse pattern */
function isFunInterfaceNode(node: SyntaxNode): boolean {
let hasFun = false;
let hasInterfaceType = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === 'fun' && !child.isNamed) hasFun = true;
if (child.type === 'user_type') {
const typeId = child.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier');
if (typeId && typeId.text === 'interface') hasInterfaceType = true;
}
// Pattern 2b: user_type("interface") is inside an ERROR child
if (child.type === 'ERROR') {
for (let j = 0; j < child.childCount; j++) {
const gc = child.child(j);
if (gc && gc.type === 'user_type') {
const typeId = gc.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier');
if (typeId && typeId.text === 'interface') hasInterfaceType = true;
}
}
}
}
return hasFun && hasInterfaceType;
}
export const kotlinExtractor: LanguageExtractor = {
functionTypes: ['function_declaration'],
classTypes: ['class_declaration'],
methodTypes: ['function_declaration'], // Methods are functions inside classes
interfaceTypes: [], // Handled via classifyClassNode
structTypes: [], // Kotlin uses data classes
enumTypes: [], // Handled via classifyClassNode
enumMemberTypes: ['enum_entry'],
typeAliasTypes: ['type_alias'],
importTypes: ['import_header'],
callTypes: ['call_expression'],
variableTypes: ['property_declaration'],
fieldTypes: ['property_declaration'],
extraClassNodeTypes: ['object_declaration'],
nameField: 'simple_identifier',
bodyField: 'function_body',
visitNode: (node, ctx) => {
// Handle Kotlin `fun interface` declarations.
// Tree-sitter-kotlin doesn't support `fun interface` syntax (Kotlin 1.4+).
// It produces two different misparse patterns:
// Pattern 1 (simple): ERROR node + sibling lambda_literal for body
// Pattern 2 (complex): function_declaration misparse with ERROR child
// Skip lambda_literal bodies that were already consumed by a fun interface ERROR node
if (node.type === 'lambda_literal') {
const prev = node.previousSibling;
if (prev && prev.type === 'ERROR' && isFunInterfaceNode(prev)) return true;
return false;
}
if (node.type !== 'ERROR' && node.type !== 'function_declaration') return false;
// Skip ERROR nodes that are class bodies (start with `{`). These contain parent
// methods + trailing `fun interface` tokens. The methods are extracted via
// resolveBody; handling the ERROR here would consume the whole body.
if (node.type === 'ERROR') {
const firstChild = node.child(0);
if (firstChild && firstChild.type === '{') return false;
}
if (!isFunInterfaceNode(node)) return false;
// Extract the interface name.
// For function_declaration misparses (patterns 2a/2b), the real name is inside
// an ERROR child — direct simple_identifier children are the misparsed method name.
let nameText: string | null = null;
if (node.type === 'function_declaration') {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === 'ERROR') {
for (let j = 0; j < child.childCount; j++) {
const gc = child.child(j);
if (gc && gc.type === 'simple_identifier') {
nameText = gc.text;
break;
}
}
if (nameText) break;
}
}
}
// Fallback: direct simple_identifier child (Pattern 1: ERROR node at top level)
if (!nameText) {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === 'simple_identifier') {
nameText = child.text;
break;
}
}
}
if (!nameText) return false;
// Create the interface node
const ifaceNode = ctx.createNode('interface', nameText, node);
if (!ifaceNode) return false;
ctx.pushScope(ifaceNode.id);
if (node.type === 'ERROR') {
// Pattern 1: body is in the next sibling lambda_literal
const nextSibling = node.nextSibling;
if (nextSibling && nextSibling.type === 'lambda_literal') {
for (let i = 0; i < nextSibling.namedChildCount; i++) {
const child = nextSibling.namedChild(i);
if (child && child.type === 'statements') {
for (let j = 0; j < child.namedChildCount; j++) {
const stmt = child.namedChild(j);
if (stmt) ctx.visitNode(stmt);
}
}
}
}
}
// Pattern 2 (function_declaration): nested classes are siblings at source_file level,
// already visited by the normal traversal. The single abstract method is misparsed
// and cannot be reliably recovered, but the interface node itself is the key value.
ctx.popScope();
return true;
},
paramsField: 'function_value_parameters',
returnField: 'type',
resolveBody: (node, _bodyField) => {
// Kotlin's tree-sitter grammar doesn't use field names, so getChildByField fails.
// Find body by type: function_body for functions/methods, class_body for classes,
// enum_class_body for enums.
//
// Special case: when a class/interface contains a nested `fun interface`, tree-sitter
// misparsed the parent's body as an ERROR node (starting with `{`) and creates
// a class_body sibling for the nested interface's body. Prefer the ERROR body
// so the parent's methods are extracted.
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && child.type === 'ERROR') {
const firstChild = child.child(0);
if (firstChild && firstChild.type === '{') {
return child;
}
}
if (child && (child.type === 'function_body' || child.type === 'class_body' || child.type === 'enum_class_body')) {
return child;
}
}
return null;
},
classifyClassNode: (node) => {
// Kotlin reuses class_declaration for classes, interfaces, and enums.
// Detect by checking for keyword children:
// interface Foo { } → has 'interface' keyword child
// enum class Level { } → has 'enum' keyword child
// class / data class / abstract class → default 'class'
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === 'interface') return 'interface';
if (child.type === 'enum') return 'enum';
}
return 'class';
},
getReceiverType: (node, source) => {
// Kotlin extension functions: fun Type.method() { }
// AST: function_declaration > user_type, ".", simple_identifier
// The user_type before the dot is the receiver type.
let foundUserType: SyntaxNode | null = null;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === 'user_type') {
foundUserType = child;
} else if (child.type === '.' && foundUserType) {
// The user_type before the dot is the receiver type
const typeId = foundUserType.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier');
return typeId ? getNodeText(typeId, source) : getNodeText(foundUserType, source);
} else if (child.type === 'simple_identifier' || child.type === 'function_value_parameters') {
// Past the function name — no receiver
break;
}
}
return undefined;
},
getSignature: (node, source) => {
// Kotlin function signature: fun name(params): ReturnType
const params = getChildByField(node, 'function_value_parameters');
const returnType = getChildByField(node, 'type');
if (!params) return undefined;
let sig = getNodeText(params, source);
if (returnType) {
sig += ': ' + getNodeText(returnType, source);
}
return sig;
},
getVisibility: (node) => {
// Check for visibility modifiers in Kotlin
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers') {
const text = child.text;
if (text.includes('public')) return 'public';
if (text.includes('private')) return 'private';
if (text.includes('protected')) return 'protected';
if (text.includes('internal')) return 'internal';
}
}
return 'public'; // Kotlin defaults to public
},
isStatic: (_node) => {
// Kotlin doesn't have static, uses companion objects
return false;
},
isAsync: (node) => {
// Kotlin uses suspend keyword for coroutines
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers' && child.text.includes('suspend')) {
return true;
}
}
return false;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
const identifier = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
if (identifier) {
return { moduleName: source.substring(identifier.startIndex, identifier.endIndex), signature: importText };
}
return null;
},
};
+152
View File
@@ -0,0 +1,152 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
// Node names follow the vendored ABI-15 grammar (@tree-sitter-grammars/
// tree-sitter-lua), NOT the older tree-sitter-wasms build — see grammars.ts.
/** First descendant of a given type (breadth-first), or null. */
function findDescendant(node: SyntaxNode, type: string): SyntaxNode | null {
const queue: SyntaxNode[] = [...node.namedChildren];
while (queue.length) {
const n = queue.shift()!;
if (n.type === type) return n;
queue.push(...n.namedChildren);
}
return null;
}
/**
* If `callNode` is a `require(...)` call, return the module name; otherwise null.
* Lua/Luau have no import statement — modules are loaded by calling the global
* `require`. Handles both:
* - string requires: `require("net.http")` / `require "net.http"` → "net.http"
* - Roblox/Luau path requires: `require(script.Parent.Signal)` → "Signal"
* (the dominant idiom in Roblox code, where the argument is an instance path
* rather than a string — use the trailing field as the module name).
*/
function requireModule(callNode: SyntaxNode, source: string): string | null {
// function_call > name: <callee>, arguments: arguments
const name = getChildByField(callNode, 'name');
// A dotted/colon callee (e.g. `socket.connect`) is dot/method_index_expression,
// never a bare `require`.
if (!name || name.type !== 'identifier') return null;
if (getNodeText(name, source) !== 'require') return null;
const args = getChildByField(callNode, 'arguments');
if (!args) return null;
// String require — `string > content: string_content` gives the bare name.
const content = findDescendant(args, 'string_content');
if (content) return getNodeText(content, source).trim() || null;
const str = findDescendant(args, 'string');
if (str) {
const mod = getNodeText(str, source)
.trim()
.replace(/^\[\[/, '')
.replace(/\]\]$/, '')
.replace(/^["']/, '')
.replace(/["']$/, '');
if (mod) return mod;
}
// Roblox/Luau instance-path require: `require(script.Parent.Signal)` → "Signal".
const idx = findDescendant(args, 'dot_index_expression') ?? findDescendant(args, 'method_index_expression');
if (idx) {
const field = getChildByField(idx, 'field') ?? getChildByField(idx, 'method');
if (field) return getNodeText(field, source).trim() || null;
}
return null;
}
export const luaExtractor: LanguageExtractor = {
// function_declaration covers global (`function f`), table (`function t.f`),
// method (`function t:m`), and local (`local function f`) forms — the form is
// distinguished by the `name:` child (identifier / dot_index_expression /
// method_index_expression) and a `local` token, not by separate node types.
// Anonymous `function() ... end` (function_definition) has no name and is
// captured via its enclosing variable instead.
functionTypes: ['function_declaration'],
classTypes: [], // Lua has no classes/structs/interfaces/enums — tables are used for everything
methodTypes: [],
interfaceTypes: [],
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: [], // `require` is a function_call — handled in visitNode below
callTypes: ['function_call'],
variableTypes: ['variable_declaration'], // see the `lua` branch in extractVariable
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
return params ? getNodeText(params, source) : undefined;
},
// `function t.f()` / `function t:m()` are methods on table `t`: return the
// table as the receiver so they extract as methods with a `t::f` qualified
// name. Plain `function f()` / `local function f()` have no receiver and stay
// functions. (For `a.b.c`, the receiver is the nested `a.b`.)
getReceiverType: (node, source) => {
const name = getChildByField(node, 'name');
if (name && (name.type === 'dot_index_expression' || name.type === 'method_index_expression')) {
const table = getChildByField(name, 'table');
if (table) return getNodeText(table, source);
}
return undefined;
},
// Emit import nodes for `require(...)`. The local-declaration form is handled
// explicitly because the variable branch skips the initializer subtree; bare
// and global `require` calls are caught when the walker reaches the
// function_call node.
visitNode: (node, ctx) => {
const source = ctx.source;
const emit = (callNode: SyntaxNode): void => {
const mod = requireModule(callNode, source);
if (!mod) return;
const imp = ctx.createNode('import', mod, callNode, {
signature: getNodeText(callNode, source).trim().slice(0, 100),
});
if (imp && ctx.nodeStack.length > 0) {
const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
if (parentId) {
ctx.addUnresolvedReference({
fromNodeId: parentId,
referenceName: mod,
referenceKind: 'imports',
line: callNode.startPosition.row + 1,
column: callNode.startPosition.column,
});
}
}
};
// Bare / global `require("x")` — claim it so it isn't double-counted as a call.
if (node.type === 'function_call') {
if (requireModule(node, source)) {
emit(node);
return true;
}
return false;
}
// `local x = require("x")` — variable_declaration wraps an assignment_statement
// whose initializer subtree the variable branch will skip, so dig it out here.
if (node.type === 'variable_declaration') {
const assign = node.namedChildren.find((c) => c.type === 'assignment_statement');
const exprList = assign?.namedChildren.find((c) => c.type === 'expression_list');
if (exprList) {
for (const val of exprList.namedChildren) {
if (val.type === 'function_call') emit(val);
}
}
return false;
}
return false;
},
};
+36
View File
@@ -0,0 +1,36 @@
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
import { luaExtractor } from './lua';
// Luau (https://luau.org) is a gradually-typed superset of Lua. The
// tree-sitter-luau grammar reuses the same node names as the vendored Lua
// grammar (function_declaration, variable_declaration, function_call,
// dot/method_index_expression, …), so the Luau extractor extends the Lua one
// and adds the type-system pieces Luau introduces:
// - `type X = ...` / `export type X = ...` → type_definition (type_alias)
// - typed parameters and return types → richer signatures
//
// require detection, receiver-splitting (t.f / t:m → methods), and local
// variable extraction are inherited unchanged from luaExtractor. The shared
// `extractVariable` core branch is gated on `lua` || `luau`.
export const luauExtractor: LanguageExtractor = {
...luaExtractor,
// `type X = ...` and `export type X = ...`
typeAliasTypes: ['type_definition'],
// Only Luau `export type` is exported; the keyword leads the node.
isExported: (node, source) => source.slice(node.startIndex, node.startIndex + 7) === 'export ',
// Params + Luau return type (the named child after `parameters`, before the body).
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
if (!params) return undefined;
let sig = getNodeText(params, source);
const kids = node.namedChildren;
const idx = kids.findIndex((c) => c.startIndex === params.startIndex);
const ret = idx >= 0 ? kids[idx + 1] : null;
if (ret && ret.type !== 'block') sig += `: ${getNodeText(ret, source)}`;
return sig;
},
};
+62
View File
@@ -0,0 +1,62 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const pascalExtractor: LanguageExtractor = {
functionTypes: ['declProc'],
classTypes: ['declClass'],
methodTypes: ['declProc'],
interfaceTypes: ['declIntf'],
structTypes: [],
enumTypes: ['declEnum'],
typeAliasTypes: ['declType'],
importTypes: ['declUses'],
callTypes: ['exprCall'],
variableTypes: ['declField', 'declConst'],
nameField: 'name',
bodyField: 'body',
paramsField: 'args',
returnField: 'type',
getSignature: (node, source) => {
const args = getChildByField(node, 'args');
const returnType = node.namedChildren.find(
(c: SyntaxNode) => c.type === 'typeref'
);
if (!args && !returnType) return undefined;
let sig = '';
if (args) sig = getNodeText(args, source);
if (returnType) {
sig += ': ' + getNodeText(returnType, source);
}
return sig || undefined;
},
getVisibility: (node) => {
let current = node.parent;
while (current) {
if (current.type === 'declSection') {
for (let i = 0; i < current.childCount; i++) {
const child = current.child(i);
if (child?.type === 'kPublic' || child?.type === 'kPublished')
return 'public';
if (child?.type === 'kPrivate') return 'private';
if (child?.type === 'kProtected') return 'protected';
}
}
current = current.parent;
}
return undefined;
},
isExported: (_node, _source) => {
// In Pascal, symbols declared in the interface section are exported
return false;
},
isStatic: (node) => {
for (let i = 0; i < node.childCount; i++) {
if (node.child(i)?.type === 'kClass') return true;
}
return false;
},
isConst: (node) => {
return node.type === 'declConst';
},
};
+105
View File
@@ -0,0 +1,105 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const phpExtractor: LanguageExtractor = {
functionTypes: ['function_definition'],
classTypes: ['class_declaration', 'trait_declaration'],
methodTypes: ['method_declaration'],
interfaceTypes: ['interface_declaration'],
structTypes: [],
enumTypes: ['enum_declaration'],
enumMemberTypes: ['enum_case'],
typeAliasTypes: [],
importTypes: ['namespace_use_declaration'],
callTypes: ['function_call_expression', 'member_call_expression', 'scoped_call_expression'],
variableTypes: ['const_declaration'],
fieldTypes: ['property_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'return_type',
classifyClassNode: (node) => {
return node.type === 'trait_declaration' ? 'trait' : 'class';
},
getVisibility: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'visibility_modifier') {
const text = child.text;
if (text === 'public') return 'public';
if (text === 'private') return 'private';
if (text === 'protected') return 'protected';
}
}
return 'public'; // PHP defaults to public
},
isStatic: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'static_modifier') return true;
}
return false;
},
visitNode: (node, ctx) => {
// Handle class constants: const_declaration inside classes
// These are skipped by the main visitor because variableTypes check excludes class-like contexts
if (node.type === 'const_declaration') {
const constElements = node.namedChildren.filter((c: SyntaxNode) => c.type === 'const_element');
for (const elem of constElements) {
const nameNode = elem.namedChildren.find((c: SyntaxNode) => c.type === 'name');
if (!nameNode) continue;
const name = getNodeText(nameNode, ctx.source);
ctx.createNode('constant', name, elem, {});
}
return true; // handled
}
// Handle trait usage: use TraitName, OtherTrait; inside classes
// Creates unresolved references that will be resolved to 'implements' edges
if (node.type === 'use_declaration') {
const names = node.namedChildren.filter((c: SyntaxNode) => c.type === 'name' || c.type === 'qualified_name');
const parentId = ctx.nodeStack.length > 0 ? ctx.nodeStack[ctx.nodeStack.length - 1] : undefined;
if (parentId) {
for (const nameNode of names) {
const traitName = getNodeText(nameNode, ctx.source);
ctx.addUnresolvedReference({
fromNodeId: parentId,
referenceName: traitName,
referenceKind: 'implements',
filePath: ctx.filePath,
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
}
return true; // handled
}
return false;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
// Check for grouped imports: use X\{A, B} - return null for core fallback
const namespacePrefix = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_name');
const useGroup = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_use_group');
if (namespacePrefix && useGroup) {
return null; // Grouped imports create multiple nodes - let core handle
}
// Single import - find namespace_use_clause
const useClause = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_use_clause');
if (useClause) {
const qualifiedName = useClause.namedChildren.find((c: SyntaxNode) => c.type === 'qualified_name');
if (qualifiedName) {
return { moduleName: getNodeText(qualifiedName, source), signature: importText };
}
const name = useClause.namedChildren.find((c: SyntaxNode) => c.type === 'name');
if (name) {
return { moduleName: getNodeText(name, source), signature: importText };
}
}
return null;
},
};
+53
View File
@@ -0,0 +1,53 @@
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const pythonExtractor: LanguageExtractor = {
functionTypes: ['function_definition'],
classTypes: ['class_definition'],
methodTypes: ['function_definition'], // Methods are functions inside classes
interfaceTypes: [],
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: ['import_statement', 'import_from_statement'],
callTypes: ['call'],
variableTypes: ['assignment'], // Python uses assignment for variable declarations
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'return_type',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const returnType = getChildByField(node, 'return_type');
if (!params) return undefined;
let sig = getNodeText(params, source);
if (returnType) {
sig += ' -> ' + getNodeText(returnType, source);
}
return sig;
},
isAsync: (node) => {
const prev = node.previousSibling;
return prev?.type === 'async';
},
isStatic: (node) => {
// Check for @staticmethod decorator
const prev = node.previousNamedSibling;
if (prev?.type === 'decorator') {
const text = prev.text;
return text.includes('staticmethod');
}
return false;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
if (node.type === 'import_from_statement') {
const moduleNode = node.childForFieldName('module_name');
if (moduleNode) {
return { moduleName: source.substring(moduleNode.startIndex, moduleNode.endIndex), signature: importText };
}
}
// import_statement creates multiple imports - return null for core fallback
return null;
},
};
+111
View File
@@ -0,0 +1,111 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const rubyExtractor: LanguageExtractor = {
functionTypes: ['method'],
classTypes: ['class'],
methodTypes: ['method', 'singleton_method'],
interfaceTypes: [], // Ruby uses modules (handled via visitNode hook)
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: ['call'], // require/require_relative
callTypes: ['call', 'method_call'],
variableTypes: ['assignment'], // Ruby uses assignment like Python
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
visitNode: (node, ctx) => {
if (node.type !== 'module') return false;
const nameNode = node.childForFieldName('name');
if (!nameNode) return false;
const name = nameNode.text;
const moduleNode = ctx.createNode('module', name, node);
if (!moduleNode) return false;
// Push module onto scope stack so children get proper qualified names
ctx.pushScope(moduleNode.id);
const body = node.childForFieldName('body');
if (body) {
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
if (child) ctx.visitNode(child);
}
}
ctx.popScope();
return true; // handled
},
extractBareCall: (node, _source) => {
// Ruby bare method calls (no parens, no receiver) parse as plain identifiers.
// e.g., `reset` in a method body is `identifier "reset"` not a `call` node.
if (node.type !== 'identifier') return undefined;
const parent = node.parent;
if (!parent) return undefined;
// Only statement-level identifiers — direct children of block/body nodes
const BLOCK_PARENTS = new Set([
'body_statement', 'then', 'else', 'do', 'begin',
'rescue', 'ensure', 'when',
]);
if (!BLOCK_PARENTS.has(parent.type)) return undefined;
const name = node.text;
// Skip Ruby keywords/literals
const SKIP = new Set([
'true', 'false', 'nil', 'self', 'super',
'__FILE__', '__LINE__', '__dir__',
]);
if (SKIP.has(name)) return undefined;
// Skip constants (uppercase start) — these are class/module refs, not calls
if (name.length > 0 && name.charCodeAt(0) >= 65 && name.charCodeAt(0) <= 90) return undefined;
return name;
},
getVisibility: (node) => {
// Ruby visibility is based on preceding visibility modifiers
let sibling = node.previousNamedSibling;
while (sibling) {
if (sibling.type === 'call') {
const methodName = getChildByField(sibling, 'method');
if (methodName) {
const text = methodName.text;
if (text === 'private') return 'private';
if (text === 'protected') return 'protected';
if (text === 'public') return 'public';
}
}
sibling = sibling.previousNamedSibling;
}
return 'public';
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
// Check if this is a require/require_relative call
const identifier = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
if (!identifier) return null;
const methodName = getNodeText(identifier, source);
if (methodName !== 'require' && methodName !== 'require_relative') {
return null; // Not an import, skip
}
// Find the argument (string)
const argList = node.namedChildren.find((c: SyntaxNode) => c.type === 'argument_list');
if (argList) {
const stringNode = argList.namedChildren.find((c: SyntaxNode) => c.type === 'string');
if (stringNode) {
const stringContent = stringNode.namedChildren.find((c: SyntaxNode) => c.type === 'string_content');
if (stringContent) {
return { moduleName: getNodeText(stringContent, source), signature: importText };
}
}
}
return null;
},
};

Some files were not shown because too many files have changed in this diff Show More