Initial commit: codegraph (local-first code intelligence library + MCP server)
This commit is contained in:
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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?" }
|
||||
]
|
||||
}
|
||||
@@ -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 -->
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
|
||||
[](https://www.npmjs.com/package/@colbymchenry/codegraph)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://nodejs.org/)
|
||||
|
||||
[](#supported-platforms)
|
||||
[](#supported-platforms)
|
||||
[](#supported-platforms)
|
||||
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#supported-agents)
|
||||
[](#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">
|
||||
|
||||

|
||||
|
||||
</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>
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
Generated
+1609
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+60
@@ -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"
|
||||
Executable
+75
@@ -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);
|
||||
Executable
+103
@@ -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();
|
||||
Executable
+70
@@ -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);
|
||||
Executable
+68
@@ -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 ===================="
|
||||
Executable
+107
@@ -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
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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)}`);
|
||||
Executable
+34
@@ -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
|
||||
Executable
+67
@@ -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 ##############################"
|
||||
Executable
+118
@@ -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))"
|
||||
Executable
+130
@@ -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');
|
||||
Executable
+41
@@ -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"
|
||||
Executable
+246
@@ -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);
|
||||
}
|
||||
Executable
+95
@@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+244
@@ -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);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
);
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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];
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user