chore: import upstream snapshot with attribution
Deploy site to GitHub Pages / build (push) Failing after 1s
Deploy site to GitHub Pages / deploy (push) Has been skipped

This commit is contained in:
wehub-resource-sync
2026-07-13 12:02:56 +08:00
commit 5f98960d22
495 changed files with 149129 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
---
name: add-lang
description: Add tree-sitter language support to codegraph end-to-end — wire the grammar + extractor, write tests, then benchmark extraction quality and retrieval value on 3 popular real-world repos. Use when the user runs /add-lang <language> or asks to add/support a new language (e.g. Lua, Elixir, Zig, OCaml) in codegraph.
---
# Add a language to CodeGraph
Wire a new tree-sitter language into codegraph's extraction pipeline, prove it
extracts real symbols on popular repos, and prove it beats no-codegraph for an
agent. Runs **fully autonomously** — pick repos, benchmark, update docs, then
report. **Never commit, push, publish, or tag** (house rule); leave all changes
for the user to review.
The argument is the language token used throughout the `Language` union, e.g.
`lua`, `elixir`, `zig`. If none was given, ask which language. Use the lowercase
single-token form everywhere (`csharp`, not `c#`).
## Prerequisites
- Run from the codegraph repo root. `node`, `git`, `gh`, and a logged-in
`claude` CLI (the benchmark spawns real `claude -p` runs).
- The benchmark uses the local dev build — Step 8 builds + links it on PATH.
## Workflow
Copy this checklist and work through it in order:
```
- [ ] 1. Resolve language; bail early if already supported (just benchmark)
- [ ] 2. Find a grammar + health-check it (ABI / heap corruption)
- [ ] 3. Discover the grammar's AST node types (dump-ast.mjs)
- [ ] 4. Wire the language (4 files; sometimes a 5th core touch)
- [ ] 5. Build + verify-extraction loop until PASS
- [ ] 6. Add extraction tests; make them green
- [ ] 7. Auto-pick 3 popular repos by size tier; add to corpus.json
- [ ] 8. Benchmark all 3: extraction + with/without A/B
- [ ] 9. Update README + CHANGELOG
- [ ] 10. Report; do NOT commit
```
### Step 1 — Resolve + short-circuit
Check whether the language is already wired: look for the token in the
`LANGUAGES` const (`src/types.ts`) and the `EXTRACTORS` map
(`src/extraction/languages/index.ts`). If it is already supported (e.g.
`typescript`, `rust`), **skip Steps 26** and go straight to benchmarking
(Steps 78) 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** ~1501500 ·
**Large** >~1500. Skip repos that are tagged `<lang>` but mostly another
language. Write one cross-file architecture **question** per repo (the kind that
needs tracing across files). Add a `"<Language>"` block to
`.claude/skills/agent-eval/corpus.json` (fields: `name`, `repo`, `size`,
`files`, `question`) so `/agent-eval` can reuse them.
### Step 8 — Benchmark all 3 (extraction + A/B)
Make the dev build the codegraph on PATH **once**, then loop:
```bash
npm run build && ./scripts/local-install.sh
scripts/add-lang/bench.sh <lang> <name> <url> "<question>" headless # ×3
```
`bench.sh` clones (shared `/tmp/codegraph-corpus`), wipes + indexes, runs
`verify-extraction.mjs`, then the with/without retrieval A/B via
`scripts/agent-eval/run-all.sh` (skips the paid A/B if extraction is broken).
Read each `parse-run.mjs` summary printed by `run-all.sh`: tool calls, file
`Read`s, Grep/Bash, codegraph-tool calls, duration, and **cost** — for both the
`with` and `without` arms. After the loop, restore the dev link if needed:
`./scripts/local-install.sh`.
### Step 9 — Docs + CHANGELOG
- **README.md**: add `<Lang>` to the "19+ Languages" feature bullet, and add a
row to the **Supported Languages** table:
`| <Lang> | \`.ext\` | Full support (classes, methods, …) |`.
- **CHANGELOG.md**: add an `## [Unreleased]` section at the top (above the
latest version) with `### Added` → a user-perspective bullet, e.g.
*"CodeGraph now indexes **<Lang>** (`.ext`) — functions, classes, imports, and
call edges."* If `## [Unreleased]` already exists, append under it. (It's
folded into the next versioned block at release time.)
### Step 10 — Report (do NOT commit)
Summarize for review:
- **Files changed**: the 4 wiring edits + new extractor + tests + README +
CHANGELOG + corpus.json (+ any vendored `.wasm`).
- **Extraction** per repo: files / nodes / edges / `verify-extraction` result.
- **A/B** per repo: `with` vs `without` (tool calls, file Reads, cost) and a
one-line verdict — did codegraph reduce effort, and did both arms reach a
correct answer?
- **Gaps / follow-ups** (node types not yet mapped, resolution edges missing,
framework routes, etc.).
Hand the changes to the user. **Do not** run `git commit`/`push` or publish —
releases go through the GitHub Actions Release workflow.
## Notes
- The A/B spawns real **paid** `claude -p` runs (opus, `--max-budget-usd`),
2 arms × 3 repos. The corpus dir `/tmp/codegraph-corpus` is shared with
`/agent-eval`, so clones are reused across runs.
- Any new `*.wasm` must live in `src/extraction/wasm/` — `copy-assets` (run by
`npm run build`) ships it; otherwise it won't be in `dist/`.
- An index must be served by the **same** binary that built it. Step 8 builds +
links the dev build first, so this holds.
- If a grammar can't be obtained, or extraction can't reach PASS, **STOP and
report** — don't ship a half-wired language.
+74
View File
@@ -0,0 +1,74 @@
---
name: agent-eval
description: Benchmark CodeGraph retrieval quality on a real codebase by comparing agent behavior with vs without CodeGraph. Use when the user runs /agent-eval or asks to test, benchmark, audit, or validate a codegraph version (the local dev build or a published npm version) against a language's repo.
---
# CodeGraph Quality Audit
Measures how much CodeGraph helps an agent versus plain grep/read, for a chosen
codegraph version on a chosen real-world repo. Drives the harness in
`scripts/agent-eval/`.
## Prerequisites
- `tmux` 3+, a logged-in `claude` CLI, `node`, `git` (macOS/Linux).
- Run from the codegraph repo root.
## Workflow
Copy this checklist:
```
- [ ] 1. Pick version (local or npm)
- [ ] 2. Pick language
- [ ] 3. Pick repo by size
- [ ] 4. Pick harness (headless / tmux / both)
- [ ] 5. Run audit.sh in the background
- [ ] 6. Report results
```
**Step 1 — version.** Ask with `AskUserQuestion`: which codegraph version to test.
Offer "Local dev build" and "Latest published"; the free-text "Other" lets the
user type a specific version (e.g. `0.7.10`). Map the answer to a VERSION token:
- "Local dev build" → `local`
- "Latest published" → `latest`
- a typed version → that string (e.g. `0.7.10`)
**Step 2 — language.** Read `.claude/skills/agent-eval/corpus.json`. Ask with
`AskUserQuestion` which language to test, listing the languages that have entries.
**Step 3 — repo.** From the chosen language's entries, ask which repo. Label each
option with its size and file count, e.g. `excalidraw — Medium (~600 files)`.
Each entry carries the `repo` URL and a representative `question`.
**Step 4 — harness.** Ask with `AskUserQuestion` which harness to run, and map
the answer to a MODE token:
- "Headless" → `headless``claude -p` with stream-json: exact tokens/cost and a
clean tool sequence (2 runs, fast, no TTY).
- "Interactive (tmux)" → `tmux` — drives the real Claude TUI in tmux: faithful
Explore-subagent behavior, metrics from session logs (2 runs, slower).
- "Both" → `all` — headless + interactive (4 runs).
**Step 5 — run.** Launch in the background (sets the version, clones if missing,
wipes + re-indexes, runs the chosen arms — several minutes):
```bash
scripts/agent-eval/audit.sh <VERSION> <repo-name> <repo-url> "<question>" <MODE>
```
**Step 6 — report.** When the job finishes, read the log and report per arm:
- Headless (`parse-run.mjs`): total tool calls, file `Read`s, Grep/Bash,
codegraph-tool calls, duration, **total cost**.
- Interactive (`parse-session.mjs`): the `VERDICT: codegraph_explore used Nx |
Read N | Grep/Bash N` and `TOKENS:` lines.
Lead with cost + tool/Read counts — they are the reliable signals; raw token
in/out are confounded by subagent delegation and prompt caching. State whether
codegraph reduced effort and whether both arms reached a correct answer.
## Notes
- The index is rebuilt every run (`audit.sh` wipes `.codegraph`) — different
versions extract differently, so an index must be served by the same binary
that built it.
- `audit.sh` temporarily mutates the global `codegraph` install for the test,
then restores your dev link via `local-install.sh`.
- Corpus repos are cloned to `/tmp/codegraph-corpus` (reused if already present).
- Add or edit repos in `corpus.json` (fields: `name`, `repo`, `size`, `files`,
`question`).
+611
View File
@@ -0,0 +1,611 @@
{
"_comment": "Test corpus for /agent-eval. Add entries freely. size: Small (<~150 files), Medium (~150-1500), Large (>~1500). 'question' is a representative architectural question that exercises cross-file understanding.",
"TypeScript": [
{
"name": "ky",
"repo": "https://github.com/sindresorhus/ky",
"size": "Small",
"files": "~25",
"question": "How does ky implement request retries and timeouts?"
},
{
"name": "excalidraw",
"repo": "https://github.com/excalidraw/excalidraw",
"size": "Medium",
"files": "~600",
"question": "How does Excalidraw render and update canvas elements?"
},
{
"name": "vscode",
"repo": "https://github.com/microsoft/vscode",
"size": "Large",
"files": "~10000",
"question": "How does the extension host communicate with the main process?"
}
],
"JavaScript": [
{
"name": "express",
"repo": "https://github.com/expressjs/express",
"size": "Small",
"files": "~50",
"question": "How does Express route a request through its middleware stack?"
}
],
"Go": [
{
"name": "cobra",
"repo": "https://github.com/spf13/cobra",
"size": "Small",
"files": "~50",
"question": "How does cobra parse commands and flags?"
},
{
"name": "gin",
"repo": "https://github.com/gin-gonic/gin",
"size": "Medium",
"files": "~150",
"question": "How does gin route requests through its middleware chain?"
},
{
"name": "terraform",
"repo": "https://github.com/hashicorp/terraform",
"size": "Large",
"files": "~4000",
"question": "How does Terraform build and walk the resource dependency graph?"
},
{
"name": "cosmos-sdk",
"repo": "https://github.com/cosmos/cosmos-sdk",
"size": "Large",
"files": "~5000",
"question": "How does a bank module MsgSend message reach the account balance update? Trace the cross-module call path from the bank keeper's Send handler through to the account/balance store update."
}
],
"Python": [
{
"name": "click",
"repo": "https://github.com/pallets/click",
"size": "Small",
"files": "~60",
"question": "How does click parse command-line arguments into commands?"
},
{
"name": "flask",
"repo": "https://github.com/pallets/flask",
"size": "Medium",
"files": "~90",
"question": "How does Flask dispatch a request to a view function?"
},
{
"name": "django",
"repo": "https://github.com/django/django",
"size": "Large",
"files": "~2700",
"question": "How does Django's ORM build and execute a query from a QuerySet?"
}
],
"Rust": [
{
"name": "clap",
"repo": "https://github.com/clap-rs/clap",
"size": "Medium",
"files": "~200",
"question": "How does clap parse arguments against a derived command definition?"
},
{
"name": "tokio",
"repo": "https://github.com/tokio-rs/tokio",
"size": "Large",
"files": "~700",
"question": "How does tokio schedule and run async tasks on its runtime?"
},
{
"name": "deno",
"repo": "https://github.com/denoland/deno",
"size": "Large",
"files": "~1500",
"question": "How does Deno load and execute a TypeScript module?"
}
],
"Java": [
{
"name": "gson",
"repo": "https://github.com/google/gson",
"size": "Medium",
"files": "~200",
"question": "How does Gson serialize an object to JSON?"
},
{
"name": "okhttp",
"repo": "https://github.com/square/okhttp",
"size": "Medium",
"files": "~640",
"question": "How does OkHttp process a request through its interceptor chain?"
},
{
"name": "guava",
"repo": "https://github.com/google/guava",
"size": "Large",
"files": "~3000",
"question": "How does Guava's CacheBuilder build and configure a cache?"
}
],
"Kotlin": [
{
"name": "koin",
"repo": "https://github.com/InsertKoinIO/koin",
"size": "Medium",
"files": "~300",
"question": "How does Koin resolve and inject dependencies?"
},
{
"name": "leakcanary",
"repo": "https://github.com/square/leakcanary",
"size": "Medium",
"files": "~250",
"question": "How does LeakCanary detect and analyze a memory leak?"
}
],
"Swift": [
{
"name": "alamofire",
"repo": "https://github.com/Alamofire/Alamofire",
"size": "Small",
"files": "~100",
"question": "How does Alamofire build, send, and validate a request?"
}
],
"C#": [
{
"name": "serilog",
"repo": "https://github.com/serilog/serilog",
"size": "Medium",
"files": "~250",
"question": "How does Serilog route a log event to its sinks?"
},
{
"name": "jellyfin",
"repo": "https://github.com/jellyfin/jellyfin",
"size": "Large",
"files": "~2500",
"question": "How does Jellyfin scan and identify items in a media library?"
}
],
"Ruby": [
{
"name": "sinatra",
"repo": "https://github.com/sinatra/sinatra",
"size": "Small",
"files": "~60",
"question": "How does Sinatra match a request to a route handler?"
},
{
"name": "discourse",
"repo": "https://github.com/discourse/discourse",
"size": "Large",
"files": "~3000",
"question": "How does Discourse create and render a new post?"
}
],
"PHP": [
{
"name": "slim",
"repo": "https://github.com/slimphp/Slim",
"size": "Small",
"files": "~80",
"question": "How does Slim handle a request through its middleware?"
},
{
"name": "laravel",
"repo": "https://github.com/laravel/framework",
"size": "Large",
"files": "~3000",
"question": "How does Laravel resolve and dispatch a route to a controller?"
}
],
"C": [
{
"name": "redis",
"repo": "https://github.com/redis/redis",
"size": "Large",
"files": "~600",
"question": "How does Redis parse and dispatch a client command?"
}
],
"C++": [
{
"name": "json",
"repo": "https://github.com/nlohmann/json",
"size": "Small",
"files": "~100",
"question": "How does nlohmann::json parse a JSON string into a value?"
},
{
"name": "grpc",
"repo": "https://github.com/grpc/grpc",
"size": "Large",
"files": "~3000",
"question": "How does gRPC dispatch an incoming RPC to its handler?"
}
],
"Dart": [
{
"name": "flutter",
"repo": "https://github.com/flutter/flutter",
"size": "Large",
"files": "~6000",
"question": "How does Flutter build and lay out a widget tree?"
}
],
"Svelte": [
{
"name": "shadcn-svelte",
"repo": "https://github.com/huntabyte/shadcn-svelte",
"size": "Medium",
"files": "~600",
"question": "How do shadcn-svelte components compose and apply their styling?"
}
],
"Lua": [
{
"name": "lualine.nvim",
"repo": "https://github.com/nvim-lualine/lualine.nvim",
"size": "Small",
"files": "~120",
"question": "How does lualine assemble and render its statusline sections and components?"
},
{
"name": "telescope.nvim",
"repo": "https://github.com/nvim-telescope/telescope.nvim",
"size": "Medium",
"files": "~80",
"question": "How does Telescope wire a picker to its finder, sorter, and previewer?"
},
{
"name": "kong",
"repo": "https://github.com/Kong/kong",
"size": "Large",
"files": "~1330",
"question": "How does Kong execute plugins across a request's lifecycle phases?"
}
],
"Luau": [
{
"name": "Knit",
"repo": "https://github.com/Sleitnick/Knit",
"size": "Small",
"files": "~10",
"question": "How does Knit register services and expose them to clients?"
},
{
"name": "vide",
"repo": "https://github.com/centau/vide",
"size": "Small",
"files": "~40",
"question": "How does vide track reactive sources and re-run effects when state changes?"
},
{
"name": "Fusion",
"repo": "https://github.com/dphfox/Fusion",
"size": "Medium",
"files": "~115",
"question": "How does Fusion build and update its reactive UI graph from state objects?"
}
],
"Objective-C": [
{
"name": "Masonry",
"repo": "https://github.com/SnapKit/Masonry",
"size": "Small",
"files": "~50",
"question": "How does Masonry build and activate Auto Layout constraints from its block DSL?"
},
{
"name": "FMDB",
"repo": "https://github.com/ccgus/fmdb",
"size": "Medium",
"files": "~80",
"question": "How does FMDB execute a prepared SQL statement and bind parameters?"
},
{
"name": "SDWebImage",
"repo": "https://github.com/SDWebImage/SDWebImage",
"size": "Large",
"files": "~400",
"question": "How does SDWebImage download, cache, and decode an image for a UIImageView?"
}
],
"Mixed iOS (Swift+ObjC)": [
{
"name": "Charts",
"repo": "https://github.com/danielgindi/Charts",
"size": "Small",
"files": "~270",
"question": "How does the ChartsDemo ObjC demo controller drive the Swift Charts library to animate and notify a data update?"
},
{
"name": "realm-swift",
"repo": "https://github.com/realm/realm-swift",
"size": "Medium",
"files": "~370",
"question": "How does a Swift `Realm.write { realm.add(obj) }` reach the Objective-C persistence layer?"
},
{
"name": "wikipedia-ios",
"repo": "https://github.com/wikimedia/wikipedia-ios",
"size": "Large",
"files": "~1700",
"question": "How does tapping a search result reach the article-fetch network call across the Swift / ObjC boundary?"
}
],
"React Native (legacy bridge + TurboModule)": [
{
"name": "@react-native-async-storage",
"repo": "https://github.com/react-native-async-storage/async-storage",
"size": "Small",
"files": "~60",
"question": "How does `setItem` in JS reach the native `legacy_multiSet` implementation?"
},
{
"name": "react-native-svg",
"repo": "https://github.com/software-mansion/react-native-svg",
"size": "Medium",
"files": "~700",
"question": "How does a JS `Svg.getTotalLength(...)` reach the iOS / Android native implementation via TurboModule?"
},
{
"name": "react-native-firebase",
"repo": "https://github.com/invertase/react-native-firebase",
"size": "Large",
"files": "~1100",
"question": "How does a native iOS push notification reach the JS `messaging().onMessage(...)` listener?"
}
],
"Expo Modules": [
{
"name": "expo-haptics",
"repo": "https://github.com/expo/expo/tree/main/packages/expo-haptics",
"size": "Small",
"files": "~15",
"question": "How does `Haptics.notificationAsync(...)` in JS reach `UINotificationFeedbackGenerator` in the Swift Module?"
},
{
"name": "expo-camera",
"repo": "https://github.com/expo/expo/tree/main/packages/expo-camera",
"size": "Medium",
"files": "~70",
"question": "How does a JS `CameraView.takePictureAsync(options)` reach the native AVCaptureSession / CameraDevice call?"
}
],
"React Native Fabric (view components)": [
{
"name": "react-native-segmented-control",
"repo": "https://github.com/react-native-segmented-control/segmented-control",
"size": "Small",
"files": "~25",
"question": "How does JSX `<SegmentedControl onChange={cb}/>` reach the native onChange handler on iOS/Android?"
},
{
"name": "react-native-screens",
"repo": "https://github.com/software-mansion/react-native-screens",
"size": "Medium",
"files": "~1200",
"question": "How does JSX `<ScreenStack>` reach the native RNSScreenStackView component?"
},
{
"name": "react-native-skia",
"repo": "https://github.com/Shopify/react-native-skia",
"size": "Large",
"files": "~1000",
"question": "How does a `<SkiaPictureView/>` JSX usage reach the iOS / Android native renderer?"
}
],
"R": [
{
"name": "AnomalyDetection",
"repo": "https://github.com/twitter/AnomalyDetection",
"size": "Small",
"files": "~24",
"question": "How does AnomalyDetectionTs go from the exported entry function to the underlying S-H-ESD statistical test? Name the functions on the path in order."
},
{
"name": "dplyr",
"repo": "https://github.com/tidyverse/dplyr",
"size": "Medium",
"files": "~450",
"question": "When mutate() is called on a grouped data frame, which functions handle the grouping and expression evaluation, in order, from mutate() down?"
},
{
"name": "ggplot2",
"repo": "https://github.com/tidyverse/ggplot2",
"size": "Large",
"files": "~1150",
"question": "When a ggplot object is printed, how does the plot actually get built and drawn — trace the path from print/plot to where geoms render. Name the key functions in order."
}
],
"COBOL": [
{
"name": "cics-genapp",
"repo": "https://github.com/cicsdev/cics-genapp",
"size": "Small",
"files": "~60",
"question": "When a new insurance policy is added through the LGTESTP1 CICS test harness, which programs handle the request on the way to the DB2 insert? Trace the path and name the programs in order."
},
{
"name": "carddemo",
"repo": "https://github.com/aws-samples/aws-mainframe-modernization-carddemo",
"size": "Medium",
"files": "~75",
"question": "How does the online bill-payment flow work — from the COBIL00C screen program to the transaction record being written? Name the paragraphs performed in order and the copybooks that define the records involved."
},
{
"name": "CobolCraft",
"repo": "https://github.com/meyfa/CobolCraft",
"size": "Medium",
"files": "~270",
"question": "How does an incoming player chat message travel from packet handling to being broadcast to the other connected players? Name the programs on the path in order."
}
],
"VB.NET": [
{
"name": "policyplus",
"repo": "https://github.com/Fleex255/PolicyPlus",
"size": "Small",
"files": "~94",
"question": "When the user toggles a policy to Enabled in the policy-setting editor and clicks OK, how does the new state end up written into the loaded policy source (POL file or registry)? Trace the path from the EditSetting dialog to the concrete write."
},
{
"name": "scrawler",
"repo": "https://github.com/AAndyProgram/SCrawler",
"size": "Medium",
"files": "~320",
"question": "When a user download is started for a Reddit user, how does the request flow from the user-level download entry point through the shared downloader base into the Reddit site plugin, and where do downloaded media items get appended to the user's content list?"
},
{
"name": "staxrip",
"repo": "https://github.com/staxrip/staxrip",
"size": "Medium",
"files": "~145",
"question": "When a job finishes video encoding, how does staxrip decide which muxer runs and how does the muxer command line get built and executed? Trace from job processing to the mkvmerge invocation."
}
],
"Erlang": [
{
"name": "cowboy",
"repo": "https://github.com/ninenines/cowboy",
"size": "Small",
"files": "~190",
"question": "How does an incoming HTTP request travel from cowboy's connection process to a user-defined handler's init/2 callback? Trace the path through the stream handler and middleware chain."
},
{
"name": "ejabberd",
"repo": "https://github.com/processone/ejabberd",
"size": "Medium",
"files": "~410",
"question": "When a client sends a chat message, how does the stanza get from the receiving C2S process to the recipient's session on the same node? Trace the path through the router and session manager."
},
{
"name": "emqx",
"repo": "https://github.com/emqx/emqx",
"size": "Large",
"files": "~2450",
"question": "How does a PUBLISH packet from an MQTT client reach the sessions of matching subscribers? Trace the flow from the connection/channel layer through the broker's routing to session delivery."
}
],
"Solidity": [
{
"name": "solmate",
"repo": "https://github.com/transmissions11/solmate",
"size": "Small",
"files": "~60",
"question": "How does solmate's ERC20 transferFrom enforce allowance and update balances? Trace the flow including the permit() signature path."
},
{
"name": "solady",
"repo": "https://github.com/Vectorized/solady",
"size": "Medium",
"files": "~270",
"question": "How does solady's ERC20 implementation handle a permit() call — from signature recovery through nonce update to allowance write?"
},
{
"name": "openzeppelin-contracts",
"repo": "https://github.com/OpenZeppelin/openzeppelin-contracts",
"size": "Large",
"files": "~400",
"question": "How does an OpenZeppelin AccessControl-protected function check the caller's role? Trace from the onlyRole modifier through hasRole to the role storage."
}
],
"CUDA": [
{
"name": "llm.c",
"repo": "https://github.com/karpathy/llm.c",
"size": "Small",
"files": "~76",
"question": "How does the attention forward pass reach the GPU in the CUDA training path? Trace from attention_forward to the kernels it launches, and explain where softmax happens."
},
{
"name": "flash-attention",
"repo": "https://github.com/Dao-AILab/flash-attention",
"size": "Medium",
"files": "~900",
"question": "How does a Python call to flash_attn_func reach the CUDA kernel that computes forward attention? Trace the dispatch path from the Python API through the C++ binding to the kernel launch."
},
{
"name": "cutlass",
"repo": "https://github.com/NVIDIA/cutlass",
"size": "Large",
"files": "~2700",
"question": "When a cutlass device-level GEMM (cutlass::gemm::device::Gemm) is invoked, how does it reach the GPU kernel? Trace from the operator() call to the kernel entry point and its launch site."
}
],
"Terraform": [
{
"name": "terraform-aws-vpc",
"repo": "https://github.com/terraform-aws-modules/terraform-aws-vpc",
"size": "Small",
"files": "~77",
"question": "How does the private_subnets variable shape the NAT gateway setup? Trace from the variable through the subnet and NAT gateway resources to the outputs that expose the private subnets."
},
{
"name": "cloud-foundation-fabric",
"repo": "https://github.com/GoogleCloudPlatform/cloud-foundation-fabric",
"size": "Medium",
"files": "~990",
"question": "In the project module (modules/project), how does the iam variable turn into actual IAM bindings on the project, and what depends on the module's project_id output elsewhere in the repo?"
},
{
"name": "terraform-aws-components",
"repo": "https://github.com/cloudposse/terraform-aws-components",
"size": "Large",
"files": "~1800",
"question": "In the eks/cluster component, how does the cluster IAM role get created and reach the EKS cluster resource, and which outputs expose cluster identity to other components?"
}
],
"ArkTS": [
{
"name": "HarmoneyOpenEye",
"repo": "https://github.com/WinWang/HarmoneyOpenEye",
"size": "Small",
"files": "~82",
"question": "How does the home page get its feed data from the network layer, and how does that data end up rendered as the list on screen? Trace the flow from the HTTP request through the view model into the home page UI."
},
{
"name": "CoolMallArkTS",
"repo": "https://github.com/Joker-x-dev/CoolMallArkTS",
"size": "Medium",
"files": "~528",
"question": "When the user adds a product to the cart from the goods detail page, how does the item travel from the UI action to persistent storage? Trace the flow across the feature and core modules."
},
{
"name": "applications_app_samples",
"repo": "https://github.com/openharmony/applications_app_samples",
"size": "Large",
"files": "~9500",
"question": "In the OrangeShopping sample app, how does the product detail page's bottom bar (add to cart / buy) lead to the order placement flow? Trace from the bottom navigation component to where the order is created."
}
],
"Nix": [
{
"name": "agenix",
"repo": "https://github.com/ryantm/agenix",
"size": "Small",
"files": "~13",
"question": "When a NixOS system activates, how does a secret declared under age.secrets get decrypted and installed at its runtime path? Trace the flow from the age.secrets option definition to the activation machinery that performs the decryption."
},
{
"name": "nix-darwin",
"repo": "https://github.com/nix-darwin/nix-darwin",
"size": "Medium",
"files": "~207",
"question": "How does setting services.yabai.enable = true become a running launchd service? Trace the flow from the yabai module's options to the launchd daemon definition that generates the plist."
},
{
"name": "home-manager",
"repo": "https://github.com/nix-community/home-manager",
"size": "Large",
"files": "~3390",
"question": "How does programs.git.enable produce the final git config file in the user's home directory? Trace the flow from the git program module to the home-files machinery that links generated files into place."
}
]
}
+24
View File
@@ -0,0 +1,24 @@
---
description: CodeGraph MCP usage guide — one tool, codegraph_explore
alwaysApply: true
---
<!-- CODEGRAPH_START -->
## CodeGraph
This project has a CodeGraph MCP server configured, exposing a single tool: `codegraph_explore`. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot.
### Use codegraph_explore instead of reading files
Reach for `codegraph_explore` before grep/find or Read for any **structural** question — how does X work, how does X reach Y, what calls what, where is X defined, or surveying an area. It takes a natural-language question or a bag of symbol/file names and returns the relevant symbols' **verbatim, line-numbered source** grouped by file (the same `<n>\t<line>` shape Read gives you, safe to Edit from), plus the call paths between them — including dynamic-dispatch hops (callbacks, React re-render, JSX children) grep can't follow — and a blast-radius summary of what depends on them. Name a file or symbol in the query to read its current source.
### Rules of thumb
- **Answer directly — don't delegate exploration.** ONE `codegraph_explore` usually answers the whole question; follow up with another `codegraph_explore` naming more specific symbols if you need more. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer.
- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context.
- **Don't grep or Read first** to find or understand indexed code — one `codegraph_explore` returns the relevant source in a single round-trip. Reach for raw Read/Grep only to confirm a specific detail codegraph didn't cover, or for what it doesn't index (configs, docs).
- **Index lag — check the staleness banner, don't guess a wait.** When a codegraph response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Files NOT in that banner are fresh and codegraph is authoritative for them.
### If `.codegraph/` doesn't exist
The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"*
<!-- CODEGRAPH_END -->
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.git
.codegraph
.kommandr
docs
assets
+43
View File
@@ -0,0 +1,43 @@
name: Deploy site to GitHub Pages
on:
push:
branches: [main]
paths:
- 'site/**'
- '.github/workflows/deploy-site.yml'
workflow_dispatch:
# Allow GITHUB_TOKEN to deploy to Pages and verify the deployment origin.
permissions:
contents: read
pages: write
id-token: write
# One deploy at a time; let an in-progress run finish.
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build with Astro
uses: withastro/action@v3
with:
path: site
node-version: 22
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+204
View File
@@ -0,0 +1,204 @@
name: Release
# Manually triggered ("Run workflow"). On trigger it:
# 1. reads the version from package.json,
# 2. promotes `## [Unreleased]` content into `## [<version>]` in
# CHANGELOG.md (and commits + pushes that change back to main), so
# the published release notes are never sparse just because the
# maintainer didn't pre-stage the [<version>] block by hand,
# 3. builds a self-contained bundle for every platform (one runner — there's no
# native compilation, so cross-packaging is fine),
# 4. creates the GitHub Release (tag v<version>) with all archives, using the
# release notes from CHANGELOG.md,
# 5. publishes the npm thin-installer (shim + per-platform packages).
#
# Before triggering: bump package.json. CHANGELOG.md entries can live under
# `## [Unreleased]` — step 2 takes care of moving them. Set the NPM_TOKEN secret.
on:
workflow_dispatch: {}
permissions:
contents: write # create the GitHub Release + tag, push the CHANGELOG promote
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
# Default checkout is detached at a SHA; we need an actual branch
# so the CHANGELOG-promote commit knows where to push.
ref: ${{ github.ref }}
# Authenticate as the maintainer (admin), not as github-actions[bot].
# The "Require PR approval for main branch" ruleset only lets the
# Admin repo role bypass — and GitHub blocks adding the GitHub
# Actions integration to bypass_actors on user-owned (non-org)
# repos with "Actor GitHub Actions integration must be part of
# the ruleset source or owner organization." So the auto-promote
# and auto-sync `git push origin HEAD:main` steps below both fail
# under the default GITHUB_TOKEN. Using a fine-grained PAT owned
# by the admin makes the push go through cleanly. Set the
# RELEASE_PAT secret with: contents:write on this repo, no other
# scopes. Rotate per your token policy; the workflow only runs
# on manual dispatch so the blast radius is small.
token: ${{ secrets.RELEASE_PAT }}
- uses: actions/setup-node@v6
with:
node-version: 22
registry-url: https://registry.npmjs.org
- name: Sync package-lock.json if version drifted
# When the maintainer bumps the version on package.json only — for
# example via a GitHub web-UI edit — `npm ci` would refuse to run
# with `EUSAGE: npm ci can only install packages when your
# package.json and package-lock.json … are in sync`. This step
# rewrites just the lock-file's version fields (top-level + the
# `packages.""` entry) to match package.json, then auto-commits
# and pushes the result so on-disk truth on `main` stays
# consistent. Idempotent: if the lock file already matches, no
# commit is made.
run: |
set -euo pipefail
PKG_V=$(node -p "require('./package.json').version")
LOCK_V=$(node -p "require('./package-lock.json').version")
if [ "$PKG_V" = "$LOCK_V" ]; then
echo "package-lock.json already at $PKG_V — nothing to sync."
exit 0
fi
echo "Lock-file version drift: lock=$LOCK_V, package=$PKG_V. Syncing."
# `--package-lock-only` rewrites only the lock file, doesn't
# touch node_modules or actually install anything. Cheap.
npm install --package-lock-only --ignore-scripts
# Sanity: lockfile should now report the package version.
NEW_LOCK_V=$(node -p "require('./package-lock.json').version")
if [ "$NEW_LOCK_V" != "$PKG_V" ]; then
echo "::error::lock-file still at $NEW_LOCK_V after sync attempt; expected $PKG_V"; exit 1
fi
if git diff --quiet -- package-lock.json; then
echo "lock file unchanged after sync? bailing"; exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package-lock.json
git commit -m "release: sync package-lock.json to ${PKG_V}" -m "[skip ci] Auto-generated by Release workflow."
git push origin "HEAD:${GITHUB_REF#refs/heads/}"
- run: npm ci
- name: Ensure zip/unzip
run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip
- name: Resolve version
id: ver
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Promote [Unreleased] → [<version>] in CHANGELOG.md
# Idempotent: a no-op if [Unreleased] is empty OR if the previous
# run already moved everything. Auto-commit + push the change back
# so the version block on main is the source of truth going
# forward (and so subsequent extract-release-notes.mjs calls
# surface the full content even if this run is re-triggered).
run: |
set -euo pipefail
V="${{ steps.ver.outputs.version }}"
before=$(git rev-parse HEAD)
node scripts/prepare-release.mjs "$V"
if git diff --quiet -- CHANGELOG.md; then
echo "CHANGELOG.md unchanged — nothing to commit."
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
git commit -m "docs(changelog): promote [Unreleased] into [${V}]" -m "[skip ci] Auto-generated by Release workflow."
# Push to the branch the workflow was triggered on (main).
git push origin "HEAD:${GITHUB_REF#refs/heads/}"
fi
- name: Build all platform bundles
run: |
for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64 win32-arm64; do
bash scripts/build-bundle.sh "$t"
done
ls -lh release
- name: Generate SHA256SUMS
# Published as a release asset; the npm launcher verifies downloaded
# bundles against it (basenames only, so its path.basename match works).
run: |
( cd release && sha256sum codegraph-* > SHA256SUMS )
cat release/SHA256SUMS
- name: Release notes from CHANGELOG.md
# The [<version>] block was guaranteed-populated by the
# "Promote" step above, so the [Unreleased] fallback should
# never be needed in practice. Kept for defense-in-depth.
run: |
V="${{ steps.ver.outputs.version }}"
node scripts/extract-release-notes.mjs "$V" > notes.md 2>/dev/null \
|| node scripts/extract-release-notes.mjs Unreleased > notes.md 2>/dev/null || true
if [ ! -s notes.md ]; then
echo "::error::No release notes in CHANGELOG.md for [$V] or [Unreleased]."
exit 1
fi
echo "----- release notes -----"; cat notes.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="v${{ steps.ver.outputs.version }}"
# Idempotent: create the release once, otherwise (re-run) refresh assets.
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" release/codegraph-* release/SHA256SUMS --clobber
else
gh release create "$TAG" release/codegraph-* release/SHA256SUMS --title "$TAG" --notes-file notes.md
fi
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
V="${{ steps.ver.outputs.version }}"
bash scripts/pack-npm.sh "$V"
# Platform packages first, then the main shim (which depends on them).
# Skip any already on the registry so a re-run only fills in gaps.
for dir in release/npm/codegraph-* release/npm/main; do
name=$(node -p "require('./$dir/package.json').name")
if npm view "$name@$V" version >/dev/null 2>&1; then
echo "skip $name@$V (already published)"
else
echo "publishing $name@$V"
( cd "$dir" && npm publish --access public )
fi
done
- name: Verify every package is actually on the registry
run: |
V="${{ steps.ver.outputs.version }}"
# npm publish can print success without persisting; confirm against the
# registry (with retries for propagation) so green means really shipped.
for dir in release/npm/codegraph-* release/npm/main; do
name=$(node -p "require('./$dir/package.json').name")
ok=
for i in 1 2 3 4 5 6; do
if npm view "$name@$V" version >/dev/null 2>&1; then ok=1; break; fi
echo "waiting for $name@$V to appear ($i)…"; sleep 10
done
[ -n "$ok" ] || { echo "::error::$name@$V never appeared on the registry"; exit 1; }
echo "verified $name@$V"
done
- name: Sync packages to npmmirror
# npmmirror/cnpm mirror lazily and frequently never pull the per-platform
# optionalDependencies on their own, so `npm i` there fails with
# "no prebuilt bundle" (issue #303). Nudge a sync now so mirror users get
# the bundle without waiting. Best-effort — the launcher also self-heals
# from GitHub Releases — so a mirror hiccup never fails the release.
continue-on-error: true
run: |
for dir in release/npm/codegraph-* release/npm/main; do
name=$(node -p "require('./$dir/package.json').name")
enc=$(node -p "encodeURIComponent(require('./$dir/package.json').name)")
echo "sync $name"
curl -s -X PUT "https://registry.npmmirror.com/-/package/$enc/syncs" || true
echo
done
+75
View File
@@ -0,0 +1,75 @@
# Dependencies
node_modules/
# Build output
dist/
.cmem
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Test coverage
/coverage/
.nyc_output/
# Environment
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
# TypeScript build info
*.tsbuildinfo
# SQLite WAL mode files
*.db-wal
*.db-shm
# Local Claude settings
.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/handoffs/
# Parallels Windows VM SSH/connection config (local machine, see CLAUDE.md)
.parallels
# Confidential business / product / strategy docs — must NOT land in the
# public engine repo (see the IP boundary in CLAUDE.md)
docs/business/
# CodeGraph data directories (in test projects)
.codegraph/
test_frameworks
# Test language repos for manual testing
test-languages/
nul
release/
.antigravitycli/
# Local-only: browser-based tmux session launcher (see tmux-web/README.md)
tmux-web/
assets/__pycache__/
assets/generate-waitlist.py
.kommandr/
# Local scratch tests (never commit)
__tests__/zz-scratch*
+74
View File
@@ -0,0 +1,74 @@
# Distribution: self-contained bundles
CodeGraph ships a **vendored Node runtime** alongside the app. Because Node 22.5+
has a built-in real SQLite (`node:sqlite`, with WAL + FTS5), bundling Node means:
- **No native build** — `better-sqlite3` is gone, so there are zero native addons
to compile or rebuild.
- **No wasm fallback** — and therefore no more `database is locked` (issue #238).
- **No Node-version dependence** — the app always runs on the bundled Node,
whatever the user has (or doesn't have) installed.
## What's in a bundle
Built by [`scripts/build-bundle.sh`](scripts/build-bundle.sh) — one archive per
platform, identical recipe (only the Node download differs):
```
codegraph-<target>/
node | node.exe # official Node runtime for <target>
lib/
dist/ # compiled app (+ tree-sitter .wasm grammars, schema.sql)
node_modules/ # production deps only (pure JS / wasm — portable)
bin/
codegraph | codegraph.cmd # launcher → runs the bundled Node with the app
```
Targets: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`,
`win32-arm64`. Unix targets produce `.tar.gz` (shell launcher); Windows produces
`.zip` (`node.exe` + a `.cmd` launcher).
```bash
scripts/build-bundle.sh linux-x64 # -> release/codegraph-linux-x64.tar.gz
scripts/build-bundle.sh win32-x64 # -> release/codegraph-win32-x64.zip
```
Because dropping better-sqlite3 left **zero native addons**, building a bundle is
pure file-packaging — **any** target builds on **any** OS (the whole matrix builds
on one Linux runner). Cross-compilation isn't a concern; only *run-testing* a
bundle needs the target platform (or emulation, e.g. `docker run --platform
linux/amd64`).
## Install channels (all deliver the same bundle)
1. **`curl | sh`** ([`install.sh`](install.sh)) — no Node required; ideal for a
fresh Linux VPS over SSH. Detects os/arch, pulls the archive from GitHub
Releases, symlinks `codegraph` onto PATH. Re-run to upgrade; `--uninstall` to
remove.
2. **npm** ([`scripts/npm-shim.js`](scripts/npm-shim.js)) — preserves
`npm i -g @colbymchenry/codegraph`. The main package is a tiny shim; the
bundles ship as per-platform `optionalDependencies`
(`@colbymchenry/codegraph-<target>` with `os`/`cpu`), so npm installs only the
matching one. The shim — run by the user's Node — execs the bundle, so the
real work runs on the bundled Node 24. Works even on old Node. On Windows it
invokes the bundled `node.exe` against the app entry directly (not the `.cmd`
launcher) — modern Node throws `EINVAL` when asked to spawn a `.cmd`/`.bat`.
3. **Windows** ([`install.ps1`](install.ps1)) — `irm … | iex`; same flow as
install.sh (detect arch, pull the `.zip` from Releases, add to PATH).
4. **Homebrew / Scoop** — TODO (tap + cask pointing at the Release archives).
## Release pipeline
[`.github/workflows/release.yml`](.github/workflows/release.yml) — manually
triggered. Reads the version from `package.json`, builds every platform bundle on
one runner, creates the GitHub Release (notes from `CHANGELOG.md`), and publishes
the npm shim + per-platform packages. Requires the `NPM_TOKEN` repo secret.
Still TODO:
- **Code signing** — the main gap for "download & run": macOS Gatekeeper needs a
Developer ID + notarization; Windows needs Authenticode. Homebrew softens the
macOS case (handles quarantine).
- Retire the now-vestigial Node-version gate in `src/bin/codegraph.ts` — the
bundle always runs Node 24, and the npm shim does no tree-sitter work.
- Re-wire `npm uninstall` cleanup (the agent-config `preuninstall`) through the
shim — the generated main package doesn't carry it.
+652
View File
@@ -0,0 +1,652 @@
# Changelog
All notable changes to CodeGraph are documented here. Each entry also ships as
a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged
`vX.Y.Z`, which is where most people will look.
This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixes
- Callers and impact analysis no longer silently under-count a function that calls the same callee many times. When one caller contained several call sites to the same callee and an internal resolution batch boundary happened to split them, cleanup after the first batch removed the later sites' pending rows before they were ever attempted — their edges were never created, deterministically, and which edges went missing shifted with unrelated changes to the project's total reference count. Post-pass cleanup now targets the exact database row each processed reference came from. Found while validating the operator-call fix on nlohmann/json, where `write_cbor`'s 11 calls to `to_char_type` indexed as 10. (#1269)
- C++ explicit operator calls — `a.operator+(b)`, `p->operator+(b)`, `a.operator[](3)`, and the other symbolic forms — now produce a `calls` edge to the operator method, so an operator invoked only through the explicit syntax no longer looks uncalled in callers and impact analysis. tree-sitter parses these call sites with the operator name stranded in an error node (never as a normal member access), so the call's target was silently read as just the receiver variable; the operator name is now recovered from the error node and resolved through receiver-type inference like any other member call — a same-named operator on an unrelated class can never capture the edge. Infix uses (`a + b`, `a[i]`) need real type inference and are tracked separately. (#1247)
## [1.4.1] - 2026-07-10
### New Features
- The MCP server now notices when a newer CodeGraph release exists and tells you — a long-running server used to drift behind releases silently until something broke. On startup it checks the latest release in the background (never blocking, at most once a day, cached across all servers on the machine) and surfaces a one-line "update available — run `codegraph upgrade`" notice in the server log, in the instructions your agent sees on connect, and in `codegraph_status`. Nothing updates by itself, and being offline just means no notice. Opt out with `CODEGRAPH_NO_UPDATE_CHECK=1`; `DO_NOT_TRACK=1` disables it too. (#1243)
### Fixes
- `codegraph upgrade` on a Windows npm install actually runs npm again — modern Node refuses to launch `npm.cmd` directly, so the upgrade failed with a spawn error before doing anything. npm is now invoked the way a terminal would run it. (#1238)
- `codegraph uninstall` now actually uninstalls CodeGraph. It used to remove only the agent configurations and leave every installed binary behind, so `codegraph` still ran afterward — especially confusing when both an npm global install and a standalone install were present and removing one still left the other answering on PATH. Uninstall now finds every install on the machine (the standalone bundle, the npm global package, the launcher link) and removes them all, after showing you exactly what it found and asking first (`--yes` skips the prompt). Machine-level settings like your telemetry choice are preserved, a source checkout is never touched, and the new `--keep-cli` flag restores the old configs-only behavior. (#1071)
- `codegraph_explore` no longer lets ordinary English words in a natural-language question hijack the ranking when they happen to match a code symbol's name. A question like "how does the upgrade flow check the latest version" used to treat "check" as a symbol the agent asked for by name, rank that unrelated definition's file first, and crowd the files the question is actually about out of the answer entirely. Precisely written symbol names (camelCase, PascalCase, snake_case, qualified names) still get top billing exactly as before, as do plain-word symbol bags whose words belong together in the same file.
- PHP method calls made through a class property — `$this->dep->method()`, the dominant call shape in constructor-injection codebases (Symfony, Laravel) — now resolve to the method on the property's declared type, so callers and impact analysis see production call sites instead of reporting a DI-heavy method as uncalled or test-only. Promoted constructor parameters, typed properties, classic constructor assignment (including multi-line signatures), and typed setter injection all count; interface-typed properties resolve to the interface method, and inherited methods resolve through the type hierarchy. Only property-shaped declarations are consulted — a same-named local variable or parameter elsewhere can never mistype the property — and a property whose type can't be recovered statically stays unlinked rather than guessed. Thanks @w0lan. (#1220)
- `codegraph upgrade` now also refreshes what previous versions installed into your agents — the CodeGraph section in CLAUDE.md / AGENTS.md / GEMINI.md and the MCP entry — so upgrading no longer leaves agents following instructions written for tools that have since been renamed or removed. Refresh-only: agents you never configured are not touched, and your permission and hook choices are preserved. Also available manually as `codegraph install --refresh`, and skippable with `CODEGRAPH_NO_INSTALL_REFRESH=1`. (#1238)
- `codegraph upgrade` on an npm install now upgrades through npm again instead of quietly creating a second copy that never wins the PATH race — previously `codegraph --version` kept reporting the old version forever, no matter how many times you upgraded. (#1238)
- After every upgrade, CodeGraph now checks that the `codegraph` command your terminal resolves actually serves the freshly installed version — confirming you don't need a new terminal, or telling you exactly which stale install is shadowing the new one. (#1071)
- The safety watchdog no longer kills a healthy index on severely degraded storage. It used to judge liveness purely by the event loop, so one long database write on a struggling disk looked identical to a hung process and could get a valid, in-progress index terminated. During `codegraph index`/`codegraph init` the watchdog now also checks whether the index files on disk are advancing before it acts: slow-but-progressing work is left alone (bounded by a hard cap), while a genuinely hung process is still killed exactly as fast as before. (#1231)
- Incremental sync now picks up cross-file relationships that only become resolvable after an edit — for example, when a file gains an export that another, unchanged file was already importing or calling. Previously the reference in the unchanged file was never revisited, so callers, impact, and flow results silently omitted the new edge (while status reported a clean index) until a full re-index. References that can't be resolved yet are now remembered and automatically retried whenever a change introduces a symbol that could satisfy them — this also covers a class gaining a new method that other files already call. Thanks @loadcosmos for the report with a minimal reproduction. (#1240)
- The reverse case is fixed too: when an edit removes or moves a symbol (or deletes its file), callers in unchanged files now re-resolve during the same sync — rebinding to the symbol's new home when it moved, or waiting to reconnect automatically when it comes back — instead of silently losing their relationship until a full re-index. (#1240)
## [1.4.0] - 2026-07-10
### New Features
- Indexing is dramatically faster on slow storage — mechanical HDDs, network folders, and virtualized disks. The database no longer folds its write journal back into the main file thousands of times during a bulk index (that folding was ~95% of all disk activity); it now streams writes sequentially and folds them back in a few large, coalesced passes that run off the main thread. In a disk-throttled benchmark matching the reported hardware, a mid-size Java project went from over 25 minutes to under a minute, and there is no change on fast disks. Opt out with `CODEGRAPH_NO_WAL_DEFER=1`; tune the fold-back threshold with `CODEGRAPH_WAL_VALVE_MB`. (#1231)
- New `CODEGRAPH_PARSE_TIMEOUT_MS` environment variable to raise the per-file parse budget on unusually slow storage, the same way `CODEGRAPH_PARSE_WORKERS` already tunes the worker count. (#1231)
### Fixes
- Indexing on slow storage (mechanical HDDs, network folders) no longer collapses into false "parse timeout" failures. When disk writes stalled the coordinating thread, parses that had already finished — including empty files — were being misjudged as hung, their workers killed, and the files silently dropped from the index. A parse result is now judged by the worker's own clock, so a stalled coordinator accepts the finished result instead of killing the worker; only a genuinely hung parse is terminated (after a wider grace window). Files that do hit the timeout are retried at the end of indexing instead of being silently lost. Thanks @KnifeOfLife for the exceptional report. (#1231)
- Parse workers now receive their grammar files from memory instead of each re-reading them from disk on spawn, eliminating a feedback loop on slow disks where every worker restart added more disk contention — and making worker restarts cheaper everywhere. (#1231)
## [1.3.1] - 2026-07-09
### Fixes
- Fixed `codegraph init` grinding for 20+ minutes (or getting killed by the safety watchdog) near the end of the "Resolving refs" step on large PHP and JavaScript codebases — a regression since 0.9.x reported on a 12,000-file project that used to index in about a minute. One of the dynamic-dispatch analysis passes only ever applies to Swift/Kotlin closure collections, but it was scanning every function in every language, and on `.push(`-heavy JavaScript (or any codebase with big generated functions, especially with non-ASCII text) its per-match bookkeeping went quadratic. The pass now skips languages it can't apply to, does its line accounting in constant time, and stays responsive even inside a single pathological function — the graph produced is identical. Thanks @sniperrenren for the report. (#1235) Two failure modes are fixed: on multi-million-symbol projects (e.g. the Linux kernel, ~95,000 files) the final analysis phase ran out of memory and crashed the process outright, and on large projects on slower machines (reported on a 24,000-file Java project on Windows) the same phase could stall long enough that the safety watchdog killed a healthy, still-progressing index at ~98% (#1212). The whole phase now streams its work instead of holding whole-graph snapshots in memory, keeps the process responsive throughout, and skips analysis passes for languages a project doesn't contain — which also makes the tail of indexing noticeably faster on single-language repos. The resulting graph is identical, and a genuinely wedged process is still detected and killed.
- Indexing and `codegraph sync` stay responsive through their heaviest internal steps on huge projects: the post-index database maintenance (which on a multi-gigabyte index could stall the process for minutes and get a fully successful index killed by the safety watchdog at the finish line) now runs on a background thread, storing a giant generated file no longer freezes the process mid-extraction, and the reference-resolution bookkeeping between progress updates is broken into small responsive steps. The resulting graph is byte-for-byte identical.
- Fixed a race that could leave a freshly-attached MCP session permanently silent: when a client's first messages arrived glued together during the daemon's connection handshake (roughly one attach in five on a busy machine), the daemon could drop them and stop reading that connection entirely — every tool call from that session then hung with no reply. The handshake now hands the connection over losslessly, and the fix is validated by hammering the previously-flaky attach test 25× under load.
- The first tool call after the shared daemon starts no longer waits behind the query workers' cold start (which can take many seconds on a busy machine) — it's served directly until the first worker is warm, so a fresh session answers immediately.
## [1.3.0] - 2026-07-07
### New Features
- CodeGraph now indexes **Nix** (`.nix`) — flakes, NixOS and home-manager modules, overlays, and package sets join the graph: `let` and attrset bindings, functions (simple, destructured `{ pkgs, ... }`, and curried), and `inherit` bindings all become searchable symbols, with call edges between bindings. File-level wiring follows the ways Nix actually connects files: `import ./relative/path.nix` (with `import ./dir` reaching the directory's `default.nix`), NixOS module `imports = [ ./hardware.nix ../common ]` lists, flake-style `modules = [ ./configuration.nix ]` lists, and the nixpkgs `callPackage ./pkgs/foo { }` idiom — so "what does this configuration actually pull in" and "what uses this module" are answerable on real setups. Dynamic references (`import <nixpkgs>`, variable paths, flake-input module references) are deliberately left unlinked rather than guessed. Thanks @TyceHerrman. (#324, #332, #648)
- The NixOS module system's option wiring is bridged, so flow questions cross the module boundary instead of going dark at it: a config write like `launchd.user.agents.myapp = { ... }` or `home.file.".gitconfig" = { ... }` links to the module that declares that option (`options.launchd.user.agents = mkOption { ... }` — flat and nested declaration spellings both count, quoted keys like `system.defaults.NSGlobalDomain."com.apple.dock"` match their exact declaration), which makes "how does enabling this service produce the launchd daemon / generated config file" traceable end-to-end and "what sets this option" answerable across modules, tests included. Precision is deliberately conservative: interpolated `${...}` paths, options declared in more than one module, and submodule-internal option namespaces stay unlinked rather than guessed, and every bridged hop is labeled as heuristic module-system wiring rather than shown as a plain reference.
- CodeGraph now indexes **ArkTS** (`.ets`) — the language of HarmonyOS / OpenHarmony apps. Everything TypeScript gets extracted (classes, interfaces, enums, type aliases, imports/exports, call edges), plus ArkTS's own constructs: `@Component` / `@ComponentV2` structs with their decorators (`@Entry`, `@State`, `@Prop`, `@Link`, `@Local`, `@Param`, …) captured and searchable, `build()` view trees linked parent→child so "which pages render this component" is answerable, chained attributes connected to the `@Extend`/`@Styles` functions they invoke, `@Builder` methods and functions wired into the call graph, and `.onClick(this.handler)`-style event bindings linked to their handler methods. Modular HarmonyOS projects resolve across module boundaries too: a bare `import { CartRepository } from "data"` follows the `oh-package.json5` `file:` dependency to the right module — honoring each module's declared `main` entry, from `.ets` and `.ts` consumers alike — while ambiguous names in multi-app monorepos deliberately stay unlinked rather than guessed, and mixed `.ets`/`.ts` codebases cross-link freely. Validated on real HarmonyOS apps including the official OpenHarmony samples monorepo. (#396, #512, #648, #890)
- ArkUI's dynamic hops are bridged so flow questions cross them instead of going dark, each labeled as dynamic dispatch rather than shown as a plain call: methods that assign a reactive property (`@State`, `@Local`, …) link to the component's `build()` (the re-render hop — assignment-gated, so a method that merely reads state gets no edge); `emitter.emit(eventId)` links to the matching `emitter.on/once` subscriber when both sides share a statically-recoverable event key (numeric ids pair within one file only, named constants within one module, so unrelated samples in a monorepo never cross-link); and `router.pushUrl({ url: 'pages/Detail' })` links to the target page's `@Entry` struct, with ambiguous urls left unlinked rather than guessed.
- Interrupted or incomplete indexing is now visible instead of silent: a run killed mid-index (crash, out-of-memory, watchdog) leaves a marker that `codegraph status` reports as a truncated index, a completed run that dropped files reports itself as partial — both in the human output and in `status --json` — and `codegraph index` prints a warning with the exact counts when its result doesn't add up to what the scan discovered.
- CodeGraph now indexes **Terraform and OpenTofu** (`.tf`, `.tfvars`, `.tofu`) — resources, data sources, modules, variables, outputs, providers, and every `locals` attribute become symbols (e.g. `aws_s3_bucket.my_bucket`, `var.region`, `module.vpc`, `local.prefix`), and uses like `var.region`, `module.vpc.id`, `data.aws_caller_identity.current`, or `aws_s3_bucket.my.arn` are wired up cross-file, so search, callers, and impact queries return real results on infrastructure repos instead of nothing. Module calls are bridged across the module boundary: a `module` block's inputs link to the child module's variables, `module.vpc.vpc_id` reaches the child's `output "vpc_id"` definition, and the block's local `source` path links to the module's files — so "what breaks if I change this module's variable" reaches every caller instead of dead-ending at the declaration (registry and git sources are deliberately left as visible boundaries rather than guessed). Cross-component wiring through the cloudposse/atmos `remote-state` module connects too: `module.vpc.outputs.vpc_cidr` in one component reaches the `vpc` component's own output when the component name is statically declared (a literal, or a variable with a literal default) and exactly one directory matches — anything dynamic or ambiguous stays unlinked. Aliased providers are first-class: `provider "aws" { alias = "east" }` gets its own symbol, and `provider = aws.east` on a resource (or a module's `providers` map) links to that configuration, found up the module tree the way Terraform actually inherits it. `moved`/`import`/`removed` state-migration blocks and `check` assertions reference the resources they name, so a refactor's paper trail is part of the graph. `.tfvars` assignments link to the variables they set, including var-files kept in a subdirectory. Resolution follows Terraform's real per-directory scoping, so same-named variables across modules never cross-link and "what depends on `var.project_id`" in a multi-module repo never mixes in unrelated modules. Thanks @Javviviii2. (#83, #310, #648)
- CodeGraph now indexes **CUDA** (`.cu`, `.cuh`) — kernels, device/host functions, structs, and classes become symbols, and the host→kernel call edge survives the `<<<grid, block>>>` launch syntax, so questions like "how does this call reach the GPU kernel?" trace across the CPU/GPU boundary instead of going dark at the launch site. Real-world launch styles all connect: templated launches (`my_kernel<Traits, 256><<<grid, block>>>(args)`), launches through a local function pointer (`auto kernel = &my_kernel<...>; ... kernel<<<grid, block>>>(args)` — each branch-assigned target linked), brace-initialized launch configs (`<<<dim3{1,1,1}, dim3{256,1,1}>>>`), and kernels defined through a name-in-first-argument macro (flash-attention's `DEFINE_FLASH_FORWARD_KERNEL(kernel_name, ...) { ... }` style), which now index under their real kernel names. CUDA that lives in plain `.h`/`.hpp` headers — where much real-world device code sits, launch-template headers included — is recognized by content and indexed the same way. Validated on llm.c, flash-attention, and NVIDIA CUTLASS. (#387, #648)
- C++ symbols defined inside `namespace` blocks now carry the namespace in their qualified name (`flash::compute_attn`, C++17 `namespace a::b {` included), and namespace-qualified calls (`ns::fn(...)`) resolve to their definitions — previously such calls never linked at all, which hid much of the call graph in namespace-heavy C++ codebases from callers and impact analysis.
- C++ calls that spell out template arguments (`fn<T, 256>(args)`) now link to the function they instantiate, the same normalization templated base classes already had.
- CodeGraph now indexes **Solidity** (`.sol`) — contracts, libraries, interfaces, structs, enums, modifiers, events, errors, and state variables become first-class symbols, with call edges that follow `emit`, `revert`, modifier guards (`onlyOwner`-style, including base-constructor chains like `constructor() ERC20(...)`), and library/method calls (including `using` directives). `import` directives resolve to the imported file, so cross-contract questions like "trace `transferFrom` through allowance and balance updates" or "how does `onlyRole` reach the role storage?" work out of the box on real Solidity codebases (validated on solmate, solady, and OpenZeppelin Contracts). Thanks @naiba. (#374, #648)
- Erlang behaviour dispatch is now followed through the graph: a framework call through a variable module — cowboy's `Handler:init`/`Middleware:execute` folds, a plugin manager's `Mod:callback(...)` — links to the repo's implementations of the behaviour that declares that callback, so flow traces and impact cross the OTP callback boundary instead of stopping at it. The links are precision-gated: the callback arity must match, exactly one behaviour may own that callback shape (a collision stays unlinked rather than guessed), the implementer must actually export the callback, and the fan-out is bounded — a behaviour with hundreds of implementers stays a visibly dynamic boundary. Every bridged hop is labeled as dynamic dispatch with its wiring site, never shown as a plain static call.
- CodeGraph now indexes **Erlang** (`.erl`, `.hrl`) — functions, with clauses and arities of the same name grouped as one symbol spanning all of them, plus records with their fields, `-type`/`-opaque` aliases, `-define` macros, and `-spec` signatures attached to every function. Cross-module `mod:fn(...)` calls resolve to the target module's function, `fun name/arity` values are captured as references (so callback registrations like `lists:foreach(fun submit/1, ...)` link up), `-include`/`-include_lib` connect to the header files they pull in, `-behaviour` declarations link a callback module to its behaviour (and only ever to a module — a same-named macro or function elsewhere in the repo is never mistaken for one), and `-export` lists (plus `-compile(export_all)`) drive each function's public/private flag. OTP's indirection idioms are followed where the target is static: `spawn`/`apply`/`proc_lib`/`timer`/`rpc` calls that name their target as `(Module, Function, Args)` arguments produce call edges, and `gen_server:call`/`cast` connects to the target module's `handle_call`/`handle_cast` — its own when targeting `?MODULE` (including the `-define(SERVER, ?MODULE)` idiom), and the named module when a registered name follows OTP's name-the-server-after-its-module convention (`gen_server:call(other_mod, ...)`, directly or through a `-define(STORE, other_mod)` macro); a registered name that matches no module stays unlinked. Macros participate in the graph too: a `-define` body's calls belong to the macro, each `?MACRO(...)` use site links into the call chain (and bare `?CONSTANT` reads are tracked as references), so a call path hidden behind a macro — `set_password → ?SQL_UPSERT_T → sql_query_t` — traces end-to-end and "where is this macro used" is answerable. escripts index like any module (the shebang line is understood), and OTP application resource files (`.app.src`, `.app`) join the graph: `{mod, ...}` links an app to its callback module and `{applications, [...]}` connects umbrella sibling apps — resolving only ever to modules, so an OTP app name like `ssl` is never mistaken for a same-named function. Truly dynamic dispatch (`Mod:handle(...)`, message sends, var-module spawns) is deliberately left unlinked rather than guessed. `codegraph_explore` also understands Erlang-native symbol spelling in queries — `mod:fn/3` and `init/2` find the symbols they name. (#635, #648)
- CodeGraph now indexes **Visual Basic .NET** (`.vb`) — classes, Modules, interfaces, structures, enums, properties, events, `MustOverride` abstract members, and `Declare` P/Invoke signatures, with `Inherits`/`Implements` hierarchy edges, call edges (resolved through VB's ambiguous call-vs-index parentheses), and `New`/`As New` instantiation links. Real-world VB styles parse cleanly: WinForms designer files, interpolated and multi-line strings, XML literals (embedded `<%= %>` expressions included), single-line and multi-line LINQ queries, multi-line lambdas, `Handles`/`WithEvents` event wiring, Custom Events, date literals, classic type-character identifiers (`i%`, `name$`), and non-English (Unicode) identifiers. (#648, #639, #170)
- CodeGraph now indexes **COBOL** (`.cbl`, `.cob`, `.cpy`) — programs, sections and paragraphs with `PERFORM`/`GO TO` call edges, `CALL` cross-program calls, `COPY` copybook imports (standalone copybooks included), and DATA DIVISION records with 88-level condition names, in both fixed and free source format. Impact queries work on data items: every `MOVE`/`ADD`/`COMPUTE`/`SUBTRACT` write-site links back to the field it changes, so "what touches this copybook field" answers across programs. CICS flows connect too: `EXEC CICS LINK`/`XCTL` program targets, `EXEC SQL INCLUDE` copybooks, and pseudo-conversational `RETURN TRANSID(...)` hops resolve to the program owning the transaction id. (#590, #648)
- CodeGraph now indexes **CFML** (`.cfc`, `.cfm`, `.cfs`) — both the classic tag-based style (`<cfcomponent>`/`<cffunction>`) and modern bare-script `component { ... }` syntax, including `extends`/`implements`, embedded `<cfscript>` blocks (at any nesting depth, including inside `<cfif>`/`<cfloop>`/`<cftry>`), call edges, and calls embedded in `#hash#` expressions inside `<cfquery>` SQL bodies. Files saved with a UTF-8 byte-order mark and tags with unquoted attribute values — both common in long-lived CFML codebases — are handled too. Thanks @ghedwards. (#1118)
- CFML inheritance written as a component path now links to the right component. `extends="coldbox.system.web.Controller"` names its supertype by dotted path and `extends="../base"` by relative path (the FW/1 style) — both previously produced no inheritance edge at all, which on framework-style CFML apps hid most of the type hierarchy from impact and blast-radius analysis (on ColdBox's own core, over 90% of inheritance was invisible). Resolution is deliberately conservative: the target's directory layout must corroborate the declared path — so a supertype that lives in an out-of-repo library (testbox, mxunit, an installed framework) correctly stays unlinked rather than being guessed at, and an ambiguous path produces no edge rather than a wrong one. (#1152)
- CFML method calls made through a local variable, typed argument, or component property now resolve to the right method — the same receiver-type inference the other object-oriented languages already had. `var svc = new UserService(); svc.save()`, `createObject("component", "path.UserService")`, a typed `<cfargument>` or cfscript parameter, and `variables.`/`this.`-scoped fields — including the pseudoconstructor pattern (`variables.svc = new UserService()` in `init()`) and WireBox-injected properties (`property name="svc" inject="UserService"`) — all now link the call to the declared component's method, with methods inherited from a supertype resolved through the inheritance links above. This makes callers, impact/blast-radius, and `codegraph_explore` flow traces follow CFML service calls instead of dropping them or guessing among same-named methods.
- The Claude Code context hook now recognizes prompts that describe code in plain words — in any language — by checking the prompt's words against the symbol names actually in your project's index. Asking about "the state machine des commandes" finds `OrderStateMachine` with no keyword involved. Confidence decides how much gets injected: structural questions and prompts naming a real symbol still get full context up front; a plain-words match gets a short pointer to the matching symbols so the agent queries them itself; everything else stays silent, exactly as before.
- Anonymous usage telemetry now counts how often the context hook injected context, offered a hint, or stayed silent — fixed counter names only; the prompt's content is never stored or sent. This makes the hook's accuracy measurable instead of guessed. The counters record what actually happened, not what was attempted: a lookup that errors or comes back empty counts as a distinct silent outcome, never as delivered context (#1143, thanks @inth3shadows).
- Metal shader files (`.metal`) are now indexed. Metal Shading Language is close enough to C++ that vertex/fragment/kernel functions, structs, type aliases, and the calls between them all land in the graph — so shader pipelines in Apple-platform projects show up in impact analysis and flow traces instead of being silently skipped. Metal's `[[buffer(0)]]`-style attribute annotations are handled so they can't corrupt what gets extracted. Thanks @FluxKo for the report. (#1121)
- CodeGraph now indexes legacy **iBatis 2** SQL maps (`<sqlMap>`), not just MyBatis 3 `<mapper>` files. `<select>`/`<insert>`/`<update>`/`<delete>`, iBatis's `<statement>`/`<procedure>`, and `<sql>` fragments inside a `<sqlMap>` become searchable statement symbols — for both namespaced maps and the namespace-less `Map.statement` id style — and `<include>` references resolve to the fragment they pull in, so search, callers, and impact queries return results on iBatis codebases that previously produced no statement symbols at all. Thanks @ESPINS for the report and the reproduction. (#1182)
- You can now force gitignored first-party source **into** the index with an `include` list in `codegraph.json`. The case this solves: a project tracked by a second VCS (SVN, Perforce, …) alongside Git, where some real source is committed to that VCS and deliberately listed in `.gitignore` so it never lands in Git — git never lists those files, so CodeGraph never indexed them, and neither `includeIgnored` (which only revives *embedded git repositories* inside a gitignored directory) nor `exclude` (its opposite) could help. Add a root `codegraph.json` with, e.g., `{ "include": ["Tools/", "Local/typescript/"] }` and CodeGraph discovers those files directly off disk — overriding `.gitignore` — and indexes them on the full index, incremental `sync`, and file-watching, on both git and non-git projects. Patterns are gitignore-style and matched against project-root-relative paths (a directory, a recursive `**` glob, or a single file). An explicit `exclude` still wins, and built-in skips like `node_modules`, `dist`, and `.git` are never re-included. This complements the existing `exclude` (its opposite — keep tracked files *out*) and `includeIgnored` (opt *in* to gitignored embedded repos).
### Fixes
- Indexing a large Java or Kotlin **Spring** monorepo is dramatically faster. The reference-resolution phase — the bulk of a first-time `codegraph index` — could run for the better part of an hour on a big multi-module project and now finishes in a few minutes, producing the same graph. The cause: every `receiver.method()` call in the codebase was repeatedly scanning the project's entire set of configuration keys looking for a match — work that only ever applies to Spring `@Value` / `@ConfigurationProperties` bindings, never to ordinary method calls. As part of the same fix, a method call whose name coincides with a configuration key (say a `service.process()` call alongside a `service.process` entry in `application.yml`) is no longer mislinked to that config key — it now resolves to the actual method. Thanks @bayernjava for the report. (#1180)
- `codegraph init` at a parent repository whose `.gitignore` excludes its child repositories no longer silently indexes nothing and reports success. The "super-repo of gitignored child repos" layout — a top-level Git repo that `.gitignore`s each `service-*/` or `packages/*` child so `git status` stays quiet — used to index only the parent's few top-level files and print "Done" with 0 nodes, even though running `codegraph init` inside any child worked fine (CodeGraph respects `.gitignore` by default, so the excluded children were skipped). Now, when an index comes up empty, CodeGraph detects the gitignored child repositories that were skipped, names them, and — in an interactive terminal — offers to index them (writing an `includeIgnored` entry to `codegraph.json` and re-indexing on the spot); non-interactive runs print the exact `codegraph.json` snippet to add. Projects that legitimately keep gitignored reference clones out of a working index are never nagged: the offer only appears when the index would otherwise be empty. Thanks @small-thanks for the report. (#1156)
- The MyBatis mapper reader is sturdier on real-world XML. Single-quoted attribute values (`id='getById'`, legal XML and common in older mappers) are no longer skipped, so those statements make it into the graph. Statements and `<include>`s that were commented out with `<!-- ... -->` no longer produce phantom symbols. And two vendor-split statements — the same `id` with `databaseId="oracle"` / `databaseId="mysql"` — written on a single line no longer silently drop one of the pair. Thanks @ESPINS for the report, the reproductions, and the fixes. (#1182)
- `codegraph init` and `codegraph index` no longer get killed by the safety watchdog at the "Resolving refs" step on large method-name-heavy codebases (big Java/enterprise monorepos were the main victims, especially on slower machines). Resolution used to come up for air only every 500 references, so a dense stretch of expensive ones could starve the watchdog long enough for it to assume the process was stuck and kill a perfectly healthy index. Resolution now checkpoints after every reference, and two of the expensive steps got much cheaper: repeated method lookups on the same type are now cached, and source files are no longer re-split line-by-line for every call being resolved — indexing such repos is several times faster as a result. Generated or minified single-line files are also skipped during receiver-type inference instead of being scanned per call. Thanks @UchihaYong and @wangmeng-95 for the reports. (#1122)
- An index left incomplete by an interrupted run now heals itself on the next sync instead of silently staying wrong forever. If indexing died partway through resolving references (a crash, Ctrl-C, or the watchdog kill fixed above), the affected files still looked indexed but their caller/impact edges were missing — a too-small blast radius clustering by package or module, e.g. a Spring `@Resource`-injected method reporting 3 of its 10 real caller files — and because incremental syncs only re-resolve files that changed, the damage was permanent until a full re-index. Any sync (a watched file change, or a bare `codegraph sync`) now detects the leftover references and finishes resolving them, `codegraph status` warns when an index is in that state instead of passing it off as healthy, and a rare early-stop that could abandon resolution on repos whose first files reference only external libraries is fixed too. Thanks @KnifeOfLife for the report and the package-correlation observation that pinned it down. (#1187)
- The shared background server no longer shuts down out from under a live editor/agent session that simply hasn't queried CodeGraph in a while. A safety timer meant to reap an *abandoned* server — one whose client vanished without the connection ever closing — was reaping **any** server that saw no requests for 30 minutes, including a perfectly live session that just wasn't asking CodeGraph anything; that silently dropped the session (and every other session sharing the same background server) to a slower in-process mode for the rest of its life. On one machine over a day it fired 20 times on live sessions and caught zero real phantoms. The timer now checks whether the connected clients are actually still alive and only reaps when none of them are, so a quiet-but-live session keeps its shared server while a genuinely abandoned one is still cleaned up. (#1200)
- CodeGraph's background server no longer leaves a lingering Node process behind when your editor or agent kills its launcher during startup. If the app that started CodeGraph (Codex, Claude Code, or another MCP client) was killed within the server's first fraction of a second — a config probe, a cancelled request, a startup timeout — while keeping the connection's pipes open, the server could be handed off to the system before its orphan-detection watchdog had captured a reference point, leaving it running (idle, ~30 MB) until the launching app itself exited; over a long day of repeated launches these accumulated. The server now records its parent at the earliest possible moment, and the npm and standalone launchers pass the real host's process id down to it so it can watch the host directly instead of only the launcher that may already be gone. As a final backstop, a server that never receives a single request after starting now shuts itself down instead of waiting for the host to exit — tunable with `CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS` (0 disables). Thanks @ruslan33321 for the report. (#1185)
- The automatic context hook for Claude Code now fires for structural questions asked in nearly thirty languages — French, Spanish, Portuguese, German, Italian, Dutch, Polish, Czech, Romanian, Hungarian, Greek, Swedish, Danish, Norwegian, Finnish, Russian, Ukrainian, Turkish, Indonesian, Vietnamese, Thai, Hindi, Arabic, Farsi, Hebrew, Japanese, Korean, and both simplified and traditional Chinese — instead of just English and simplified Chinese. Previously a natural question like "comment marche la state machine des commandes ?" injected nothing unless it happened to contain a code-shaped symbol name, making the hook look broken for non-English teams. English questions phrased with derived word forms ("explain the architecture…", "what are the dependencies…") now fire too, and prompts in any other language still fire when they name a symbol from the index. Thanks @anthonyle-roy-lgtm for the report. (#1126)
- Lua and Luau method calls with capitalized names (`obj:Method()` — the standard Roblox convention) now link to the right method. Because Lua's method-call syntax looks identical to a Luau type annotation, a capitalized call like `lg:Log()` was misread as declaring the variable's type, so whenever two or more classes shared a method name (`Init`, `Update`, `Destroy`, …) the call was silently dropped from callers, impact/blast-radius, and flow traces. Lowercase method names were unaffected. Thanks @inth3shadows for the precise root-cause analysis and repro. (#1124)
- Removed dead code left behind by the discontinued managed-reasoning feature. Its `codegraph login` flow was unplugged before ever shipping in a release, but the unused module still shipped inside the platform bundles, and a security review flagged its Windows browser-open step (it routed the login URL through `cmd`, which would have been unsafe had the flow ever been wired back up). The leftover module and its tests are now fully deleted. Thanks @inth3shadows for the report. (#1114)
- The Claude Code context hook no longer treats ordinary English words that merely start with "call", "trace", "affect", or "connect" — callus, calligraphy, Connecticut, connective, affectionate, Tracey — as structural questions, which used to inject full CodeGraph context into prompts that had nothing to do with code structure. Genuinely structural forms (calls, callers, callbacks, call site, traced, tracing, affected, connections, connectivity, …) still fire exactly as before. Thanks @inth3shadows for the report. (#1138)
- A stuck git command can no longer hang CodeGraph indefinitely. The git checks behind worktree detection and git-hook setup, and the installer's optional `npm install -g` step, now time out and fail gracefully instead of blocking forever — this matters most for the background MCP server, where an unbounded git hang (network filesystems, a wedged fsmonitor) could previously freeze it long enough for the safety watchdog to kill it. Thanks @inth3shadows for the report. (#1139)
- The context hook's new plain-words matching works immediately on projects indexed by an older CodeGraph version. The word lookup it relies on is built at index time, so a project indexed before the upgrade had an empty one, and the hook would silently find nothing until something else happened to refresh the index; the hook now fills it in on first use (a one-time step — normally the background MCP server's startup catch-up gets there first). Thanks @inth3shadows for the report. (#1142)
- Several accuracy fixes to the plain-words matching: a renamed symbol (for example a NestJS route after its module prefix is applied) stays findable under its new name (#1141); a word that only appears in your code as an import statement's package name is no longer presented as a matched symbol (#1144); plural words no longer generate garbled lookup keys ("services" no longer also looks up "servic") (#1145); and a name matching both the singular and plural of one word can no longer squeeze out a genuine two-word match (#1146). Thanks @inth3shadows for the reports.
- Heavily-reflected Unreal Engine C++ classes are no longer dropped from the index. Reflection markup that decorates members — `UPROPERTY(...)`, `UFUNCTION(...)`, `UCLASS(...)`, `GENERATED_BODY()`, `UE_DEPRECATED_*(...)`, `DECLARE_DELEGATE_*(...)` — are no-semicolon macro calls that tree-sitter doesn't recognize, so each drops into error recovery; in a big class the errors pile up until the whole `class_specifier` collapses and the class, its base clause, and its members vanish (`UCharacterMovementComponent`, with ~240 such macros, disappeared entirely, breaking every subclass/type-hierarchy and blast-radius query that went through it). These line-leading annotation macros are now blanked (offset-preserving) before parsing so the class survives. Thanks @luoyxy for the report and root-cause analysis. (#1093 follow-up)
- Unreal Engine members and methods prefixed by an export/visibility macro are no longer lost. The `*_API` macro doesn't only sit on the class header — it prefixes almost every exported member of a large UE class (`ENGINE_API virtual void Tick(...)`, `static ENGINE_API void AddReferencedObjects(...)`); the parser read the macro as an extra type token and each such declaration fell into error recovery, so on headers like `Actor.h` and `World.h` hundreds of return types piled up as orphan errors and could still tip the class into collapse. Member/method-level `*_API` / `*_EXPORT` / `*_ABI` macros (Unreal, Qt/Boost, LLVM) are now blanked before parsing, mirroring the existing class-header recovery. (#1093 follow-up)
- Unreal Engine annotation macros that appear mid-line — an enum value's `UMETA(DisplayName=...)`, a parameter's `UPARAM(ref)`, or a deprecation tag wedged into a `using` alias (`using FOnNetTick UE_DEPRECATED(5.5, "...") = ...;`, which alone collapsed `UWorld` in `World.h`) — are now stripped too. These sit in positions the line-leading recovery structurally can't reach, and a single one could take down the surrounding enum or class. They are matched by an Unreal-only name list (`UMETA`, `UPARAM`, `UE_DEPRECATED*`) so no standard-C++ or other-library code is affected. Together these three fixes recover the main class of every large Unreal Engine header tested (`Actor`, `ActorComponent`, `SkeletalMeshComponent`, `World`, `LightComponent`, `CharacterMovementComponent`). (#1093 follow-up)
- A C++ header whose only C++ signal is an export-macro-annotated class is no longer misdetected as C — which had silently dropped the class. A `.h` file defaults to C and is only reclassified as C++ when it shows a C++-specific construct, but that check couldn't see through an export/visibility macro: `class ENGINE_API UFoo : public UObject` didn't match the macro-blind `class Name :` pattern, so a lean Unreal-Engine-style header carrying just `GENERATED_BODY()` and no explicit `public:` / `virtual` / `namespace` / `template` was parsed as C. The C extractor emits no class nodes, so the class — and its inheritance link — vanished from the graph, quietly undoing the macro-class recovery added in #1061. The C++ detection heuristic now recognizes an export-macro-annotated `class`/`struct` declaration (the same shape #1061 blanks before parsing), matching how `class Foo : Bar` was already detected; the two-token `<keyword> <MACRO> <Name>` before a `[:{]` never occurs in valid C, so genuine C headers are unaffected. Thanks @luoyxy for the report and root-cause analysis. (#1133)
## [1.2.0] - 2026-07-02
### New Features
- Method calls made through a local variable now resolve to the method in many more languages. When code does `const logger = new Logger(); logger.log();` (or the equivalent), CodeGraph infers the local variable's type from its declaration or initializer and links the call to the right method — so these calls now show up in callers, impact/blast-radius, and `codegraph_explore` flow traces instead of being dropped. Previously only C++ handled this; it now also covers TypeScript, JavaScript, Python, Java, C#, Kotlin, Swift, Go, Rust, Dart, Scala, and PHP. (#1108)
- Ruby method calls made on a receiver (`logger.log`) now record an edge to the method. Previously the Ruby indexer kept only the receiver and discarded the method name, so a method called through a variable or object had no recorded callers and was missing from impact/blast-radius and flow traces; combined with the local-variable type inference above, `logger = Logger.new; logger.log` now links to `Logger#log`. Calls to a class method (`Foo.bar`) and object construction (`Foo.new`) are still recorded too. (#1110)
- The same local-variable method-call resolution now extends to Lua, Luau, R, and Pascal/Delphi. A method invoked through a local — Lua/Luau `local lg = Logger.new(); lg:log()`, R `lg <- Logger$new(); lg$log()`, or Pascal `var lg: TLogger; ... lg.Log` — now links to the right method instead of being dropped. (#1112)
### Fixes
- Indexing a large project no longer gets killed partway through with a "Main thread unresponsive — killing the wedged process" message. The safety watchdog that stops a genuinely stuck index was mistaking slow-but-normal work for a hang: on a big repo, linking up references and cross-file relationships can legitimately run for a while, and that work now regularly yields so the watchdog can tell real progress from a true stall. Projects that previously failed to finish `codegraph init` / `codegraph index` (and had to fall back to `CODEGRAPH_NO_WATCHDOG=1`) now complete normally, while a genuinely hung process is still caught. Thanks @zmcrazy, @YoungLiao, and @GeeLab-Mob for the reports. (#1091)
- On Windows, a console window no longer briefly flashes when CodeGraph runs as a background MCP server. When the npm launcher started the bundled runtime — which happens every time an editor starts the server or reconnects after the daemon idles out — and during its self-heal step that extracts a missing platform bundle, Windows would pop up a black console (conhost) window for a moment. Both now launch hidden, matching how the daemon already behaved; the browser-open step of `codegraph login` was hardened the same way. Thanks @luoyerr for the report and root-cause analysis. (#1092)
- C++ forward declarations no longer crowd out the real class definition. A `class Foo;` forward declaration — common in large C++ and Unreal Engine codebases, where a heavily used class is forward-declared across dozens of headers — was indexed as its own class node every time it appeared. So exploring that class returned mostly forward-declaration sites, and could even pick one of them as the representative for blast-radius, burying the actual definition and its members and callers. Bodiless forward declarations are now skipped for C and C++, exactly as forward-declared structs and enums already were, so only the real definition is indexed. Languages where a class with no body is a complete definition — such as Kotlin's `class Empty` and Scala — are unaffected. Thanks @luoyxy for the report and root-cause analysis. (#1093)
- C++ methods that return a reference, and user-defined conversion operators, are now indexed under their correct names. An inline getter like `const FGameplayTagContainer& GetActiveTags() const` — everywhere in Unreal Engine headers — was indexed as `& GetActiveTags() const` instead of `GetActiveTags`, and a conversion operator like `operator EALSMovementState() const` kept its trailing `() const` instead of reading `operator EALSMovementState`. In both cases the garbled name meant you couldn't find the symbol by name and its callers weren't linked. Both now read cleanly, matching how pointer-returning and value-returning methods already worked. (#1096)
- C++ functions written with an inline-specifier macro before the return type are now indexed correctly. In Unreal Engine, inline helpers are commonly written `FORCEINLINE FString GetEnumerationToString(...)`; the `FORCEINLINE` macro made the parser read the return type as part of the function's name (`FString GetEnumerationToString` instead of `GetEnumerationToString`) and lose the real return type, so the function couldn't be found by name and its callers weren't linked. CodeGraph now recognizes the standard Unreal inline macros (`FORCEINLINE`, `FORCENOINLINE`, `FORCEINLINE_DEBUGGABLE`), so both the name and the return type are captured. (#1100)
- The same function-name recovery now covers inline macros from common third-party C++ libraries, not just Unreal Engine — including pugixml (`PUGI__FN`, `PUGIXML_FUNCTION`), Godot (`_FORCE_INLINE_`), Boost (`BOOST_FORCEINLINE`), and generic `ALWAYS_INLINE` / `FORCE_INLINE`. Functions decorated with these are now indexed under their real names. On a large Unreal project vendoring these libraries this cleaned up the large majority of remaining function-name garbling. (#1101)
- C++ function names are now recovered even when decorated with a macro CodeGraph doesn't specifically know about. A function written `SOME_LIBRARY_MACRO ReturnType doWork(...)` previously had the macro or return type absorbed into its name whenever the macro wasn't one CodeGraph recognized; now the real name (`doWork`) is recovered regardless of the macro, so it's findable and its callers link — no per-library configuration needed. The recognized-macro list was also broadened (Qt, Folly, Abseil, LLVM, V8, Eigen, rapidjson) so those additionally capture the return type. This only ever cleans up an already-garbled name and is limited to C and C++, so ordinary names — and languages like Kotlin and Scala where identifiers can legitimately contain spaces — are unaffected. (#1102)
- The set of C++ libraries whose macros are recognized for full return-type recovery was expanded well beyond Unreal Engine — now spanning Mozilla, Protobuf, {fmt}, nlohmann/json, GLM, Bullet, Skia, OpenCV, EASTL, Cocos2d-x, GLib, SQLite, and the common Windows calling conventions (so `HRESULT WINAPI CreateThing(...)` indexes as `CreateThing` returning `HRESULT`). Functions from libraries not on the list still get their name recovered automatically; being listed additionally recovers the return type. (#1103)
- Graph traversal and blast-radius results no longer drop or miscount relationships in a handful of edge cases. When a symbol could be reached by more than one path, an impact/blast-radius query could leave out a direct dependency between two symbols that were already linked another way; separately, the lower-level graph traversal used by the library API could keep only one of several relationships between the same pair of symbols (for example a symbol that both calls and references another), count a caller reached through two different call sites twice, or return slightly more results than the requested size limit on a very highly-connected symbol. These were long-standing and mostly masked by later de-duplication, so day-to-day query results were largely unaffected, but the traversal now returns the complete, correctly-bounded set. Thanks @inth3shadows for the precise, individually-traced reports. (#1086, #1087, #1088, #1089, #1090)
- Method calls to same-named classes in different files now resolve to the right definition. If two files each declared, say, a `Logger` class with its own `log()` method, a call could be linked to whichever definition happened to be indexed first — so a call in one file wrongly pointed at the class in another, mixing up that method's callers and blast radius. This affected calls written as `obj.log()`, `Logger.log()`, and `Logger::log()` across many languages, including C++, Python, TypeScript, Java, C#, and Rust. When a method name is ambiguous, CodeGraph now prefers the definition in the calling file itself — the correct target in the common case — while Java/Kotlin calls that an `import` already pins to another file are unaffected. Thanks @inth3shadows for the minimal repro and root-cause analysis. (#1079)
- Live auto-sync now gives up cleanly if indexing keeps failing, instead of retrying forever in the background. If a repeatable indexing error kept every sync from completing — for example a file that reliably crashes one language's parser, a full disk, or a corrupt database — a long-running server or daemon would retry every couple of seconds indefinitely, quietly wasting work and filling the logs while the graph silently stopped updating. Auto-sync now backs off after repeated failures and, if they persist, disables itself with an actionable message (naming the underlying error and pointing you to run `codegraph sync`), so the stalled index is surfaced rather than hidden — matching how prolonged file-lock contention and watch-limit exhaustion already degrade. A single successful sync resets everything, so an occasional transient hiccup is unaffected. Thanks @inth3shadows for the precise, code-traced report. (#1127)
- Method calls made through a typed function parameter now resolve to the right method in more languages. A call like `function use(lg: Logger) { lg.log(); }` wasn't linking to `Logger`'s `log()` — CodeGraph inferred a receiver's type from a local declaration but not from a bare typed parameter, so when another class defined a method of the same name the call was dropped from callers and impact/blast-radius. Typed parameters (and method receivers) are now recognized in TypeScript, JavaScript, Rust, Go, Dart, and PHP — including TypeScript generic types (`repo: Repository<User>`) — matching how Java, C#, Kotlin, Swift, Scala, Python, and Pascal already handled it, so the parameter case is now covered across every language that has typed parameters. Thanks @inth3shadows for the isolated repro and root-cause analysis. (#1125)
## [1.1.6] - 2026-06-30
### Fixes
- The standalone installer (`install.sh`) no longer leaves old versions piling up on disk. Each upgrade installed the new release into its own directory and re-pointed the launcher at it, but never removed the previous ones — so on macOS and Linux a full vendored Node runtime (tens of MB per version) accumulated with every update. The installer now keeps only the version it just installed and removes the older ones automatically (the npm installer's download-fallback cache prunes the same way). Windows installs already replaced a single directory in place, so they were never affected. Anything still left behind under `~/.codegraph/versions` from earlier upgrades is safe to delete. Thanks @lalanbv for the report. (#1074)
- `codegraph index` can now rebuild an existing oversized index from an older version, instead of hanging until the watchdog kills it. The previous fix (#1065) stopped *new* indexes from sweeping in a gitignored corpus of nested repos, but a project that had already built the multi-gigabyte graph before upgrading couldn't recover: `codegraph index` is meant to rebuild from scratch, yet it cleared the old graph by deleting every row one at a time, and on a graph of well over a million symbols that took longer than the 60-second responsiveness watchdog allows — so the command was killed before indexing even started, leaving the bad index in place. A full re-index now discards the old database outright and starts fresh, which is near-instant regardless of the old size and also frees the disk the bloated database was holding. Thanks @AriaShishegaran for the detailed follow-up report. (#1067)
## [1.1.5] - 2026-06-30
### Fixes
- C++ classes annotated with an export or visibility macro are now indexed as real classes. This is the `class MYMODULE_API UMyComponent : public UActorComponent` style used throughout Unreal Engine — where an `XXX_API` macro sits between `class`/`struct` and the type name — as well as the equivalent `*_EXPORT` / `*_ABI` macros common in Qt, Boost, LLVM, and many other libraries. Previously that macro made the parser misread the whole declaration as a function, so the class was dropped entirely: it never appeared in the graph and its base class went unrecorded, which made "find subclasses", type-hierarchy, and impact-through-inheritance queries come back empty for effectively every gameplay class in an Unreal Engine project. The class, its members, and its inheritance link are now all captured. Thanks @luoyxy for the detailed report and proposed fix. (#1061)
- `codegraph_explore` now surfaces the options/config type behind a function when you ask, in plain language, what to change to add a parameter to it. A question like "what do I need to change to add a new parameter to X" shares no words with the file that actually defines X's options — for example a functional-options struct and its `With…` builders living in a separate `options.go`, reachable only through X's signature — so that file scored near-zero on every text and connectivity signal and got dropped: explore returned X itself but not the file you'd edit, and the agent fell back to grep. Explore now follows a named function's parameter and return types and pulls in the file that defines them when ranking would otherwise bury it, so the options/config file shows up with its fields. Well-connected types that already rank are left untouched, so ordinary "how does X work" flow questions are unchanged. (The separate tools `codegraph_search`/`codegraph_impact`/`codegraph_node` remain available via `CODEGRAPH_MCP_TOOLS` for anyone who prefers driving each step explicitly.) Thanks @wauxhall for the detailed investigation. (#1064)
- The standalone installers (`install.sh` / `install.ps1`) now warn when a different `codegraph` earlier on your PATH will run instead of the one they just installed. The usual cause is a leftover `npm i -g @colbymchenry/codegraph` from an earlier version, whose launcher keeps running its own pinned version — so the installer reports installing the latest while `codegraph --version` keeps printing the old one, with no hint as to why. The installer now points at the shadowing copy and tells you how to resolve it (remove the other install, or put the new one first on PATH). Thanks @SpringYear for the report. (#1071)
## [1.1.4] - 2026-06-29
### Fixes
- CodeGraph again respects `.gitignore` for nested repositories that git tracks as gitlinks. The recent change that taught CodeGraph to descend into nested repos recorded as `160000` "commit" pointers (#1031, #1033) did so even when your `.gitignore` excludes the directory those repos live in — so a gitignored reference or benchmark corpus full of cloned repositories got pulled into the index anyway. One project with a gitignored `benchmark/repos/` of 19 cloned repos saw over 138,000 files swept in and a 4.8 GiB graph, and a full index then stalled in the "Resolving refs" phase until the watchdog killed it. CodeGraph now treats a gitignored gitlink the same as any other gitignored embedded repo: excluded by default, and re-included only when you opt the directory in with `codegraph.json` `includeIgnored`. Nested repos in non-ignored locations — the case #1031/#1033 fixed — are unchanged. Thanks @AriaShishegaran for the detailed report. (#1065)
## [1.1.3] - 2026-06-29
### Fixes
- CodeGraph now indexes nested repositories that git records as gitlinks, so a workspace built by stacking several repos inside one another indexes completely from a single `codegraph init` at the top. When a repo contains another git repo that was `git add`ed into it — so git tracks it as a `160000` "commit" pointer rather than a folder of files — or a submodule that isn't an active, initialized submodule in your checkout, that nested repo's source used to be skipped entirely: indexing the top level stopped at the nested repo's boundary and pulled in only the outer repo's own files, so a stacked-repo project came up nearly empty (one report saw ~10 files indexed at the root). CodeGraph now descends into each such nested repo that has a real working tree on disk and indexes it as its own embedded repository, recursively, so every layer of a stacked workspace is covered. Active submodules (already handled) and plain untracked nested clones are unchanged; a nested repo under a dependency directory such as `vendor/` or `node_modules/` stays excluded; and a submodule with nothing checked out on disk is correctly left alone rather than reported as empty. Thanks @ofergr and @kun-yx for the reports. (#1031, #1033)
- CodeGraph no longer shows a misleading "different git working tree" warning when you work inside a submodule (or other nested repo) of a workspace you indexed at its root. Because indexing a workspace now pulls in its submodules and embedded clones, a query run from inside one correctly resolves up to the workspace's single index — but it was still warning that the results came from "a different working tree" and suggesting you run `codegraph init -i`, which would have split the submodule back out into its own separate index and undone the unified view. CodeGraph now recognizes that the nested repo's code is already part of the workspace index and stays quiet. The warning still appears for a genuine git worktree — a second checkout of the *same* repository on another branch, which really does have its own uncommitted symbols — since that's the case it exists for. (#1031, #1033)
- On Windows, CodeGraph's background server now shuts down cleanly instead of occasionally aborting with a crash error. When the indexed project contained a nested repository (a submodule or embedded clone), stopping the server could race the file watcher's teardown and exit with a Windows crash code rather than a clean exit. Shutdown now lets that teardown finish first, so the server stops cleanly and promptly. (Windows only; other platforms were unaffected.) (#1033)
- C++ classes that inherit from a templated base — `class Widget : public Base<int>`, a CRTP base like `class App : public CRTPBase<App>`, or a struct inheriting a template — are now linked to that base class in the graph. Previously the template arguments (`<int>`) made the inheritance go unrecognized, so these classes looked like they inherited from nothing and impact/callers analysis stopped at the boundary; the connection is now followed like any other base class. Thanks @ryancu7 for the report. (#1043)
- C++ objects constructed on the stack — `Calculator calc(0)` or `Widget w{1, 2}` — now record that the enclosing function instantiates that class, the same as heap construction (`new Calculator(0)`) already did. Previously only the `new` form was tracked, so a function that built objects with the ordinary stack syntax looked like it didn't construct them and the dependency was missing from impact/callers. Thanks @Dshuishui for the report. (#1035)
- The graph no longer stores duplicate copies of the same relationship. The same dependency between the same two symbols at the same spot could be recorded more than once, which inflated edge counts and let callers/impact results list a relationship twice. Each relationship is now stored exactly once, and existing projects are de-duplicated automatically the next time CodeGraph opens them. Thanks @inth3shadows for the detailed report. (#1034)
- `codegraph node` can now read a file from the command line. File-read mode — pass `-f`/`--file` to get a file's source with line numbers plus the files that depend on it, the same output as the `codegraph_node` MCP tool — was rejected with "missing required argument 'name'", because the command always demanded a symbol name even though file mode has none, leaving the feature unreachable from the CLI. The symbol name is now optional: `codegraph node -f src/auth.ts` (or `codegraph node src/auth.ts`) reads the file, `codegraph node parseToken` looks up a symbol, and running it with neither prints a short usage hint instead of a cryptic error. Thanks @jcrabapple for the report. (#1044)
- `codegraph query` no longer prints meaningless relevance percentages like "12042%" next to each result. The number was a raw full-text search score — useful only for ordering the results, not as a real 0100% figure — so multiplying it by 100 produced wild values that made the output look broken. Results are already listed best-match first, so the CLI now just shows them in that order with no score, matching what the search tool reports to AI agents. If you script against `codegraph query --json`, the raw `score` is still included for sorting or thresholding. Thanks @jcrabapple for the report. (#1045)
- `codegraph explore` no longer reports an alarming, inflated result count on broad natural-language queries. The "Found N symbols across M files" summary used to count every symbol the search swept in while ranking, so a broad query (for example "publish status to the API") on a large project could announce hundreds of symbols across a big fraction of the codebase — reading as if you had to wade through all of them — even though only the most relevant handful are actually shown with their source. The summary now counts just the files explore returns source for, so the number matches what you see. Ranking and results are unchanged: the right symbols still come first, and any further relevant files are still listed by name under "Not shown above" so nothing is hidden. Thanks @jcrabapple for the report. (#1046)
- Android resource files no longer bloat the index. A `res/` tree — layouts, drawables, value bags (strings, colors, styles), menus, navigation graphs — contains no code symbols, but on an Android app it can be the overwhelming majority of files (one project: 26,000+ XML files, ~97% of everything, contributing zero symbols), which inflated the database, slowed indexing, and padded file counts and `codegraph explore`/search results with entries that have nothing to find. CodeGraph now skips Android resource directories by default — `res/layout/`, `res/values/`, `res/drawable/`, `res/menu/`, and the rest, including their locale/density/version variants like `res/values-es/` or `res/drawable-hdpi/`. Your actual code is untouched, and so is the one kind of XML that does carry symbols — MyBatis mapper files, which live under `src/main/resources/`, not `res/`. `res/raw/` is deliberately kept (it can hold real assets), and you can re-include any excluded directory with a `.gitignore` negation such as `!res/values/`. Thanks @jcrabapple for the report. (#1047)
## [1.1.2] - 2026-06-28
### New Features
- You can now exclude committed directories from the index with an `exclude` list in `codegraph.json` — even when they're git-tracked. `.gitignore` can't drop a directory git already tracks, so a vendored theme or SDK that's checked into your repo (a committed Metronic theme under `static/`, a bundled vendor library) had no supported way to be kept out — it just bloated the graph and slowed indexing. Add a root `codegraph.json` with, e.g., `{ "exclude": ["static/", "**/vendor/**"] }` and those paths are skipped on indexing, sync, and file-watching, on both git and non-git projects. Patterns are gitignore-style and matched against repo-root-relative paths. This complements the existing `includeIgnored` (its opposite — opt *in* to gitignored embedded repos). (#999)
- CodeGraph now follows C/C++ commands that are dispatched through macro-built function-pointer tables, so the handler functions they reach are no longer dead-ends in the graph. Many C projects register a handler into a struct's function-pointer field through a macro and a generated table — redis is the classic case: every command (`getCommand`, `decrbyCommand`, …) is wired into the command struct's `proc` field by a `MAKE_CMD(…)` table that lives in a generated, `#include`-d file, then invoked as `c->cmd->proc(c)`. CodeGraph now reads those macro-built tables — including ones whose struct type is itself a macro alias, whose table sits in an `#include`-d file that is never indexed on its own, or that are wrapped in conditional compilation (`#ifdef`) and defined inline with the struct. It recognizes function-pointer fields declared through a function typedef, and follows the receiver — a chained access (`c->cmd->proc`) or an array subscript through a file-scope table (`(cmdnames[i].cmd_func)(…)`) — across field types. It also follows dispatch through a bare array of function pointers with no struct wrapper at all — the opcode/handler-table pattern common in interpreters and emulators, where a table like `opcodes[op](…)` invokes one of many registered handler functions by index — linking the dispatcher to every handler in the array. The upshot: asking for the callers or blast radius of a command handler now finds the dispatcher that reaches it. For redis, `call` shows up as a caller of every command; for SQLite, the builtin SQL functions registered through `FUNCTION(...)` link to where they're invoked; for Vim, every `:ex` and normal-mode command links from the dispatcher. (#991, extending #932)
- CodeGraph no longer times out when many agents query it at once. The shared background server that serves all your editor and agent sessions used to run every query on a single thread, so a burst of concurrent requests — for example a swarm of subagents exploring a large monorepo together — queued up behind one another and, while the heavy ones ran, froze the connection so finished answers couldn't even be sent back until the whole batch drained. Past a handful of simultaneous callers that routinely surfaced as MCP request timeouts. The shared server now answers queries across a pool of worker threads, so concurrent requests run in parallel and the connection stays responsive the whole time; when it's genuinely saturated a call returns a brief "busy, retry shortly" note (not an error) instead of hanging past your client's timeout. The pool sizes itself to your machine — roughly one worker per core, leaving one for coordination — and a single editor session is unaffected (no pool, no overhead). Set `CODEGRAPH_QUERY_POOL_SIZE` to choose a specific number of workers, or `0` to revert to single-threaded in-process queries.
- Indexing now parses files across multiple CPU cores instead of one, so building a project's graph — `codegraph index`, the first index of a project, and the background re-index after changes — is faster on multi-core machines, most noticeably on large or parse-heavy codebases. The graph it produces is identical to before and re-indexing stays deterministic: parsing runs in parallel, but results are still committed in a fixed order, so the same project always yields the same graph. CodeGraph sizes the pool to your machine automatically (leaving a core free for everything else); set `CODEGRAPH_PARSE_WORKERS` to choose a specific number of parse workers, or `CODEGRAPH_PARSE_WORKERS=1` to restore the previous single-core behavior. Peak memory is unchanged — workers reclaim parser memory independently, so it doesn't grow with the number of cores. (#1015, #320)
- When CodeGraph's MCP server runs with no default project of its own — started outside any repository (for example behind an MCP gateway), or at a monorepo root whose indexes live in sub-projects — it now marks `projectPath` as a required argument on every tool call. Before, `projectPath` was always optional, so an agent talking to such a server would often omit it, get back guidance to pass it, and not reliably retry — you had to nudge it by hand every time. Now the requirement is part of the tool definition the agent sees, so it supplies the path to the project it's working on the first time. When the server does have a default project — the normal case, launched inside your repo — `projectPath` stays optional and a call without it falls back to that project exactly as before. Thanks @wauxhall for the report. (#993)
- CodeGraph's MCP tools now work in Cursor's **Ask mode** (and any other client that only permits read-only tools). Every CodeGraph tool just reads your indexed code — it never changes your workspace — but it didn't advertise that, so Cursor's Ask mode blocked every call with "you are in ask mode and cannot run non read-only tools," and you had to switch to Agent mode to use CodeGraph at all. CodeGraph now declares all its tools read-only using the standard MCP tool annotations, so Cursor (and similar clients) allow them in read-only contexts. Nothing about how the tools behave changes. Thanks @CDsouza315 for the report. (#1018)
### Fixes
- A `codegraph index` or `codegraph init` that gets orphaned or wedged now stops itself instead of pinning a CPU core forever. If you killed the command (or the terminal/agent that launched it), the underlying indexer process used to keep running in the background — the parent couldn't pass the signal along — and a genuinely stuck index had nothing watching it either, since the self-recovery watchdogs were wired only into the background MCP server. Both gaps are closed: indexing now self-terminates when its parent goes away, and a main thread that stops making progress is killed so it can't hang indefinitely. Opt out with `CODEGRAPH_NO_WATCHDOG=1` (liveness) or `CODEGRAPH_PPID_POLL_MS=0` (orphan detection), matching the server. (#999)
- Indexing no longer hangs at "Resolving refs" on a repo that commits a large JavaScript/TypeScript theme or SDK. A vendored admin theme (Metronic is the classic case — ~1,300 committed `.js` files) re-declares the same method names (`init`, `update`, `render`, `destroy`, …) on hundreds of widgets, and resolution used to score *every* same-named definition against *every* call — work that grows with the square of how many times a name repeats. On such a repo it pinned a CPU core for 1530 minutes and effectively never finished. Resolution now declines to guess when a name is defined more times than any real codebase ever repeats one (the cutoff is generous — normal projects top out far below it and are completely unaffected), since no proximity heuristic can pick the one true target among thousands anyway. Indexing that previously wedged now completes in seconds, and precise resolution (imports, qualified names, class-name matches) is unchanged. This is the same class of slowdown as the 1.1.0 import-name fix, now closed for repeated method/symbol names. Tune the cutoff with `CODEGRAPH_AMBIGUOUS_NAME_CEILING` if you ever need to. Thanks @DANOX2 for the detailed report and repro. (#999)
- Claude Code's front-load prompt hook now fires for non-English prompts. The optional hook that injects CodeGraph context for structural questions only recognized English keywords, so a structural question written in Chinese — or any non-Latin-script language — silently injected nothing: the hook looked like it wasn't wired up despite a correct setup, with no error to explain why. The gate is now language-aware. It recognizes Chinese structural keywords (如何/流程/调用/依赖/实现/架构…), and — in any language — a prompt that names a real code symbol from your project, such as `getUserId`, `article_publish`, `user.login`, or `parseToken()` (the name is checked against the index, so an ordinary word that merely looks like code doesn't trigger it). Non-structural prompts ("fix this typo", in any language) stay a no-op as before, so nothing fires where there's no structural answer to give. Thanks @whinc for the detailed report and repro. (#994)
- The background auto-sync server now starts for projects kept on an ExFAT or FAT external drive (and some network mounts). Those filesystems don't support the operations the server relies on to coordinate and to listen locally, so it failed immediately and re-logged the same error on every retry — background indexing was broken, so you had to run `codegraph sync` by hand after changes. (The MCP tools, the prompt hook, and manual `codegraph index`/`sync` were unaffected, since none of them need the server.) The server now works around those limitations automatically — falling back to a different coordination method and relocating its local socket to your system temp directory — so background indexing works there exactly like anywhere else, with no configuration needed. Verified end-to-end on real removable-drive filesystems on macOS, Linux, and Windows. Thanks @zengwenliang416 for the detailed report. (#997)
- If you use CodeGraph as a library, the `QueryBuilder.deleteResolvedReferences()` helper no longer throws "too many SQL variables" when handed a very large list of ids — it issued one unbounded query, so a list longer than SQLite's parameter limit aborted the call. It now splits the work into batches like every other bulk query in the API. CodeGraph's own indexing and reference resolution never called this method (they use a different, already-batched path), so the CLI and MCP server were unaffected. Thanks @inth3shadows for the static analysis. (#1001)
- Swift computed properties are now indexed, so you can search for them. A computed property — a `var isCloudProxy: Bool { … }` read all over a codebase, a SwiftUI view's `var body: some View { … }`, a protocol's `var title: String { get }` requirement — produced no symbol at all, so `codegraph query` and `codegraph_explore` answered "No results found" for it, and an agent that trusts an empty result would wrongly conclude the property doesn't exist. Computed properties (and protocol property requirements) are now graph symbols you can find and explore like anything else. A computed property's getter is also read as its body, so a SwiftUI view's `body` links to the subviews and helpers it builds — making a view's render flow traceable through the property. Stored properties were already indexed; this closes the computed-property gap. Thanks @monochrome3694 for the precise report and repro. (#1020)
## [1.1.1] - 2026-06-24
### Fixes
- CodeGraph respects your `.gitignore` again when looking for nested git repositories. A directory you've gitignored — a `resource/` or `.repos/` folder of cloned reference projects, a large vendored data dir — is no longer walked into and indexed: version 1.0.0 started searching inside *every* gitignored directory for embedded git repos and pulling them all in, which could multiply a graph many times over and slow indexing to a crawl on a multi-gigabyte folder of clones, even though you'd explicitly excluded it. Indexing the repos inside a gitignored directory is now opt-in — add an `includeIgnored` list to a `codegraph.json` at your repo root, e.g. `{ "includeIgnored": ["packages/", "services/"] }`, to index the embedded repos under the directories you name. The "super-repo of independent clones" layout from 1.0.0 still works, just declared explicitly. Nested repos you *haven't* gitignored (untracked clones) are indexed as before, and a project without this layout is unaffected. (#970, #976, #622)
- The MCP server no longer drops out with `Transport closed` when its connection to the shared background server hits a transient socket error. To serve all your editor/agent sessions from a single index, CodeGraph runs one shared background server they reach over a local socket; a stray error on that socket while a session was connecting used to take the whole server process down, so your agent saw a bare `Transport closed` even though `codegraph status` and `codegraph sync` were perfectly healthy. Now such an error is handled gracefully — the session falls back to serving itself in-process instead of crashing. This is most common on WSL2 when your project lives on a Windows drive (a `/mnt/c` or `/mnt/d` path), where that socket is flaky; if you still hit problems there, set `CODEGRAPH_NO_DAEMON=1` to skip the shared server entirely and run each session in its own process. Affects Codex and any other MCP client. (#974)
- A shared background server that can't bind its socket now cleans up its own lock file before exiting, instead of leaving a stale lock that the next launch trips over — which previously could let duplicate `serve --mcp` processes pile up for the same project. (#974)
## [1.1.0] - 2026-06-23
### New Features
- **Claude Code:** an optional front-load hook makes your agent reach for CodeGraph automatically. When you ask a structural question — "how does X work", "what calls Y", "trace the flow from A to B" — CodeGraph injects the relevant source and call paths into the prompt up front, so the agent answers from the graph instead of grepping around to rebuild it. You're asked during `codegraph install` (default yes; Claude Code only, since it's the agent with prompt hooks), it's removed by `codegraph uninstall`, and `codegraph upgrade` turns it on for existing Claude setups. It's strictly additive and degradable — non-structural prompts and un-indexed projects are left alone — and you can switch it off any time without uninstalling by setting `CODEGRAPH_NO_PROMPT_HOOK=1`.
- Vue store actions, mutations, and getters are now indexed as symbols you can find and read. Whether your store is **Vuex** (`mutations` / `actions` objects in a module) or **Pinia** — both the options form (`defineStore({ actions: { … } })`) and the setup form (`defineStore('id', () => { … })`, where actions are local functions) — each action, mutation, and getter is now a real node. So `codegraph search` finds `login` or `getSessionList`, and `codegraph_explore` / `codegraph_node` show its body and what it calls, instead of "not found" because the function only existed as an object-literal property.
- `codegraph_explore` now connects a Vue component to the **Pinia** store action it calls. When code does `const store = useUserStore()` and then `store.fetchUser()`, that call now links through to the `fetchUser` action in the store module — so "what happens when this view loads its data?" traces from the component into the action's body instead of stopping at the `store.fetchUser()` line. Works for both Pinia store styles (options and setup), and stays precise (a built-in like `store.$patch()` or an unrelated same-named method isn't mislinked).
- `codegraph_explore` now follows **Vuex** string dispatch. A `dispatch('user/login')` or `commit('SET_TOKEN')` call — namespaced `'module/action'` keys included — now links to the action or mutation it names, resolved to the correct store module even when several modules share an action name (and without being fooled by a same-named `api/` helper). So "what runs when this dispatches?" traces from the call into the store handler and on to the mutations it commits. Vuex's canonical `export default { namespaced, actions, mutations }` module shape is now indexed too, so those handlers are findable symbols.
- `codegraph_explore` now connects React data-fetching flows built on **RTK Query** (Redux Toolkit's `createApi`). An endpoint defined inside `createApi({ endpoints })` and the `useGetXQuery` / `useUpdateYMutation` hook it generates were both invisible to analysis — so "what does this component fetch?" or "where does `useGetThingQuery` get its data?" dead-ended, because the hook, the endpoint, and the component had nothing linking them. CodeGraph now indexes each endpoint and each generated hook as real symbols and wires the path `component → useGetXQuery → getX → queryFn`, so the flow resolves in one explore call instead of reading the API slice by hand. Both the arrow (`endpoints: build => ({ … })`) and method (`endpoints(builder) { return { … } }`) styles are recognized, along with the `useLazyGetXQuery` variant; hand-written hooks of a similar name are left untouched.
- `codegraph_explore` now follows **Celery** task dispatch in Python. A `send_email.delay(...)` or `send_email.apply_async(...)` call now links to the `@shared_task` / `@app.task` function it runs — typically defined in a different module (`tasks.py`) from where it's triggered (a view or service) — so "what actually happens when this is dispatched?" traces from the call site straight into the task body instead of stopping at the `.delay()` line. Both decorator dialects are recognized (bare `@shared_task` and the arg'd `@app.task(bind=True, …)` form), including the module-qualified `tasks.invalidate_cache.apply_async()` call style. It stays precise: a `.delay()` on something that isn't a Celery task is never mislinked, so a project that doesn't use Celery is unaffected.
- `codegraph_explore` now follows **Spring application events** in Java. A `publishEvent(new OrderShippedEvent(...))` call now links to every `@EventListener` that handles that event — usually in a different class — so "what reacts when this is published?" traces from the publisher straight into each listener method instead of dead-ending at `publishEvent(...)`. The link is by event type, and all the common listener styles are recognized: a `@EventListener` typed on its parameter, the `@EventListener(SomeEvent.class)` form, `@TransactionalEventListener`, and the older `implements ApplicationListener<SomeEvent>`. One event fans out to all its listeners, and a plain Spring app with no event bus is unaffected.
- `codegraph_explore` now follows **MediatR** request and notification dispatch in C#/.NET. A `_mediator.Send(command)` or `_mediator.Publish(notification)` call now links to the `Handle` method of the matching `IRequestHandler<>` / `INotificationHandler<>` — usually in a different file in a Clean Architecture layout — so "what handles this command?" traces from the controller straight into the handler instead of stopping at the mediator call. The sent type is recognized whether it's constructed inline (`Send(new GetFooQuery())`), built into a local first (`var cmd = new …; Send(cmd)`), or passed in as a parameter, and it's matched by type — so a `MessagingCenter.Send(...)` or a same-named DTO that isn't a request is never mislinked, and a project without MediatR is unaffected.
- `codegraph_explore` now follows **Sidekiq** background-job dispatch in Ruby. A `DestroyUserWorker.perform_async(id)` (or `.perform_in` / `.perform_at`) call now links to that worker's `perform` method — usually in `app/workers/` away from the controller or model that enqueues it — so "what runs in the background here?" traces from the enqueue straight into the job body. Both the modern `include Sidekiq::Job` and the older `Sidekiq::Worker` are recognized, namespaced workers resolve to the right class even when several share a name (e.g. `Comments::NotifyWorker` vs `Articles::NotifyWorker`), and Rails ActiveJob's `perform_later` — a different mechanism — is intentionally left alone.
- `codegraph_explore` now follows **Laravel events** in PHP. An `event(new OrderShipped($order))` call now links to every listener that handles it — each listener's `handle()` method, usually a separate `app/Listeners/` class — so "what reacts to this event?" traces from the dispatch straight into the listener bodies. Listeners are found both ways Laravel registers them: by a typed `handle(OrderShipped $event)` (auto-discovery, including a `handle(A|B $event)` union that listens for two events) and by the `protected $listen` map in your `EventServiceProvider` (which also catches a listener whose `handle()` has no type-hint). One event fans out to all its listeners, and queued jobs — dispatched via `::dispatch()` rather than `event()` — are correctly left out.
- CodeGraph now understands **Lombok**-generated methods in Java. `@Getter`, `@Setter`, `@Data`, `@Value`, and `@Builder` generate getters, setters, `builder()`, `equals`/`hashCode`/`toString`, and the `@Slf4j` `log` field at compile time, so those methods never appear in the source — and a `user.getName()`, `User.builder()`, or `log.info(...)` call used to resolve to nothing, silently breaking call-chain analysis (the agent would conclude the method didn't exist and reconstruct it by hand). Those members are now indexed from the annotations and fields, so they appear in `codegraph search` and `codegraph_explore`/`codegraph_node`, and callers trace through them like any hand-written method. They're marked as Lombok-generated so they read as generated, not hand-written; a method you write yourself is never overridden, static fields get no accessor, and a class without Lombok is unaffected. Thanks @git87663849. (#912)
- `codegraph_explore` now follows **C and C++ function-pointer dispatch**. C does polymorphism with function pointers: a struct carries a function-pointer field, concrete functions are registered into it through a table (`static struct cmd commands[] = {{"add", cmd_add}, …}`), a designated initializer (`.handler = on_open`), or an assignment, and the code dispatches indirectly (`p->fn(argv)`). None of that was visible to analysis — the indirect call resolved to nothing, so `git`'s command runner looked like it called nothing and a vtable's implementations had no callers. CodeGraph now links the dispatch site to the registered handlers, keyed by the struct field, so "what runs when this dispatches?" traces from `p->fn(...)` into every function registered for that field. This covers the command-table idiom (git, redis) and the ops-struct/vtable idiom (curl's content-encoders, protocol handlers), including the case where a generic hook slot is reassigned from a registry (`h->func = found->fn`). It stays precise — distinct function-pointer fields don't cross-link, a plain data field is never treated as a dispatch, and a project without function-pointer dispatch is unaffected. (#932)
- `codegraph_explore` now follows **GoFrame** route bindings in Go. GoFrame's standard router wires routes reflectively: the path and method live in a `g.Meta` struct tag on a request type (`` g.Meta `path:"/user/sign-in" method:"post"` ``), the controller method that serves it is matched by that request type, and the two are joined at runtime by `group.Bind(...)` — so there was no path string and no edge from a route to its handler, and "where is `/user/sign-in` handled?" or "where are the routes bound to controllers?" could only be answered by reading. CodeGraph now indexes each `g.Meta` route as a real route node and links it to the controller method whose signature takes that request type, so a route resolves to its handler structurally in one `codegraph_explore` call. The link is by request type, not method name — so it's correct even when the two differ (a `DeptSearchReq` served by a `List` method); it tells apart the many identical request types a large app defines one-per-module (`cash.ListReq` vs `order.ListReq`) by package, including cloned addon modules; and a route whose handler isn't present is left unlinked rather than guessed. (#747)
- The MCP server now works in monorepos and multi-project setups. Before, if you started CodeGraph somewhere with no `.codegraph/` of its own — most often a monorepo root where you only indexed individual services — the server exposed **no tools at all**, so your agent couldn't query CodeGraph even for the sub-projects that *were* indexed. Now the tools are always available: point a query at any indexed project with the `projectPath` argument (its path, or anywhere inside it) and CodeGraph answers from that project's index — for as many projects as you like in one session. It also means a project you index *after* the server started is picked up without restarting, where before the tools stayed hidden because the server only checked for an index once at launch. A project that genuinely has no index still cleanly tells your agent to use its built-in tools there (and that you can run `codegraph init` to enable it), so single-project use is unchanged. Thanks @MaiLunJiye. (#964)
- The Claude front-load hook now finds your indexed sub-projects in a monorepo. The optional `UserPromptSubmit` hook that injects CodeGraph context for structural questions previously only looked for an index at or above your working directory — so if you opened the monorepo root but indexed individual packages (`packages/api`, `services/auth`), it found nothing and stayed silent exactly where it was most useful. It now also looks *into* sub-projects: a single indexed sub-project gets its context front-loaded automatically, and with several the hook front-loads the one your question names (and lists the rest so the agent can target them by `projectPath`). Single-project repos are unaffected, and the scan is bounded and skipped entirely outside a recognizable workspace root. (#964)
- `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking.
- Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.
- C file-scope constants and globals — `static const` scalars, pointer/array lookup tables, and shared mutable globals — are now recognized as symbols in their own right. They previously weren't extracted at all, so they never appeared in search or carried any dependents; now they show up in `codegraph search` and participate in impact analysis (see above), so changing a C lookup table surfaces the same-file functions that read it.
- Java `static final` constants, C# `const` / `static readonly` constants, Scala `object` vals, and Kotlin top-level / `object` / `companion object` `val`s are now classified as constants rather than generic fields, so they participate in the constant-reader impact analysis above — change a `public static final` table, a `const string`, a Scala `object Config { val Timeout = … }`, or a Kotlin `companion object { const val … }` and the methods that read it now show up as affected. (Per-object Java `final` / C# `readonly` / Scala & Kotlin `class` instance properties are unchanged.) Kotlin constants were previously not indexed as their own symbols at all, so they now also appear in `codegraph search`.
- Swift top-level `let`s and `static let` constants (including those namespaced in an `enum`/`struct`, the common Swift pattern) are now indexed as constants and participate in the constant-reader impact analysis above — change a `static let defaultRetryLimit` or an `enum Constants { static let … }` and the same-file code that reads it shows up as affected. Computed properties and per-instance `let`s are not treated as constants.
- Dart top-level `const`/`final` and class `static const`/`static final` constants are now indexed as constants and participate in the constant-reader impact analysis above. Instance fields, `var`s, and locals are not treated as constants. (Generated Dart code with the standard `.g.dart`/`.freezed.dart`/`.pb.dart` suffixes is already skipped.)
- You can now teach CodeGraph about custom file extensions. Drop a `codegraph.json` at your repo root with an `extensions` map — `{ "extensions": { ".dota_lua": "lua", ".tpl": "php" } }` — and files with those extensions get indexed under the language you name, instead of being silently skipped because the extension wasn't one of the built-in defaults. It's opt-in and committed alongside your code so the whole team shares it, your mappings layer on top of the built-ins and win on conflict (you can even re-point a built-in, e.g. `.h``cpp`), and a typo'd language or a malformed config is warned about and skipped rather than breaking indexing. Projects without a `codegraph.json` behave exactly as before. (#906)
### Fixes
- `codegraph index` and `codegraph init` no longer crawl during the "Resolving refs" phase on large projects — most painfully ones that mix a big front-end and back-end, where the phase could stretch to many minutes. A package or module imported across hundreds or thousands of files (`react`, a shared UI package, Python `logging` / `typing`) was being treated as if every one of those import statements might be its definition, so the resolver compared each import against all the others — work that grows with the *square* of how widely a package is imported, which is why it blew up only on big, import-heavy repos. Imports now resolve straight to the definitions they actually point at, so those redundant comparisons are gone (reference resolution is dramatically faster on large repos), and the graph no longer accumulates the meaningless import-to-import links the old fallback created. (#915)
- MCP tool results no longer show up as oversized headings in Markdown-rendering clients (such as the Claude Code VSCode extension). Results used Markdown headings (`##`/`###`) for things like the status summary, each search hit, and every file section in an exploration, so a normal query filled the transcript with large-font lines — worst with `codegraph_search` and `codegraph_explore`, where the noise grew with the number of results. Section headers are now bold labels, which render at normal text size while keeping the same structure. Terminal/CLI output is unchanged. (#778)
- An MCP server pointed at a very large repository (tens of thousands of files) no longer hangs on the first tool call after a fresh start. On startup CodeGraph reconciles its index against the current files on disk, and on a huge repo that reconcile could run for minutes while blocking the very first request — long enough that the background server was sometimes force-restarted mid-scan, so the first query never came back at all. The reconcile now yields as it runs (keeping the server responsive instead of pinning it), and the first tool call waits only briefly for it before answering and letting the rest finish in the background — so you get a fast first response and the index still catches up. Set `CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS` to tune how long that first call waits (default 3000ms), or `=0` to always wait for the full reconcile. (#905)
- `codegraph install` now wires up your agents and stops there — it no longer indexes the current directory. Building a project's graph is always the explicit `codegraph init` (or `codegraph index`), so you decide what gets indexed and when, and the steps are the same whether you installed globally or just for one project. This clears up the confusion where a project-local install silently indexed but a global one didn't, and where the docs and the tool disagreed about whether you still had to run `init`. (#826)
- React components declared with `forwardRef`, `memo`, or styled-components / emotion (`const Button = forwardRef(...)`, `const Card = memo(...)`, `const Box = styled.button\`…\``) are now recognized as components, so finding where they're used works. Before, they were indexed as plain constants, so `codegraph callers` and impact analysis reported "no callers found" even when the component was rendered across dozens of files — a dangerous false "safe to change" right before refactoring a shared component. Now every `<Button/>` usage links back to the component, so callers and blast radius are complete. This is the standard shadcn/ui declaration style, so for typical React design systems the whole UI layer is no longer invisible to impact analysis. Thanks @Arlandaren for the report and @maxmilian for the root-cause. (#841)
- React Router and Next.js routes defined in `.tsx` / `.jsx` files are now indexed. Routes written as JSX — `<Route path="/users" element={<UsersPage/>}/>`, `createBrowserRouter([...])`, and Next.js `app/`/`pages/` page files — were being skipped entirely (only routes that happened to live in plain `.ts`/`.js` were picked up), so "what renders at this path?" and the route → page-component link were missing for most React apps. Now those routes show up in `codegraph search`/`codegraph_explore` and connect to the component they render, just like the backend route → handler links on other frameworks.
- `codegraph sync` (and the file-watcher auto-sync behind always-on / MCP use) no longer drops a function's callers when you edit the file that function lives in. Re-indexing a file after an edit — even a docstring-only change — was severing the `calls` / `references` edges coming from *other, unchanged* files (e.g. `from pkg import mod; mod.fn()` callers, or any cross-file reference), so `codegraph callers` / `impact` would abruptly report "no callers" for a function that's used throughout the codebase, until the next full re-index. Sync now preserves those incoming cross-file edges across the re-index, re-matching them to the symbol even when the edit shifted its line number. Most visible in large Python package trees, but it applies to every language. Thanks @JosefAschauer. (#899)
- `codegraph index` now rebuilds the full graph from scratch, so it produces the same result as a fresh `codegraph init` instead of reporting "0 nodes, 0 edges" and looking like it wiped your index. Previously, re-running `index` on an unchanged project skipped every file (their contents hadn't changed) and showed an empty-looking summary; it now clears and re-indexes for an honest, complete rebuild every time. Use `codegraph sync` for fast incremental updates between full rebuilds. Thanks @Arc-univer. (#874)
- The file watcher that auto-syncs the graph now fails cleanly when live watching can no longer be trusted, instead of looking healthy while the index quietly goes stale. If the operating system runs out of file-watch resources, or another process holds the write lock far longer than a normal save, CodeGraph now disables auto-sync once — with a single clear message telling you to run `codegraph sync` (or rely on the git sync hooks) to refresh — rather than retrying forever or repeating the same error on a loop. And while auto-sync is disabled, CodeGraph's tool responses (and `codegraph status`) now say so plainly, so your AI agent knows to read files directly instead of trusting a frozen index. This mostly matters for long-running MCP/daemon sessions, which could otherwise keep serving stale results while appearing to work. Thanks @thismilktea. (#876)
- On Linux, hitting the kernel's inotify watch limit on a large project no longer silently leaves half the tree unwatched. CodeGraph now tells you once — naming the exact setting to raise (`fs.inotify.max_user_watches`, e.g. `sudo sysctl fs.inotify.max_user_watches=1048576`) — and keeps live-watching the directories it could register while `codegraph sync` (or the git sync hooks) covers the rest. (#876)
- A long-running MCP server now notices when a git worktree gains its own index. Before, if the server (or shared daemon) first saw a worktree before you ran `codegraph init` in it — so the lookup walked up to the main checkout's index — it pinned that decision for its whole life: even after the worktree had its own `.codegraph/`, every query kept hitting the main checkout's index and every result carried a false "this index belongs to a different git working tree" warning, until you restarted the server. The CLI got it right but the MCP server didn't, and re-indexing didn't help. The server now re-checks which index a path belongs to on each call, so the worktree's own index is picked up — and the stale warning drops — without a restart. (#926)
- A long-running MCP server now recovers when your index is deleted and rebuilt at the same path. If `.codegraph/` was removed and recreated while the server held it open — most easily by recreating a git worktree at the same path, or `rm`-ing `.codegraph/` and running `codegraph init` again — the server kept reading the old, deleted database file and served a frozen snapshot: renamed or removed symbols still showed as live, new ones were missing, and `codegraph sync` couldn't refresh it — only restarting the server fixed it. The server now detects that the database file was swapped out from under it and reopens the live one in place, so results stay correct without a restart. (On Linux and macOS; Windows doesn't allow deleting an open file, so it isn't affected.) (#925)
- The MCP server now opens and auto-syncs a project that lives inside a folder an enclosing git repository ignores. Before, if the directory you indexed sat within a larger repo that gitignored it, the shared MCP daemon failed to open the project — its log repeated `Failed to open project … path should be a` `path.relative()` `d string, but got "./"` — so the file watcher never started and the index silently went stale until you ran `codegraph sync` by hand (setting `CODEGRAPH_NO_DAEMON=1` was the only workaround). The daemon now opens the project and starts watching as expected. Most visible with Codex on Windows, but the cause wasn't platform-specific. (#936)
- A git worktree of a submodule is no longer indexed as a duplicate copy of that submodule's code. CodeGraph already skips ordinary worktrees (a second working view of a repo it indexes), but a worktree created *from a submodule* — common in monorepos that check submodules out into worktrees for parallel feature work — was mistaken for a genuine embedded repo and swept in, duplicating every symbol it shared with the real submodule checkout (one report had ~28% of its index as duplicates, inflating both query results and the database). These submodule worktrees are now recognized and skipped, while the submodule's own checkout stays indexed as distinct code. Thanks @charlesxu2026-ship-it. (#945)
- A C++ class or struct annotated with an export/visibility macro — `class MYLIB_EXPORT Foo : public Bar { … }`, the common DLL-export / visibility pattern in headers — is no longer mis-indexed as a function spanning the whole class body. Not knowing the macro is a macro, the parser read it as a type and the whole declaration as a function, so the class surfaced as a phantom `function` that showed up as a false caller in `codegraph callers`, `codegraph impact`, and blast-radius analysis, and skewed symbol counts. CodeGraph now recognizes this misparse and drops the bogus node. Thanks @spwlyzx. (#946)
- `codegraph status` no longer reports phantom pending changes for files CodeGraph deliberately keeps out of the index — a tracked file inside a committed dependency dir (a checked-in `vendor/` or `node_modules/`), or a tracked file under a `.gitignore`d directory. A full index correctly excludes these, and `codegraph sync` never indexes them, but the fast change-detector behind `codegraph status` flagged every edit to such a file as "modified" (and a new one as "added") — so the pending-changes count never cleared no matter how many times you synced. Change detection now applies the same ignore rules the full index does, so `status` agrees with `sync`, and tools built on CodeGraph's change-detection API get the same corrected list. (#766)
- Files reached through a symlinked directory that points outside your project now index instead of being silently skipped. When a folder in your repo is a symlink to a location outside the repo root — the standard layout for Dota 2 custom games and similar SDK-linked projects, where `game/` and `content/` link into the engine tree — CodeGraph followed the symlink to discover those files but then blocked every one of them while reading, logging `Path traversal blocked in batch reader` and indexing nothing under them. The reader now agrees with the directory scan and indexes those files. The guard against `../` path escapes is unchanged, and the protection that keeps your agent from being served file contents from outside the repo is also unchanged — only the indexer's own read path follows these in-repo symlinks. (#935)
## [1.0.1] - 2026-06-13
### New Features
- New `codegraph daemon` command (alias `daemons`) — an interactive manager for the background daemons. It shows what's running (your current project's daemon first, pre-selected), and you arrow-key to one and press enter to stop it, or pick "Stop all". Previously the only way to shut a daemon down was to hunt for its pid and `kill` it by hand. (#845)
- Checking your installed version is now easy to reach however you guess at it: `codegraph version`, `codegraph -v`, and `codegraph -version` all print it, alongside the existing `codegraph --version`. (#864)
- The CodeGraph MCP server now self-heals if its main thread ever locks up. A lightweight watchdog notices when the process has stopped responding and stops it so a fresh one starts on your next request — it can no longer sit pinned at 100% CPU with no way to recover. Tune the detection window with `CODEGRAPH_WATCHDOG_TIMEOUT_MS`, or turn it off entirely with `CODEGRAPH_NO_WATCHDOG=1`. (#850)
### Fixes
- Git worktrees nested inside your project — like the `.claude/worktrees/` that Claude Code creates — are no longer indexed as duplicate copies of your whole codebase. CodeGraph deliberately indexes genuine embedded repos (a real second project checked out inside yours), but a worktree is just another working view of a repo it already indexed, so each one was multiplying every symbol — one report went from ~1,850 files to over 24,000, with search and `explore` flooded by stale duplicates. CodeGraph now recognizes worktrees and skips them, while still indexing real embedded repos and submodules. Thanks @tphakala. (#848)
- Running `codegraph serve --mcp` by hand no longer just hangs in silence. That command is the MCP server your AI agent starts for itself — not a step you run directly — and in a terminal it used to sit there waiting for input that never comes, looking broken. It now recognizes when a person runs it and explains what to do instead (`codegraph status`, `codegraph daemon`), and it's been dropped from the command listing so it stops looking like something you need to launch.
- Cross-file static method calls like `ClassName.staticMethod()` now resolve correctly. CodeGraph was linking the call to the *class* instead of the method (and recording it as a construction), so `callers` and `impact` for a static method came back empty — a real blind spot in TypeScript and JavaScript codebases that lean on static utility classes (Python and other languages with the same call shape benefit too). The call now links to the method itself. Thanks @contextFlow-lab. (#825)
- `codegraph affected` now accepts `./`-prefixed and absolute file paths, not just bare project-relative ones. Passing `./src/x.ts` or an absolute path — common when the file list comes from another tool — used to silently match nothing and report no affected tests. Thanks @contextFlow-lab. (#825)
- The CodeGraph MCP server no longer risks getting stuck at 100% CPU after an unexpected internal error. Previously such an error was logged but the process was left running in a broken state, where it could spin a CPU core indefinitely and had to be killed by hand. The server now logs the error and exits cleanly, so a fresh one starts on the next request. Thanks @songhlc. (#850)
- CodeGraph no longer indexes your entire home directory by accident. Running the installer — or `codegraph init` / `codegraph index` — from your home folder or a filesystem root would index everything underneath it (caches, `Library`, every other project), producing a multi-gigabyte index and constant file-watching churn. CodeGraph now refuses these roots and points you at a specific project instead; pass `--force` if you genuinely mean to. (Combined with the macOS file-descriptor fix already in 1.0.0, this closes the report of a runaway watcher exhausting the system file limit.) Thanks @ligson. (#845)
## [1.0.0] - 2026-06-12
### Security
- Closed a path-traversal hole where a symbolic link inside an indexed project that pointed *outside* the project root could make CodeGraph serve that out-of-root file's contents (for example a file under your home directory) to the AI agent. CodeGraph now resolves symlinks when validating file access and refuses to read anything whose real location is outside the project, while still allowing symlinks that stay within it. Thanks @sulthonzh. (#527)
- CodeGraph now indexes Spring configuration files (`application.properties` / `application.yml`) by key only, and never includes their values in `codegraph_explore` or `codegraph_node` output. Previously a secret committed to one of these files — a database password, API key, or connection string with embedded credentials — could be surfaced to an AI agent that asked about nearby code, even though the agent never opened the file. The configuration keys are still indexed, so reference and impact analysis are unaffected; an agent that genuinely needs a value reads the file itself. Shopify Liquid `{% schema %}` blocks are likewise indexed by name only. (#383)
### New Features
- **CodeGraph now indexes R** (`.R` / `.r`) — functions in every assignment form (`name <- function(...)`, `name = function(...)`, nested definitions), S4 / Reference / R6 classes with their methods, `setGeneric`/`setMethod` generics, top-level variables and constants, `library()` / `require()` imports, `source()` file references, and call edges — including calls inside tidyverse pipe chains. Statistical and research codebases get the full explore / impact / callers surface. (#828) (R)
- **Workspaces holding multiple git repositories now index as a whole.** Running `codegraph init` at the root of a directory that contains several independent git repos — including the common "super-repo" layout where the parent repo's `.gitignore` hides the child repos to keep `git status` quiet — now indexes every nested project into one graph, with each child repo's own `.gitignore` respected. `codegraph sync` and live file watching pick up changes inside the nested repos too (previously change detection only consulted the parent repo, so edits in child repos were invisible until a full re-index). Git repositories inside `node_modules` (npm git-dependencies) remain excluded. (#514)
- **`codegraph_explore` now explains where a flow ends instead of going silent.** When the symbols you ask about don't connect statically — because the code dispatches through a runtime mechanism like a computed call (`handlers[action.type](...)`), Python's `getattr`, a command/mediator bus (`sender.Send(new DeleteCommand(...))`), reflection, or `new Proxy` — explore now announces the exact dispatch site (file and line) where the static path stops, and when the dispatch key is visible in the source it shortlists the likely runtime targets (for example pointing a MediatR command straight at its `Handler.Handle` method). Detection is deterministic and runs only when a flow fails to connect; fully connected flows are unchanged, and nothing about indexing or the graph itself changes. Relatedly, a custom event bus whose emit and handler connect through a single synthesized hop now shows that hop explicitly (with the registration site) — it previously rendered nothing because the connection was "too short" for the flow section. (#687)
- **Anonymous usage telemetry, documented field-by-field and easy to turn off.** CodeGraph now collects a small set of anonymous usage statistics — which commands and MCP tools get used, which languages get indexed, which agents connect — so language and agent support work goes where real usage is. Never any code, file paths, file or symbol names, search queries, or IP addresses; usage aggregates locally into daily totals before anything is sent, and the ingest endpoint is public, auditable code in the repository that enforces the documented field list. The installer asks up front with a visible default-on toggle (and never re-asks); everywhere else a one-line notice prints before the first send. Disable any time with `codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, or the cross-tool `DO_NOT_TRACK=1` standard — off means off: nothing is recorded, nothing is sent, and buffered data is deleted. `TELEMETRY.md` documents every field.
- **Subagents and non-MCP agents can now reach CodeGraph.** Two new CLI commands — `codegraph explore "<symbols or question>"` and `codegraph node <symbol-or-file>` — print exactly what the matching MCP tools return (relevant symbols' source + call paths; one symbol's source + callers; file reads with line numbers), so any agent with a shell can use the graph. And `codegraph install` now writes a small marker-fenced CodeGraph section into each agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) pointing at both surfaces — that file is what Task-tool subagents actually see, where the MCP server's own guidance only reaches the main agent. Measured on a delegated code-exploration task: subagents went from almost never using CodeGraph (~1 in 9 runs) to using it in every run, including runs with zero grep/file-reading fallback. The section is small, survives your own content, upgrades cleanly from the old long block, and `codegraph uninstall` removes it. Thanks @liuyao37511. (#704)
- **The MCP tool list is now a focused default of four** — `codegraph_explore`, `codegraph_node`, `codegraph_search`, and `codegraph_callers`. The other four (`codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) remain fully functional — the CLI and library API are unchanged, and `CODEGRAPH_MCP_TOOLS` re-enables any of them — but they're no longer listed to agents by default: measured agent behavior shows they're never or rarely picked, and the information they carry already arrives inline on the tools agents do use (explore's blast-radius section, node's dependents note, a symbol's own body as its callee list). A leaner list saves context tokens every session and steers agents to the right tool by presence alone.
- **CodeGraph now goes quiet instead of failing loudly in unindexed projects.** When an AI agent's session starts in a workspace that has no CodeGraph index, the MCP server now announces itself as inactive with a short note and lists no tools at all — instead of presenting the full toolset and erroring on every call, which taught agents to distrust CodeGraph even where it works. Querying another project that isn't indexed likewise returns clear guidance (use your regular tools for that codebase; the user can run `codegraph init` there to enable CodeGraph) instead of an error, and genuine internal errors now tell the agent to retry once rather than give up on CodeGraph entirely. Indexing stays your decision — agents are told not to run it themselves. (#769)
- **Astro projects are now indexed.** `.astro` files previously weren't parsed at all — on a typical Astro site that left most of the codebase invisible to search, impact, and `codegraph_explore`. CodeGraph now extracts the TypeScript frontmatter (functions, imports, `getStaticPaths`, …) and client-side `<script>` blocks, captures function calls and `<Component>` usages in template markup so cross-component dependencies trace end-to-end, resolves the `Astro` global and `astro:*` module imports as framework-provided, and maps `src/pages/` file-based routing to route nodes (`.astro` pages and `.ts` endpoints, including `[param]` and `[...rest]` dynamic segments, with underscore-prefixed files correctly excluded). Validated on two real-world Astro sites with 93% measured cross-file coverage and every page mapping to its route. Thanks @xingwangzhe. (#768) (Astro)
- Same-named symbols across a monorepo's apps are no longer conflated. In a NestJS-style workspace with one `UserService` per app, `codegraph_callers`, `codegraph_callees`, and `codegraph_impact` now report **one section per distinct definition** — each app's callers and blast radius under its own file-labeled heading — instead of a single merged list, and accept a `file` argument to focus exactly the definition you mean (like `codegraph_node` already did). Impact in particular no longer overstates a change's blast radius by merging unrelated same-named classes. Thanks @Igorgro. (#764)
- Fixed a related source of cross-package wrong edges: PascalCase **type references from plain `.ts` files were being resolved as React components**, which could link a file's own type alias to an arbitrary same-named class in another package (on one large monorepo this produced over a thousand wrong cross-package reference edges; 96% are now gone, and the remainder are genuine shared-model imports). Component resolution now applies only to references from JSX-capable files and never guesses between multiple candidates without a positional signal. The **Svelte and Vue component resolvers had the same arbitrary-pick flaw** (Vue resolved the first same-named `.vue` file found anywhere in the tree) and now follow the same rule: same-directory first, otherwise only an unambiguous name resolves. Re-index a project to benefit. (#764) (TypeScript, React, Svelte, Vue)
- TypeScript and JavaScript **class fields are now reported as properties instead of methods**. A plain field like `public fonts: Fonts;` previously extracted as a method, misrepresenting class shape and letting calls to same-named functions resolve to data fields (a boolean field named `isArray` was soaking up `Array.isArray(...)` call edges). Fields holding arrow functions or function expressions (`onClick = () => {…}`, including wrapped ones like `onScroll = throttle(() => {…})`) correctly remain methods and their bodies are still analyzed. Field initializers are analyzed too, so `history = createHistory()` records its call — and JavaScript class fields, which previously produced no symbol at all, now appear in the graph. Re-index a project to benefit. (#808) (TypeScript, JavaScript)
- Callback registration through `this` now resolves precisely in TypeScript and JavaScript: `window.addEventListener("online", this.onOfflineStatusToggle)` or an API object like `{ mutateElement: this.mutateElement }` produces a reference edge to the **enclosing class's own method** — never a same-named method on an unrelated class, and never a data field. Builds on the callback-registration support below. (#808) (TypeScript, JavaScript)
- Callback-registration coverage deepened across four more shapes: a `this.<member>` registration whose method lives on a **base class** now resolves through the inheritance chain (`bus.on("submit", this.handleSubmit)` in a subclass links to the parent's `handleSubmit`); Java and Kotlin **method references to other classes** (`Handlers::onMessage`, `OtherClass::handle`) resolve across files, with `this::` and `super::` scoped to the defining class and references through a variable deliberately left out; and Swift bare callback names now match only the **enclosing type's** methods (implicit `self`), eliminating a class of wrong edges where a parameter like `request` linked to a same-named method on an unrelated type. (Java, Kotlin, Swift, TypeScript, JavaScript)
- PHP **string and array callables** now register: a string passed to a callable-taking core function (`usort($items, 'cmp_items')`, `array_map('absint', …)`, `call_user_func`, `spl_autoload_register`, …) links to that function — including across files — and the array forms `[$this, 'method']` and `[Foo::class, 'method']` link to the named method (the `$this` form resolves through the class and its parents). Strings passed to arbitrary functions are deliberately ignored: only known callable positions are trusted. Validated on WordPress core (+556 edges, every sampled edge a genuine registration). (PHP)
- Ruby **lifecycle-hook symbols** now register: `before_action :authenticate`, `after_save :reindex`, `around_create`, `validate :check`, `rescue_from(…, with: :handler)` and friends link the symbol to the method it names — on the class itself or **inherited from a parent** (`before_action :authenticate` in a controller resolves to `ApplicationController`'s method). `validates` (plural) is excluded since its symbols name attributes, not methods. Validated on rails/rails (+385 edges, every sampled edge genuine). (Ruby)
- Method references to a type that needed **no import** now resolve: Java/Kotlin same-package references (`.concatMapMaybe(Maybe::just, …)`), **Kotlin companion-object members** (`KtHandlers::handle`), and cross-file C++ member pointers (`&TestSuite::RunSetUpTestSuite`). Resolution stays anchored to the named type, so a same-named member on a different class never matches. (Java, Kotlin, C++)
- CodeGraph now sees where a function is **registered as a callback**, not just where it's called. A function name passed as an argument (`signal(SIGINT, handler)`, `qsort(…, compare)`, `addEventListener(…, onBlur)`), assigned to a function pointer or field (`ops->recv_cb = my_cb`, `OnClick := Handler`), or placed in a struct initializer or handler table (`{ .recv_cb = my_cb }`, `{ "get", getCommand }`) now produces a reference edge from the registration site to the function — so `codegraph_callers` and `codegraph_impact` surface callback wiring that previously looked like dead code. Works across all supported languages, including the language-specific forms: C/C++ `&fn`, Java `Class::method`, Kotlin `::fn`, Swift `#selector`, Objective-C `@selector`, Ruby `method(:fn)`, Scala eta-expansion, and Delphi/Pascal `@Handler` and `OnClick := Handler` event wiring. Callers output labels these "via callback registration". Resolution is deliberately conservative: an ambiguous name produces no edge rather than a wrong one. Re-index a project to benefit. Thanks @zmcrazy. (#756)
- The `codegraph_node` MCP tool can now **read a whole source file like the built-in Read tool — only faster, served from the index**. Pass a file path with no symbol and it returns that file's current source with line numbers (the same `<n>⇥<line>` shape Read produces, so an assistant can edit straight from it), narrowable with `offset`/`limit` exactly like Read, plus a one-line note of which files depend on it (the file's blast radius). Use it anywhere you'd reach for Read on an indexed source file. Pass `symbolsOnly: true` for just the file's structure. Configuration/data files (`.yml` / `.properties`) are summarized by key only, never dumped, so secrets in them are never surfaced. The agent-facing guidance was also retuned so assistants reach for codegraph while *implementing* a change (not only when answering questions), since one codegraph call returns the same bytes plus the blast radius, faster than re-reading the file.
- New `codegraph upgrade` command updates CodeGraph to the latest release in place — it detects how you installed (the standalone `install.sh` / `install.ps1` bundle, npm, or npx) and does the right thing for each, on macOS, Linux, and Windows. Use `codegraph upgrade --check` to see whether an update is available without installing, or `codegraph upgrade <version>` to move to a specific version. After upgrading it reminds you to re-index your projects so they pick up the newer engine's improvements. (#679)
- `codegraph status` now flags when a project's index was built by an older engine than the one you're running and recommends re-indexing (also surfaced in `codegraph status --json`), so you know when a `codegraph index -f` or `codegraph sync` will add coverage a newer release introduced.
- Cross-file impact and blast-radius coverage now spans **all 22 supported languages and 14 web frameworks**, each validated on a real-world repo — see the new coverage table in the README. This release ships the cross-file resolution behind it, including Lua and Luau `require`, Shopify OS 2.0 Liquid section templates, Delphi form code-behind, Rust cross-module calls and Rocket route macros, Swift Fluent relationships, and the SvelteKit / Nuxt / Vapor / Axum route conventions. The residual everywhere is genuine static-analysis frontiers (runtime dispatch, reflection / DI, framework-convention entry points), never hidden.
- C# `record struct` and `readonly record struct` declarations now index with the correct `struct` kind, and positional one-liner records (`public record struct Money(decimal Amount);`) index reliably in every form — previously a bodiless value-type record could be skipped entirely and could halt extraction of the declarations following it in the same file. (#831) (C#)
- C# types are now tracked by their namespace-qualified name. Same-named types in different namespaces — a domain entity and a DTO both called `CatalogBrand`, say — are told apart instead of collapsing into one arbitrary match, so a reference resolves to the right one and impact no longer conflates them. (C#)
- ASP.NET Razor (`.cshtml`) and Blazor (`.razor`) markup are now parsed for code relationships. A `@model` / `@inherits` / `@inject` directive links the view to the C# view-model, base type, or service it names; a Blazor `<MyComponent/>` tag (plus `@typeof(...)` and generic `TItem="..."` arguments) links to the component class; and the C# inside `@code { }` / `@functions { }` / `@{ }` blocks is analyzed too, so services and types used in component logic are linked. A view-model, component, or service referenced only from markup is no longer reported as having no dependents, and editing it surfaces the views that use it. (ASP.NET, Blazor)
- A Razor/Blazor type reference now resolves through the component's `@using` namespaces — including the folder's cascading `_Imports.razor` — so a simple name that exists in several namespaces lands on the right one. A `@model` / `<MyComponent>` / `@code` reference to `CatalogBrand` resolves to the `@using`'d DTO (`BlazorShared.Models.CatalogBrand`) rather than a same-named domain entity. (ASP.NET, Blazor)
- `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329)
- TypeScript service/RPC contracts defined as a tuple of generic types — `type MyServiceList = [Service<'query_apply_record', …>, Service<'apply_confirm', …>]` — now index each entry's string-literal name as a searchable symbol. Previously these names existed only as type arguments, so `codegraph query query_apply_record` found nothing even though the names are the app's primary API surface. The pattern is common in typed RPC / BFF clients and mock servers where the types are the source of truth for a runtime proxy object. Utility types (`Pick`, `Omit`, `Record`) and route paths are deliberately left out to avoid noise. Thanks @jiezhiyong. (#634) (TypeScript)
- New `CODEGRAPH_DIR` environment variable sets the per-project index directory name (default `.codegraph`). This lets one working tree hold two independent indexes — most usefully when you open the same checkout from both **Windows** and **WSL**, which can't safely share a single `.codegraph/`: the background-server lock and the SQLite database are tied to the OS that wrote them, and SQLite locking across the WSL2/Windows filesystem boundary is unreliable. Set `CODEGRAPH_DIR=.codegraph-win` on the Windows side, leave WSL on the default, and each keeps its own index in the same folder without clobbering the other. CodeGraph also skips any sibling `.codegraph-*` directory when indexing and watching, so neither environment trips over the other's data. Thanks @rrtt2323. (#636)
### Fixes
- **opencode on Windows now finds CodeGraph.** The installer wrote opencode's global MCP entry to `%APPDATA%\opencode\`, but opencode reads its config from `~/.config/opencode/` on every platform (honoring `XDG_CONFIG_HOME`), so the entry was invisible to it. Installs now write where opencode actually looks, and `codegraph install` / `codegraph uninstall` both clean a stale CodeGraph entry out of the old `%APPDATA%` location — other servers and comments in that file are left untouched. Thanks @fucknoobhanzo for the report and @WodenJay for the first patch. (#535)
- The `codegraph_search` tool's `kind: "type"` filter — a value its own schema advertises — silently matched nothing; it now correctly finds type aliases. The `codegraph_explore` tool's parameter guidance also no longer suggests running `codegraph_search` first, which contradicted explore's call-it-first design and cost agents an extra round-trip.
- Symbols defined in Svelte and Vue `<script>` blocks were reported one line below where they actually are — a function on line 3 was reported at line 4 — which offset every script-block symbol's location in search, `codegraph_node`, and explore output. Line numbers now match the file exactly. Re-index a project to benefit. (Svelte, Vue)
- Doc comments are now captured for exported, `const`-assigned, and decorated declarations, and the documentation a symbol carries is now clean across every supported language. Previously a comment above `export class X`, `export const fn = () => …`, a plain `const fn = () => …`, or a decorated Python `def`/`class` (`@app.route(...)`, `@dataclass`) was dropped entirely — only comments directly above a plain declaration were kept. CodeGraph now finds the comment through the `export` / `const` / decorator wrapper. Comment-marker cleanup was also rounded out for every language CodeGraph supports: Rust/Swift/Kotlin doc lines (`///`, `//!`), Python/Ruby/shell `#`, Lua/Luau (`--` and `--[[ ]]`), and Pascal (`{ }` and `(* *)`) no longer leave stray markers in the stored text — validated end-to-end across all 19 code languages plus Svelte/Vue `<script>` blocks. (#780). Thanks @caleb-kaiser.
- Go method calls made through a chained factory function now resolve to the correct type. A call like `New().Method()` used to drop the receiver, so the chained method attached to a same-named method on an unrelated type — or didn't resolve. CodeGraph now captures Go return types (a pointer `*Foo` resolves to `Foo`, and a multi-return `(*Foo, error)` to its first result), infers the chained receiver's type from what the factory function returns, and resolves the method on it — including methods promoted from an embedded struct — creating the edge only when the type or an embedded type genuinely has the method. Existing Go indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Go)
- Scala method calls made through a companion-object factory, a fluent chain, or a case-class `apply` now resolve to the correct type. A call like `Foo.create().bar()` or `Builder(cfg).bar()` used to drop the receiver, so the chained method silently attached to a same-named method on an unrelated type — most often mis-attributing a standard-library `Option` / `Iterator` `.map` / `.flatMap` / `.foreach` onto your own same-named class. CodeGraph now captures Scala return types (a generic `List[Foo]` resolves to its container `List`, a qualified `pkg.Foo` to `Foo`), infers the chained receiver's type from what the inner call returns or constructs, and resolves the method on it — including methods inherited from a trait the type extends — creating the edge only when that type or one of its traits genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Existing Scala indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Scala)
- Rust method calls made through a chained associated function now resolve to the correct type. A call like `Foo::new().bar()` or `Foo::with(cfg).build()` used to drop the receiver, so the chained method silently attached to a same-named method on an unrelated type — or didn't resolve. CodeGraph now captures Rust return types (`-> Self` resolves to the implementing type), infers the chained receiver's type from what the associated function returns, and resolves the method on it — including methods provided by a trait the type implements (via the new `impl Trait for Type` relationships) — creating the edge only when the type or one of its traits genuinely has the method. Existing Rust indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Rust)
- Dart method calls made through a static factory, a factory or named constructor, or a fluent chain now resolve to the correct type. A call like `Foo.create().bar()` used to drop the receiver, so the chained method silently attached to a same-named method on an unrelated type — most often mis-attributing a standard-library `Option` / `Iterator` `.map` / `.where` onto your own same-named class. CodeGraph now indexes Dart **factory and named constructors** (`factory Foo.create()`, `Foo.named()`) as first-class members so calls to them resolve, captures Dart return types (a generic `List<Foo>` resolves to its container `List`), infers the chained receiver's type from what the inner call returns or constructs, and resolves the method on it — including methods inherited from a superclass or mixin — creating the edge only when that type genuinely has the method. Plain construction (`Foo(...)`) is still recorded as instantiation. Existing Dart indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Dart)
- Objective-C methods called through a chained message send now resolve to the correct class. A call like `[[Foo create] doIt]` used to drop the receiver, so `doIt` silently attached to a same-named method on an unrelated class — most often a test helper or stdlib class. CodeGraph now captures Objective-C method return types and infers the chained receiver's type from what the inner message returns. For the ubiquitous `[[X alloc] init]` and singleton (`[[X sharedInstance] …]`) patterns — where the factory returns `instancetype` — the receiver is the class `X` itself, so the chained method resolves on `X` (including methods inherited from a superclass), creating the edge only when the class genuinely has the method. Existing Objective-C indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Objective-C)
- Pascal/Delphi methods called through a chained factory call now resolve to the correct class. A call like `TFoo.GetInstance().DoIt()` used to drop the receiver, so `DoIt` silently attached to a same-named method on an unrelated class. CodeGraph now captures Pascal return types and infers the chained receiver's type from what the factory function returns — resolving to the declared type (including an interface return like `IFoo`), and for a constructor (`TFoo.Create().…`) or a typecast (`TFoo(x).…`) to the class `TFoo` itself, since both yield a `TFoo`. The edge is created only when that type genuinely has the method (so a wrong inference produces no edge). Existing Pascal/Delphi indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Pascal/Delphi)
- Pascal/Delphi **paren-less method calls are now tracked**. Pascal lets a no-argument method or procedure drop its parentheses (`Obj.Free;`, `List.Clear;`, `TFoo.GetInstance.DoIt;`), which previously weren't recorded as calls at all — so callers, impact, and trace missed them. CodeGraph now extracts these, scoped to statement position so a field or property access (which looks identical) is never mistaken for a call. On a real Delphi codebase this added ~1,100 previously-missing call edges with no false positives. Existing Pascal/Delphi indexes should be re-indexed (`codegraph index -f`) to benefit. (Pascal/Delphi)
- Pascal/Delphi calls inside a **standalone procedure or function** (one with no `interface` declaration, defined only in the `implementation` section) are now attributed to that routine instead of the whole file. Previously such a routine had no symbol of its own, so everything it called was lumped under the unit — `codegraph_callers` returned the file, and impact couldn't tell which routine was responsible. These routines are now indexed and their calls attributed correctly. Existing Pascal/Delphi indexes should be re-indexed (`codegraph index -f`) to benefit. (Pascal/Delphi)
- Chained method calls now resolve when the chained method is **inherited from a superclass or declared on an interface/protocol** the receiver's type conforms to — for example a call on a sealed-subclass instance (`Either.Right(x).combine(...)`) that invokes a method defined on its parent type. Previously these chains found no caller edge even though the factory's type was known, so the call was invisible to callers, impact, and trace. CodeGraph now walks the type's supertypes (its `extends` / `implements` relationships) to find the method, creating the edge only when a supertype genuinely declares it (so a wrong inference still produces no edge). This makes Java, Kotlin, and C# factory and fluent chains more complete. Existing indexes should be re-indexed (`codegraph index -f`) to benefit. (#750)
- Swift method calls made through a static factory, fluent chain, or constructor now resolve to the correct class. A call like `Foo.make().draw()` or `Foo().draw()` used to drop the receiver, so the chained method silently attached to a same-named method on an unrelated class — or didn't resolve at all. CodeGraph now captures Swift return types and infers the chained receiver's type from what the inner call returns (or the constructed type), creating the edge only when that class genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Existing Swift indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Swift)
- C# method calls made through a static factory or fluent chain now resolve to the correct class. A call like `Foo.Create().Bar()` or `JObject.Parse(s).Property(...)` used to lose the receiver's type, so the chained method didn't resolve and the call was invisible to callers/impact/trace. CodeGraph now captures C# return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge). Existing C# indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (C#)
- Kotlin method calls made through a companion-object factory or fluent chain now resolve to the correct class. A call like `Foo.getInstance().bar()` or `Config.create(opts).build()` used to drop the receiver entirely, so the chained method silently attached to a same-named method on an unrelated class — or didn't resolve at all — corrupting callers, impact, and trace. CodeGraph now captures Kotlin return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Existing Kotlin indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Kotlin)
- Java method calls made through a static factory or fluent chain now resolve to the correct class. A call like `Foo.getInstance().bar()` or `Config.create(opts).build()` used to lose the receiver's type, so when two classes had a same-named method the call silently attached to whichever was indexed first — or didn't resolve at all — corrupting callers, impact, and trace. CodeGraph now captures Java return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Covers factories and fluent builders that take arguments (`hashKeys().arrayListValues()`), including builders that return a nested type. Existing Java indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Java)
- PHP: a method called through a chained static factory — `Cls::for($x)->method(...)`, the canonical Laravel per-credential / per-tenant client idiom — now records a caller edge. Previously the receiver type (what `for()` returns) was never recovered, so `codegraph_callers` returned nothing for the method and the call was invisible to `codegraph_impact`. CodeGraph now captures PHP return types — `: self` / `: static` resolve to the declaring class, `: SomeClass` to that class — and resolves the chained method on the factory's result, creating the edge only when that class actually has the method (so a wrong inference produces no edge). Existing PHP indexes should be re-indexed (`codegraph index -f`) to benefit. Thanks @cvanderlinden. (#608) (PHP)
- Search relevance: including the project name in a query (a user naturally writes `MyApp backend routes`) no longer buries the part of the codebase the query is actually about. The project name lexically matches whatever stack embeds it — a `MyAppFrontend/` directory, a `MyAppApp` class — and it was over-weighted two ways: a single PascalCase word was scored once per sub-token (`my` / `app` / `myapp`), so one concept boosted that path several times over; and the name carried full path / disambiguation weight even though it names the whole repo, not any symbol. Now path relevance counts each query word once, and a word matching the project name (derived from `go.mod`, `package.json`, or the repo directory) is dropped from path scoring and from `codegraph_explore`'s type-disambiguation bias — unless it's the only term, so a bare project-name search still works. In a mixed-stack repo, a backend question now surfaces the backend even with the project name in the query. Thanks @MiNuo1. (#720)
- Go: a function called only from inside an anonymous closure — a cobra `RunE: func(…) {…}` handler, a goroutine literal, or a callback closure stored in a package-level `var` — now shows its real caller. Previously the call leaked to the file node, so `codegraph_callers` and `codegraph_impact` reported such a function as having no meaningful caller; the call is now attributed to the enclosing declaration, so editing the function surfaces the closures that use it. Existing Go indexes should be re-indexed (`codegraph index -f`) to benefit. Thanks @Cyclone1070. (#693) (Go)
- Indexing no longer aborts when a `.gitignore` contains non-UTF-8 bytes or an unparseable pattern. A `.gitignore` transparently encrypted in place by corporate DLP / endpoint-security software (a common enterprise scenario) — or one with a stray pattern the matcher can't compile (`\[`, producing "Unterminated character class") — used to crash the entire `sync` / `index` with a screen of garbled bytes and never name the offending file, leaving `Files: 0 / Nodes: 0`. CodeGraph now skips a `.gitignore` that isn't valid UTF-8 text whole, drops only the individual unparseable patterns from a text one, and logs a warning naming the file — indexing continues either way. Thanks @zhanghang-9527. (#682)
- C++ method calls made through a singleton, factory, or chained getter now resolve to the correct class. A call like `Foo::instance().bar()`, `WidgetFactory::create().draw()`, `openSession()->run()`, or the same stored in an `auto` local first, used to lose the receiver's type — so when two classes had a same-named method the call silently attached to whichever was indexed first (or didn't resolve at all), corrupting callers, impact, and trace. CodeGraph now infers the receiver's type from what the inner call returns (capturing C++ return types for the first time) and creates the edge only when that class genuinely has the method, so a wrong guess produces no edge instead of a misleading one. Covers singletons and self-returning accessors, factories that return a different type, free-function factories, `make_unique` / `make_shared` / `new` / direct construction, and single-level member chains. Existing C/C++ indexes should be re-indexed (`codegraph index -f`) to benefit. Thanks @stabey. (#645) (C/C++)
- The shared background server no longer logs a scary-looking `[error] … undefined` line on every session start. Attaching to the shared daemon is normal, healthy behavior, but the informational message was being surfaced by MCP hosts (Claude Code and others) as an error; it's now silent by default — set `CODEGRAPH_MCP_LOG_ATTACH=1` to surface it when debugging daemon attach. Thanks @mturac. (#618)
- On Windows, CodeGraph's background processes no longer pile up without bound and saturate CPU over a long session. When the editor or agent that launched CodeGraph exited, its helper process couldn't tell its parent had gone — Windows reports process lineage differently than macOS and Linux — so the helper kept running, the shared background server never saw the client disconnect, and its idle timer never fired to shut it down. CodeGraph now detects parent-process exit directly on Windows, so helpers and the idle background server wind down promptly, the same as they already did on macOS and Linux. (#692, #576, #680)
- The MCP server now shuts down cleanly when its editor/agent connection drops abruptly, instead of risking an orphaned process that pins a CPU core. Editors talk to a stdio MCP server over a socket; if that socket failed with an error rather than closing cleanly — which can happen when the editor window is reloaded or the launching process is killed — the server didn't treat it as a disconnect and could be left running. CodeGraph now treats any failure of its input stream as a shutdown signal and tears the stream down, so an orphaned server exits promptly. (#799)
- The shared background server has two further safeguards against ever lingering: it now drops a client the moment it detects that client's process is gone (even if the disconnect arrived uncleanly — a force-quit or a dropped connection that never closed the socket), and it won't stay running indefinitely with clients attached but no activity. Together these guarantee it always winds down, on every platform. (#692)
- A session no longer loses CodeGraph when the shared background server is restarted out from under it — for example when your MCP host (opencode and others) stops and restarts the server as you open another session. Previously the affected session's connection died silently and any request in flight at that moment hung; now CodeGraph keeps that session working by serving it locally, so the tools stay available without restarting the session. (#662)
- React Native native→JS events now connect through the common `sendEvent(context, "X", body)` wrapper. Many libraries (react-native-device-info and others) wrap the event emitter behind a helper whose `.emit(eventName, …)` takes a *variable*, so the matcher — which looked for `.emit("literal", …)` — missed it; the literal event name actually lives in the wrapper call. Now a native method that fires `sendEvent(…, "batteryLevelChanged", …)` links to the JS `addListener('batteryLevelChanged', …)` handler, so editing the native emitter surfaces the JS subscriber. (React Native)
- React Native / Expo cross-language bridges are more complete and more precise. An Expo Module method declared with a generic type — Android's `AsyncFunction<Float>("getBatteryLevelAsync")` — is now indexed (the `<Float>` used to defeat the matcher, so every Android Expo method was dropped and a JS call resolved only to the iOS Swift impl). The iOS and Android implementations of the same JS-visible method — both Expo Modules and classic NativeModules (`@ReactMethod` on Android, the matching method on iOS) — are now linked to each other, so a JS call that resolves to one platform still reaches the other and editing either platform's native code surfaces the JS caller. And a `Type.member` static read in native code (e.g. Android's `BatteryManager.EXTRA_LEVEL`) no longer falsely links to a coincidentally same-named class in another language (a web `BatteryManager`) — type references stay within a language family, while genuine cross-language bridges (config→code, JS↔native calls) are unaffected. (React Native, Expo)
- A TypeScript/JavaScript reference or import no longer gets mis-linked to a same-named class in a native language. In a React Native / Expo repo that has both a TypeScript `TestRunner` type and a Kotlin `TestRunner` class, a TS reference to `TestRunner` — or an `import React` sitting next to a Swift `React` — used to resolve onto the native symbol (the component resolver matched any same-named class regardless of language, and import statements weren't language-checked at all). References and imports now stay within their language family, so they land on the right symbol while genuine cross-language bridges (JS↔native calls, config→code) are untouched. A C/C++ `#include "Foo.h"` likewise no longer resolves to a same-named header from another platform (an iOS Objective-C `Foo.h`). (React Native, Expo, TypeScript, C/C++)
- Native includes and Kotlin Multiplatform imports now resolve to the correct file in multi-platform projects. A C/C++ `#include "Foo.h"` now resolves to the header in the including file's own directory first (the C quoted-include rule), so when a module ships a same-named header per platform (a Windows, an Apple, and an Android `Foo.h` side by side) the local one correctly shows its dependents instead of an arbitrary other-platform header looking like the dependency. And a Kotlin Multiplatform `expect` declaration is no longer reported as having no dependents: a `commonMain` import now resolves to the `commonMain` `expect` (matched within the importing source set) rather than being absorbed by one platform's `actual`. (C/C++, Kotlin)
- `codegraph affected` now reports the tests and files that actually depend on your changes. It used to follow only `import` statements — but those never cross file boundaries in CodeGraph's graph — so it returned **no affected tests for any change, in every language**. It now traces the real cross-file usage graph (calls, references, instantiations, and class `extends` / `implements`), so `git diff --name-only | codegraph affected` surfaces the test files that exercise the changed code. Circular-dependency detection, which had the same blind spot, now works too.
- Blast radius, callers, and `codegraph affected` now recognize far more of the dependencies that were already in your code. A symbol now counts as a dependency whether it's called, used only in a type annotation inside a function body (`const items: Foo[] = []`), imported and placed in a registry array or passed as an argument, used as a JSX component, simply re-exported from a barrel (`export { X } from './x'`), or pulled in as a namespace (`import * as ns from '@/x'`) — including through tsconfig path aliases like `@/`. Previously only called, instantiated, or signature-typed symbols created a cross-file link, so a file that used a dependency in any other way could look like it depended on nothing — and the file that defined a widely-used symbol could look like nothing depended on it. The graph still indexes exactly the same symbols; it just connects the ones that were already there. (TypeScript/JavaScript)
- The same completeness fix now applies to **Python**: a name brought in with `from module import X` is recorded as a dependency on that module even when `X` is only stored in a list/dict, passed as an argument, used as a decorator, or re-exported through an `__init__.py`. Previously Python linked only imports that were called or instantiated, so a module consumed purely by value — or only re-exported — looked like nothing depended on it.
- `codegraph_callers` (and the `callers` command) now lists the places a class is **instantiated**, not just where it's imported. Constructing a class — `Foo(...)` in Python, `new Foo()` elsewhere — is calling its constructor, so asking who calls a class now returns the construction sites, and `codegraph_callees` / trace cross the instantiation the same way. Previously a class's instantiation sites were invisible to `callers`, so "what breaks if I change this class?" could come back empty even when the constructor was called from many places. Works on your existing index — no re-index needed. (#774)
- Rust impact and `codegraph affected` now connect far more of the module graph. Struct literals (`Widget { n: 1 }`) are recorded as instantiations; a `use` / `pub use` brings its item into the dependency graph — so a `pub use` re-export hub (a `mod.rs` re-exporting its submodules) depends on the modules it re-exports — resolved by Rust module path (`crate::`/`self::`/`super::`), so a re-export of a common name like `read` links to the right module instead of a same-named symbol elsewhere; and trait dispatch reaches implementations — a struct whose methods cover a trait's is treated as implementing it, and a call through `&dyn Trait` resolves to the concrete method. Previously a Rust type linked only when called or used in a type position, so structs built by literal, modules surfaced only through `pub use`, and trait-only implementations looked like they had no dependents. (#584 for Rust traits)
- Rust cross-module function calls now resolve to the right file. A call to a sibling submodule's function — `users::router()`, the common router-assembly / handler-registration pattern where `mod users;` makes `users` a child of the current module — is now resolved relative to the current module, not only the crate root. Deeper module-path calls (`database::profiles::find()` — the `db.run(|c| …)` data-access shape) now resolve too; these were being discarded before resolution even ran, because the path's leaf function name was never checked. Previously such a call linked to nothing, so a module reached only as `module::path::function()` looked like it had no dependents; a web app wired this way (Axum, Rocket, and similar) now surfaces its handler and data-access modules' real callers. (Rust)
- Rocket route handlers now connect to where they're mounted. A handler registered in a `routes![a::b::handler, …]` or `catchers![…]` macro used to be invisible — the macro body is a raw token tree, so the handler looked like it had no caller (Rocket mounts it at runtime) and its file showed no dependents. The handler paths are now read out of the macro and linked to the `mount`/`register` call, so editing a Rocket handler surfaces its route registration and a routes module is no longer reported as unused. (Rust, Rocket)
- SvelteKit pages now connect to their server `load` functions. SvelteKit wires a `+page.server.js` / `+page.js` `load` (and form `actions`) to the sibling `+page.svelte`'s `data` by file path, with no import between them — so editing a `load` previously showed no impact on the page it feeds. Each page is now linked to the `load`/`actions` in its own route directory (and likewise for `+layout`), so editing a loader surfaces the page that renders its data, and tracing a page reaches its server-side data source. (Svelte, SvelteKit)
- Nuxt nested components are now connected to where they're used. Nuxt auto-imports a component in a subdirectory by a directory-prefixed name — `components/media/Card.vue` is used in templates as `<MediaCard/>` — but it was tracked by its file name (`Card`), so the usage didn't resolve and the component looked unused. PascalCase component tags (`<MediaCard>`, `<NavBar>`) in a `.vue` template are now matched, falling back to the Nuxt directory-prefixed name, so editing a nested component surfaces every page and component that renders it. (Vue, Nuxt)
- Lua and Luau `require` calls now connect to their module files. A dotted module path (`require("telescope.config")``telescope/config.lua` or `.../config/init.lua`) and a Roblox/Luau instance-path require (`require(script.Parent.Signal)` → the `Signal` module) now link to the file they load, so editing a module surfaces every file that requires it. Previously requires resolved to nothing, so a Lua/Luau module looked like it had no dependents. (Lua, Luau)
- Shopify OS 2.0 sections now connect to the JSON templates that use them. Modern Shopify themes define templates as JSON (`templates/*.json`, plus section groups `sections/*.json`) that list sections by `type` rather than with a `{% section %}` Liquid tag, so a section used only from a JSON template was reported as having no dependents. Those JSON files are now read and each section `type` is linked to its `sections/<type>.liquid`, so editing a section surfaces the templates that render it. (Liquid, Shopify)
- Delphi form definitions now connect to their code-behind units. A `.dfm` (VCL) or `.fmx` (FireMonkey) form is owned by its same-named `.pas` unit through the `{$R *.dfm}` directive rather than a `uses` clause, so a form file used only as a definition was reported as having no dependents. The unit is now linked to its form, so editing a form surfaces the unit that owns it. (Pascal/Delphi)
- Swift property wrappers and attributes are now connected. A `@Argument` / `@Published` / `@State` / custom `@propertyWrapper` on a property — and attributes on types, methods, and functions (`@objc`, `@MainActor`, …) — now record a dependency on the wrapper/attribute type. Previously these were dropped entirely (Swift attributes parse differently from other languages, and stored properties weren't being inspected), so the wrapper type looked unused and the file using it depended on nothing — a big gap for SwiftUI and argument-parser-style code.
- Swift Fluent relationship models are no longer orphaned. A type referenced only through a property-wrapper *argument*`@Siblings(through: AcronymCategoryPivot.self, …)`, the many-to-many pivot/join model — now records a dependency on that type. Previously only the wrapper itself (`Siblings`) and the property's declared type were captured, so a pivot model reached solely through the relationship looked like nothing depended on it and editing it surfaced no impact. (Swift, Vapor/Fluent)
- Java annotations are now connected. Annotation definitions (`@interface Foo`) are indexed as types, and every `@Foo` usage on a class, method, or field is recorded as a dependency on it. Previously neither side was captured — annotation usages were dropped (they live inside the declaration's modifiers) and `@interface` types weren't indexed at all — so annotation-driven code (Spring `@GetMapping`, JPA `@Entity`, Gson `@SerializedName`, …) showed the annotation as having no users and the annotated class as not depending on it.
- Kotlin Multiplatform `expect`/`actual` declarations are now connected. A platform implementation — `actual fun`, `actual class`, or an `actual typealias` in a `jvm` / `native` / `js` / `wasm` source set — is linked to the common `expect` declaration it fulfills (including the common case of an `expect class` fulfilled by an `actual typealias`). Previously a caller in common code resolved to the `expect` declaration, so every platform `actual` looked like it had no dependents and editing one showed an empty blast radius; now changing a platform implementation surfaces the common API and everything that uses it. (Kotlin)
- Scala impact and `codegraph affected` now connect the type graph that typeclass-style code is built on. A parameterized supertype (`trait Monoid[A] extends Semigroup[A] with Serializable`) now links to each parent; a type used in a `val`/`def` signature, as a type argument, or as a context bound (`def f[A: Monoid]`) — including the trailing implicit parameter list (`(implicit M: Monoid[A])`) where typeclass instances are passed — now records a dependency; and `new T[...] { … }` counts as an instantiation. Previously Scala linked only plain calls and bare, non-generic supertypes, so a trait extended with type parameters, used as a type, or required as an implicit looked like nothing depended on it — which on a typeclass-heavy codebase (cats, algebra) was most of the graph. (Scala)
- PHP impact and `codegraph affected` now understand namespaces and `use` imports. Classes are tracked by their namespaced name, so the many same-named classes a framework defines (Laravel has 7+ `Factory` interfaces, several `Dispatcher`s, across namespaces) are told apart instead of collapsing into one arbitrary match. A `use App\Contracts\Cache\Factory;` now records a dependency on exactly that class — so a contract or interface that's imported and constructor-injected (the dependency-injection pattern) is no longer reported as having no dependents — and parameter, property, and return type-hints are recorded too. Previously PHP ignored namespaces entirely and linked only calls, `new`, and inheritance. (PHP)
- Objective-C impact and `codegraph affected` are dramatically more complete. Four gaps are fixed: a single-argument message (`[cache storeImage:key]` — the most common call form) now matches its `storeImage:` method instead of dropping the colon; a class-message receiver (`[SDImageCache sharedCache]`, `[[Foo alloc] init]`) now records a dependency on the class, whose `@interface` lives in the header; `#import "Foo.h"` now resolves to the header file, so a header is no longer reported as having no dependents; and class-method message calls now resolve through the receiver type. Together these took typical libraries from a third-to-half of their files showing real dependents to ~90%. (Objective-C)
- A type referenced only through a static member or enum value now records a dependency. Reading an enum value (`MediaKind.video`), a static constant (`Colors.red`, `JsonScope.EMPTY_DOCUMENT`), or a class constant (`Foo::BAR`) now links to the type — previously only method calls and `new` did, so a type or enum used purely *by value* (enum-heavy APIs, constants classes — a very common pattern) looked like nothing depended on it. Applies to Java, C#, Kotlin, Swift, Scala, Dart, PHP, and C++.
- Dart impact and `codegraph affected` now follow mixins and method type annotations. A `with` mixin — Dart's core composition mechanism, which Flutter is built on — now records a dependency, so editing a mixin surfaces every class that mixes it in (the whole `with` clause used to be dropped, and a class declared `with M` alone even lost its real superclass link). And types used in a method's parameters or return value now link to their definition, so a class or enum referenced only as a type — not constructed or called — is no longer reported as having no dependents. (Dart)
- C++ free functions are now indexed under their real name. A function written with a qualified-type parameter (`std::string TableFileName(const std::string& dbname)`) or an `auto … -> std::string` trailing return type was mistakenly named after that type (`string`), so calls to it never resolved, `codegraph_node` couldn't find it by name, and the file defining it looked like nothing depended on it. The function now keeps its real name, so cross-file calls, callers, and blast radius work — a meaningful gain for any namespaced C++ codebase (this is how most free functions in a library look). (C++)
- Ruby impact and `codegraph affected` now follow mixins and `require`s. `include`, `extend`, and `prepend` of a module — Ruby's primary composition mechanism (ActiveSupport concerns, `Comparable`, `Enumerable`) — now record a dependency on that module, so editing a concern surfaces every class that mixes it in; previously these were read as a call to a method named `include`, so a module whose methods are exercised only by application code looked like nothing depended on it. And `require "lib/foo"` / `require_relative "../foo"` now link to the required file, so a file pulled in only by a `require` (config-loaded components, gems that don't autoload) is no longer reported as having no dependents. Together these took a typical gem from ~71% of its files showing real dependents to ~100%. (Ruby)
- C# `record` types are now indexed. `record`, `record class`, and `record struct` declarations (everywhere in modern C# — DTOs, value objects, CQRS messages, MediatR notifications) were previously skipped entirely, so every reference, generic type argument (`IEnumerable<MyRecord>`), and `new MyRecord(...)` pointed at nothing and the file defining a record looked like it had no callers or dependents. (#237)
- C# is now parsed with an up-to-date grammar that understands C# 12 **primary constructors**. A class or struct written as `class OrderService(IRepo repo, [FromKeyedServices("primary")] ICache cache) { … }` is now indexed reliably — previously the constructor parameter list confused the parser and could drop the whole class (and all of its methods) from the index, most often exactly when a parameter carried an attribute, as in the ASP.NET keyed-dependency-injection pattern. The primary-constructor parameters are also recorded as dependencies, so the services a type is constructed with show up in its blast radius and "who depends on this contract" answers. Method return types, base types, and members all continue to resolve, and `#if`-guarded members in multi-targeting code keep parsing correctly. (#237)
- Go interfaces now connect to their implementations. Go has no `implements` keyword — a type satisfies an interface just by having the right methods — so CodeGraph now infers that link: a struct whose methods cover an interface's method set is treated as implementing it, and a call through the interface (`API.Marshal(...)`) reaches every concrete implementation. This means a type used only via an interface (the common plugin/strategy pattern — e.g. JSON-codec or renderer implementations selected at runtime) is no longer reported as having no callers or no dependents, and impact now flows from an interface method to the implementations behind it. (#584)
- Go now records cross-package struct creation. A composite literal like `render.XML{...}` or `pkga.Widget{...}` — including ones registered in a package-level `var registry = map[string]R{...}` — now links to the package that defines the type. Cross-package function calls and type references already resolved; this closes struct instantiation, so a package whose types are only *constructed* elsewhere (a common pattern for interface implementations) is no longer reported as having no dependents. Go type conversions such as `(*Wrapped)(x)` now link to the converted-to type as well.
- Python now follows whole-module imports — `from . import certs` then `certs.where()`, or `from pkg import sub` then `sub.run()`. Calls and attribute access through an imported submodule now resolve to that submodule, and importing a module is recorded as a dependency on it even when the member you use is itself re-exported from a third-party package. This also fixed Python relative-import path resolution generally (`from .sub.mod import x`), so `codegraph affected` and impact see the real module graph of a package.
- Python now also links a whole-module **absolute** import (`import conduit.apps.signals`) to that module's file, not just `from x import y`. A module imported by its dotted path — common in package setup and side-effect imports — is no longer reported as having no dependents. Standard-library imports (`import os`) correctly create no edge. (Python)
- Python `from package import submodule` now links to that submodule's file, resolved through the import's package so it lands on the right one when same-named modules exist in sibling packages (the FastAPI / Django router-aggregator pattern: `from app.api.routes import authentication` with an unrelated `authentication.py` elsewhere). So a route/handler module pulled in only by an aggregator is no longer reported as having no dependents. (Python)
- Python now records the actual call edge for a function invoked through an imported module — `from pkg import module` (or `import pkg.module`) followed by `module.func()`, a common testing and namespacing pattern. Previously only the module-level dependency was tracked, so `codegraph_callers`, `codegraph_callees`, and impact on the target function came back empty even though the import itself resolved. (#578) (Python)
- Django `include('app.urls')` now records a dependency from the project URLconf onto the included app's `urls.py`, so an app's routes module is no longer reported as having no dependents and editing it surfaces the project that mounts it. (Django)
- A chained method call (`builder.Services.AddCoreServices(...)`) now resolves to its definition. Previously only a single-segment receiver (`obj.method()`) resolved, so a call through a property/member chain — very common for C# extension methods like ASP.NET dependency-injection registration (`AddCoreServices`/`AddWebServices`) and Guard clauses — found no definition. (C#, and any language with chained calls)
- A renamed default import (`import articlesController from './article.controller'` where the module does `export default router`) now records a dependency on the imported module. Previously only named imports linked, so a module consumed only through a default import — the standard Express/NestJS route-controller pattern — looked like nothing depended on it. External packages (`import React from 'react'`) still create no edge. (TypeScript/JavaScript)
- The background file watcher no longer exhausts your machine's file-descriptor budget. On macOS it previously kept **one open file handle per watched file**, so on a large project the running MCP server could pile up tens of thousands of handles and blow past the system-wide limit — at which point *unrelated* apps (your shell, editor, Docker, browser) started failing with "too many open files" until the codegraph process was killed. The watcher now uses a single recursive watch on macOS and Windows, and bounded per-directory watches on Linux, so its cost stays flat no matter how large the project is. (#644, #496, #555, #628, #579)
- Indexing a project with very symbol-dense files (tens of thousands of functions or methods in a single file) no longer runs out of memory. The step that links dynamic call relationships used to load every function and method into memory at once, which could exhaust the heap and abort indexing with "JavaScript heap out of memory" on large or generated codebases; it now streams them, so memory stays flat no matter how many symbols the project has. (#610)
- Indexing a very large repository no longer aborts during its first sync with a "too many SQL variables" error. (#540)
- Files under directories with non-ASCII names (for example CJK characters) are no longer silently skipped during indexing. (#541)
- The `.codegraph/` index folder no longer clutters `git status`: its generated ignore file now excludes everything in the folder except itself, so the database, `daemon.pid`, sockets, and logs stop showing up as untracked changes. (#492, #484)
- Projects initialized by an older version now get that fix automatically: a `.codegraph/.gitignore` written before this change — which listed only the database, cache, and logs and so let the daemon's `daemon.pid` get committed — is upgraded in place the next time you run any CodeGraph command. A `.gitignore` you've customized yourself is left untouched. (#788)
- SAP HANA `.xsjs` / `.xsjslib` files are now indexed as JavaScript. (#556)
- TypeScript `.mts` and `.cts` module files are now indexed instead of being skipped. (#366)
- JavaScript modules that wrap their code in an anonymous function — AMD/RequireJS, NetSuite SuiteScript, IIFE bundles — now have their inner functions and calls indexed, instead of the file coming up nearly empty. (#528)
- Go methods declared on generic types (e.g. `func (s *Stack[T]) Push(...)`) are now correctly attached to their type, so callers, callees, and impact include them. (#583)
- Go methods now attach to their receiver type even when declared in a different file from the `type` itself — the idiomatic split where `type User struct{…}` lives in one file and `func (u *User) Save()` in another. Previously a cross-file method was orphaned from its struct, so the type's member list, callers/callees, and impact missed it; as a knock-on, a struct whose interface-satisfying methods are spread across files now also links to the interfaces it implements. (#583)
- Asking what a symbol impacts no longer drags in every unrelated sibling method of its class — impact now follows real dependencies instead of the structural "contains" relationship, keeping the result focused on what actually depends on the symbol. (#536)
- CodeGraph's MCP server now answers an agent's `resources/list` and `prompts/list` probes with an empty list instead of an error, clearing the `-32601` messages some clients (opencode, Codex) logged on connect. (#621)
- Svelte and Vue components used through a barrel file — `export { default as Button } from './Button.svelte'` re-exported from an `index.ts` and imported elsewhere — are no longer falsely reported as having **0 callers**. CodeGraph now follows the default re-export all the way to the component and resolves the imports that `.svelte` / `.vue` files themselves use, so `codegraph_callers` and `codegraph_impact` see every place a component is used. This also covers components imported from another package in a workspace/monorepo (`@scope/ui/widgets`) and bare directory imports (`import { x } from './'`). Previously a live component consumed only through a barrel looked like dead code. Thanks @nakisen. (#629)
- Components used in a Vue Single-File Component's `<template>``<MyButton />`, or the kebab-case `<my-button />` — are now indexed as usages, so `codegraph_callers` and `codegraph_impact` include components that appear only in another component's markup (including through a barrel re-export). Previously only a Vue component's `<script>` block was analyzed, so template-only usages were invisible. (#629)
- PHP: `include` / `require` / `include_once` / `require_once` of a static path now create a file→file dependency edge, so `codegraph_callers` and `codegraph_impact` follow includes in procedural / script-style PHP codebases — previously only namespace `use` statements became dependency edges. Dynamic includes (`include $var`, `require __DIR__ . '/x'`) are skipped. Thanks @atahan150 (#660).
## [0.9.9] - 2026-06-02
### New Features
- `codegraph_explore` is now the primary tool, and one call is usually all an agent needs: it returns the verbatim source of the symbols relevant to your question (a plain question works as the query — you no longer need exact symbol names), grouped by file and Read-equivalent, so the agent answers without falling back to read/grep. The narrower `codegraph_context` and `codegraph_trace` tools were removed in favor of it — explore already surfaces the call flow among the symbols you name (the job trace did), so there's one obvious tool to reach for instead of three.
- `codegraph_explore` now includes a compact "Blast radius" for the symbols you're looking at — who depends on each (just the locations, not their source) and which test files cover it — so before editing, the agent can see what else to update and which tests to run, without a separate impact lookup. Symbols nothing depends on are skipped, so it stays short.
- Functions defined inside a store or handler object — the actions in a Zustand `create((set, get) => ({ … }))` store, and the same shape in Redux, Pinia, MobX, or any exported handler/route map — are now indexed as real symbols. Previously they existed only as object properties, so looking one up by name or asking who calls it returned "not found" and the agent had to read the whole store file to follow the flow; now `codegraph_node`, `codegraph_callers`, and `codegraph_explore` resolve them directly — including calls made through `useStore.getState().fetchUser()` or a destructured `const { fetchUser } = useStore.getState()`.
- `codegraph_explore` now surfaces the *right* definition when a method name is overloaded across types. Asking about, say, `DataRequest`'s `task` and `validate` used to return a same-named method from an unrelated file (or an abstract base stub) and bury the one you meant; explore now recognizes the type you named in the query and leads with that type's own overloads, in full.
### Fixes
- Search ranking no longer lets a common word in your request hijack the results: asking about, say, a "flat object" screen used to surface an unrelated constant that merely happened to be named the same, because the exact-name match outweighed everything else. Ranking now weighs how well each result is corroborated by the rest of your request, so the symbols you actually meant come first (this improves `codegraph_explore`'s results).
- `codegraph_node` now returns *every* definition when a name is ambiguous — an overloaded method, or the same method name on different types — instead of returning one (sometimes the wrong one) with a note listing the rest. Asking for such a symbol now hands back all of the matching definitions with their source in a single call, so the agent stops having to read the file by hand to find the specific overload it wanted (common in Swift, Go, Java, and C#). For a heavily-overloaded name (a `poll`/`validate` with dozens of definitions), pass `file` (and/or `line`) — e.g. the `file:line` shown in a trail — to get that exact definition's body. Large overload sets show the most relevant ones in full and list the remainder by location.
- `codegraph_explore` never returns half a method anymore: when output runs up against its size budget it drops whole methods or whole files (and lists what it dropped, so you can ask for them in another call) instead of cutting off a method body partway. A truncated method was the one case that still sent the agent to read the file for the rest — so the source explore returns is now always complete and usable as-is.
## [0.9.8] - 2026-06-01
### New Features
- `codegraph init` now builds the initial index by default — you no longer need the `-i`/`--index` flag (it's still accepted, so existing commands and scripts keep working). (#483)
- Go: Gin middleware chains now connect end-to-end in `codegraph_trace` and `codegraph_explore` — following a request reaches the middleware and route handlers registered via `.Use()` / `.GET()` instead of dead-ending where the framework dispatches the chain dynamically.
- `codegraph_explore` now sizes its response to the *answer* instead of the file count: it shows the mechanism and the exact methods you asked about in full — even when they're buried deep in a large file — while collapsing the redundant interchangeable implementations of an interface (an HTTP interceptor chain, a query-compiler family) down to signatures. Fewer tokens for a more complete answer, so on the flows that used to occasionally cost more than plain grep/read it's now clearly cheaper — and the win holds across small, medium, and large codebases. Distinct, non-interchangeable code is shown in full as before. Disable with `CODEGRAPH_ADAPTIVE_EXPLORE=0`.
- Swift deferred-validation flows (and similar "handler array" patterns) now connect end-to-end in `codegraph_trace` and `codegraph_explore` — following a request's lifecycle reaches the validators registered with `.validate { … }` instead of dead-ending where the framework runs them by iterating a stored list of closures. Any pattern where closures are appended to a collection and later invoked by looping over it is now traced.
- `codegraph_explore` now spells out the dynamic-dispatch relationships of the symbols you ask about — e.g. "the closures registered here are run by `didCompleteTask`" — so the indirect hops you'd otherwise grep to reconstruct are listed alongside the call flow.
- `codegraph_explore` answers multi-phase questions that span a large "god file" far more completely. For a flow like "build, send, and validate a request" — where one big file holds the build chain and the validate logic lives in others — it now keeps every method *on the flow path* in full, collapses the file's off-path methods to one-line signatures, and guarantees each phase's defining file is shown (instead of truncating at a fixed size and dropping whichever phase came last, which sent you to read it by hand). Incidental files that merely name-drop the flow are still trimmed, so the response stays focused on the code that answers the question.
- CodeGraph is usable as an embedded library again: `require("@colbymchenry/codegraph")` and `import` now resolve the programmatic API — the `CodeGraph` class plus building blocks like `DatabaseConnection`, `QueryBuilder`, `initGrammars`, and `FileLock` — so you can drive the graph directly from your own app (for example an Electron process) instead of only through the CLI or MCP server. Embedding runs on your own runtime, so it needs Node 22.5+ for the built-in SQLite. (#354)
### Fixes
- `codegraph_trace` now resolves an overloaded symbol name to its real implementation instead of an empty protocol/delegate stub. Tracing a flow through a heavily-overloaded API (common in Swift, Java, C#, and Go) could land on an unrelated no-op method that happened to share the name and report "no path"; it now picks the substantive definition the flow actually runs through.
- CodeGraph's MCP server now answers an agent's opening handshake the instant it launches instead of blocking while the index loads, so a fresh session's very first tool call no longer occasionally races a server that's still warming up and falls back to grep/read. The first question in a new session now reliably goes through CodeGraph.
- Indexing a project that contains only config-style files (YAML, Twig, or `.properties`) no longer misleadingly reports "No files found to index" — these files are tracked at the file level and are now counted as indexed. Thanks @luojiyin1987 (#357).
## [0.9.7] - 2026-05-28
### New Features
- Go: gRPC interface stubs now connect to their hand-written implementation, so callers, callees, impact, and trace land on the real method instead of an empty generated stub.
- Generated files (protobuf, gRPC stubs, mocks, build output) now rank last in search, trace, and explore, so results land on your real implementation instead of an auto-generated placeholder.
- When `codegraph_trace` can't find a static path (a dynamic-dispatch break), it now inlines both endpoints' source, callers, and callees in one response, so the agent gets the full picture without a flurry of follow-up calls.
- Trace now picks the right endpoints in large multi-module repos by preferring symbols that share a directory, instead of grabbing an arbitrary same-named symbol from an unrelated module.
- Test files are now deprioritized in `codegraph_explore` (Go, Ruby, JS/TS, Java/Kotlin/Scala), so the explore budget goes to your real implementation source.
- Small projects (under ~500 files) now resolve flow questions in fewer MCP calls, with a leaner tool surface and tuned context and explore output sized for the project.
- `codegraph_context` now auto-traces flow questions like "how does X reach Y" or "trace the path from A to B", splicing the trace into the response so you don't need a separate `codegraph_trace` call.
- `codegraph_context` now inlines a URL-to-handler routing table and the source of your main routes file for routing questions on small projects, so you don't have to go read `routes.rb` or `web.php` yourself.
- `codegraph_context` search now boosts results in the directory of a project's core framework file, so a small same-named extension file no longer outranks the actual framework core.
- Interface-to-implementation linking now works for C#, TypeScript, JavaScript, Swift, and Scala (previously Java/Kotlin only), so investigating an interface method surfaces its concrete implementations.
- MCP tool descriptions are now shorter, trimming per-session overhead while keeping the steering guidance.
- Java and Kotlin imports now resolve by fully-qualified name, so same-name classes in different packages are told apart correctly in multi-module Spring and Android codebases, including across the Java/Kotlin interop boundary.
- Java and C# anonymous classes (`new T() { ... }`) and their overridden methods are now indexed as real class nodes, so an agent sees those hidden overrides in its trail without a Read.
- The installer no longer writes a duplicate `## CodeGraph` instructions block into your agent's instructions file (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, Cursor's `.cursor/rules/codegraph.mdc`, or Kiro's steering doc) — the MCP server is now the single source of truth, and re-running `codegraph install` or `codegraph uninstall` strips a block a previous version left behind (#529). If you added your own notes inside the `CODEGRAPH_START`/`CODEGRAPH_END` markers, move them outside the markers first, since the whole marked block is removed.
### Fixes
- MCP tools no longer return results for files that were deleted while no server was running — the first query of a session now waits for the catch-up sync, so you get the correct index instead of stale rows.
- Windows: black console windows no longer flash on every file save or MCP reconnect (#485, #510, #530).
- `codegraph index` and `init -i` now report the true edge count in their summary, instead of undercounting by missing resolution and synthesizer edges.
## [0.9.6] - 2026-05-27
### New Features
- Enterprise Spring and MyBatis flows now trace end-to-end: MyBatis XML mappers are indexed and linked to their Java mapper interfaces, Spring `@Value` and `@ConfigurationProperties` references resolve to the matching keys in your `application.yml`/`.properties` config (including relaxed kebab/camel/snake binding), and field-injected concrete beans like `this.field.method()` resolve through to their implementation (#389).
- Gemini CLI (and the rebranded Antigravity CLI) plus the Antigravity IDE are now supported by `codegraph install`, detected and configured out of the box with sibling settings and MCP servers preserved across re-installs (#399).
- Kiro (CLI and IDE) is now supported by `codegraph install` on macOS, Linux, and Windows, with its own steering file so it loads CodeGraph guidance naturally (#385).
### Fixes
- C/C++: bare `#include "header.h"` directives now connect to the real header file instead of a phantom import, so includes show up as true file-to-file edges; system and stdlib headers are filtered out so they don't false-resolve (#453).
- Java/Kotlin: imports now disambiguate same-name classes across modules using the fully-qualified import path, so callers, callees, and trace land on the right class in multi-module projects instead of guessing by file proximity (#314).
- TypeScript: `type` aliases with object shapes (including function-typed members and intersection types) now surface their members in the graph, so a call like `handle.stop()` resolves to the alias member instead of an unrelated look-alike class in a sibling directory (#359).
- C#: parameter, return, property, and field types now produce reference edges, so callers and callees on a DTO or service type return real results instead of nothing (#381).
- Go: cross-package qualified calls like `pkg.Func()` now resolve to the right package by reading your `go.mod`, so callers, callees, impact, and trace return complete results on Go monorepos instead of almost nothing (#388).
- `codegraph_files` now returns the whole project when an agent passes a root-ish path like `/`, `.`, `./`, `""`, or a Windows-style `\`, and subdirectory filters like `/src`, `./src`, and `src\components` all resolve correctly instead of returning "No files found" (#426).
- The file watcher no longer marks edited files as fresh when another process holds the index lock, so the per-file staleness signal stays accurate until the edit is actually indexed (#449).
- TypeScript/JavaScript: calls inside top-level variable initializers (`const token = getToken()`) and inside inline object-literal methods are no longer dropped, so they show up in callers as expected, including in Vue single-file components (#425).
- Watch sync no longer aborts with a `FOREIGN KEY constraint failed` error in a long-running daemon; a stale lookup now drops a single edge instead of failing the whole sync (#455).
- Hermes: `codegraph install --target hermes` no longer corrupts `~/.hermes/config.yaml`, correctly handling PyYAML's block-style lists and re-installing cleanly even on an already-corrupted file (#456).
- NestJS: route prefixes from `RouterModule.register([...])` (including nested `children`) now propagate to controller routes, so a route shows up at its full path like `GET /admin/users` instead of `GET /` (#459).
- C++: callers now resolve through typed member pointers such as `m_alg->Processing()`, including out-of-line method definitions and the common case of two classes sharing a method name (#445, #454).
## [0.9.5] - 2026-05-25
### New Features
- Running multiple AI agents in the same project no longer multiplies the cost: two Claude Code windows, a worktree agent, or parallel sub-agents now share one background daemon per project with a single file watcher, SQLite connection, and tree-sitter warm-up instead of N independent copies (#411).
- The daemon runs detached so it outlives any single session, meaning closing one editor or terminal never severs the others; it lingers briefly after the last client disconnects so back-to-back sessions skip the startup cost, then exits and cleans up after itself. Tune the idle wait with `CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS` (default five minutes).
- Set `CODEGRAPH_NO_DAEMON=1` to opt out and get one independent server per client, handy for debugging or sandboxes that disallow local sockets; the daemon is also version-pinned, so upgrading CodeGraph never mixes versions over the connection.
- CodeGraph responses now tell the agent which files are pending re-index: when the watcher has seen edits since the last sync, tool responses add a warning banner naming the stale files and their state so the agent reads just those directly while trusting the rest, with zero cost when nothing is pending (#403).
- `CODEGRAPH_WATCH_DEBOUNCE_MS` lets you tune the file-watcher quiet window (default 2000ms) for workspaces with bursty writes like format-on-save chains or large generated outputs, without touching your agent's command line (#403).
- Objective-C indexing: `.m`, `.mm`, and content-sniffed `.h` files now parse with full structural extraction, including full multi-part selectors, properties, imports, and superclass/protocol relationships, so trace, callers, and callees work across iOS codebases (#165).
- Mixed iOS, React Native, and Expo projects now trace end-to-end across language boundaries: Swift to Objective-C auto-bridging, the React Native legacy bridge and TurboModules, native-to-JS event channels, Expo Modules, and Fabric/Codegen view components are all bridged so flows connect through gaps that static parsing alone can't follow (#401).
### Fixes
- TypeScript: types used only in an interface's property or method signatures now produce references edges, so impact and callers on a type include every consumer that imports it just for an interface shape (#432).
- Git worktrees no longer silently borrow another tree's index; running CodeGraph from a worktree nested inside the main checkout used to return the wrong branch's code with no warning, and now both the status command and every read tool call out the conflict and point you to `codegraph init -i` in the worktree (#155).
- The file watcher no longer exhausts the OS file-watch budget on large repos: it now excludes the same directories the indexer ignores (defaults plus your `.gitignore`) before registering watches, so CodeGraph can run alongside your editor or dev server without hitting the per-user watch ceiling (#276).
- The index now stays in sync after `git pull`, branch switches, and edits made outside your editor; change detection is filesystem-based instead of relying on `git status`, so pulled or checked-out code is picked up without a full re-index.
- The MCP server now catches up on connect, reconciling anything that changed while it wasn't running so your first query reflects the current code instead of a stale snapshot.
- Dependency, build, and cache directories like `node_modules`, `vendor`, `dist`, `build`, `target`, `.venv`, `__pycache__`, `Pods`, and `.next` are now excluded by default, so context and search reflect your code instead of third-party noise even in a project with no `.gitignore`; add a `.gitignore` negation to index one anyway (#407).
## [0.9.4] - 2026-05-24
### New Features
- Request-to-handler flows now trace end-to-end across many web stacks, with new or improved route resolution for Express, Rails, Spring (Java and Kotlin), Django/DRF, Laravel, Flask, FastAPI, Gin, chi, ASP.NET, Drupal, Axum, actix, Vapor, Play, Vue/Nuxt, Svelte/SvelteKit, and React Router.
- `codegraph_trace`, `codegraph_callees`, and `codegraph_explore` now follow flows that have no static call edge — callback and observer registration, EventEmitter, React re-renders and JSX children, Flutter `setState` to `build`, C++ virtual overrides, and Java/Kotlin interface-to-implementation dispatch (like Spring's `@Autowired` service calls) — and each bridged hop is labeled inline in trace with where it was wired up.
- `codegraph_trace` now returns a self-contained flow dossier: every hop shows its full body inline plus the destination's own outgoing calls, so a single trace usually answers a "how does X reach Y" question without a follow-up explore, node, or Read.
- `codegraph_explore` now leads with the execution flow when your query names the symbols of a flow, finding the call path among those symbols (including across dynamic-dispatch hops) so you get a trace-quality answer without switching tools.
- `codegraph_node` and `codegraph_trace` now emit line-numbered source (matching `codegraph_explore` and Read), so you can cite or edit exact lines without re-reading the file just to recover line numbers.
- New `CODEGRAPH_MCP_TOOLS` environment variable lets you expose only a chosen subset of codegraph tools over MCP (e.g. `trace,search,node,context`) without editing your client's MCP config; unset exposes all of them.
- Release archives now ship with a `SHA256SUMS` file, and the npm launcher verifies the bundle it downloads against it, aborting on a mismatch (releases published before this change skip verification rather than failing).
### Fixes
- Several static-extraction and resolution correctness fixes underpin the routing work above: C++ inheritance edges that were previously missing, Dart methods that were extracted signature-only, Python handlers named `index`/`get`/`update` that were being silently dropped, and an explore output-budget issue that under-returned source on repos with very large files.
- `codegraph serve --mcp` no longer keeps running after its parent agent is force-killed (OOM, `kill -9`, or container teardown) on Linux, where it used to hold inotify watches, file descriptors, and the SQLite WAL indefinitely; the server now shuts down as soon as its parent process changes, tunable via `CODEGRAPH_PPID_POLL_MS` (#277).
- Installing `@colbymchenry/codegraph` through a registry mirror that hadn't yet mirrored the matching per-platform package no longer fails with `no prebuilt bundle for <platform>`; the launcher now downloads the bundle from GitHub Releases and caches it, with `CODEGRAPH_NO_DOWNLOAD=1` to disable the fallback and `CODEGRAPH_DOWNLOAD_BASE` to point it at your own mirror (#303).
- `install.sh` no longer fails with `403` / "could not resolve latest version" on shared or cloud hosts that exhaust GitHub's unauthenticated API rate limit; it now resolves the version through the unthrottled releases redirect, and `CODEGRAPH_VERSION` accepts a bare version like `0.9.4` as well as `v0.9.4` (#325).
## [0.9.3] - 2026-05-22
### New Features
- New `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, asking whether to clean up your global or this project's local config and reporting exactly which agents it touched; it accepts `--location`, `--target`, and `--yes` for scripted or non-interactive use, removes only what `codegraph install` wrote, and leaves your `.codegraph/` index alone (#313).
### Fixes
- Indexing a large multi-language project no longer aborts partway through with a `Fatal process out of memory: Zone` crash on Node.js 22 and 24, even with plenty of RAM free — CodeGraph now launches with a V8 flag that keeps grammar compilation off the optimizing tier, and any launch path that doesn't get the flag directly re-execs once with it automatically (#298, #293). Node 25 stays blocked for now, since its variant of this bug isn't fixed by the same flag.
- Uninstalling from Cursor now deletes the leftover `.cursor/rules/codegraph.mdc` file outright instead of leaving an orphaned, empty rule behind, while keeping any content you added outside CodeGraph's markers.
## [0.9.2] - 2026-05-21
### Breaking Changes
- CodeGraph no longer has a config file: `.codegraph/config.json` and the entire config surface are gone, and the library API for it (the config type, the `config` option on `init()`, and the get/update config exports) has been removed — existing config files are now ignored, and `.gitignore` is the single source of truth for what gets indexed. The `.codegraphignore` marker is also no longer supported; use `.gitignore` instead.
### New Features
- `codegraph install` now supports Hermes Agent (Nous Research), wiring up the CodeGraph MCP server so Hermes can drive the knowledge graph like the other agents.
- Drupal projects (8/9/10/11) are now detected and indexed with framework smarts: routes from `*.routing.yml` link to their controller, form, or entity-handler, and hook implementations across modules are connected to their canonical hook name, so asking for callers of a hook returns every implementation (#268).
- Indexing is now zero-config and honors your `.gitignore` everywhere — in git repos via git, and in non-git projects by reading `.gitignore` files directly — so to keep something out of the graph you just add it to `.gitignore`. Behavior change: committed files that aren't gitignored are now indexed even under `vendor/`, `Pods/`, or a committed `dist/`; add a `.gitignore` negation to exclude them (#283).
### Fixes
- Windows: installing globally and then running any `codegraph` command no longer fails — the launcher now invokes the bundled runtime directly instead of a `.cmd` file that modern Node refuses to spawn, so `codegraph` works regardless of your Node version (#289).
### Security
- The temp-dir marker written on each `codegraph_context` call is now opened safely so it can't follow a symlink, closing a hole where another local user on a shared machine could redirect that write onto a file you can write (#280).
## [0.9.1] - 2026-05-21
### Fixes
- The standalone installers (`curl … | sh` and `irm … | iex`) no longer fail to launch on a machine that has no Node installed.
- Installing with `npm i -g` on Linux x64 now finds its bundle, after the 0.9.0 release silently shipped without the linux-x64 package; the release pipeline now verifies every package reached the npm registry so a release can't pass green-but-broken again.
## [0.9.0] - 2026-05-21
CodeGraph now ships its own self-contained runtime, so it installs on any Node version — or none at all — with no native build step, and the old intermittent "database is locked" errors are gone for good.
### New Features
- One-line standalone installers that need no Node.js: `install.sh` on macOS and Linux, and `install.ps1` on Windows fetch the self-contained bundle and put `codegraph` on your PATH (you can still use `npm`/`npx` on any Node version too).
- CodeGraph now uses real SQLite with full WAL and FTS5 built into its bundled runtime, which fixes the concurrent-read "database is locked" errors at the root, removes the native build step entirely, and runs faster for anyone who had been stuck on the old WASM fallback (#238).
- Lua: CodeGraph now indexes `.lua` projects (Neovim plugins, Kong, OpenResty, game code), surfacing functions, table methods, local variables, `require(...)` imports, and the call edges between them.
- Luau: CodeGraph now indexes `.luau`, Roblox's typed superset of Lua, adding type and `export type` aliases, typed function signatures, generics, and Roblox instance-path requires on top of everything Lua extracts (#232).
- `codegraph status` now reports the effective journal mode, so a "database is locked" report is easy to triage at a glance.
### Fixes
- Re-running `codegraph install` now strips the broken auto-sync hooks that pre-0.8 versions wrote into Claude Code's settings, which had been causing a "Stop hook error: unknown command 'sync-if-dirty'" on every turn. The cleanup is surgical and leaves unrelated hooks untouched. Re-run `codegraph install` once on an affected machine to clear the error.
## [0.8.0] - 2026-05-20
### Breaking Changes
- The minimum supported Node.js version is now 20 (Node 18 is end-of-life); Node 22 LTS and Node 24 get the fast native backend out of the box, other Node versions still run via the slower WASM fallback, and Node 25+ remains blocked (#81). If you're on an older Node, upgrade to 20 or newer.
### New Features
- NestJS: CodeGraph now recognizes NestJS projects and surfaces the route that binds each handler across HTTP controllers, GraphQL resolvers, microservice handlers, and WebSocket gateways, detected automatically from any `@nestjs/*` dependency (#220).
- `codegraph_explore` source now includes line numbers, so an agent can cite `file:line` straight from the result instead of reopening the file to find a line number; set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable.
- On WSL2 `/mnt/*` drives, where the live file watcher is too slow and could break MCP startup, CodeGraph now skips the watcher and offers to keep the index fresh with git hooks instead; new `CODEGRAPH_NO_WATCH=1` (or `serve --mcp --no-watch`) forces the watcher off anywhere, and `CODEGRAPH_FORCE_WATCH=1` overrides the WSL auto-detect when your setup is actually fast.
- CodeGraph now guides agents to answer "how does X work" and architecture questions directly with a couple of codegraph calls instead of delegating to a file-reading sub-agent or a grep-and-read loop, which gives faster, cheaper, `file:line`-cited answers on medium and large repos.
- `codegraph_node` with code on a class, interface, struct, or enum now returns a compact member outline (fields plus method signatures with line numbers) instead of the entire class body; functions and methods still return their full source.
- `codegraph_explore` output now scales with project size, so small projects get tighter responses than your native grep-and-read flow would produce while large codebases keep their fuller budget, and a per-file cap stops a single dense file from collapsing into a whole-file dump (#185). Thanks @essopsp.
- Search ranking now correctly de-prioritizes CamelCase test files (`FooTest.kt`, `BarTests.swift`, `BazSpec.scala`, `QuxTestCase.cs`) and test source-set directories in Kotlin, Swift, Scala, and C#, so real implementations no longer get outranked by tests.
### Fixes
- `codegraph_explore` output is now hard-capped to its size budget, so an oversized response no longer overruns the cap and sits in the agent's context to be re-read every turn.
- Newly created untracked files are no longer reported as pending forever and re-indexed from scratch on every `codegraph sync`; CodeGraph now hash-compares them against the index the same way it does tracked files (#206). Thanks @15290391025.
- `codegraph init -i` now finds source inside nested, independent git repositories that aren't submodules (common in CMake super-repo layouts), instead of reporting "No files found to index" (#193). Thanks @timxx.
- On Node 24, indexing no longer silently drops to the slower fallback backend with a warning that couldn't be cleared; a fresh install on Node 22 or 24 now gets the fast native backend with no compiler, and `codegraph status` should report it (#203). Thanks @Finndersen.
- MCP tools no longer fail with "CodeGraph not initialized" when the index actually exists; when the client doesn't report a workspace root, the server now asks for it via the standard MCP `roots/list` request before falling back, and the error message is actionable when a project still can't be resolved (#196). Thanks @zhangyu1197.
- The MCP server no longer hangs on startup under WSL2 when the project lives on an NTFS `/mnt/*` mount, so the codegraph tools actually appear; CodeGraph auto-skips the watcher there with manual and git-hook sync fallbacks (#199). Thanks @mengfanbo123.
- Claude Code project-local installs now write the MCP server to `.mcp.json` (the file Claude Code actually reads for project-scoped servers) instead of a file it ignores, and re-running `codegraph install` migrates an affected project automatically (#207). Thanks @Jhsmit.
- Source-omission markers in `codegraph_explore` and `codegraph_context` output are now language-neutral instead of C-style comments, so they no longer look wrong inside Python, Ruby, and other non-C source blocks.
## [0.7.10] - 2026-05-19
### Fixes
- CodeGraph tools now reliably appear in your client on slow filesystems (Docker Desktop VirtioFS on macOS, WSL2), where the startup handshake could previously time out and leave the process running with no tools visible (#172). Thanks @sashanclrp and @sgrimm.
- On Windows PowerShell and cmd.exe, terminal output during `codegraph index` and `codegraph sync` no longer turns into garbled characters; CodeGraph now uses plain ASCII glyphs by default on Windows, with `CODEGRAPH_UNICODE=1` to opt back into the Unicode glyphs or `CODEGRAPH_ASCII=1` to force ASCII on any platform (#168). Thanks @starkleek and @Bortlesboat.
- Module-qualified symbol lookups now resolve in the codegraph tools, so you can pass names like `module::symbol` (Rust, C++, Ruby), `Module.symbol` (TypeScript, JavaScript, Python), or `module/symbol`, including multi-level paths and Rust prefixes like `crate`, `super`, and `self` (#173). Thanks @joselhurtado.
## [0.7.9] - 2026-05-17
### New Features
- opencode: the installer now writes an `AGENTS.md` file with CodeGraph usage guidance, so opencode reaches for the `codegraph_*` tools instead of falling back to its native search.
- opencode: your comments and formatting in `opencode.jsonc` now survive install, re-install, and uninstall, because the installer makes surgical edits instead of rewriting the whole file.
### Fixes
- opencode: `codegraph install` now wires up the MCP server in the file opencode actually reads — previously it wrote to a config file opencode ignores by default, so the CodeGraph entry never appeared in any opencode session; re-run `codegraph install --target=opencode` after upgrading so the entry lands in the right place.
## [0.7.7] - 2026-05-17
### New Features
- `codegraph install` now sets up Claude Code, Cursor, Codex CLI, and opencode from one multi-select prompt, with any agents it detects pre-checked, so a single install wires up every editor you use (#137).
- You can install non-interactively for scripting and CI with flags like `--target`, `--location`, `--yes`, `--no-permissions`, and `--print-config`.
- `codegraph init` now auto-wires project-local agent config for any agent you installed globally, so one global `codegraph install` works in every project you open without re-installing per project.
- Agent instructions are now agent-agnostic and tell each agent to trust codegraph results instead of re-verifying with grep, fixing the case where Cursor and Codex fell back to native search even with codegraph available.
- The install prompts are clearer: the agent picker comes first, and the separate "install the CLI on your PATH" and "apply to all projects or just this one" questions no longer both read as "Global".
### Fixes
- Cursor: a globally-installed codegraph no longer reports "not initialized" in every workspace; the installer now passes the correct project path into Cursor's MCP config to work around Cursor launching MCP servers with the wrong working directory.
Thanks @andreinknv for the substantive draft this release was based on.
## [0.7.6] - 2026-05-13
### Fixes
- Fixed the `codegraph` command failing with `permission denied` right after a fresh global install — the 0.7.5 package shipped the CLI without its executable bit, so your shell refused to run it. New installs work out of the box. If you're stuck on 0.7.5, upgrade to 0.7.6 or unblock yourself in place by making the installed binary executable with `chmod +x`.
[0.9.7]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.7
[0.9.6]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.6
[0.9.5]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.5
[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]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.0
[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.9]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.9
[0.7.7]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.7
[0.7.6]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.6
[0.9.8]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.8
[0.9.9]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.9
[1.0.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.0.0
[1.0.1]: https://github.com/colbymchenry/codegraph/releases/tag/v1.0.1
[1.1.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.1.0
[1.1.1]: https://github.com/colbymchenry/codegraph/releases/tag/v1.1.1
[1.1.2]: https://github.com/colbymchenry/codegraph/releases/tag/v1.1.2
[1.1.3]: https://github.com/colbymchenry/codegraph/releases/tag/v1.1.3
[1.1.4]: https://github.com/colbymchenry/codegraph/releases/tag/v1.1.4
[1.1.5]: https://github.com/colbymchenry/codegraph/releases/tag/v1.1.5
[1.1.6]: https://github.com/colbymchenry/codegraph/releases/tag/v1.1.6
[1.2.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.2.0
[1.3.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.3.0
[1.3.1]: https://github.com/colbymchenry/codegraph/releases/tag/v1.3.1
[1.4.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.4.0
[1.4.1]: https://github.com/colbymchenry/codegraph/releases/tag/v1.4.1
+269
View File
@@ -0,0 +1,269 @@
# 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: `>=20.0.0 <25.0.0`. There is a hard exit on Node 25.x and below 20 (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`, `sqlite-adapter.ts`. Backed by Node's built-in **`node:sqlite`** (`DatabaseSync`) — real SQLite with WAL + FTS5, exposed through a thin better-sqlite3-shaped adapter. The bundled runtime always ships Node ≥22.5, so `node:sqlite` is always available: **no native build step and no wasm fallback**. (Running from source needs Node ≥22.5.) `codegraph status` reports the live backend (`node-sqlite`, the sole backend).
- `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 and MCP-server JSON/TOML/JSONC writing. (Targets no longer write an instructions file — see below.)
- 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` no longer holds an instructions body — it exports only the `<!-- CODEGRAPH_START -->`/`<!-- CODEGRAPH_END -->` markers. The installer **stopped writing** a `## CodeGraph` block into each agent's instructions file (`CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.config/opencode/AGENTS.md` / `~/.gemini/GEMINI.md` / `.cursor/rules/codegraph.mdc` / Kiro steering doc) because it duplicated the MCP `initialize` instructions verbatim (issue #529). Each target's `install` (self-heal on upgrade) and `uninstall` use the markers to **strip** a block a previous install left behind. `server-instructions.ts` is the single source of truth for agent-facing guidance.
- 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, and as of issue #529 it is the **single source of truth** for agent-facing tool guidance — the installer no longer writes a duplicate `## CodeGraph` instructions block into `CLAUDE.md` / `AGENTS.md` / `.cursor/rules/codegraph.mdc`. Edit tool guidance here and nowhere else.
## Retrieval performance & dynamic-dispatch coverage (do not regress)
CodeGraph's core value is letting an agent answer **structural/flow** questions ("how does X reach Y", trace, impact, callers) with a few **fast** codegraph calls and **zero Read/Grep**. The optimization target is **wall-clock latency + tool-call count***don't optimize for token cost*. (Cost is **lower**, not "flat" as earlier framing claimed: a current-build with-vs-without A/B across the 7 README repos, median of 4, saved on average **35% cost · 57% tokens · 46% time · 71% tool calls** — reproducing the published README. The mechanism is **far fewer turns over a much smaller accumulated context** — NOT cache-ability: the without-arm's huge token volume is *mostly* cheap cache-reads, which is why token-count savings (57%) look bigger than cost savings (35%). Measure tokens by **summing per-turn assistant usage**, not `result.usage` (last-turn only in current Claude Code). See `docs/benchmarks/call-sequence-analysis.md`.) The mechanism that drives everything here: **an agent falls back to Read/Grep the instant a codegraph answer is insufficient.** So every change is judged by one question — is codegraph's answer sufficient enough to *stop* the agent from reading?
**Target behavior:** a flow question resolves in **1 codegraph call on small repos, scaling to 35 on large**, with **Read/Grep = 0**. When reviewing a PR or trying something new, do not regress this.
### Adapt the tool to the agent — don't try to change the agent
The lever that decides whether a retrieval change lands. **Test before building anything here: does this make a tool the agent _already calls_ do more with the input it _already gives_? If it instead needs the agent to behave differently — pick a different tool, query differently, learn from examples — it hits the low-salience wall and won't land.**
CodeGraph's only channels to influence the agent are low-salience: the MCP `initialize` instructions (`server-instructions.ts`) and the tool descriptions. Changing them does **not** reliably move the agent's tool _choice_ or query style — validated: trace-first steering ported into the server-instructions + tool descriptions (3 wording variants) never reproduced what a CLI `--append-system-prompt` achieved, and **regressed** wall-clock vs baseline. New tools fare worse (rarely chosen — the agent under-picks even `trace`); "better examples" is the same steering. The agent's tool-choice does improve on its own as host models get better at tool use — but that is not ours to force.
What works is meeting the agent where it already is:
- **explore-flow** — `codegraph_explore` is the PRIMARY tool the agent reliably calls; its query is a precise bag of symbol names (incl. qualified `Class.method`) spanning the flow the agent is after; explore finds the call path _among those named symbols_ (riding synthesized edges) and leads its output with it. (`buildFlowFromNamedSymbols`: segment/co-naming disambiguation; ≤1 unnamed bridge so it never wanders a god-function's fan-out. Overload-aware: a PascalCase type token in the query biases an overloaded name to that type's own def — `DataRequest task` → DataRequest's `task`, not the abstract base; named-symbol files sort first.)
- **Sufficiency** — make the tool's output complete enough that the agent stops. `codegraph_node` returns the full body + the caller/callee trail, and for an AMBIGUOUS name returns **every overload's body in one call** (so the agent never Reads a file to find the right overload — validated on Alamofire/gin). This is the after-explore depth tool (labeled SECONDARY).
- **Errors teach abandonment** — one or two `isError: true` responses early in a session and the agent stops calling codegraph entirely (maintainer-observed, repeatedly). `isError` is reserved for genuine "stop trying" cases: security refusals (`PathRefusalError`) and real malfunctions (which carry a retry-once note). Every expected/recoverable condition — project not indexed, symbol not found, file not in the index — returns a **SUCCESS-shaped response carrying the guidance** (`NotIndexedError``textResult`, see `ToolHandler.execute`'s catch). The same principle is why the tool surface is **always exposed, even at an un-indexed root** (the old empty-`tools/list` gate was removed in #964 — it broke monorepos where only sub-projects carry a `.codegraph/`, and hid the tools from a session that started before `codegraph init`): safety comes from the response SHAPE (success-shaped guidance, never `isError`), not from hiding tools. An un-indexed root's `initialize` sends a per-project variant (`SERVER_INSTRUCTIONS_NO_ROOT_INDEX` — "pass `projectPath` to a project that has a `.codegraph/`"), not an "inactive" note; indexing is still deliberately the user's call, never the agent's.
What fails is the inverse — folding a precise answer into a **fuzzy-input** tool: the now-removed `codegraph_context` took a description, not symbols, so it couldn't disambiguate a flow's endpoints and surfaced the _wrong feature_ (which is why it was cut). Precise output needs precise input — explore takes a symbol bag for exactly this reason. (`codegraph_trace` was likewise removed: explore-flow does its job and the agent under-picked it.)
The remaining lever under this axis is **coverage**: every flow made to connect statically (a new dynamic-dispatch synthesizer, or extracting symbols static parsing skipped — e.g. object-literal store actions in `create((set,get)=>({...}))`) is then surfaced automatically by explore-flow, no agent change needed. Reactive/reconciler runtimes (Halo's `ReactiveExtensionClient`, MediatR, Vue Proxy) are the frontier — flows there have no static edges, so nothing surfaces (correctly — silent beats wrong). Full investigation + A/B record: `docs/benchmarks/call-sequence-analysis.md` + auto-memory `project_codegraph_read_displacement`.
### Explore budget — keep BOTH budgets monotonic with repo size
Two functions in `src/mcp/tools.ts` scale explore with indexed file count. This is the expected resolution (a regression here silently forces agents back to Read):
| Repo | files | explore calls | chars/call | per-file |
|---|---|---|---|---|
| express (small) | 147 | 1 | 18K | 3800 |
| excalidraw/django (medium) | 6433043 | 2 | 28K | 6500 |
| vscode (large) | 10446 | 3 | 35K | 7000 |
| ~20k / ~40k | — | 4 / 5 | 38K | 7000 |
- `getExploreBudget(fileCount)`**call** budget: `<500→1, <5000→2, <15000→3, <25000→4, ≥25000→5` (max 5).
- `getExploreOutputBudget(fileCount)`**per-call** output (chars / files / per-file). **Invariant: a larger tier must never get a smaller `maxCharsPerFile` than a smaller tier.** (Regression that motivated this doc: the `<5000` tier's 2500 was *below* the `<500` tier's 3800, so on a god-file repo — excalidraw's 415 KB `App.tsx` — one explore returned <1% of the file and forced a Read.)
- Explore output must **never tell the agent to "use Read"** — steer to another `codegraph_explore` and "treat returned source as already Read."
### Dynamic-dispatch coverage — the flow must EXIST in the graph end-to-end
Static tree-sitter extraction misses computed/indirect calls, so flows break at dynamic dispatch and the agent reads to reconstruct them. Synthesizers/resolvers bridge these so `codegraph_explore` connects them end-to-end (`src/resolution/callback-synthesizer.ts`, `src/resolution/frameworks/`). Channels today: callback/observer, EventEmitter, **React re-render** (`setState``render`), **JSX child** (`render`→child component), django ORM descriptor. All synthesized edges are `provenance:'heuristic'` with `metadata.synthesizedBy` + `registeredAt` (the wiring site), surfaced inline in `codegraph_explore`'s Flow section and the `codegraph_node` trail.
**Principle: partial coverage is WORSE than none.** Bridging one boundary but not the next reveals a hop the agent then drills + reads to finish. Measured on excalidraw: react-render alone *raised* reads to 57; only completing the flow (adding the jsx-child hop) dropped it to 01. **Always close the flow end-to-end and re-measure** — never ship a half-bridged flow.
### Validation methodology (REQUIRED for every new language/framework)
For each **language × framework**, validate on **small, medium, and large** real repos with **≥3 different flow prompts** each:
1. **Pick the canonical flow** for the framework ("how does X reach Y": state→render, request→handler→view, query→SQL, action→reducer→store…).
2. **Deterministic probes** (`scripts/agent-eval/probe-{node,explore}.mjs` against the built `dist/`): `codegraph_explore` with the flow's symbol names connects from→to end-to-end with no break (its Flow section shows the path); **no node explosion** (`select count(*) from nodes` stable before/after re-index); synthesized-edge **precision** spot-check (`select … where provenance='heuristic'`).
3. **Agent A/B** (`scripts/agent-eval/run-all.sh <repo> "<Q>"`): with vs without codegraph, **≥2 runs/arm** (run-to-run variance is large — never conclude from n=1). Record **duration, total tool calls, Read, Grep**. Optional forced-Read-0 sufficiency proof via the block-read hook (`scripts/agent-eval/hook-settings.json`).
- **Model policy — every A/B arm runs Claude with `--model sonnet --effort high`. Always. Never Opus/Fable.** All `scripts/agent-eval/*.sh` default to this (`MODEL`/`EFFORT` env override exists — don't raise it without an explicit reason from the maintainer). Two reasons, and the second matters more than cost: (a) Sonnet doesn't burn tokens; (b) **Sonnet is the deliberate floor model** — codegraph's real users attach it to whatever agent they already run (Cursor Composer, Gemini, etc.), so we validate on a "dumber" model on purpose: a stronger model's tool-use covers up the salience/sufficiency problems a weaker one exposes. An affordance that lands on Sonnet generalizes up to every host; one that only works on Opus/Fable doesn't generalize down to the agents most users actually have. Both arms always use the same model.
- **MCP attach is a startup-latency issue, not a hard block.** On a multi-step task the agent dives into Read/grep before codegraph finishes its ~2-3s startup (worse when the eval is itself run nested inside a Claude session, under CPU contention), so it runs with no codegraph. Fix: **pre-warm a persistent daemon** for the target (`CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS` high; spawn `serve --mcp --path <target> </dev/null &`; wait for `.codegraph/daemon.sock`) **and skip the startup re-exec** (`CODEGRAPH_WASM_RELAUNCHED=1`) so claude connects before the agent's first turn. Don't trust claude's `init` snapshot — it can read `status:"pending"` / 0 tools even when it then connects; judge by actual codegraph usage in `parse-run.mjs`'s `by type`. To isolate a change — **new-build vs baseline-build, both codegraph-on** (vs run-all.sh's with-vs-without) — use `scripts/agent-eval/ab-new-vs-baseline.sh <indexed-repo> "<task>" [baseline-ref]` (it bakes in the pre-warm).
4. **Pass bar:** a normal flow question reaches **~0 Read/Grep within the repo's explore-call budget**, runs **faster** than without-codegraph, and shows **no regression on a control repo**. Record the numbers in `docs/design/dynamic-dispatch-coverage-playbook.md` (the coverage matrix).
Full playbook + per-mechanism design: `docs/design/dynamic-dispatch-coverage-playbook.md` and `docs/design/callback-edge-synthesis.md`.
### Worked example — Excalidraw (TS/React, medium, 643 files)
The template to replicate per language/framework. Question: *"how does updating an element re-render the canvas on screen?"* (the full flow crosses three React boundaries: observer callback, `setState``render`, and JSX child).
| Stage | duration | Read | Grep | codegraph |
|---|---|---|---|---|
| Without codegraph | 115139s | 910 | 1011 | 0 |
| Broken (explore-budget regression) | 131139s | 510 | 35 | 614 |
| Fixed (budget + msgs + synthesis) | 64112s | 02 | 24 | 3**10** |
| + trace-first steering | **5174s** | **02** | 04 | **34** |
n=4 unhooked runs/stage, same prompt. After steering flow questions to `codegraph_trace` first: **best run 0 Read / 0 Grep / 3 codegraph / 51s**; **2 of 4 fully clean** (0 Read, 0 Grep). Steering eliminated the over-drill variance — call count tightened from 310 to 34, trace adoption went 3/4 → 4/4, and the `search`+`callers` path-reconstruction floundering dropped to 0. Run-to-run variance is still real; report the range, never a single run. **Residual reads/greps are all the nonce data-flow** (`canvasNonce` — a local prop with no graph edges); that's the def-use/data-flow frontier, left deliberately uncovered (tracking every local would explode the graph). Validated: `trace(mutateElement, renderStaticScene)` connects in **6 hops** across all three boundaries (`mutateElement → triggerUpdate → [callback] triggerRender → [react-render] render → [jsx] StaticCanvas → renderStaticScene`), each hop showing inline source + the wiring site; node count stable at 9,289; 1 callback + 46 react-render + 280 jsx-render synthesized edges (no explosion, precision-checked).
## 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` / `node-sqlite-backend.test.ts` — pin that `node:sqlite` is the sole backend: `getBackend()` reports `node-sqlite` and the DB comes up in WAL.
- `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.
## Cross-platform validation
The dev machine — and the default `npm test` target — is **macOS**, so local runs cover the macOS path. The other two platforms aren't here; when a change is platform-sensitive (file watching, sockets / named pipes, path & symlink handling, process lifecycle, inotify budget) validate them for real rather than guessing.
### Linux (Docker)
When asked to test or validate on Linux, use **Docker** — there's no Linux box, but Docker runs on the macOS host. Build a throwaway image from the repo and run the suite inside it:
- `FROM node:22-bookworm`; `COPY` the repo with a `.dockerignore` excluding `node_modules`/`dist`/`.git`/`.codegraph`; `RUN npm ci && npm run build`. Don't reuse the Mac `node_modules``esbuild`/`rollup` ship platform-specific binaries.
- Run with **`docker run --rm --init`**. The `--init` is load-bearing for any process-lifecycle test (daemon reaping, the #277 PPID watchdog, idle-timeout): without a zombie-reaping PID 1, a SIGKILL'd/exited process lingers as a zombie and `process.kill(pid, 0)` still reports it *alive*, so exit-detection assertions false-fail even though the process did exit.
- Linux is where the inotify watch budget actually bites: count a process's watches via `/proc/<pid>/fdinfo/*` (sum `^inotify ` lines on the fd whose `readlink` is `anon_inode:inotify`).
### Windows (Parallels VM + 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 failures (they reproduce on `main`, unrelated to your change — confirm against `origin/main` before blaming your PR, and don't let them mask new regressions): `security.test.ts > Session marker symlink resistance > does not follow a pre-planted symlink` (symlink creation needs privileges on Windows); and the `mcp-initialize.test.ts` / `mcp-roots.test.ts` suites, which fail in `afterEach` with `EPERM` removing the temp dir because a spawned `serve --mcp` (its `--liftoff-only` re-exec grandchild) still holds the cwd / SQLite file open — a Windows file-locking quirk, not a logic bug.
## 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
**Default: write entries under `## [Unreleased]`** — that's the section reserved for work landing between releases. **Don't pre-create a `## [X.Y.Z]` block** for the next release: the Release workflow's first step is `scripts/prepare-release.mjs`, which automatically promotes everything under `[Unreleased]` into a new `## [X.Y.Z] - <YYYY-MM-DD>` block at release time (or merges into a pre-existing `[X.Y.Z]` block if one exists — but you don't need one). Pre-staging is what caused the v0.9.5 sparse-release-notes incident: a sparse `[0.9.5]` block hand-added before the rest of the work landed got picked by the extractor over the much-larger `[Unreleased]` section above it. Don't do that.
Formatting rules for any entry (anywhere — `[Unreleased]` or otherwise):
1. **Write friendly, user-facing notes — not engineer-facing ones.** Group under `### New Features` and `### Fixes` (sentence-case). Surface `### Breaking Changes` and `### Security` as their own sections **only when the release has them**; fold improvement-flavored changes into New Features. Omit empty sections. (This replaces the old Keep-a-Changelog `Added/Changed/Fixed/Removed/Deprecated` grouping: the GitHub Release page extracts each version block **verbatim** via `scripts/extract-release-notes.mjs`, and the old dense, implementation-focused entries rendered as an unreadable wall of text — so the whole CHANGELOG was rewritten to this format and every published release re-noted to match.)
2. **One plain-language sentence per bullet:** what changed and why it matters to a user. Lead with the capability, or with the symptom that's now fixed.
3. **Strip the internals.** No internal file paths (`src/...`), no internal symbol / function / class names, no benchmark numbers / percentages / node-or-edge counts. **Keep:** language & framework names (Go, Spring, NestJS, …), things a user types or sets (`codegraph install`, `codegraph_explore`, the `CODEGRAPH_*` env vars), agent / IDE names (Claude Code, Cursor, opencode, Kiro, …), and a brief `Thanks @user` when a contributor is credited.
4. Issue / PR references in entries are by number (`(#403)` etc.); the GitHub renderer auto-links them in the published release notes.
5. **Don't add a `[X.Y.Z]: https://...` link reference yourself** — `prepare-release.mjs` appends it automatically when it promotes the version (idempotent: a re-run is a no-op if it already exists).
Multi-word headings like `### New Features` are safe on the normal release path: `prepare-release.mjs` **Case A** moves the whole `[Unreleased]` body verbatim into `[X.Y.Z]`. (Only its rarely-used **Case B** *merge* splits sub-sections with a single-word `^### (\w+)$` regex that wouldn't match them — and Case B fires only if a `[X.Y.Z]` block was pre-created, which rule above already forbids.)
### Release flow (the user runs these)
Releases are built and published by the **GitHub Actions "Release" workflow**
(`.github/workflows/release.yml`). It runs `scripts/prepare-release.mjs` to
promote `[Unreleased]` into `[<version>]` (and auto-commit + push that
CHANGELOG change back to `main` so on-disk truth matches the published
notes), then 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.
**Claude does NOT bump the version unless explicitly asked.** The maintainer
typically does it themselves — often by editing `package.json` directly via
the GitHub web UI. Don't proactively commit a version bump as part of
unrelated work, and don't propose one when summarizing a PR.
When the maintainer DOES bump the version, the only edit strictly required is
to `package.json` — the workflow's "Sync package-lock.json" step detects a
mismatch between `package.json` and `package-lock.json`, runs
`npm install --package-lock-only --ignore-scripts` to rewrite the lock file's
version fields (top-level + `packages.""`), and auto-commits + pushes the
result back to `main` with `[skip ci]`. So a GitHub-web-UI single-file edit to
`package.json` is enough to kick off a clean release. (If they edit both files
locally, that's fine too — the sync step no-ops.)
Once `package.json` is at the target version on `main`, trigger
**Actions → Release → Run workflow** (on `main`). The workflow:
1. Syncs `package-lock.json` to `package.json`'s version if they've drifted; commits + pushes that change.
2. Runs `prepare-release.mjs <X.Y.Z>` → promotes `[Unreleased]` → `[X.Y.Z] - <today>` in `CHANGELOG.md`, appends the link reference, commits + pushes the move with `[skip ci]`.
3. Builds every platform bundle on one runner, generates `SHA256SUMS`.
4. Creates the GitHub Release with notes from the freshly-promoted `[X.Y.Z]` block.
5. Publishes the npm shim + per-platform packages. 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, edit `src/mcp/server-instructions.ts` — it is the **single source of truth** for agent-facing tool guidance (issue #529). The installer no longer writes a duplicate instructions block into `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` / `.cursor/rules/codegraph.mdc` / Kiro steering, so there's nothing to keep in sync anymore. (The repo's own checked-in `.cursor/rules/codegraph.mdc` is dogfooding config — update it too if you use Cursor on this repo, but it ships nowhere.)
- 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.
- **When the user references issues, PR comments, or external reports, anchor them to a date and version before drawing conclusions.** Check the comment's `createdAt` against:
- The **last released version** — `grep -m1 '^## \[' CHANGELOG.md` shows the top-of-file version (older releases follow). A comment dated before the latest `## [X.Y.Z] - YYYY-MM-DD` is reacting to *released* state — work that's only on `main` or on an unmerged branch doesn't apply.
- The **last main commit** — `git log --first-parent main -1 --format='%ai %h %s'`. A comment after the last release but before a fix on main may already be addressed there but unreleased.
- The **current branch's tip** — your own unmerged work obviously can't be what the comment is reacting to.
Always disambiguate "released," "merged-but-unreleased," and "in-progress" before agreeing that a user-reported problem is unfixed (or that a fix is incomplete). A user saying "your fix only covers X" about a recent PR is usually pointing at the *released* shortcomings — your in-flight branch may already address them but they have no way to know that.
- **Version-tag every image referenced in `README.md`.** GitHub caches README images (`raw.githubusercontent.com` with a 5-minute TTL; third-party hosts sit behind the long-lived camo proxy), so updating an asset in place can keep showing the stale version. Give each README image URL a `?v=N` query tag and **bump `N` in the same commit whenever the asset bytes change** — e.g. `assets/waitlist.svg?v=2`. The changed URL sidesteps every cache so the new image shows immediately instead of waiting on a TTL to expire.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Colby Mchenry
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+885
View File
@@ -0,0 +1,885 @@
<div align="center">
# CodeGraph
## 🎉 1.0 Released!
Already installed? Run `codegraph upgrade`
Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates.
### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence
**Surgical context · fewer tool calls · faster answers · 100% local**
### [Documentation & Website →](https://colbymchenry.github.io/codegraph/)
[![npm version](https://img.shields.io/npm/v/@colbymchenry/codegraph.svg)](https://www.npmjs.com/package/@colbymchenry/codegraph)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Self-contained](https://img.shields.io/badge/Node.js-bundled%20%C2%B7%20none%20required-brightgreen.svg)](https://nodejs.org/)
[![Windows](https://img.shields.io/badge/Windows-supported-blue.svg)](#supported-platforms)
[![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#supported-platforms)
[![Linux](https://img.shields.io/badge/Linux-supported-blue.svg)](#supported-platforms)
[![Claude Code](https://img.shields.io/badge/Claude_Code-supported-blueviolet.svg)](#supported-agents)
[![Cursor](https://img.shields.io/badge/Cursor-supported-blueviolet.svg)](#supported-agents)
[![Codex](https://img.shields.io/badge/Codex-supported-blueviolet.svg)](#supported-agents)
[![opencode](https://img.shields.io/badge/opencode-supported-blueviolet.svg)](#supported-agents)
[![Hermes Agent](https://img.shields.io/badge/Hermes_Agent-supported-blueviolet.svg)](#supported-agents)
[![Gemini](https://img.shields.io/badge/Gemini-supported-blueviolet.svg)](#supported-agents)
[![Antigravity](https://img.shields.io/badge/Antigravity-supported-blueviolet.svg)](#supported-agents)
[![Kiro](https://img.shields.io/badge/Kiro-supported-blueviolet.svg)](#supported-agents)
<br>
**The CodeGraph platform is coming** — for every PR, know exactly what to test, what could break, which flows are affected, and whether business logic is compromised.
<a href="https://getcodegraph.com"><img alt="Join the waitlist for early beta access" src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/waitlist.svg?v=2" height="52"></a>
<sub>Get <b>early beta access</b> to the hosted product · <a href="https://getcodegraph.com">getcodegraph.com</a></sub>
</div>
## Contents
- [Get Started](#get-started)
- [Language Support](#language-support)
- [Why CodeGraph?](#why-codegraph)
- [Key Features](#key-features)
- [Framework-aware Routes](#framework-aware-routes)
- [Mixed iOS / React Native / Expo bridging](#mixed-ios--react-native--expo-bridging)
- [Quick Start](#quick-start)
- [How It Works](#how-it-works)
- [CLI Reference](#cli-reference)
- [MCP Tools](#mcp-tools)
- [Library Usage](#library-usage)
- [Configuration](#configuration)
- [Telemetry](#telemetry)
- [Supported Platforms](#supported-platforms)
- [Supported Agents](#supported-agents)
- [Supported Languages](#supported-languages)
- [Measured cross-file coverage](#measured-cross-file-coverage)
- [Troubleshooting](#troubleshooting)
- [Star History](#star-history)
- [License](#license)
## Get Started
### 1. Install the CLI
**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
```
<details>
<summary><b>Already have Node? Use npm instead (works on any version)</b></summary>
```bash
npm i -g @colbymchenry/codegraph
```
<sub>CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The installer puts `codegraph` on your PATH but **doesn't change your current shell** — open a new terminal before the next step so the command resolves.</sub>
<sub>**Upgrade any time** with `codegraph upgrade` — it detects how you installed (bundle, npm, or npx) and updates in place. Add `--check` to see if an update is available, or `codegraph upgrade <version>` to pin one.</sub>
</details>
### 2. Wire up your agent(s)
In a **new terminal**, run the installer to connect CodeGraph to the agents you use:
```bash
codegraph install
```
<sub>Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.)</sub>
### 3. Initialize each project
```bash
cd your-project
codegraph init
```
<sub>`codegraph init` creates the local `.codegraph/` directory and builds the full graph in the same step — one command, done.</sub>
<div align="center">
![1_C_VYnhpys0UHrOuOgpgoyw](https://github.com/user-attachments/assets/f168182f-4d9a-44e0-94d7-08d018cc8a3a)
</div>
### 4. No more syncing!
Auto-sync is enabled by default. CodeGraph watches the project and updates the graph on every file change — while your agent edits code, or you add, modify, or delete files. **The index is never stale, and there is nothing to re-run.**
### Uninstall
Changed your mind? One command removes CodeGraph from every agent it configured **and** the CLI itself — every install it finds (standalone bundle, npm global package, launcher link), shown to you before anything is deleted:
```bash
codegraph uninstall
```
Pass `--keep-cli` to remove only the agent configurations and keep the CLI installed.
<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>
---
## Language Support
Every language below gets the same treatment — full structural extraction and cross-file resolution into one graph, no per-language setup:
<p align="center">
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/typescript.svg?v=1" width="104" height="104" alt="TypeScript" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/javascript.svg?v=1" width="104" height="104" alt="JavaScript" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/arkts.svg?v=1" width="104" height="104" alt="ArkTS" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/python.svg?v=1" width="104" height="104" alt="Python" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/go.svg?v=1" width="104" height="104" alt="Go" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/rust.svg?v=1" width="104" height="104" alt="Rust" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/java.svg?v=1" width="104" height="104" alt="Java" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/csharp.svg?v=1" width="104" height="104" alt="C#" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/php.svg?v=1" width="104" height="104" alt="PHP" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/ruby.svg?v=1" width="104" height="104" alt="Ruby" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/c.svg?v=1" width="104" height="104" alt="C" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/cpp.svg?v=1" width="104" height="104" alt="C++" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/objective-c.svg?v=1" width="104" height="104" alt="Objective-C" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/metal.svg?v=1" width="104" height="104" alt="Metal" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/cuda.svg?v=1" width="104" height="104" alt="CUDA" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/swift.svg?v=1" width="104" height="104" alt="Swift" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/kotlin.svg?v=1" width="104" height="104" alt="Kotlin" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/scala.svg?v=1" width="104" height="104" alt="Scala" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/dart.svg?v=1" width="104" height="104" alt="Dart" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/svelte.svg?v=1" width="104" height="104" alt="Svelte" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/vue.svg?v=1" width="104" height="104" alt="Vue" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/astro.svg?v=1" width="104" height="104" alt="Astro" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/liquid.svg?v=1" width="104" height="104" alt="Liquid" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/delphi.svg?v=1" width="104" height="104" alt="Pascal / Delphi" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/lua.svg?v=1" width="104" height="104" alt="Lua" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/r.svg?v=1" width="104" height="104" alt="R" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/luau.svg?v=1" width="104" height="104" alt="Luau" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/cfml.svg?v=1" width="104" height="104" alt="CFML" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/cobol.svg?v=1" width="104" height="104" alt="COBOL" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/vbnet.svg?v=1" width="104" height="104" alt="Visual Basic .NET" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/erlang.svg?v=1" width="104" height="104" alt="Erlang" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/solidity.svg?v=1" width="104" height="104" alt="Solidity" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/terraform.svg?v=1" width="104" height="104" alt="Terraform / OpenTofu" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/nix.svg?v=1" width="104" height="104" alt="Nix" />
</p>
<sub>Per-language details — extensions, frameworks, and what exactly gets extracted — in [Supported Languages](#supported-languages).</sub>
---
## Why CodeGraph?
When an AI agent needs to understand code — to answer a question or make a change — it discovers structure the slow way: grep, glob, and Read, one file at a time, rebuilding call paths and dependencies by hand. That's a pile of tool calls and round-trips before it even starts the real work.
**CodeGraph hands the agent the exact code it needs in one call.** It's a pre-built knowledge graph of every symbol, call edge, and dependency in your codebase — so instead of crawling files, the agent asks one question and gets back the relevant source, the call paths between those symbols (including dynamic-dispatch hops grep can't follow), and the blast radius of a change. **Surgical context, not a file-by-file search** — which means fewer tool calls and faster answers on every codebase, large or small.
<img width="1536" height="1024" alt="token-cost-savings-scale" src="https://github.com/user-attachments/assets/eb74a11a-a3ab-4b01-80a6-19f78352ae8e" />
> **A note on cost:** CodeGraph's win on *every* codebase is precision and speed — fewer tool calls, faster answers. It cuts token and dollar cost too, but those savings are **scale-dependent**: small and noisy on a modest codebase, and material only once a repo is large and tangled — at the scale of a Google or Microsoft monorepo, multiplied by a whole team's daily agent usage — for them to compound into a real line item. On a 500-file project, adopt CodeGraph for the speed; the cost savings show up when the codebase (and the team) gets big.
### 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, at the **median of 4 runs per arm**. _Re-validated on Opus 4.8 (2026-06-02), on the current build (`codegraph_explore` as the primary tool)._
> **The universal win — every repo, every size: 58% fewer tool calls · 22% faster · file reads cut to ~zero.**
The reliable, universal payoff is **surgical context and speed**: CodeGraph collapses the agent's grep/find/Read crawl into a few direct queries — returning the exact methods you asked about even when they're buried in a multi-thousand-line file — so it answers with **near-zero file reads** while the no-CodeGraph agent spends its budget on discovery. The **Tokens** and **Cost** columns are real too, but — as noted above — they're **scale-dependent**: small and noisy per query, compounding into real money only at large-codebase, high-volume scale.
| Codebase | Language | Tool calls | Time | File reads | Tokens | Cost |
|----------|----------|------------|------|------------|--------|------|
| **VS Code** | TypeScript · ~10k files | 81% fewer | 11% faster | 0 vs 9 | 64% fewer | 18% cheaper |
| **Excalidraw** | TypeScript · ~640 | 40% fewer | 27% faster | 0 vs 7 | 25% fewer | even |
| **Django** | Python · ~3k | 77% fewer | 13% faster | 0 vs 9 | 60% fewer | 8% cheaper |
| **Tokio** | Rust · ~790 | 57% fewer | 18% faster | 0 vs 8 | 38% fewer | even |
| **OkHttp** | Java · ~645 | 50% fewer | 31% faster | 0 vs 4 | 54% fewer | 25% cheaper |
| **Gin** | Go · ~110 | 44% fewer | 24% faster | 1 vs 6 | 23% fewer | 19% cheaper |
| **Alamofire** | Swift · ~110 | 58% fewer | 33% faster | 0 vs 9 | 64% fewer | 40% cheaper |
<sub>**File reads** = median files the agent opened **with** vs **without** CodeGraph — the surgical-context win in one column. **Tokens** and **Cost** are the same with-vs-without deltas; they're directional (they move run-to-run) and, per query, small in absolute terms — which is why they only become a line item at scale. `codegraph_explore` also collapses redundant interchangeable implementations to signatures, so a response is sized to the *answer* rather than the file count.</sub>
<details>
<summary><strong>Per-repo breakdown — WITH vs WITHOUT (median of 4)</strong></summary>
**VS Code** · ~10k files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 59s | 2m 13s | 11% faster |
| File Reads | 0 | 9 | 9 |
| Grep/Bash | 0 | 11 | 11 |
| Tool calls | 4 | 21 | 81% fewer |
| Total tokens | 640k | 1.79M | 64% fewer |
| Cost | $0.68 | $0.83 | 18% cheaper |
**Excalidraw** · ~640 files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 32s | 2m 6s | 27% faster |
| File Reads | 0 | 7 | 7 |
| Grep/Bash | 1 | 8 | 7 |
| Tool calls | 9 | 15 | 40% fewer |
| Total tokens | 1.27M | 1.69M | 25% fewer |
| Cost | $0.78 | $0.78 | even |
**Django** · ~3k files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 43s | 1m 58s | 13% faster |
| File Reads | 0 | 9 | 9 |
| Grep/Bash | 0 | 5 | 5 |
| Tool calls | 3 | 13 | 77% fewer |
| Total tokens | 559k | 1.41M | 60% fewer |
| Cost | $0.57 | $0.62 | 8% cheaper |
**Tokio** · ~790 files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 55s | 2m 20s | 18% faster |
| File Reads | 0 | 8 | 8 |
| Grep/Bash | 0 | 6 | 6 |
| Tool calls | 6 | 14 | 57% fewer |
| Total tokens | 1.08M | 1.73M | 38% fewer |
| Cost | $0.82 | $0.82 | even |
**OkHttp** · ~645 files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 1s | 1m 29s | 31% faster |
| File Reads | 0 | 4 | 4 |
| Grep/Bash | 2 | 6 | 4 |
| Tool calls | 5 | 10 | 50% fewer |
| Total tokens | 502k | 1.10M | 54% fewer |
| Cost | $0.41 | $0.55 | 25% cheaper |
**Gin** · ~110 files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 14s | 1m 37s | 24% faster |
| File Reads | 1 | 6 | 5 |
| Grep/Bash | 1 | 2 | 1 |
| Tool calls | 5 | 9 | 44% fewer |
| Total tokens | 651k | 847k | 23% fewer |
| Cost | $0.46 | $0.57 | 19% cheaper |
**Alamofire** · ~110 files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 35s | 2m 21s | 33% faster |
| File Reads | 0 | 9 | 9 |
| Grep/Bash | 0 | 4 | 4 |
| Tool calls | 5 | 12 | 58% fewer |
| Total tokens | 766k | 2.10M | 64% fewer |
| Cost | $0.57 | $0.95 | 40% cheaper |
</details>
<details>
<summary><strong>Full benchmark details</strong></summary>
**Methodology.** Each arm is `claude -p` (Claude Opus 4.8) 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. Re-validated 2026-06-02 on the current build. These numbers are lower than the prior Opus 4.7 validation — not a CodeGraph regression but a stronger native baseline: Opus 4.8 greps/reads efficiently on the main thread instead of fanning out into large Explore-subagent sweeps, so the no-CodeGraph arm is leaner than it used to be. Per-repo numbers move run-to-run with how hard the without-arm thrashes (the median-of-4 smooths it, but tails remain — e.g. Django's without-arm hit $2.71/14m one batch).
**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?" |
**Why CodeGraph wins:** with the index available, the agent answers directly — usually one `codegraph_explore` returns the relevant source — and stops, usually with zero file reads. Without it, the agent 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
| | |
|---|---|
| **Surgical Context** | One tool call returns entry points, related symbols, and code snippets — no slow file-by-file exploration |
| **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 |
| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
<details>
<summary><strong>How auto-syncing works — and why you don't need to run <code>codegraph sync</code> manually</strong></summary>
When your agent (Claude Code, Cursor, Codex, opencode) launches `codegraph serve --mcp`, three layers keep the index in step with your code — and make sure the agent never gets a silent wrong answer in the brief window between an edit and the next sync:
1. **File watcher with debounced auto-sync.** A native FSEvents / inotify / ReadDirectoryChangesW watcher captures every source-file create / modify / delete and triggers a re-index after a debounce window (default `2000ms`, tunable via `CODEGRAPH_WATCH_DEBOUNCE_MS`, clamped to `[100ms, 60s]`). Bursts of edits collapse into a single sync.
2. **Per-file staleness banner.** During the brief debounce window, MCP tool responses that would reference a still-pending file prepend a `⚠️` banner naming it and telling the agent to `Read` it directly. Pending files NOT referenced by the response surface as a small footer instead. Either way, the agent gets an explicit signal — validated with Claude Code, where the agent literally says "Reading the file directly for the live content" before opening it.
3. **Connect-time catch-up.** When the MCP server (re)connects, codegraph runs a fast `(size, mtime)` + content-hash reconciliation against the working tree before answering the first query — so edits made while no MCP server was running (a `git pull` from the terminal, edits from another editor, a previous agent session that exited) get absorbed on the next session's first tool call.
```
agent writes src/Widget.ts
→ watcher fires (<100ms)
→ debounce (default 2s)
→ sync; Widget.ts is in the index
→ next agent query sees it
```
**Verify any time** with `codegraph status` (CLI). If anything is pending, you'll see a `### Pending sync:` section naming the files and their edit age.
The handful of cases where manual `codegraph sync` makes sense: the watcher is disabled (sandboxed environments, or `CODEGRAPH_NO_DAEMON=1`), or you're scripting against the index outside an agent session and want a pre-flight sync at the start of your script.
→ Full deep-dive in [Guides → Indexing a Project](https://colbymchenry.github.io/codegraph/guides/indexing/#stay-fresh-automatically).
</details>
---
## 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 |
| **Play** | `GET`/`POST`/… verb routes in `conf/routes``Controller.method` actions (Scala + Java) |
| **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 |
| **Vue Router** / **Nuxt** | `pages/` file-based routes, `server/api/` endpoints, route middleware |
| **Astro** | `src/pages/` file-based routes (`.astro` pages + `.ts` endpoints, `[param]`/`[...rest]` syntax) |
---
## Mixed iOS / React Native / Expo bridging
Real iOS and React Native codebases live across multiple languages — a Swift caller invokes an Objective-C selector that's been auto-bridged, a JS file calls into a native module via the React Native bridge, a JSX component delegates to a native view manager. Static tree-sitter extraction stops at each language boundary. CodeGraph bridges them so `codegraph_explore` connects the flow end-to-end across the gap — call paths and blast radius cross the boundary instead of stopping at it.
| Boundary | JS / Swift side | Native side | How |
|---|---|---|---|
| **Swift → ObjC** | Swift `obj.foo(bar:)` | ObjC selector `-fooWithBar:` | `@objc` auto-bridging rules (including init/property/protocol forms) + Cocoa preposition prefixes (`With`/`For`/`By`/`In`/`On`/`At`/…) |
| **ObjC → Swift** | ObjC `[obj fooWithBar:]` | Swift `@objc func foo(bar:)` | Reverse-bridge name candidates; verifies `@objc` exposure from source |
| **React Native legacy bridge** | JS `NativeModules.X.fn(...)` | ObjC `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` · Java/Kotlin `@ReactMethod` | Parses macro/annotation declarations to build a JS-name → native-method map |
| **React Native TurboModules** | JS `import M from './NativeM'; M.fn(...)` | Native impl matching the Codegen spec | Treats the `Native<X>.ts` spec interface as ground truth |
| **RN native → JS events** | JS `new NativeEventEmitter(...).addListener('e', cb)` | ObjC `[self sendEventWithName:@"e" body:...]` · Swift `sendEvent(withName: "e", ...)` · Java/Kotlin `.emit("e", ...)` | Synthesized cross-language event channel keyed by literal event name |
| **Expo Modules** | JS `requireNativeModule('X').fn(...)` | Swift / Kotlin `Module { Name("X"); AsyncFunction("fn") { ... } }` | Parses the Expo DSL literals; synthetic method nodes resolve via existing name-match |
| **Fabric view components** | JSX `<MyView prop={v}/>` | TS Codegen spec + native impl class | Spec → `component` node; convention-based name+suffix lookup (`View`/`ComponentView`/`Manager`/`ViewManager`) bridges to native |
| **Legacy Paper view managers** | JSX `<MyView prop={v}/>` | ObjC `RCT_EXPORT_VIEW_PROPERTY` · Java/Kotlin `@ReactProp` | Same as Fabric — Paper-era declarations also produce `component` + `property` nodes |
**Validated on real codebases** (small + medium + large for each bridge):
| Bridge | Small | Medium | Large |
|---|---|---|---|
| Swift ↔ ObjC | [Charts](https://github.com/danielgindi/Charts) | [realm-swift](https://github.com/realm/realm-swift) | [Wikipedia-iOS](https://github.com/wikimedia/wikipedia-ios) |
| RN legacy bridge | [AsyncStorage](https://github.com/react-native-async-storage/async-storage) | [react-native-svg](https://github.com/software-mansion/react-native-svg) | [react-native-firebase](https://github.com/invertase/react-native-firebase) |
| RN native → JS events | [RNGeolocation](https://github.com/Agontuk/react-native-geolocation-service) | — | react-native-firebase |
| Expo Modules | expo-haptics | expo-camera | expo SDK sweep (7 packages) |
| Fabric / Paper views | [react-native-segmented-control](https://github.com/react-native-segmented-control/segmented-control) | [react-native-screens](https://github.com/software-mansion/react-native-screens) | [react-native-skia](https://github.com/Shopify/react-native-skia) |
Each bridge emits edges tagged `provenance:'heuristic'` with `metadata.synthesizedBy:` set to a stable channel name (e.g. `swift-objc-bridge`, `rn-event-channel`, `fabric-native-impl`, `expo-module-extract`), so the agent can tell at a glance how a hop got into the graph.
---
## 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**, **Gemini CLI**, **Antigravity IDE**, **Kiro**
- 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, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`.
- Set up auto-allow permissions when Claude Code is one of the targets
The installer **wires up your agents only — it does not index your code.** After it finishes, build each project's graph yourself with `codegraph init` (step 3). One global `codegraph install` covers every project; you run `codegraph init` once per project.
**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 / Gemini CLI / Antigravity IDE / Kiro) for the MCP server to load.
### 3. Initialize Projects
```bash
cd your-project
codegraph init
```
Builds the per-project knowledge graph index, which then auto-syncs on every file change. 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__*"
]
}
}
```
<sub>One wildcard auto-approves every CodeGraph tool — `codegraph_explore` is the only one listed by default, but if you re-enable others via `CODEGRAPH_MCP_TOOLS` they're already permitted, no prompt.</sub>
</details>
<details>
<summary><strong>Agent Tool Guidance</strong></summary>
CodeGraph's MCP server delivers its usage guidance to your agent **automatically**, in the MCP `initialize` response. In short, it tells the agent to:
- **Answer structural questions directly with CodeGraph** — it *is* the pre-built index, so a grep/read loop just repeats work it already did. Treat the returned source as already read.
- **Reach for `codegraph_explore` for almost anything** — "how does X work", a flow/"how does X reach Y", or surveying an area. One call returns the relevant symbols' verbatim source grouped by file, the call paths between them (dynamic-dispatch hops included), and a blast-radius summary. Name a file or symbol in the query to read its current line-numbered source.
- **Trust the results — don't re-verify with grep**, and check the staleness banner after edits.
- Works **per project**: query any project that has a `.codegraph/` index by passing `projectPath` — so a monorepo where only some services are indexed, or a second repo, works in one session. A path with no index returns clean guidance to use built-in tools; indexing stays your decision.
The exact text is `src/mcp/server-instructions.ts` — the single source of truth for the main agent. Because subagents and non-MCP harnesses never see the MCP guidance, the installer also writes a short marker-fenced section into the agent's instructions file pointing at the `codegraph explore` CLI equivalent.
</details>
---
## How It Works
```
┌───────────────────────────────────────────────────────────────────┐
│ Claude Code │
│ │
│ "How does a request reach the database?" │
│ calls CodeGraph tools directly — no Explore sub-agent │
│ │ │
└─────────────────────────────────┬─────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────┐
│ CodeGraph MCP Server │
│ │
│ explore · one call → verbatim source + call flow + blast radius │
│ │ │
│ ▼ │
│ SQLite knowledge graph │
│ symbols · edges · files · FTS5 full-text search │
└───────────────────────────────────────────────────────────────────┘
```
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 AND the CLI (--keep-cli for configs only)
codegraph init [path] # Initialize a project + build its graph (one step)
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 unlock [path] # Remove a stale lock file that's blocking indexing
codegraph query <search> # Search symbols (--kind, --limit, --json)
codegraph explore <query> # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)
codegraph node <symbol|file> # One symbol's source + callers, or read a file with line numbers (same output as codegraph_node)
codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json)
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 daemon # Manage background daemons — pick one to stop (alias: daemons)
codegraph telemetry [on|off] # Show or change anonymous usage telemetry
codegraph upgrade [version] # Update to the latest release (--check, --force)
codegraph version # Print the installed version (also -v, --version)
codegraph help [command] # Show help, optionally for one command
```
### `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 a **single tool**`codegraph_explore`. Measured agent behavior showed that one strong tool steers agents better than a menu of narrower ones — fewer mis-picks, and it saves context every session:
| Tool | Purpose |
|------|---------|
| `codegraph_explore` | Answer almost any question in one call — "how does X work", a flow ("how does X reach Y"), or surveying an area — returning the relevant symbols' verbatim source grouped by file, plus the call paths between them and a blast-radius summary. Surfaces dynamic-dispatch hops (callbacks, React re-render, interface→impl) grep can't follow. Name a file or symbol in the query to read its current line-numbered source, the same shape the Read tool gives you. |
The other tools (`codegraph_node`, `codegraph_search`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) stay fully functional but **unlisted by default** — everything they return already arrives inline on `codegraph_explore` (its blast-radius section, the relationship map, a symbol's body as its callee list). Re-enable any of them for the MCP surface with the `CODEGRAPH_MCP_TOOLS` environment variable (e.g. `CODEGRAPH_MCP_TOOLS=explore,node,search,callers`), or use their CLI equivalents (`codegraph node` / `query` / `callers` / `callees` / `impact` / `files` / `status`).
Even when the server's own root has no `.codegraph/` index, the tools stay available: pass `projectPath` to query any indexed project — a sub-service in a monorepo, or a second repo — in the same session. A path that has no index returns clean guidance to use built-in tools instead, so nothing fails loudly, and indexing stays your decision.
---
## Library Usage
CodeGraph can be embedded directly. The npm package re-exports its programmatic
API, so both `import` and `require` resolve the `CodeGraph` class in your own
process — handy for embedding it in an app (e.g. an Electron main process).
```typescript
import CodeGraph from '@colbymchenry/codegraph';
// CommonJS works too:
// const { CodeGraph } = require('@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();
```
Lower-level building blocks are exported from the same entry point for callers
that drive the graph directly: `DatabaseConnection`, `QueryBuilder`,
`getDatabasePath`, `initGrammars` / `loadGrammarsForLanguages`, and `FileLock`.
**Embedding requirements**
- Install from npm (`npm i @colbymchenry/codegraph`) so the matching
per-platform package — which carries the compiled library and its
dependencies — is fetched alongside the shim.
- The API runs on **your** runtime, so it needs **Node 22.5+** for the built-in
`node:sqlite` (Electron qualifies when its bundled Node is 22.5+). The CLI and
MCP server are unaffected — they run on the self-contained bundled runtime.
- TypeScript types ship with the package. As with any Node-targeting library,
keep `@types/node` available and `skipLibCheck: true` (the common default).
---
## Configuration
Next to none — CodeGraph is **zero-config by default**, with nothing to write or
keep in sync to get started. Language support is automatic from the file
extension; there's nothing to wire up per language. The one optional file is for
mapping [custom file extensions](#custom-file-extensions).
What it skips out of the box:
- **Dependency, build, and cache directories** — `node_modules`, `vendor`,
`dist`, `build`, `target`, `.venv`, `Pods`, `.next`, and the like across every
[supported stack](#supported-languages) — so the graph is your code, not
third-party noise. This holds even with no `.gitignore`.
- **Anything in your `.gitignore`** — honored in git repos via git, and in
non-git projects by reading `.gitignore` directly (root and nested).
- **Files larger than 1 MB** — generated bundles, minified JS, vendored blobs.
To keep something else out, add it to `.gitignore`. To pull a default-excluded
directory back **in** (say you really do want a vendored dependency indexed),
add a negation — `!vendor/`. The defaults apply uniformly, so committing a
dependency or build directory doesn't force it into the graph; the `.gitignore`
negation is the explicit opt-in.
`.gitignore` can't drop a directory you've **committed**, though. For a vendored
theme or SDK that's checked into the repo (e.g. a Metronic theme under
`static/`), list it under `exclude` in `codegraph.json` — gitignore-style
patterns, matched against repo-root-relative paths, honored on index, sync, and
watch:
```json
{
"exclude": ["static/", "**/vendor/**"]
}
```
Conversely, when real source is gitignored on purpose — a project under a second
VCS (SVN, Perforce) that `.gitignore`s its own source so it stays out of Git —
force it back in with `include` (the opposite of `exclude`; `includeIgnored`
only revives embedded git repos, not plain source):
```json
{
"include": ["Tools/", "Local/typescript/"]
}
```
CodeGraph discovers those files off disk, overriding `.gitignore`, on index,
sync, and watch. An explicit `exclude` still wins, and built-in skips
(`node_modules`, `dist`, `.git`) are never re-included.
### Custom file extensions
If your project uses a non-standard extension for a [supported
language](#supported-languages) — say `.dota_lua` for Lua, or `.tpl` for PHP —
those files are skipped by default, because the extension isn't one CodeGraph
recognizes. Map them with an optional **`codegraph.json`** at your project root:
```json
{
"extensions": {
".dota_lua": "lua",
".tpl": "php"
}
}
```
Each value is a supported language id. The mappings merge on top of the built-in
defaults and win on conflict, so you can also re-point a built-in (e.g.
`".h": "cpp"`). Commit the file to share the mapping with your team. A typo'd
language or a malformed file is warned about and skipped — it never breaks
indexing — and a project with no `codegraph.json` behaves exactly as before.
Re-index (`codegraph index`) after adding or changing mappings.
## Telemetry
CodeGraph collects **anonymous usage statistics** — which tools and commands get
used, which languages get indexed — to guide where language and agent support
work goes. **Never** any code, paths, file or symbol names, queries, or IP
addresses; usage is aggregated locally into daily totals before anything is
sent, and the ingest endpoint is [public code in this repo](telemetry-worker/)
that enforces the documented field list. The installer asks up front; turn it
off any time:
```bash
codegraph telemetry off # or: CODEGRAPH_TELEMETRY=0, or DO_NOT_TRACK=1
```
[`TELEMETRY.md`](TELEMETRY.md) lists every field, with the off-switches and the
full data-handling story.
## 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 (which delivers its own usage guidance, so no instructions file
is written):
- **Claude Code**
- **Cursor**
- **Codex CLI**
- **opencode**
- **Hermes Agent**
- **Gemini CLI**
- **Antigravity IDE**
- **Kiro**
## Supported Languages
| Language | Extension | Status |
|----------|-----------|--------|
| TypeScript | `.ts`, `.tsx` | Full support |
| JavaScript | `.js`, `.jsx`, `.mjs` | Full support |
| ArkTS (HarmonyOS) | `.ets` | Full support (everything TypeScript has, plus `@Component`/`@ComponentV2` structs with their ArkUI decorators (`@State`/`@Prop`/`@Link`/`@Local`/`@Builder`/…), `build()` view trees — parent→child component edges, chained-attribute links to `@Extend`/`@Styles` functions, `.onClick(this.handler)` event bindings — dynamic-dispatch bridges for state→`build()` re-renders, `@ohos.events.emitter` emit→subscriber pairs (static event keys only), and `router.pushUrl` literal urls → the target page struct; ohpm workspace modules resolve bare `import { X } from "data"` through `oh-package.json5` `file:` dependencies, honoring each module's `main` entry) |
| 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 |
| Objective-C | `.m`, `.mm`, `.h` | Partial support (classes, protocols, methods, `@property`, `#import`, message sends; `.mm` ObjC++ may parse incompletely) |
| Metal | `.metal` | Full support (vertex/fragment/kernel functions, structs, type aliases, call edges — MSL parses as C++, with `[[attribute]]` annotations handled) |
| CUDA | `.cu`, `.cuh` | Full support (kernels and device/host functions, structs, classes, host→kernel call edges through `<<<grid, block>>>` launch syntax — templated launches, function-pointer launches (`auto kernel = &fn<...>`), `dim3{...}` configs, and macro-defined kernels included; `__global__`/`__device__`/`__launch_bounds__` specifiers handled; CUDA in plain `.h`/`.hpp` headers recognized by content) |
| 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) |
| Astro | `.astro` | Full support (frontmatter + script extraction, template component/call references, `src/pages/` 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) |
| R | `.R` `.r` | Full support (functions in every assignment form, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) |
| Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) |
| CFML | `.cfc`, `.cfm`, `.cfs` | Full support (tag-based `<cfcomponent>`/`<cffunction>` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `<cfscript>` delegation, call edges) |
| COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) |
| Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) |
| Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) |
| Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) |
| Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) |
| Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) |
## Measured cross-file coverage
Impact and blast-radius queries are only as good as the dependency graph behind them, so coverage is measured rather than asserted. **Fair coverage** = the share of symbol-bearing source files that have at least one *resolved cross-file dependent* — something that imports, calls, references, or (through a framework convention) routes to them — on a real-world benchmark repo per language. The residual is always a genuine static-analysis frontier (runtime dynamic dispatch, reflection / DI containers, framework-convention entry points, vendored third-party code), never hidden by gaming the denominator.
| Language | Benchmark repo | Coverage |
|---|---|---|
| TypeScript / JavaScript | this repo | 95.8% |
| Python | psf/requests | 100% |
| Go | gin-gonic/gin | 96.6% |
| Rust | BurntSushi/ripgrep | 86.7% |
| Java | google/gson | 93.3% |
| C# | jbogard/MediatR | 85.2% |
| PHP | guzzle/guzzle | 100% |
| Ruby | sidekiq/sidekiq | 100% |
| C | redis/redis | 92.2% |
| C++ | google/leveldb | 94.8% |
| Objective-C | SDWebImage | 91.6% |
| Swift | Alamofire | 95.3% |
| Kotlin | square/okhttp | 96.2% |
| Scala | gatling/gatling | 91.2% |
| Dart | flutter/packages | 92.4% |
| Svelte / SvelteKit | sveltejs/realworld | 100% |
| Vue / Nuxt | nuxt/movies | 93.5% |
| Astro | xingwangzhe/stalux | 93.0% |
| Lua | nvim-telescope/telescope.nvim | 84.2% |
| Luau | dphfox/Fusion | 92.2% |
| Liquid | Shopify/dawn | 73.8% |
| Pascal / Delphi | PascalCoin | 77.4% |
Framework routing is validated the same way, on a canonical app per framework: Express 100%, FastAPI 98%, Flask 100%, NestJS 96.8%, Gin 96.5%, Axum 100%, Rocket 93.8%, Vapor 100%, Laravel 92%, Rails 89.6%, React Router 100% — and the convention/reflection-heavy ones at their honest static-analysis ceiling: ASP.NET 83.9%, Spring 83.3%, Drupal 78.9%, Play 76.3%, Django 74.1%. SvelteKit, Vue/Nuxt, and Astro use file-based routing, so their page/endpoint coverage is the Svelte/SvelteKit (100%), Vue/Nuxt (93.5%), and Astro (93.0% — every `src/pages/` file maps to a route node on the two validation repos) figures in the table above.
## 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** — Your agent starts the server itself, so you don't launch it by hand. Make sure the project is initialized and indexed (`codegraph status`) and that the path in your MCP config is correct. If it still won't connect, re-run `codegraph install` to rewrite the config.
**MCP tool calls fail with `Transport closed` while `codegraph status`/`sync` are healthy** — almost always WSL2 with the project on a Windows drive (a `/mnt/c` or `/mnt/d` path), where the local socket CodeGraph uses to share one background server across sessions is unreliable. CodeGraph now falls back to serving the session in-process instead of dropping the connection, but if you still hit it, set `CODEGRAPH_NO_DAEMON=1` in your MCP server's environment to skip the shared server entirely (each session runs in its own process). Moving the project onto the Linux-native filesystem (e.g. under `~/` instead of `/mnt/`) restores the shared server.
**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 inside a `.gitignore`d or default-excluded directory (e.g. `node_modules`, `dist`).
**Sharing one checkout between Windows and WSL** — Don't point both at the same `.codegraph/`: the background-server lock and the SQLite index are tied to the OS that wrote them, and SQLite locking across the WSL2/Windows filesystem boundary is unreliable. Give each side its own index in the same tree by setting `CODEGRAPH_DIR` to a distinct name on one of them — e.g. `CODEGRAPH_DIR=.codegraph-win` on Windows, leaving WSL on the default `.codegraph`. CodeGraph skips any sibling `.codegraph-*` directory when indexing and watching, so the two never trip over each other.
## 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, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro**
[Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues)
</div>
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`colbymchenry/codegraph`
- 原始仓库:https://github.com/colbymchenry/codegraph
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+93
View File
@@ -0,0 +1,93 @@
# Telemetry
CodeGraph collects a small set of **anonymous usage statistics** — which commands and
tools get used, which languages get indexed, which agents drive usage — so we can tell
which of the 20+ languages and 8 agent integrations deserve the most work. This page is
the complete list of what is collected. If a field isn't on this page, it isn't collected;
the ingest endpoint enforces this list as an allowlist and is itself
[public, auditable code](telemetry-worker/) in this repository.
## Turning it off
Any of these works, permanently:
```bash
codegraph telemetry off # stores your choice (and deletes any unsent data)
```
```bash
export CODEGRAPH_TELEMETRY=0 # per-shell / per-CI override
export DO_NOT_TRACK=1 # the cross-tool standard — always honored
```
`codegraph telemetry status` shows the current state, what decided it, and your machine ID.
The interactive installer (`codegraph install`) asks up front with a visible default-on
toggle and never re-asks. If you never saw the installer (e.g. `npx` straight into `init`),
a one-line notice is printed to stderr before the first time anything is sent.
Off means off: when disabled, CodeGraph records nothing, opens no connection to the
telemetry endpoint, and sends no "opted out" ping.
Separately from telemetry, the MCP server checks GitHub for a newer release in the
background (at most once a day) so it can tell you an update exists — it fetches a
version number and sends nothing about you or your machine. `DO_NOT_TRACK=1` disables
this check too; to turn off only the update check, use `CODEGRAPH_NO_UPDATE_CHECK=1`.
## What is collected
Every payload carries this envelope:
| field | example | notes |
|---|---|---|
| `machine_id` | `b3a8c1…` | random UUID minted on first send — derived from nothing |
| `codegraph_version` | `0.9.9` | |
| `os` / `arch` | `darwin` / `arm64` | platform identifiers only |
| `node_major` | `22` | major version only |
| `ci` | `false` | whether the `CI` env var was set |
| `schema_version` | `2` | bumped when this page changes (v2 dropped the `index` event's `sqlite_backend` field) |
And one of four events:
- **`install`** — when `codegraph install` configures agents: which agents
(`["claude","cursor",…]`), global vs project-local, and whether it was a fresh install,
an upgrade, or a re-run.
- **`index`** — when a full index completes: the **language names** present (e.g.
`["typescript","go"]`), the file count as a **coarse bucket** (`<100`, `100-1k`,
`1k-10k`, `10k+`), and the duration as a bucket (`<10s`, `10-60s`, `1-5m`, `5m+`).
- **`usage_rollup`** — one line per day per tool: the tool or CLI command **name** (e.g.
`codegraph_explore`, `init`), how many times it ran, how many errored, and — for MCP
tools — the connecting agent's name and version from the MCP handshake (e.g.
`Claude Code 2.1`). The Claude Code prompt hook also counts its **gate decision**
(fired fully, fired as a hint, or did nothing — fixed counter names like
`prompt-hook-gate-medium-segment`); the prompt itself is never read, stored, or sent.
- **`uninstall`** — when `codegraph uninstall`/`uninit` runs: which agents were removed.
Usage is **aggregated locally into daily totals** before anything is sent — there is no
per-call event stream, and nothing is sent in real time.
## What is never collected
- **No source code.** No file paths, file names, directory names, repository names or
URLs, symbol names, search queries, or anything else derived from the contents of an
indexed project.
- **No IP addresses.** The ingest endpoint never reads, logs, or forwards the client IP,
and IP discarding is enabled at the analytics backend on top of that. No geolocation.
- **No fingerprinting.** The machine ID is a random UUID stored in
`~/.codegraph/telemetry.json` — delete that file (or run `codegraph telemetry off`,
then `on`) and the old ID is gone forever, with no way to reconnect it.
- **No personal data.** No usernames, hostnames, emails, or environment variables.
## How it travels
Events POST to `telemetry.getcodegraph.com` — a first-party endpoint whose complete
source lives in [`telemetry-worker/`](telemetry-worker/) in this repository. It validates
every event and property against the allowlist above (anything else is dropped), strips
IPs, rate-limits, and forwards to a managed analytics store (PostHog, US region) as
anonymous events. Sends are fire-and-forget with a short timeout: offline or air-gapped
machines buffer a bounded local file (256 KB cap) and never retry-loop, log errors, or
slow a command down. Telemetry never adds latency to MCP tool calls — recording is an
in-memory counter.
The engineering contract behind all of this — including the rule that schema changes must
update this page, the client, and the public endpoint in one PR — is in
[`docs/design/telemetry.md`](docs/design/telemetry.md).
+394
View File
@@ -0,0 +1,394 @@
/**
* Regression test for adaptive `codegraph_explore` sizing — sibling
* skeletonization (branch `feat/adaptive-explore-sizing`, commit d6d059f).
*
* Feature: when a file is BOTH (1) off the synthesized flow spine AND (2) a
* polymorphic sibling — its class implements/extends a supertype shared by
* >= MIN_SIBLINGS (3) implementers — `codegraph_explore` renders it as a
* class + member *signature* skeleton (bodies elided) instead of full source,
* keeping the on-spine exemplar and the mechanism full. This sizes the
* response to the answer rather than the budget cap on sibling-heavy flows
* (OkHttp's interceptor chain) without starving diffuse ones (distinct
* pipeline steps stay full). Default ON; CODEGRAPH_ADAPTIVE_EXPLORE=0 disables.
*
* The fixture is OkHttp's interceptor chain in miniature:
* - `Interceptor` interface with FOUR implementers (>= 3 => a sibling family)
* - a 3-hop call spine `dispatch -> proceed -> handleLogging` that passes
* THROUGH LoggingInterceptor — so that file is the on-spine exemplar
* - Bridge/Cache/RetryInterceptor: off-spine members of the sibling family
* => skeletonize
* - ResponseFormatter implements `Formatter`, which has only ONE impl (< 3)
* => a distinct step: off-spine but NOT a sibling => stays full
*
* Guards the two ways the feature can silently regress: skeletonizing too much
* (a distinct step or the on-spine exemplar) or too little (the off-spine
* siblings), plus the escape hatch.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ToolHandler } from '../src/mcp/tools';
import CodeGraph from '../src/index';
// Stable marker — assert the `· skeleton` tag, not its exact trailing wording
// (the steer-to-explore phrasing changed when the Read invitation was removed).
const SKELETON_MARK = '· skeleton (signatures only';
/** Return the ``**`<path>`** ...`` section for a file basename, header through the
* line before the next bold header (or end of output). Headers are bold labels,
* not ATX headings (issue #778); file sections start with ``**` ``. */
function sectionFor(text: string, basename: string): string {
const lines = text.split('\n');
const start = lines.findIndex((l) => l.startsWith('**`') && l.includes(basename));
if (start < 0) return '';
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (lines[i].startsWith('**')) {
end = i;
break;
}
}
return lines.slice(start, end).join('\n');
}
describe('adaptive codegraph_explore sizing — sibling skeletonization', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
// Names the spine (dispatch/proceed/handleLogging), the on-spine exemplar,
// the three off-spine siblings, and the distinct step — so every file we
// assert on is gathered as relevant. maxFiles overrides the very-tiny tier's
// 4-file default so all of them land in one call.
const QUERY =
'dispatch proceed handleLogging LoggingInterceptor BridgeInterceptor CacheInterceptor RetryInterceptor ResponseFormatter';
beforeAll(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-adaptive-explore-'));
const srcDir = path.join(testDir, 'src');
fs.mkdirSync(srcDir);
const write = (name: string, body: string) =>
fs.writeFileSync(path.join(srcDir, name), body.trimStart());
// The interchangeable contract — 4 implementers below => sibling family.
write(
'interceptor.ts',
`
export interface Interceptor {
intercept(request: string): string;
}
`
);
// The mechanism + the spine: dispatch -> proceed -> (LoggingInterceptor) handleLogging.
// Unique method names so the call edges resolve unambiguously.
write(
'dispatcher.ts',
`
import { LoggingInterceptor } from './logging-interceptor';
export class RequestDispatcher {
dispatch(): string {
const chain = new InterceptorChain();
return chain.proceed();
}
}
export class InterceptorChain {
proceed(): string {
const exemplar = new LoggingInterceptor();
return exemplar.handleLogging();
}
}
`
);
// On-spine exemplar: handleLogging is the spine's tail, so this whole file
// is on-spine and must stay FULL even though it's a sibling (implements Interceptor).
write(
'logging-interceptor.ts',
`
import { Interceptor } from './interceptor';
export class LoggingInterceptor implements Interceptor {
handleLogging(): string {
const tag = 'LOGGING_BODY_MARKER';
return this.intercept(tag);
}
intercept(request: string): string {
return 'logged:' + request;
}
}
`
);
// Off-spine siblings — interchangeable impls of Interceptor => SKELETONIZE.
// Each body carries a unique marker that must NOT survive skeletonization.
write(
'bridge-interceptor.ts',
`
import { Interceptor } from './interceptor';
export class BridgeInterceptor implements Interceptor {
intercept(request: string): string {
const detail = 'BRIDGE_BODY_MARKER';
return 'bridged:' + request + detail;
}
}
`
);
write(
'cache-interceptor.ts',
`
import { Interceptor } from './interceptor';
export class CacheInterceptor implements Interceptor {
intercept(request: string): string {
const detail = 'CACHE_BODY_MARKER';
return 'cached:' + request + detail;
}
}
`
);
write(
'retry-interceptor.ts',
`
import { Interceptor } from './interceptor';
export class RetryInterceptor implements Interceptor {
intercept(request: string): string {
const detail = 'RETRY_BODY_MARKER';
return 'retried:' + request + detail;
}
}
`
);
// A 1:1 interface->impl pair: off-spine, implements something, but the
// supertype has only ONE impl (< MIN_SIBLINGS) => a DISTINCT step => FULL.
write(
'formatter.ts',
`
export interface Formatter {
format(input: string): string;
}
`
);
write(
'response-formatter.ts',
`
import { Formatter } from './formatter';
import { JsonCodec } from './codec';
export class ResponseFormatter implements Formatter {
format(input: string): string {
const detail = 'FORMATTER_BODY_MARKER';
// Calls into the Codec family from OFF the dispatch spine, so codec.ts is
// gathered as relevant but stays off-spine (mirrors Django: compiler.py is
// referenced by the flow yet off the QuerySet-iteration spine).
return new JsonCodec().encode(input) + detail;
}
}
`
);
// An off-spine sibling (implements Interceptor) the agent would otherwise
// skeletonize — BUT it owns a uniquely-named method `authenticate` the agent
// names in the query. Mirrors OkHttp's RealCall (named getResponseWith-
// InterceptorChain): a named callable means "show me this", so it stays full.
write(
'auth-interceptor.ts',
`
import { Interceptor } from './interceptor';
export class AuthInterceptor implements Interceptor {
authenticate(token: string): string {
const detail = 'AUTH_BODY_MARKER';
return 'auth:' + token + detail;
}
intercept(request: string): string {
return this.authenticate(request);
}
}
`
);
// A base class that DEFINES a >=3-impl supertype AND co-locates its
// subclasses in the same file — mirrors Django's compiler.py (SQLCompiler +
// SQLInsertCompiler/SQLUpdateCompiler/...). The subclasses' `extends` edges
// make the file look like a sibling, but it's the family's base/mechanism,
// so it must stay full.
write(
'codec.ts',
`
export class Codec {
encode(input: string): string {
const detail = 'CODEC_BASE_MARKER';
return input + detail;
}
}
export class JsonCodec extends Codec {
encode(input: string): string { return '{' + input + '}'; }
}
export class XmlCodec extends Codec {
encode(input: string): string {
const detail = 'XML_BODY_MARKER';
return '<' + input + detail + '>';
}
}
export class YamlCodec extends Codec {
encode(input: string): string { return '- ' + input; }
}
`
);
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 });
}
});
beforeEach(() => {
// Each test asserts against the default (ON) behaviour unless it opts out.
delete process.env.CODEGRAPH_ADAPTIVE_EXPLORE;
});
it('fixture sanity: Interceptor has >=3 implementers, Formatter has <3', () => {
const find = (name: string, kind: string) =>
cg.searchNodes(name).map((r) => r.node).find((n) => n.name === name && n.kind === kind);
const interceptor = find('Interceptor', 'interface');
const formatter = find('Formatter', 'interface');
expect(interceptor).toBeTruthy();
expect(formatter).toBeTruthy();
const implementers = (id: string) =>
cg.getIncomingEdges(id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length;
// The whole gate hinges on this signal — assert the fixture actually
// produces the >=3 / <3 split, so a TS-extraction change fails here loudly
// rather than silently flipping the skeletonization downstream.
expect(implementers(interceptor!.id)).toBeGreaterThanOrEqual(3);
expect(implementers(formatter!.id)).toBeLessThan(3);
});
it('skeletonizes off-spine polymorphic siblings (bodies elided, signatures kept)', async () => {
const result = await handler.execute('codegraph_explore', { query: QUERY, maxFiles: 12 });
const text = result.content?.[0]?.text ?? '';
// Precondition: the spine must have formed, or nothing skeletonizes.
expect(text).toContain('**Flow (call path among the symbols you queried)');
for (const [file, marker] of [
['bridge-interceptor.ts', 'BRIDGE_BODY_MARKER'],
['cache-interceptor.ts', 'CACHE_BODY_MARKER'],
['retry-interceptor.ts', 'RETRY_BODY_MARKER'],
] as const) {
const section = sectionFor(text, file);
expect(section, `${file} should be present in the explore output`).not.toBe('');
expect(section, `${file} should be skeletonized`).toContain(SKELETON_MARK);
// The signature line survives; the body (with its marker) is elided.
expect(section).toContain('intercept(request');
expect(section, `${file} body marker must NOT survive skeletonization`).not.toContain(marker);
}
});
it('keeps the on-spine exemplar full even though it is a sibling', async () => {
const result = await handler.execute('codegraph_explore', { query: QUERY, maxFiles: 12 });
const text = result.content?.[0]?.text ?? '';
const section = sectionFor(text, 'logging-interceptor.ts');
expect(section, 'logging-interceptor.ts should be present').not.toBe('');
expect(section, 'on-spine exemplar must NOT be skeletonized').not.toContain(SKELETON_MARK);
// Full source => the body marker is present.
expect(section).toContain('LOGGING_BODY_MARKER');
});
it('keeps a distinct step full (off-spine but supertype has < 3 implementers)', async () => {
const result = await handler.execute('codegraph_explore', { query: QUERY, maxFiles: 12 });
const text = result.content?.[0]?.text ?? '';
const section = sectionFor(text, 'response-formatter.ts');
expect(section, 'response-formatter.ts should be present').not.toBe('');
expect(section, 'a 1:1 interface impl is not a sibling and must stay full').not.toContain(SKELETON_MARK);
expect(section).toContain('FORMATTER_BODY_MARKER');
});
it('CODEGRAPH_ADAPTIVE_EXPLORE=0 disables skeletonization (siblings render full)', async () => {
process.env.CODEGRAPH_ADAPTIVE_EXPLORE = '0';
try {
const result = await handler.execute('codegraph_explore', { query: QUERY, maxFiles: 12 });
const text = result.content?.[0]?.text ?? '';
expect(text, 'no file should be skeletonized with the flag off').not.toContain(SKELETON_MARK);
// The previously-skeletonized siblings now render their full bodies.
const section = sectionFor(text, 'bridge-interceptor.ts');
expect(section).not.toBe('');
expect(section).toContain('BRIDGE_BODY_MARKER');
} finally {
delete process.env.CODEGRAPH_ADAPTIVE_EXPLORE;
}
});
// Names AuthInterceptor's `authenticate` and Codec's `encode` (both methods),
// plus the spine tokens so a spine still forms. Same Interceptor family as the
// skeleton test, plus the Codec base+subclasses family.
const SPARE_QUERY = `${QUERY} authenticate encode AuthInterceptor Codec JsonCodec`;
it('spares an off-spine sibling when the agent NAMED a callable in it (RealCall fix)', async () => {
const result = await handler.execute('codegraph_explore', { query: SPARE_QUERY, maxFiles: 15 });
const text = result.content?.[0]?.text ?? '';
expect(text).toContain('**Flow (call path among the symbols you queried)');
// auth-interceptor.ts is an off-spine Interceptor sibling — would skeletonize —
// but the agent named its method `authenticate`, so it stays FULL.
const auth = sectionFor(text, 'auth-interceptor.ts');
expect(auth, 'auth-interceptor.ts should be present').not.toBe('');
expect(auth, 'a file holding an agent-named callable must NOT be skeletonized').not.toContain(SKELETON_MARK);
expect(auth).toContain('AUTH_BODY_MARKER');
// Contrast: bridge-interceptor.ts — same family, named only by TYPE — still skeletonizes.
const bridge = sectionFor(text, 'bridge-interceptor.ts');
expect(bridge, 'a sibling named only by type still skeletonizes').toContain(SKELETON_MARK);
expect(bridge).not.toContain('BRIDGE_BODY_MARKER');
});
it('collapses a base+subclasses family file to a FOCUSED view — base method body kept, non-named subclasses signature-only (compiler.py)', async () => {
const result = await handler.execute('codegraph_explore', { query: SPARE_QUERY, maxFiles: 15 });
const text = result.content?.[0]?.text ?? '';
// codec.ts defines the base Codec (>=3 subclasses extend it) and co-locates the
// subclasses — a "family" file (Django's compiler.py). The family-override fires
// (it is NOT spared into a full clustered render despite the named `encode`), so
// it COLLAPSES — but per-symbol: the named base method `Codec.encode` keeps its
// body (so the agent doesn't Read it back — Django's SQLCompiler.execute_sql),
// while a non-named subclass (XmlCodec) collapses to a signature. That packs the
// mechanism into budget without the redundant subclass bodies.
const codec = sectionFor(text, 'codec.ts');
expect(codec, 'codec.ts should be present').not.toBe('');
expect(codec, 'a named family file collapses to a focused (not full) view').toContain('· focused');
expect(codec, 'the named base method body is kept (no Read-back)').toContain('CODEC_BASE_MARKER');
expect(codec, 'a non-named subclass body is elided to a signature').not.toContain('XML_BODY_MARKER');
});
it('naming a SHARED/polymorphic method does not spare the siblings (uniqueness-aware)', async () => {
// `intercept` is implemented by every interceptor (5 defs) — a polymorphic name,
// not a unique one. Naming it must NOT keep all five full (that floods the budget
// — Django's `as_sql`×110). The off-spine siblings still collapse, and since none
// defines the supertype, `intercept` doesn't even earn a body — pure skeleton.
const result = await handler.execute('codegraph_explore', { query: `${QUERY} intercept`, maxFiles: 12 });
const text = result.content?.[0]?.text ?? '';
const bridge = sectionFor(text, 'bridge-interceptor.ts');
expect(bridge, 'a sibling named only via a shared method is not spared').toContain(SKELETON_MARK);
expect(bridge, 'a shared method does not earn a body in a non-supertype leaf').not.toContain('BRIDGE_BODY_MARKER');
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* Android resource XML is excluded from the index by default (#1047).
*
* A `res/` tree holds only non-code resources (layouts, value bags, drawables,
* menus) split into typed, optionally qualified subdirectories. None of it yields
* a code symbol, yet on an Android app it dominates the file count (one report:
* 26k XML = 97% of files, 0 symbols), bloating the DB, slowing indexing, and
* skewing explore results. CodeGraph now default-ignores the Android resource
* type directories — `res/layout/`, `res/values/`, `res/drawable/`, … and their
* `-<qualifier>` variants — at discovery.
*
* Guardrails this locks in:
* - Real code (`.java`) is still indexed.
* - The one XML that DOES carry symbols — a MyBatis mapper under
* `src/main/resources/` — is untouched (it never lives under `res/`).
* - Plain non-`res/` XML (`pom.xml`) is unaffected.
* - `res/raw/` is deliberately KEPT — it holds arbitrary bundled assets that can
* be code-ish, so we don't drop it.
*/
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('Android resource XML exclusion (#1047)', () => {
let dir: string;
let cg: CodeGraph;
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
beforeEach(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-android-res-'));
// Android resource files (every typed subdir, incl. a locale qualifier) — all
// should be EXCLUDED.
write('app/src/main/res/layout/activity_main.xml', '<LinearLayout><TextView/></LinearLayout>\n');
write('app/src/main/res/values/strings.xml', '<resources><string name="app_name">App</string></resources>\n');
write('app/src/main/res/values-es/strings.xml', '<resources><string name="app_name">App</string></resources>\n');
write('app/src/main/res/drawable/ic_foo.xml', '<vector android:height="24dp"/>\n');
write('app/src/main/res/menu/main_menu.xml', '<menu><item android:id="@+id/x"/></menu>\n');
// Real code, a MyBatis mapper (the one XML with symbols), plain XML, and a
// res/raw asset — all should be KEPT.
write('app/src/main/java/com/example/Main.java', 'package com.example;\npublic class Main { void run(){} }\n');
write('src/main/resources/FooMapper.xml',
'<mapper namespace="com.example.FooDao"><select id="findAll">SELECT * FROM foo</select></mapper>\n');
write('pom.xml', '<project><artifactId>demo</artifactId></project>\n');
write('app/src/main/res/raw/payload.xml', '<data><item>1</item></data>\n');
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
});
it('excludes Android resource XML but keeps code, MyBatis mappers, plain XML, and res/raw', () => {
const indexed = new Set(cg.getFiles().map((f) => f.path));
// Excluded: every resource type dir, including the qualifier variant.
expect(indexed).not.toContain('app/src/main/res/layout/activity_main.xml');
expect(indexed).not.toContain('app/src/main/res/values/strings.xml');
expect(indexed).not.toContain('app/src/main/res/values-es/strings.xml');
expect(indexed).not.toContain('app/src/main/res/drawable/ic_foo.xml');
expect(indexed).not.toContain('app/src/main/res/menu/main_menu.xml');
// Kept: real code, plain XML, and the deliberately-spared res/raw asset.
expect(indexed).toContain('app/src/main/java/com/example/Main.java');
expect(indexed).toContain('pom.xml');
expect(indexed).toContain('app/src/main/res/raw/payload.xml');
// Kept AND still carries symbols: the MyBatis mapper (non-regression — the
// only valuable XML, and it never lives under res/).
const mapper = cg.getFiles().find((f) => f.path === 'src/main/resources/FooMapper.xml');
expect(mapper).toBeDefined();
expect(mapper!.nodeCount).toBeGreaterThan(1); // file node + ≥1 statement
});
});
+428
View File
@@ -0,0 +1,428 @@
/**
* ArkTS end-to-end resolution tests.
*
* Pins the precision contract for build()-DSL attribute chains: a chained
* `.attr(...)` resolves ONLY to a decorator-marked attribute helper
* (`@Extend`/`@Styles`/…) — a framework attribute like `.width(...)` must
* NEVER link to an arbitrary same-named symbol elsewhere in the project
* (measured on the OpenHarmony samples monorepo, that fallthrough produced
* 36k wrong edges — single properties with thousands of false callers).
*
* Also pins the ohpm workspace bridge: a bare `import { X } from "data"`
* follows the oh-package.json5 `file:` dependency to the member module.
*/
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('ArkTS attribute-chain resolution precision', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('links .titleStyle() to the @Extend helper but never .width() to a decoy symbol', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkts-'));
fs.mkdirSync(path.join(tmpDir, 'pages'));
fs.mkdirSync(path.join(tmpDir, 'decoy'));
// A decoy: symbols named after framework attributes, in another file.
fs.writeFileSync(
path.join(tmpDir, 'decoy/Decoy.ets'),
'export class Decoy {\n' +
' width: number = 0;\n' +
'}\n' +
'export function height(v: number): number {\n' +
' return v * 2;\n' +
'}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'pages/Home.ets'),
'@Extend(Text) function titleStyle(size: number) {\n' +
' .fontSize(size)\n' +
'}\n' +
'\n' +
'@Component\n' +
'struct Home {\n' +
' build() {\n' +
' Column() {\n' +
' Text("hello")\n' +
' .titleStyle(24)\n' +
' .width(100)\n' +
' }\n' +
' .height(50)\n' +
' }\n' +
'}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const fns = cg.getNodesByKind('function');
const titleStyle = fns.find((n) => n.name === 'titleStyle');
expect(titleStyle).toBeDefined();
expect(titleStyle?.decorators).toContain('Extend');
const structs = cg.getNodesByKind('struct');
const home = structs.find((n) => n.name === 'Home');
expect(home).toBeDefined();
// build -> titleStyle via the decorator-gated attribute strategy.
const methods = cg.getNodesByKind('method');
const build = methods.find((n) => n.qualifiedName === 'Home::build');
expect(build).toBeDefined();
const buildCallees = cg.getOutgoingEdges(build!.id).map((e) => e.target);
expect(buildCallees).toContain(titleStyle!.id);
// The decoys named after framework attributes must have NO callers.
const decoyWidth = cg
.getNodesByKind('property')
.find((n) => n.name === 'width' && n.filePath.includes('Decoy'));
expect(decoyWidth).toBeDefined();
expect(cg.getIncomingEdges(decoyWidth!.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
const decoyHeight = fns.find((n) => n.name === 'height' && n.filePath.includes('Decoy'));
expect(decoyHeight).toBeDefined();
expect(cg.getIncomingEdges(decoyHeight!.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
});
});
describe('ArkTS ohpm workspace import resolution', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('resolves a bare workspace import through oh-package.json5 file: deps', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-'));
fs.mkdirSync(path.join(tmpDir, 'core/data/src/main/ets'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'feature/goods/src/main/ets'), { recursive: true });
// Member module "data" with an Index.ets barrel (ohpm entry convention).
fs.writeFileSync(
path.join(tmpDir, 'core/data/oh-package.json5'),
'{\n // ohpm module manifest\n "name": "data",\n "main": "Index.ets",\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'core/data/Index.ets'),
"export { CartRepository } from './src/main/ets/CartRepository';\n"
);
fs.writeFileSync(
path.join(tmpDir, 'core/data/src/main/ets/CartRepository.ets'),
'export class CartRepository {\n' +
' addToCart(id: string): void {\n' +
' console.log(id);\n' +
' }\n' +
'}\n'
);
// Consumer module declares the sibling via a file: dependency and imports
// it by bare name.
fs.writeFileSync(
path.join(tmpDir, 'feature/goods/oh-package.json5'),
'{\n "name": "goods",\n "dependencies": {\n "data": "file:../../core/data", // local module\n },\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'feature/goods/src/main/ets/GoodsViewModel.ets'),
'import { CartRepository } from "data";\n' +
'\n' +
'export class GoodsViewModel {\n' +
' private cart: CartRepository = new CartRepository();\n' +
'\n' +
' add(id: string): void {\n' +
' this.cart.addToCart(id);\n' +
' }\n' +
'}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const classes = cg.getNodesByKind('class');
const repo = classes.find((n) => n.name === 'CartRepository');
const vm = classes.find((n) => n.name === 'GoodsViewModel');
expect(repo).toBeDefined();
expect(vm).toBeDefined();
// add() -> addToCart() across the module boundary.
const methods = cg.getNodesByKind('method');
const add = methods.find((n) => n.qualifiedName === 'GoodsViewModel::add');
const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart');
expect(add).toBeDefined();
expect(addToCart).toBeDefined();
const targets = cg.getOutgoingEdges(add!.id).map((e) => e.target);
expect(targets).toContain(addToCart!.id);
});
});
describe('ArkUI state → build() re-render bridge (assignment-gated)', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('links assigning methods to build(), but not read-only methods', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-state-'));
fs.writeFileSync(
path.join(tmpDir, 'Page.ets'),
'@Entry\n@Component\nstruct Page {\n' +
' @State todos: string[] = [];\n' +
' @State count: number = 0;\n' +
'\n' +
' addTodo(t: string): void {\n' +
' this.todos.push(t);\n' +
' }\n' +
'\n' +
' reset(): void {\n' +
' this.count = 0;\n' +
' }\n' +
'\n' +
' describeCount(): string {\n' +
' return `count is ${this.count}`;\n' +
' }\n' +
'\n' +
' build() {\n' +
' Column() {\n' +
' Text(this.describeCount())\n' +
' }\n' +
' }\n' +
'}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const methods = cg.getNodesByKind('method');
const build = methods.find((n) => n.qualifiedName === 'Page::build')!;
const addTodo = methods.find((n) => n.qualifiedName === 'Page::addTodo')!;
const reset = methods.find((n) => n.qualifiedName === 'Page::reset')!;
const describeCount = methods.find((n) => n.qualifiedName === 'Page::describeCount')!;
const synthEdgesTo = (from: string) =>
cg
.getOutgoingEdges(from)
.filter(
(e) =>
e.target === build.id &&
(e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-state'
);
// Array mutator and plain assignment both count as state writes.
expect(synthEdgesTo(addTodo.id)).toHaveLength(1);
expect(synthEdgesTo(reset.id)).toHaveLength(1);
// A read-only method gets NO re-render edge — the precision line.
expect(synthEdgesTo(describeCount.id)).toHaveLength(0);
});
});
describe('ArkUI @ohos.events.emitter bridge', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('links emit → on through a shared named constant, chased through a local EventsId', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter-'));
fs.writeFileSync(
path.join(tmpDir, 'Bus.ets'),
"import emitter from '@ohos.events.emitter';\n" +
'\n' +
'export class EmitterConst {\n' +
' static readonly ADD_EVENT_ID: number = 2;\n' +
'}\n' +
'\n' +
'class EventsId {\n' +
' eventId: number;\n' +
' constructor(eventId: number) {\n' +
' this.eventId = eventId;\n' +
' }\n' +
'}\n' +
'\n' +
'export class Bus {\n' +
' subscribeCart(callback: Function): void {\n' +
' let addGoodDataId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' +
' emitter.on(addGoodDataId, (eventData) => {\n' +
' callback(eventData);\n' +
' });\n' +
' }\n' +
'\n' +
' publishAdd(goodId: number): void {\n' +
' let addToCartId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' +
' emitter.emit(addToCartId);\n' +
' }\n' +
'}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const methods = cg.getNodesByKind('method');
const publishAdd = methods.find((n) => n.qualifiedName === 'Bus::publishAdd')!;
const subscribeCart = methods.find((n) => n.qualifiedName === 'Bus::subscribeCart')!;
const bridged = cg
.getOutgoingEdges(publishAdd.id)
.filter(
(e) =>
e.target === subscribeCart.id &&
(e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-emitter'
);
expect(bridged).toHaveLength(1);
});
it('numeric-literal event ids never pair across files', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter2-'));
fs.writeFileSync(
path.join(tmpDir, 'A.ets'),
"import emitter from '@ohos.events.emitter';\n" +
'export function fireA(): void {\n' +
' emitter.emit({ eventId: 1 });\n' +
'}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'B.ets'),
"import emitter from '@ohos.events.emitter';\n" +
'export function listenB(): void {\n' +
' emitter.on({ eventId: 1 }, () => {});\n' +
'}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const fns = cg.getNodesByKind('function');
const fireA = fns.find((n) => n.name === 'fireA')!;
const listenB = fns.find((n) => n.name === 'listenB')!;
const bridged = cg
.getOutgoingEdges(fireA.id)
.filter((e) => e.target === listenB.id);
expect(bridged).toHaveLength(0);
});
});
describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('links the navigating method to the target page struct, standard layout only', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-router-'));
fs.mkdirSync(path.join(tmpDir, 'entry/src/main/ets/pages'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'entry/src/main/ets/pages/Detail.ets'),
'@Entry\n@Component\nstruct Detail {\n build() {\n Column() {\n Text("detail")\n }\n }\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'entry/src/main/ets/pages/Home.ets'),
"import router from '@ohos.router';\n" +
'\n' +
'@Entry\n@Component\nstruct Home {\n' +
' openDetail(id: string): void {\n' +
" router.pushUrl({ url: 'pages/Detail', params: { id: id } });\n" +
' }\n' +
'\n' +
' build() {\n' +
' Column() {\n' +
" Button('go').onClick(this.openDetail)\n" +
' }\n' +
' }\n' +
'}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const methods = cg.getNodesByKind('method');
const openDetail = methods.find((n) => n.qualifiedName === 'Home::openDetail')!;
const detail = cg.getNodesByKind('struct').find((n) => n.name === 'Detail')!;
const bridged = cg
.getOutgoingEdges(openDetail.id)
.filter(
(e) =>
e.target === detail.id &&
(e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-route'
);
expect(bridged).toHaveLength(1);
});
});
describe('ohpm main entry (custom barrel + .ts consumer)', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('resolves a bare import through a custom main, from an .ets AND a .ts consumer', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-main-'));
fs.mkdirSync(path.join(tmpDir, 'core/data/src'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'feature/goods/src'), { recursive: true });
// Custom entry — NOT the Index.ets convention.
fs.writeFileSync(
path.join(tmpDir, 'core/data/oh-package.json5'),
'{\n "name": "data",\n "main": "src/entry.ets",\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'core/data/src/entry.ets'),
"export { CartRepository } from './CartRepository';\n"
);
fs.writeFileSync(
path.join(tmpDir, 'core/data/src/CartRepository.ets'),
'export class CartRepository {\n addToCart(id: string): void {\n console.log(id);\n }\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'feature/goods/oh-package.json5'),
'{\n "name": "goods",\n "dependencies": {\n "data": "file:../../core/data",\n },\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'feature/goods/src/GoodsVm.ets'),
'import { CartRepository } from "data";\n' +
'export class GoodsVm {\n' +
' private cart: CartRepository = new CartRepository();\n' +
' add(id: string): void {\n this.cart.addToCart(id);\n }\n' +
'}\n'
);
// The .ts consumer — resolves through the manifest's entry, no `.ets`
// in the TypeScript candidate list required.
fs.writeFileSync(
path.join(tmpDir, 'feature/goods/src/report.ts'),
'import { CartRepository } from "data";\n' +
'export function report(cart: CartRepository): string {\n' +
' return typeof cart;\n' +
'}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const classes = cg.getNodesByKind('class');
const repo = classes.find((n) => n.name === 'CartRepository')!;
expect(repo).toBeDefined();
// .ets consumer: cross-module method call connects.
const methods = cg.getNodesByKind('method');
const add = methods.find((n) => n.qualifiedName === 'GoodsVm::add')!;
const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart')!;
expect(cg.getOutgoingEdges(add.id).map((e) => e.target)).toContain(addToCart.id);
// .ts consumer: the type annotation reference reaches the .ets class.
const report = cg.getNodesByKind('function').find((n) => n.name === 'report')!;
expect(cg.getOutgoingEdges(report.id).map((e) => e.target)).toContain(repo.id);
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* Batched resolution cleanup precision (#1269)
*
* Post-batch cleanup used to delete resolved refs (and park failed ones) by
* (from_node_id, reference_name, reference_kind) — no line/col. When one
* caller contains several call sites to the SAME callee and a batch boundary
* splits them, resolving the first batch's sites deleted every row with that
* key, including sibling rows in later batches that were never attempted —
* their edges were silently never created. Observed on nlohmann/json:
* `write_cbor` calls `to_char_type` at 11 lines; the batch boundary
* deterministically dropped the last site's edge.
*
* Cleanup now targets the exact `unresolved_refs.id` for DB-loaded refs, with
* the key-tuple delete kept only as the fallback for hand-built refs (public
* resolveAndPersist API) that carry no row id.
*/
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 { createResolver } from '../src/resolution';
import { Node, UnresolvedReference } from '../src/types';
function makeNode(id: string, name: string, kind: Node['kind'], filePath: string, startLine: number): Node {
return {
id,
kind,
name,
qualifiedName: name,
filePath,
language: 'typescript',
startLine,
endLine: startLine + 2,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};
}
function makeRef(fromNodeId: string, name: string, line: number): UnresolvedReference {
return {
fromNodeId,
referenceName: name,
referenceKind: 'calls',
line,
column: 2,
filePath: 'caller.ts',
language: 'typescript',
};
}
describe('Batched ref cleanup precision (#1269)', () => {
let dir: string;
let db: DatabaseConnection;
let q: QueryBuilder;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-refcleanup-'));
db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
q = new QueryBuilder(db.getDb());
// The files the refs/nodes point at must exist for resolution context.
fs.writeFileSync(path.join(dir, 'caller.ts'), 'callee();\ncallee();\ncallee();\ncallee();\ncallee();\n');
fs.writeFileSync(path.join(dir, 'callee.ts'), 'export function callee() {}\n');
q.insertNode(makeNode('fn:caller', 'caller', 'function', 'caller.ts', 1));
q.insertNode(makeNode('fn:callee', 'callee', 'function', 'callee.ts', 1));
});
afterEach(() => {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
});
it('creates an edge for EVERY same-named call site when the sites straddle a batch boundary', async () => {
// 5 call sites, batch size 2 → boundaries after sites 2 and 4. With the
// key-tuple delete, batch 1's cleanup removed ALL five rows and only 2
// edges ever existed.
const lines = [1, 2, 3, 4, 5];
q.insertUnresolvedRefsBatch(lines.map((line) => makeRef('fn:caller', 'callee', line)));
expect(q.getUnresolvedReferencesCount()).toBe(5);
const resolver = createResolver(dir, q);
await resolver.resolveAndPersistBatched(undefined, 2);
const edges = q
.getOutgoingEdges('fn:caller')
.filter((e) => e.kind === 'calls' && e.target === 'fn:callee');
expect(edges.map((e) => e.line).sort()).toEqual(lines);
// Every processed row left the pending set (drain terminated normally).
expect(q.getUnresolvedReferencesCount()).toBe(0);
});
it('parks EVERY unresolvable same-named site as failed only after its own attempt', async () => {
// 4 sites calling a name with no definition, batch size 2. Both halves
// must drain to status='failed' (previously batch 1's key-tuple update
// also flipped batch 2's rows before they were attempted — same outcome
// here, but the loop must still terminate and leave nothing pending).
q.insertUnresolvedRefsBatch([1, 2, 3, 4].map((line) => makeRef('fn:caller', 'missingCallee', line)));
const resolver = createResolver(dir, q);
await resolver.resolveAndPersistBatched(undefined, 2);
expect(q.getUnresolvedReferencesCount()).toBe(0); // nothing pending
const failed = q.getUnresolvedReferences().filter((r) => r.referenceName === 'missingCallee');
expect(failed).toHaveLength(4); // all parked, none deleted
});
it('hand-built refs without a rowId still clean up through the key fallback', () => {
// Public resolveAndPersist API: refs built in memory (no rowId) that also
// exist as DB rows — the legacy key-tuple delete must still clear them.
q.insertUnresolvedRefsBatch([makeRef('fn:caller', 'callee', 1)]);
const resolver = createResolver(dir, q);
const inMemory = makeRef('fn:caller', 'callee', 1); // no rowId
const result = resolver.resolveAndPersist([inMemory]);
expect(result.resolved).toHaveLength(1);
expect(q.getUnresolvedReferencesCount()).toBe(0);
});
});
+369
View File
@@ -0,0 +1,369 @@
/**
* C/C++ function-pointer dispatch synthesis (#932).
*
* C polymorphism is the function pointer: a struct fn-pointer field, registered
* to concrete functions in a table (positional `{"add", cmd_add}` or designated
* `.fn = cmd_add`) or by assignment, then dispatched indirectly (`p->fn(argv)`).
* Static extraction sees neither the registration→field binding nor the
* indirect call, so the dispatcher→handler edge is missing. These tests prove
* the bridge keyed by (struct type, fn-pointer field): the command-table shape,
* designated init, the typedef'd-field + field←field double-hop (the issue's
* own hook_demo.c shape), by-value dispatch, and the precision boundaries
* (a data field is never bridged, distinct fn-pointer fields don't cross-bleed,
* and a non-C project is a no-op). Plus the BARE ARRAY of function pointers
* (no struct, no field) keyed by the array variable name — the opcode-table
* shape `opcodes[op](…)`, the designated + cast-wrapped form with a
* calling-convention typedef, same-named file-local arrays resolving without a
* cross-file leak, and a registered-but-never-dispatched array (the control).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('c-fnptr dispatch synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfp-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
const load = async () => {
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges: { src: string; tgt: string; via: string }[] = db
.prepare(
`SELECT s.name src, t.name tgt, json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'fn-pointer-dispatch'`
)
.all();
cg.close?.();
return edges;
};
const has = (edges: any[], src: string, tgt: string) => edges.some((e) => e.src === src && e.tgt === tgt);
it('bridges a {name, fn} command table dispatched through p->fn() (the git shape)', async () => {
write('cmd.c', `
struct cmd { const char *name; int (*fn)(int argc); };
static int cmd_add(int argc) { return argc + 1; }
static int cmd_rm(int argc) { return argc - 1; }
static int cmd_noop(int argc) { return argc; } /* defined, NOT in the table */
static struct cmd commands[] = {
{ "add", cmd_add },
{ "rm", cmd_rm },
};
int run_builtin(struct cmd *p, int argc) {
return p->fn(argc);
}
`);
const edges = await load();
expect(has(edges, 'run_builtin', 'cmd_add')).toBe(true);
expect(has(edges, 'run_builtin', 'cmd_rm')).toBe(true);
expect(edges.every((e) => e.via === 'cmd.fn')).toBe(true);
// PRECISION: a function not registered in the table is never a target.
expect(has(edges, 'run_builtin', 'cmd_noop')).toBe(false);
});
it('bridges designated-init (.handler = fn) and by-value c.fn() dispatch', async () => {
write('ops.c', `
struct ops { int (*handler)(void); int size; };
static int on_open(void) { return 1; }
static struct ops the_ops = { .handler = on_open, .size = 4 };
int dispatch(struct ops o) { return o.handler(); }
`);
const edges = await load();
expect(has(edges, 'dispatch', 'on_open')).toBe(true);
expect(edges.every((e) => e.via === 'ops.handler')).toBe(true);
});
it('bridges the typedef-field + field←field double-hop (the hook_demo.c shape)', async () => {
write('hook.c', `
typedef void (*hook_func)(void);
struct hooks { hook_func func; };
struct entry { const char *name; hook_func fn; };
static void hk_set(void) {}
static void hk_get(void) {}
static const struct entry registry[] = {
{ "set", hk_set },
{ "get", hk_get },
};
void call(struct hooks *h, const struct entry *found) {
h->func = found->fn; /* generic slot reassigned from the registry */
h->func(); /* dispatch through hooks.func */
}
`);
const edges = await load();
// hooks.func has no direct registration; it inherits entry.fn's via h->func = found->fn.
expect(has(edges, 'call', 'hk_set')).toBe(true);
expect(has(edges, 'call', 'hk_get')).toBe(true);
});
it('keys by (struct, field): distinct fn-pointer fields do not cross-bleed', async () => {
write('vtable.c', `
struct io { int (*read)(void); int (*write)(int); };
static int do_read(void) { return 0; }
static int do_write(int x) { return x; }
static struct io io = { .read = do_read, .write = do_write };
int only_reads(struct io *p) { return p->read(); }
`);
const edges = await load();
// only_reads dispatches ->read → do_read, and must NOT reach do_write (a different field).
expect(has(edges, 'only_reads', 'do_read')).toBe(true);
expect(has(edges, 'only_reads', 'do_write')).toBe(false);
});
it('does not bridge a plain data field, and no-ops on a struct with no dispatch', async () => {
write('data.c', `
struct box { int count; int (*fn)(void); };
static int helper(void) { return 0; }
static struct box b = { .count = 3, .fn = helper };
/* reads a data field and never dispatches the fn pointer */
int total(struct box *x) { return x->count + 1; }
`);
const edges = await load();
// No indirect dispatch happens, so there are no synthesized edges at all.
expect(edges.length).toBe(0);
});
it('is a no-op on a project with no C/C++ (clean control)', async () => {
write('app.js', `
const handlers = { add: (x) => x + 1, rm: (x) => x - 1 };
function run(name, x) { return handlers[name](x); }
`);
const edges = await load();
expect(edges.length).toBe(0);
});
// The redis command-table shape, minimized: the handler is wrapped in a
// function-like macro, the table's struct type is an object-like macro alias,
// the fn-pointer field uses a function-TYPE typedef, and the dispatch receiver
// is a chained field access through a multi-declarator field.
it('bridges a macro-built table with a typedef field, type-alias macro, and chained dispatch', async () => {
write('reg.h', `
typedef void cmdProc(int x); /* function-TYPE typedef, not (*name) */
struct command { const char *name; cmdProc *proc; };
struct context { int id; struct command *cmd, *last; }; /* multi-declarator field */
`);
write('reg.c', `
#include "reg.h"
#define ENTRY(nm, handler) nm, handler /* function-like macro wrapping the handler */
#define CMD_T command /* object-like macro: the struct-type alias */
static void getCmd(int x) {}
static void setCmd(int x) {}
static void unusedCmd(int x) {} /* defined, NOT in the table */
static struct CMD_T table[] = {
{ ENTRY("get", getCmd) },
{ ENTRY("set", setCmd) },
};
void run(struct context *ctx, int x) { ctx->cmd->proc(x); } /* context.cmd → command → proc */
`);
const edges = await load();
expect(has(edges, 'run', 'getCmd')).toBe(true);
expect(has(edges, 'run', 'setCmd')).toBe(true);
expect(edges.every((e) => e.via === 'command.proc')).toBe(true);
// PRECISION: a function not registered in the table is never a target.
expect(has(edges, 'run', 'unusedCmd')).toBe(false);
});
// redis generates its command table into a `.def` that is #included (and never
// indexed on its own). The synthesizer reads the included file with the
// includer's macros in scope so the table still resolves.
it('reads a macro-built table from a non-indexed #included file', async () => {
write('inc.h', `
typedef int opRun(void);
struct op { const char *name; opRun *run; };
`);
write('inc.c', `
#include "inc.h"
#define MK(nm, fn) nm, fn
#define CMD_T op
static int a_impl(void){return 0;}
static int b_impl(void){return 0;}
#include "ops.def"
int go(struct op *o) { return o->run(); }
`);
// `.def` is not a C source extension, so this file is never indexed — it is
// only visible to the synthesizer through inc.c's #include.
write('ops.def', `
static struct CMD_T optable[] = {
{ MK("a", a_impl) },
{ MK("b", b_impl) },
};
`);
const edges = await load();
expect(has(edges, 'go', 'a_impl')).toBe(true);
expect(has(edges, 'go', 'b_impl')).toBe(true);
expect(edges.every((e) => e.via === 'op.run')).toBe(true);
});
// The sqlite builtin-function-table shape: the table-building macro lives in a
// header (`sqliteInt.h`), separate from the file with the table (`func.c`), and
// expands to a whole brace-wrapped struct element `{ …, xFunc, … }`.
it('expands a header-defined macro that produces a brace-wrapped element', async () => {
write('fn.h', `
typedef void sqlFn(int *ctx);
struct FuncDef { int nArg; sqlFn *xFunc; const char *zName; };
#define MKFUNC(name, impl) { 1, impl, #name }
`);
write('fn.c', `
#include "fn.h"
static void absImpl(int *ctx) {}
static void lenImpl(int *ctx) {}
static struct FuncDef builtins[] = {
MKFUNC(abs, absImpl),
MKFUNC(len, lenImpl),
};
void invoke(struct FuncDef *p, int *x) { p->xFunc(x); }
`);
const edges = await load();
expect(has(edges, 'invoke', 'absImpl')).toBe(true);
expect(has(edges, 'invoke', 'lenImpl')).toBe(true);
expect(edges.every((e) => e.via === 'FuncDef.xFunc')).toBe(true);
});
// The vim command-table shape: a table-building macro and the struct are both
// behind `#ifdef`, defined INLINE with the array (`struct cmd_entry {…} table[]`)
// in a header that a `.c` #includes after setting the switch macro, and the
// dispatch is a parenthesized array subscript through the file-scope table
// (`(cmd_table[i].handler)(x)`). Exercises #ifdef evaluation, the conditionally
// redefined macro, the inline struct (never a node), and array/global dispatch.
it('bridges an #ifdef-guarded inline-struct table dispatched by array subscript', async () => {
write('cmds.h', `
#ifdef DECLARE_TABLE
# define CMD(id, name, fn) { name, fn }
typedef void (*cmd_fn)(int arg);
static struct cmd_entry { const char *cmd_name; cmd_fn handler; } cmd_table[] =
#else
# define CMD(id, name, fn) id
enum cmd_id
#endif
{
CMD(C_a, "a", do_a),
CMD(C_b, "b", do_b),
};
`);
write('main.c', `
#define DECLARE_TABLE
#include "cmds.h"
static void do_a(int arg) {}
static void do_b(int arg) {}
static void unused(int arg) {} /* defined, NOT in the table */
void run(int idx, int x) { (cmd_table[idx].handler)(x); }
`);
const edges = await load();
expect(has(edges, 'run', 'do_a')).toBe(true);
expect(has(edges, 'run', 'do_b')).toBe(true);
expect(edges.every((e) => e.via === 'cmd_entry.handler')).toBe(true);
expect(has(edges, 'run', 'unused')).toBe(false);
});
// A bare ARRAY of function pointers — no struct, no field. The element type is
// a function-TYPE typedef (`op_t *opcodes[]`), entries are literal function
// names, and dispatch is a plain subscript-then-call `opcodes[op](…)` (the
// SameBoy CPU opcode-table shape). Keyed by the array variable name.
it('bridges a bare array of function pointers dispatched by subscript (the opcode-table shape)', async () => {
write('cpu.c', `
typedef void op_t(int *vm, unsigned char opcode);
static void nop(int *vm, unsigned char opcode) {}
static void inc(int *vm, unsigned char opcode) {}
static void unreg(int *vm, unsigned char opcode) {} /* defined, NOT in the table */
static op_t *opcodes[256] = { nop, inc };
void cpu_run(int *vm) {
unsigned char opcode = 0;
opcodes[opcode](vm, opcode);
}
`);
const edges = await load();
expect(has(edges, 'cpu_run', 'nop')).toBe(true);
expect(has(edges, 'cpu_run', 'inc')).toBe(true);
expect(edges.every((e) => e.via === 'opcodes[]')).toBe(true);
// PRECISION: a function not in the array is never a target.
expect(has(edges, 'cpu_run', 'unreg')).toBe(false);
});
// The php Zend shape: a function-POINTER typedef whose declarator carries a
// calling-convention macro before the `*` (`(FASTCALL *dtor_t)`), an array of
// it filled by DESIGNATED index with CAST-wrapped entries (`[1] = (dtor_t)fn`),
// dispatched through a subscript whose index is itself a call (`t[type(p)](p)`).
it('bridges a designated + cast-wrapped array with a calling-convention typedef (the Zend dtor shape)', async () => {
write('rc.c', `
#define FASTCALL
typedef void (FASTCALL *dtor_t)(int *p);
static void empty_dtor(int *p) {}
static void str_dtor(int *p) {}
static void arr_dtor(int *p) {}
static int type_of(int *p) { return 0; }
static const dtor_t rc_dtor[] = {
[0] = (dtor_t)empty_dtor,
[1] = (dtor_t)str_dtor,
[2] = (dtor_t)arr_dtor,
};
void rc_free(int *p) { rc_dtor[type_of(p)](p); }
`);
const edges = await load();
expect(has(edges, 'rc_free', 'empty_dtor')).toBe(true);
expect(has(edges, 'rc_free', 'str_dtor')).toBe(true);
expect(has(edges, 'rc_free', 'arr_dtor')).toBe(true);
expect(edges.every((e) => e.via === 'rc_dtor[]')).toBe(true);
});
// Two file-local `static` arrays share the same name across files (SameBoy
// declares `opcodes[256]` in both the CPU and the disassembler). Dispatch must
// resolve to the SAME file's table — no cross-file leak.
it('resolves same-named file-local arrays to their own file (no cross-file leak)', async () => {
write('a.c', `
typedef void af_t(int *m);
static void a_one(int *m) {}
static void a_two(int *m) {}
static af_t *table[8] = { a_one, a_two };
void a_run(int *m, int i) { table[i](m); }
`);
write('b.c', `
typedef void bf_t(int *m);
static void b_one(int *m) {}
static void b_two(int *m) {}
static bf_t *table[8] = { b_one, b_two };
void b_run(int *m, int i) { table[i](m); }
`);
const edges = await load();
expect(has(edges, 'a_run', 'a_one')).toBe(true);
expect(has(edges, 'a_run', 'a_two')).toBe(true);
expect(has(edges, 'b_run', 'b_one')).toBe(true);
// PRECISION: a_run's `table` is a.c's, never b.c's (and vice versa).
expect(has(edges, 'a_run', 'b_one')).toBe(false);
expect(has(edges, 'b_run', 'a_one')).toBe(false);
});
// PRECISION: an array of function pointers that is REGISTERED elsewhere (passed
// by element to a registrar) but never C-dispatched `arr[i](…)` yields nothing
// — the lua `package.searchers` shape, where elements are pushed into the VM.
it('does not bridge a fn-pointer array that is registered, not dispatched (the searchers control)', async () => {
write('pkg.c', `
typedef int searcher_t(int *L);
static int s_preload(int *L) { return 0; }
static int s_lua(int *L) { return 0; }
static searcher_t *searchers[] = { s_preload, s_lua, 0 };
extern void register_one(int *L, searcher_t *s);
void setup(int *L) {
for (int i = 0; searchers[i]; i++) register_one(L, searchers[i]);
}
`);
const edges = await load();
expect(edges.length).toBe(0);
});
});
@@ -0,0 +1,129 @@
/**
* Celery task-dispatch bridge (Python).
*
* Celery decouples a task's call site from its body: a `@shared_task` / `@app.task`
* decorated `def` is invoked through `task.delay(...)` / `task.apply_async(...)`, a
* dynamic hop with no static edge. This bridges each `.delay`/`.apply_async` site to
* the task function, gated on the DECORATOR (read from the source above the `def`) so a
* `.delay()` on a non-task object resolves to nothing. Covers both decorator dialects
* (`@shared_task`, `@app.task(...)`), the module-qualified `mod.task.apply_async()` form,
* and proves the precision gates: a plain function called with `.delay()` and a canvas
* `group(...).delay()` (no single identifier before `.delay`) both contribute no edge.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('celery-dispatch synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'celery-dispatch-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('bridges .delay()/.apply_async() to decorated tasks, ignoring non-task and canvas dispatch', async () => {
// Two decorator dialects: bare @shared_task and arg'd @app.task(...).
fs.writeFileSync(
path.join(dir, 'tasks.py'),
`from celery import shared_task
from myapp.celery import app
@shared_task
def send_email(to):
return to
@app.task(bind=True, max_retries=3)
def crunch(self, n):
return n * 2
`
);
fs.mkdirSync(path.join(dir, 'services'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'services', 'tickets.py'),
`from celery import shared_task
@shared_task
def invalidate_cache():
return None
`
);
// A plain function — NOT a celery task — that nonetheless has .delay() called on it.
fs.writeFileSync(
path.join(dir, 'utils.py'),
`def process_data(x):
return x
`
);
// Dispatch sites, all inside one enclosing function.
fs.writeFileSync(
path.join(dir, 'views.py'),
`from tasks import send_email, crunch
from services import tickets
from utils import process_data
from celery import group
def handle_request(req):
send_email.delay(req.addr) # → send_email task (cross-file)
crunch.apply_async(args=[5]) # → crunch task (@app.task dialect)
tickets.invalidate_cache.apply_async() # module-qualified → invalidate_cache
process_data.delay(req.x) # NOT a task → no edge
group([send_email.s(a) for a in req.addrs]).delay() # canvas → no edge
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges = db
.prepare(
`SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'celery-dispatch'`
)
.all();
const targets = (src: string) => edges.filter((r: any) => r.source === src).map((r: any) => r.target).sort();
// handle_request dispatches exactly the three real tasks (both dialects + module-qualified).
expect(targets('handle_request')).toEqual(['crunch', 'invalidate_cache', 'send_email']);
// The @app.task target resolved to the task def, not anything else.
const crunchEdge = edges.find((r: any) => r.target === 'crunch');
expect(crunchEdge.tf).toMatch(/tasks\.py$/);
// Module-qualified `tickets.invalidate_cache.apply_async()` resolved by the last identifier.
const cacheEdge = edges.find((r: any) => r.target === 'invalidate_cache');
expect(cacheEdge.tf).toMatch(/services[\\/]tickets\.py$/);
expect(cacheEdge.via).toBe('invalidate_cache');
// PRECISION: a plain function called with .delay() is never targeted (no decorator).
expect(edges.some((r: any) => r.target === 'process_data')).toBe(false);
cg.close?.();
});
it('produces no edges in a Celery-free project (clean control)', async () => {
fs.writeFileSync(
path.join(dir, 'app.py'),
`def schedule(job):
job.delay() # a .delay() that has nothing to do with Celery
return job
def run():
schedule(make_job())
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const count = db
.prepare(
`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'celery-dispatch'`
)
.get();
expect(count.c).toBe(0);
cg.close?.();
});
});
@@ -0,0 +1,132 @@
/**
* CFML dotted / relative component-path inheritance resolution (#1152).
*
* CFML names a supertype by its component path, not a bare class name:
* `extends="coldbox.system.web.Controller"` (dots = directories from the
* webroot or a CFML mapping) or `extends="../base"` (FW/1's relative style).
* The graph indexes the class under its final segment only, so before #1152
* these references never resolved — measured on ColdBox core, 49 of 52
* extends declarations were dotted and only 3 inheritance edges existed.
*
* These tests pin the matcher's precision rules: the mapping-root prefix may
* be absent from the repo (`coldbox.` IS the repo root in the coldbox repo),
* directory comparison is case-insensitive, a candidate needs at least one
* corroborating parent directory (an uncorroborated same-named class is
* almost always an out-of-repo library supertype — mxunit/testbox), a
* corroboration tie yields no edge, and dotted `calls` refs (member-access
* chains) are never treated as component paths.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('CFML component-path inheritance resolution (#1152)', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfml-inh-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
const load = async () => {
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges: { src: string; srcFile: string; tgt: string; tgtFile: string; kind: string }[] = db
.prepare(
`SELECT s.name src, s.file_path srcFile, t.name tgt, t.file_path tgtFile, e.kind kind
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE e.kind IN ('extends', 'implements')`
)
.all();
cg.close?.();
return edges;
};
const has = (edges: any[], src: string, tgt: string, tgtFile: string, kind = 'extends') =>
edges.some((e) => e.src === src && e.tgt === tgt && e.tgtFile === tgtFile && e.kind === kind);
it('resolves a dotted path whose mapping root is absent from the repo (the ColdBox shape)', async () => {
write('system/web/Controller.cfc', `component {\n function handle() { return 1; }\n}\n`);
write('handlers/Main.cfc', `component extends="coldbox.system.web.Controller" {\n function index() { return 1; }\n}\n`);
const edges = await load();
expect(has(edges, 'Main', 'Controller', 'system/web/Controller.cfc')).toBe(true);
});
it('disambiguates same-named classes by directory corroboration', async () => {
write('system/web/Controller.cfc', `component {}\n`);
write('other/Controller.cfc', `component {}\n`);
write('handlers/Main.cfc', `component extends="coldbox.system.web.Controller" {}\n`);
const edges = await load();
expect(has(edges, 'Main', 'Controller', 'system/web/Controller.cfc')).toBe(true);
expect(has(edges, 'Main', 'Controller', 'other/Controller.cfc')).toBe(false);
});
it('compares directories case-insensitively (CFML path resolution is)', async () => {
write('system/web/Controller.cfc', `component {}\n`);
write('handlers/Main.cfc', `component extends="COLDBOX.System.Web.Controller" {}\n`);
const edges = await load();
expect(has(edges, 'Main', 'Controller', 'system/web/Controller.cfc')).toBe(true);
});
it('creates no edge when the only same-named class has no corroborating directory (out-of-repo supertype)', async () => {
// `mxunit.framework.TestCase` is an external library; the repo's own
// unrelated TestCase must NOT be claimed as the supertype.
write('lib/TestCase.cfc', `component {}\n`);
write('tests/MyTest.cfc', `component extends="mxunit.framework.TestCase" {}\n`);
const edges = await load();
expect(edges.filter((e) => e.src === 'MyTest')).toHaveLength(0);
});
it('creates no edge on a corroboration tie', async () => {
write('a/models/User.cfc', `component {}\n`);
write('b/models/User.cfc', `component {}\n`);
write('handlers/Main.cfc', `component extends="models.User" {}\n`);
const edges = await load();
expect(edges.filter((e) => e.src === 'Main')).toHaveLength(0);
});
it('resolves a relative path against the referencing file (the FW/1 shape)', async () => {
write('examples/base.cfc', `component {\n function shared() { return 1; }\n}\n`);
write('examples/sub/app.cfc', `component extends="../base" {}\n`);
write('examples/sub/sibling.cfc', `component extends="./app" {}\n`);
const edges = await load();
expect(has(edges, 'app', 'base', 'examples/base.cfc')).toBe(true);
expect(has(edges, 'sibling', 'app', 'examples/sub/app.cfc')).toBe(true);
});
it('resolves dotted implements to an interface as an implements edge', async () => {
write('app/interfaces/IService.cfc', `interface {\n public string function getName();\n}\n`);
write('app/services/Greeter.cfc', `component implements="app.interfaces.IService" {\n public string function getName() { return "hi"; }\n}\n`);
const edges = await load();
expect(has(edges, 'Greeter', 'IService', 'app/interfaces/IService.cfc', 'implements')).toBe(true);
});
it('resolves the tag-based extends attribute the same way', async () => {
write('system/Base.cfc', `component {}\n`);
write('legacy/Old.cfc', `<cfcomponent extends="app.system.Base">\n<cffunction name="run"><cfreturn 1></cffunction>\n</cfcomponent>\n`);
const edges = await load();
expect(has(edges, 'Old', 'Base', 'system/Base.cfc')).toBe(true);
});
it('lowercase dotted paths still resolve when the file name case matches (framework.one)', async () => {
write('framework/one.cfc', `component {\n function onRequest() { return 1; }\n}\n`);
write('Application.cfc', `component extends="framework.one" {}\n`);
const edges = await load();
expect(has(edges, 'Application', 'one', 'framework/one.cfc')).toBe(true);
});
it('never treats a dotted calls reference as a component path', async () => {
// `variables.dsn.getName()` is a member-access chain; the matcher is
// gated to extends/implements so this must not mint a bogus edge to
// a class that happens to share a trailing name.
write('util/getName.cfc', `component {}\n`);
write('svc/Caller.cfc', `component {\n function go() { return variables.dsn.getName(); }\n}\n`);
const edges = await load();
expect(edges.filter((e) => e.src === 'Caller' || e.src === 'go')).toHaveLength(0);
});
});
+185
View File
@@ -0,0 +1,185 @@
/**
* CFML local-variable / component-field receiver-type inference (#1108 family).
*
* `var svc = new UserService(); svc.save()` — the call's receiver type is
* recoverable from its declaration, and resolveMethodOnType validates the
* inferred type actually declares the method, so a mis-inference produces no
* edge. CFML brings four declaration idioms the shared inferrer must know:
* `new` (dotted component paths included), `createObject("component", "...")`,
* typed arguments (cfscript params and `<cfargument>` tags), and component
* properties — including WireBox DI (`property name="svc" inject="..."`),
* whose receivers are `variables.`-scoped fields declared OUTSIDE the calling
* function (so the scan must widen to the whole file, in both directions).
*
* These tests also pin the extraction prerequisite: CFML method
* qualifiedNames carry the component scope (`UserService::save`) in all three
* extraction paths (bare-script, `<cffunction>`, component-level `<cfscript>`
* blocks) — without that, type-validated resolution can never match.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('CFML receiver-type inference', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfml-recv-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
const load = async () => {
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const calls: { src: string; tgt: string; tgtQn: string }[] = db
.prepare(
`SELECT s.name src, t.name tgt, t.qualified_name tgtQn
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE e.kind = 'calls' AND t.kind = 'method'`
)
.all();
const methods: { name: string; qn: string }[] = db
.prepare(`SELECT name, qualified_name qn FROM nodes WHERE kind = 'method'`)
.all();
cg.close?.();
return { calls, methods };
};
const hasCall = (calls: any[], src: string, tgtQn: string) =>
calls.some((e) => e.src === src && e.tgtQn === tgtQn);
// Two same-named methods so resolution MUST disambiguate by receiver type —
// plain name-matching alone can't pick one.
const userService = `component {\n function save(any u) { return u; }\n}\n`;
const orderService = `component {\n function save(any o) { return o; }\n}\n`;
it('scopes method qualifiedNames under the component in all three extraction paths', async () => {
write('svc/UserService.cfc', userService);
write('tag/TagService.cfc', `<cfcomponent>\n<cffunction name="save"><cfreturn 1></cffunction>\n</cfcomponent>\n`);
write('mod/ModuleConfig.cfc', `<cfcomponent>\n<cfscript>\nfunction configure() { return 1; }\n</cfscript>\n</cfcomponent>\n`);
const { methods } = await load();
expect(methods.find((m) => m.name === 'save' && m.qn === 'UserService::save')).toBeDefined();
expect(methods.find((m) => m.name === 'save' && m.qn === 'TagService::save')).toBeDefined();
expect(methods.find((m) => m.name === 'configure' && m.qn === 'ModuleConfig::configure')).toBeDefined();
});
it('infers a local declared with new, including a dotted component path', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/Main.cfc', `component {
function bare() {
var svc = new UserService();
return svc.save(1);
}
function dotted() {
var svc2 = new svc.UserService();
return svc2.save(2);
}
}
`);
const { calls } = await load();
expect(hasCall(calls, 'bare', 'UserService::save')).toBe(true);
expect(hasCall(calls, 'dotted', 'UserService::save')).toBe(true);
expect(hasCall(calls, 'bare', 'OrderService::save')).toBe(false);
});
it('infers a local declared with createObject (two-arg and single-arg forms)', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/Legacy.cfc', `component {
function classic() {
var svc = createObject("component", "svc.UserService");
return svc.save(1);
}
function modern() {
var svc2 = CreateObject("svc.OrderService");
return svc2.save(2);
}
}
`);
const { calls } = await load();
expect(hasCall(calls, 'classic', 'UserService::save')).toBe(true);
expect(hasCall(calls, 'modern', 'OrderService::save')).toBe(true);
});
it('infers a typed cfscript parameter', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/Typed.cfc', `component {
function process(required UserService svc) {
return svc.save(1);
}
}
`);
const { calls } = await load();
expect(hasCall(calls, 'process', 'UserService::save')).toBe(true);
expect(hasCall(calls, 'process', 'OrderService::save')).toBe(false);
});
it('infers a <cfargument> typed argument used inside a <cfscript> body', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/TagTyped.cfc', `<cfcomponent>
<cffunction name="process">
<cfargument name="svc" type="svc.UserService">
<cfscript>
return svc.save(1);
</cfscript>
</cffunction>
</cfcomponent>
`);
const { calls } = await load();
expect(hasCall(calls, 'process', 'UserService::save')).toBe(true);
});
it('infers a variables-scoped field from its pseudoconstructor assignment, even when init sits below the call', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/Fielded.cfc', `component {
function handle() {
return variables.svc.save(1);
}
function init() {
variables.svc = new UserService();
return this;
}
}
`);
const { calls } = await load();
expect(hasCall(calls, 'handle', 'UserService::save')).toBe(true);
expect(hasCall(calls, 'handle', 'OrderService::save')).toBe(false);
});
it('infers a WireBox-injected property (the ColdBox DI shape)', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/Injected.cfc', `component {
property name="svc" inject="UserService";
function handle() {
return variables.svc.save(1);
}
}
`);
const { calls } = await load();
expect(hasCall(calls, 'handle', 'UserService::save')).toBe(true);
});
it('creates no method edge when the inferred type does not declare the method', async () => {
write('svc/UserService.cfc', userService);
write('handlers/Wrong.cfc', `component {
function go() {
var svc = new UserService();
return svc.destroyEverything();
}
}
`);
const { calls } = await load();
expect(calls.filter((e) => e.src === 'go')).toHaveLength(0);
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* `codegraph affected` input-path normalization (#825).
*
* The index stores project-relative, forward-slash paths. A user (or a wrapping
* script) may pass a `./`-prefixed path or an absolute path; before #825 those
* silently matched nothing and reported 0 affected tests. All three spellings
* must now resolve the same affected test file.
*
* Exercised end-to-end against the built binary.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function affected(cwd: string, arg: string): string[] {
const out = execFileSync(process.execPath, [BIN, 'affected', arg, '--quiet', '-p', cwd], {
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
return out.split('\n').map((s) => s.trim()).filter(Boolean);
}
describe('codegraph affected — input path normalization (#825)', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-affected-paths-'));
fs.mkdirSync(path.join(tempDir, 'src'));
// util.ts <- helper.ts <- helper.test.ts (transitive test dependency)
fs.writeFileSync(path.join(tempDir, 'src/util.ts'), 'export function util(x: number){ return x + 1; }\n');
fs.writeFileSync(
path.join(tempDir, 'src/helper.ts'),
"import { util } from './util';\nexport function helper(){ return util(1); }\n",
);
fs.writeFileSync(
path.join(tempDir, 'src/helper.test.ts'),
"import { helper } from './helper';\ntest('t', () => helper());\n",
);
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.close();
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('bare-relative, ./-prefixed, and absolute paths all resolve the same affected test', () => {
const expected = ['src/helper.test.ts'];
// Baseline that always worked.
expect(affected(tempDir, 'src/util.ts')).toEqual(expected);
// Both of these returned [] before the normalization fix.
expect(affected(tempDir, './src/util.ts')).toEqual(expected);
expect(affected(tempDir, path.join(tempDir, 'src/util.ts'))).toEqual(expected);
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* `codegraph node` argument handling (#1044).
*
* File-read mode (`codegraph node -f <file>`) carries no symbol name, but the
* command was defined with a REQUIRED `<name>` positional, so commander.js
* rejected the call with "missing required argument 'name'" before the action
* ever ran — making file mode unreachable from the CLI. `name` is now optional
* (`[name]`); the action validates that a symbol OR a file is supplied.
*
* Exercised end-to-end against the built binary.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function runNode(cwd: string, extraArgs: string[]): { stdout: string; stderr: string; code: number } {
try {
const stdout = execFileSync(process.execPath, [BIN, 'node', ...extraArgs, '-p', cwd], {
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
return { stdout, stderr: '', code: 0 };
} catch (err: any) {
return { stdout: err.stdout ?? '', stderr: err.stderr ?? '', code: err.status ?? 1 };
}
}
describe('codegraph node — argument handling (#1044)', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-node-cmd-'));
fs.mkdirSync(path.join(tempDir, 'src'));
fs.writeFileSync(path.join(tempDir, 'src/util.ts'), 'export function util(x: number){ return x + 1; }\n');
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.close();
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('file mode via -f reads the file (was rejected as "missing required argument")', () => {
const { stdout, code } = runNode(tempDir, ['-f', 'src/util.ts']);
expect(code).toBe(0);
expect(stdout).toContain('src/util.ts');
expect(stdout).toContain('export function util');
// The line-numbered Read-parity shape.
expect(stdout).toMatch(/1\s+export function util/);
});
it('a path-like positional still routes to file mode', () => {
const { stdout, code } = runNode(tempDir, ['src/util.ts']);
expect(code).toBe(0);
expect(stdout).toContain('src/util.ts');
expect(stdout).toContain('export function util');
});
it('a bare symbol positional still routes to symbol mode', () => {
const { stdout, code } = runNode(tempDir, ['util']);
expect(code).toBe(0);
expect(stdout).toContain('util');
expect(stdout).toContain('Location:');
});
it('neither symbol nor file gives a usage error, not commander\'s cryptic one', () => {
const { stderr, code } = runNode(tempDir, []);
expect(code).not.toBe(0);
expect(stderr).toMatch(/symbol name|file/i);
expect(stderr).not.toMatch(/missing required argument/);
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* `codegraph query` score rendering (#1045).
*
* The human-readable output used to print `(score * 100)%` next to each hit,
* but `score` is an unbounded BM25/FTS relevance magnitude (relative-ranking
* only), so it rendered as nonsensical percentages like "12042%". The CLI now
* shows no score — results are already in rank order, matching the MCP search
* tool — while `--json` still carries the raw `score` for programmatic use.
*
* Exercised end-to-end against the built binary.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function query(cwd: string, extraArgs: string[]): string {
return execFileSync(process.execPath, [BIN, 'query', 'parseToken', ...extraArgs, '-p', cwd], {
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
stdio: ['ignore', 'pipe', 'ignore'], // drop stderr (SQLite experimental warning)
});
}
describe('codegraph query — score rendering (#1045)', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-query-cmd-'));
fs.mkdirSync(path.join(tempDir, 'src'));
fs.writeFileSync(
path.join(tempDir, 'src/auth.ts'),
'export function parseToken(t: string){ return t.trim(); }\n' +
'export function parseTokenExpiry(t: string){ return Date.parse(t); }\n',
);
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.close();
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('human output ranks results without rendering a raw score as a percentage', () => {
const out = query(tempDir, ['-l', '5']);
// Still finds and lists the symbol...
expect(out).toContain('parseToken');
// ...but never prints the bogus `(12042%)`-style score.
expect(out).not.toMatch(/\(\d+%\)/);
expect(out).not.toContain('%');
});
it('--json still carries the raw numeric score for programmatic use', () => {
const parsed = JSON.parse(query(tempDir, ['-l', '5', '--json']));
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBeGreaterThan(0);
expect(typeof parsed[0].score).toBe('number');
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* Tests for the `codegraph version` affordances.
*
* The version should be reachable however a user reaches for it — the bare
* `version` subcommand, lowercase `-v`, single-dash `-version`, plus
* commander's stock `--version` / `-V`. All of them print the exact
* package.json version and nothing else.
*
* Exercised end-to-end against the built binary (same approach as
* status-json.test.ts) so the spellings survive future CLI refactors.
*/
import { describe, it, expect } from 'vitest';
import { execFileSync } 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');
const PKG_VERSION = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'),
).version as string;
function run(args: string[]): string {
return execFileSync(process.execPath, [BIN, ...args], {
encoding: 'utf-8',
// Skip the daemon and the wasm-flag re-exec so the command resolves in a
// single fast process (no graph work happens for a version print anyway).
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
describe('codegraph version affordances', () => {
for (const spelling of ['version', '-v', '-version', '--version', '-V']) {
it(`\`codegraph ${spelling}\` prints exactly the package version`, () => {
expect(run([spelling])).toBe(PKG_VERSION);
});
}
it('lists the `version` subcommand in --help', () => {
expect(run(['--help'])).toContain('version');
});
it('`codegraph help` prints usage and the command list', () => {
const out = run(['help']);
expect(out).toContain('Usage: codegraph');
expect(out).toContain('Commands:');
});
it('hides the internal `serve` command from --help', () => {
// `serve --mcp` is the stdio entry point an AI agent launches for itself,
// not a human command — it must not appear in the listing. (It stays fully
// invocable; the mcp-initialize suite covers that the agent path works.)
expect(run(['--help'])).not.toMatch(/^\s+serve\b/m);
});
it('a trailing `-v` is still the subcommand\'s --verbose, not the version intercept', () => {
// A fresh temp dir outside any indexed project: `index -v` parses `-v` as
// the index command's --verbose, then short-circuits at "not initialized"
// and exits non-zero. The point is it must NOT print the bare version,
// which would mean the top-level intercept swallowed a subcommand flag.
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-version-test-'));
let combined = '';
try {
combined = execFileSync(process.execPath, [BIN, 'index', '-v', tempDir], {
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (err: unknown) {
const e = err as { stdout?: string; stderr?: string };
combined = `${e.stdout ?? ''}${e.stderr ?? ''}`;
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
expect(combined.trim()).not.toBe(PKG_VERSION);
expect(combined).toContain('not initialized');
});
});
@@ -0,0 +1,159 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
/**
* End-to-end synthesizer test for closure-collection dynamic dispatch.
*
* A method appends a closure to a collection property; another method iterates
* that property *invoking each element* (`coll.forEach { $0() }`) — a dynamic
* dispatch tree-sitter can't resolve, so a flow into the dispatcher dead-ends
* before the registered closures. This is Alamofire's request-validation shape:
* `DataRequest.validate` does `validators.write { $0.append(validator) }`, the
* base `Request.didCompleteTask` runs `validators.forEach { $0() }`.
*
* Verify the synthesizer (1) links the dispatcher → each same-named registrar
* across files/classes, (2) handles both the Swift `prop.write { $0.append }`
* and the direct `prop.append(...)` registrar forms, (3) surfaces the wiring
* site, and (4) does NOT fire on a `.forEach` that doesn't invoke its element
* (the closure-invoke is the precision gate — a plain collection is skipped).
*/
describe('closure-collection synthesizer', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'closure-coll-fixture-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('links dispatcher → registrars across files, both append forms, and skips non-invoked collections', async () => {
// Base class: the dispatchers (iterate-and-invoke) + a non-closure control.
fs.writeFileSync(
path.join(dir, 'Request.swift'),
`class Request {
var validators: [() -> Void] = []
var handlers: [() -> Void] = []
var names: [String] = []
func didCompleteTask() {
let validators = validators
validators.forEach { $0() }
}
func runHandlers() {
handlers.forEach { $0() }
}
func printNames() {
names.forEach { print($0) }
}
}
`
);
// Subclass: the registrars (append a closure) in a DIFFERENT file/class.
fs.writeFileSync(
path.join(dir, 'DataRequest.swift'),
`class DataRequest: Request {
func validate(_ validation: @escaping () -> Void) -> Self {
let validator: () -> Void = { validation() }
validators.write { $0.append(validator) }
return self
}
func onEvent(_ handler: @escaping () -> Void) {
handlers.append(handler)
}
func addName(_ n: String) {
names.append(n)
}
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.name source_name, s.kind source_kind, t.name target_name,
json_extract(e.metadata,'$.field') field,
json_extract(e.metadata,'$.registeredAt') registeredAt
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'closure-collection'`
)
.all();
cg.close?.();
expect(rows.length).toBeGreaterThan(0);
// Every edge originates from a dispatcher method and is a real `calls` hop.
expect(rows.every((r: any) => r.source_kind === 'method')).toBe(true);
// The validators flow: didCompleteTask → validate, captured via the Swift
// Protected `prop.write { $0.append }` form, wiring site surfaced.
const validatorsEdge = rows.find(
(r: any) => r.field === 'validators' && r.target_name === 'validate'
);
expect(validatorsEdge).toBeTruthy();
expect(validatorsEdge.source_name).toBe('didCompleteTask');
// Exact wiring-site line, not just shape: pins the binary-search line
// resolver (#1235) to the same answer as the old per-match slice+split.
expect(validatorsEdge.registeredAt).toBe('DataRequest.swift:4');
// The handlers flow: runHandlers → onEvent, via the direct `prop.append`
// form — proves both registrar shapes are covered.
const handlersEdge = rows.find(
(r: any) => r.field === 'handlers' && r.target_name === 'onEvent'
);
expect(handlersEdge).toBeTruthy();
expect(handlersEdge.source_name).toBe('runHandlers');
// Precision gate: `names.forEach { print($0) }` does NOT invoke its element,
// so `names` is not a closure collection — no edge, and addName is never a target.
expect(rows.some((r: any) => r.field === 'names')).toBe(false);
expect(rows.some((r: any) => r.target_name === 'addName')).toBe(false);
});
it('is a no-op on non-Swift/Kotlin code even when the text patterns occur (#1235)', async () => {
// JS that contains every textual trigger the scanner looks for —
// `.forEach`, `.push(`, even a `{ $0(` lookalike inside a string — but
// `{ $0( ` / `{ it( ` element-invocation is Swift/Kotlin trailing-closure
// syntax, so the pass must skip the language entirely (this scan running
// ungated over `.push(`-heavy JS/PHP was the #1235 25-minute index).
fs.writeFileSync(
path.join(dir, 'queue.js'),
`class Queue {
constructor() { this.tasks = []; }
register(fn) { this.tasks.push(fn); }
drain() { this.tasks.forEach(function (t) { t(); }); }
weird() { const s = "tasks.forEach { $0( } tasks.push("; return s; }
}
module.exports = Queue;
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const count = db
.prepare(
`SELECT count(*) c FROM edges
WHERE json_extract(metadata,'$.synthesizedBy') = 'closure-collection'`
)
.get();
cg.close?.();
expect(count.c).toBe(0);
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* Issue #238 — "database is locked" on concurrent MCP tool calls.
*
* With node:sqlite (real WAL) as the backend, the fixes that remain relevant:
* 1. busy_timeout is a bounded few-second wait (not a 2-minute hang) and WAL is
* active — so a reader never blocks on a concurrent writer.
* 2. The MCP ToolHandler reuses the default instance when a tool passes a
* projectPath pointing at the default project, instead of opening a SECOND
* connection to the same DB.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src';
import { ToolHandler } from '../src/mcp/tools';
import { DatabaseConnection } from '../src/db';
/** Normalize a PRAGMA read across return shapes (array | object | scalar). */
function pragmaValue(raw: unknown, key: string): unknown {
const row = Array.isArray(raw) ? raw[0] : raw;
if (row !== null && typeof row === 'object') return (row as Record<string, unknown>)[key];
return row;
}
describe('issue #238 — connection PRAGMAs (#1)', () => {
let dir: string;
let conn: DatabaseConnection;
beforeAll(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-pragma-'));
conn = DatabaseConnection.initialize(path.join(dir, 'codegraph.db'));
});
afterAll(() => {
conn.close();
fs.rmSync(dir, { recursive: true, force: true });
});
it('uses a bounded busy_timeout, not the old 2-minute hang', () => {
const ms = Number(pragmaValue(conn.getDb().pragma('busy_timeout'), 'timeout'));
expect(ms).toBeGreaterThan(0);
expect(ms).toBeLessThanOrEqual(30000); // far below the old 120000
});
it('runs in WAL mode — the mode that lets readers proceed during a write', () => {
const mode = String(pragmaValue(conn.getDb().pragma('journal_mode'), 'journal_mode')).toLowerCase();
expect(mode).toBe('wal');
});
it('getJournalMode() surfaces the effective mode for status triage', () => {
expect(conn.getJournalMode()).toBe('wal');
});
});
describe('issue #238 — WAL lets a reader proceed during a writer', () => {
let dir: string;
beforeAll(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-wal-'));
});
afterAll(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('a read on a 2nd connection succeeds while a writer holds the lock', () => {
const dbPath = path.join(dir, 'codegraph.db');
const writer = DatabaseConnection.initialize(dbPath);
// The property only holds under WAL; skip if the filesystem couldn't enable it.
if (writer.getJournalMode() !== 'wal') {
writer.close();
return;
}
const reader = DatabaseConnection.open(dbPath);
try {
writer.getDb().prepare('BEGIN EXCLUSIVE').run(); // hard write lock, held open
const t0 = Date.now();
const row = reader.getDb().prepare('SELECT COUNT(*) AS c FROM nodes').get() as { c: number };
const waited = Date.now() - t0;
expect(row.c).toBe(0);
expect(waited).toBeLessThan(1000); // proceeds immediately, no busy wait
} finally {
try { writer.getDb().prepare('COMMIT').run(); } catch { /* ignore */ }
reader.close();
writer.close();
}
});
});
describe('issue #238 — ToolHandler reuses the default instance (#2)', () => {
let dir: string;
let cg: CodeGraph;
let root: string;
let handler: ToolHandler;
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-tools-'));
fs.writeFileSync(path.join(dir, 'a.ts'), 'export function helper(): number { return 1; }\n');
fs.writeFileSync(
path.join(dir, 'b.ts'),
"import { helper } from './a';\nexport function main(): number { return helper(); }\n"
);
cg = await CodeGraph.init(dir, { index: true });
root = cg.getProjectRoot();
handler = new ToolHandler(cg);
});
afterAll(() => {
cg.close();
fs.rmSync(dir, { recursive: true, force: true });
});
it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', () => {
const openSpy = vi.spyOn(CodeGraph, 'openSync');
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolved = (handler as any).getCodeGraph(root);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nested = (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist'));
expect(resolved).toBe(cg);
expect(nested).toBe(cg); // a sub-path resolves up to the same default project
expect(openSpy).not.toHaveBeenCalled(); // no second connection opened
} finally {
openSpy.mockRestore();
}
});
it('concurrent read tool calls (mixed projectPath) all succeed without "database is locked"', async () => {
const openSpy = vi.spyOn(CodeGraph, 'openSync');
try {
const calls: Promise<{ content: Array<{ text: string }>; isError?: boolean }>[] = [
handler.execute('codegraph_search', { query: 'helper' }),
handler.execute('codegraph_search', { query: 'helper', projectPath: root }),
handler.execute('codegraph_callers', { symbol: 'helper', projectPath: root }),
handler.execute('codegraph_callees', { symbol: 'main' }),
handler.execute('codegraph_files', { projectPath: root }),
handler.execute('codegraph_status', { projectPath: root }),
];
const results = await Promise.all(calls);
for (const r of results) {
expect(r.isError).not.toBe(true);
expect(r.content[0]?.text ?? '').not.toMatch(/database is locked/i);
}
// Passing the default project's own path must not open a second connection.
expect(openSpy).not.toHaveBeenCalled();
} finally {
openSpy.mockRestore();
}
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* #383 — CodeGraph indexes config KEYS but must never surface config VALUES.
*
* Spring `application.{yml,properties}` keys are indexed as `constant` nodes so
* `@Value` resolution works, but their values are routinely secrets (DB
* passwords, API keys, JDBC URLs with embedded creds). CodeGraph must surface
* the KEY and never the value — not in node metadata (docstring/signature),
* not via `codegraph_explore`'s verbatim source dump, and not via
* `codegraph_node` `includeCode`. An agent that genuinely needs a value can
* read the file itself (a deliberate pull); CodeGraph must never volunteer it.
*/
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';
const SECRET = 'sk-live-DO-NOT-LEAK-2f9a4c7e1b';
describe('config secret redaction (#383)', () => {
let tmpDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-config-secret-'));
const javaDir = path.join(tmpDir, 'src/main/java/com/example');
const resDir = path.join(tmpDir, 'src/main/resources');
fs.mkdirSync(javaDir, { recursive: true });
fs.mkdirSync(resDir, { recursive: true });
// pom.xml triggers Spring detection so the resolver parses the config files.
fs.writeFileSync(
path.join(tmpDir, 'pom.xml'),
'<project><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency></dependencies></project>\n',
);
fs.writeFileSync(
path.join(resDir, 'application.properties'),
`server.port=8080\nspring.datasource.password=${SECRET}\n`,
);
fs.writeFileSync(
path.join(resDir, 'application.yml'),
`app:\n api:\n key: "${SECRET}"\n`,
);
fs.writeFileSync(
path.join(javaDir, 'DataConfig.java'),
'package com.example;\n' +
'import org.springframework.beans.factory.annotation.Value;\n' +
'public class DataConfig {\n' +
' @Value("${spring.datasource.password}") private String dbPass;\n' +
' @Value("${app.api.key}") private String apiKey;\n' +
'}\n',
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
});
const configKeys = () =>
cg.getNodesByKind('constant').filter((n) => n.language === 'yaml' || n.language === 'properties');
it('still indexes config KEYS as nodes (resolution must not regress)', () => {
const byQn = (qn: string) => configKeys().find((n) => n.qualifiedName === qn);
expect(byQn('spring.datasource.password'), '.properties key indexed').toBeDefined();
expect(byQn('app.api.key'), 'yaml key indexed').toBeDefined();
});
it('never stores the secret VALUE in node metadata (docstring/signature/name)', () => {
const keys = configKeys();
expect(keys.length).toBeGreaterThan(0);
for (const n of keys) {
expect(n.docstring ?? '', `docstring of ${n.qualifiedName}`).not.toContain(SECRET);
expect(n.signature ?? '', `signature of ${n.qualifiedName}`).not.toContain(SECRET);
expect(n.name, `name of ${n.qualifiedName}`).not.toContain(SECRET);
}
});
it('codegraph_explore surfaces the config key but NEVER the secret value', async () => {
const res = await handler.execute('codegraph_explore', {
query: 'DataConfig dbPass apiKey spring.datasource.password app.api.key',
});
const text = res.content.map((c) => c.text).join('\n');
expect(text).toContain('password'); // the key is in scope (non-vacuous)
expect(text).not.toContain(SECRET); // ...but the value is never dumped
});
it('codegraph_node includeCode returns the key, not the secret value', async () => {
const res = await handler.execute('codegraph_node', {
symbol: 'spring.datasource.password',
includeCode: true,
});
const text = res.content.map((c) => c.text).join('\n');
expect(text).toContain('password'); // found the node
expect(text).not.toContain(SECRET); // value redacted from the code path
});
});
+189
View File
@@ -0,0 +1,189 @@
/**
* Context ranking: common-word precision + low-confidence handoff.
*
* Regression coverage for the failure where a prose query
* ("capture intro onboarding screen flat object") surfaced an unrelated
* constant named `FLAT` (in a download script) as a top entry point — because
* the descriptive word "flat" exact-matched it and the +exact-name bonus was
* exempt from single-term dampening. The fix: only distinctive identifiers earn
* that exemption; an isolated common-word exact match is demoted, and a query
* that resolves only to such weak matches is flagged low-confidence so the
* response hands off to explore/trace instead of bluffing.
*/
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 { LOW_CONFIDENCE_MARKER } from '../src/context';
import { isDistinctiveIdentifier, scorePathRelevance, deriveProjectNameTokens } from '../src/search/query-utils';
describe('isDistinctiveIdentifier', () => {
it('treats plain dictionary words as non-distinctive', () => {
for (const word of ['flat', 'object', 'screen', 'standing', 'capture']) {
expect(isDistinctiveIdentifier(word)).toBe(false);
}
});
it('treats leading-capital-only words (proper nouns / sentence start) as non-distinctive', () => {
expect(isDistinctiveIdentifier('Screen')).toBe(false);
expect(isDistinctiveIdentifier('Zustand')).toBe(false);
});
it('treats camelCase / PascalCase / snake_case / acronyms / digits as distinctive', () => {
expect(isDistinctiveIdentifier('setLastEmail')).toBe(true);
expect(isDistinctiveIdentifier('OrgUserStore')).toBe(true);
expect(isDistinctiveIdentifier('user_store')).toBe(true);
expect(isDistinctiveIdentifier('REST')).toBe(true);
expect(isDistinctiveIdentifier('v2')).toBe(true);
});
});
// A single PascalCase query word (notably a project name a user naturally
// includes) splits into sub-tokens that all match the SAME path segment; summed
// per sub-token it boosted that path 4×, burying the rest of the query's stack
// (#720). Path relevance must count each original WORD once per level, while
// still splitting it for cross-convention matching.
describe('scorePathRelevance per-word scoring (#720)', () => {
it('counts a single PascalCase word once per path level, not once per sub-token', () => {
// "SuperBizAgent" → super/biz/agent/superbizagent all hit the dir, but it's
// one concept: +5 (dir) once, not +20.
expect(scorePathRelevance('SuperBizAgentFrontend/app.js', 'SuperBizAgent')).toBe(5);
});
it('still splits a word so it matches across naming conventions', () => {
// getUserName must still match a snake_case path via its sub-tokens.
expect(scorePathRelevance('get_user_name.go', 'getUserName')).toBeGreaterThanOrEqual(10);
});
it('still credits distinct query words matching different path segments', () => {
// auth (dir) and handler (filename) are separate concepts — each counts.
expect(scorePathRelevance('src/auth/login_handler.go', 'auth handler')).toBeGreaterThan(
scorePathRelevance('src/auth/login_handler.go', 'auth')
);
});
});
// The project name is context, not a discriminator: dropping it from path
// scoring stops every file under a `<ProjectName>…/` tree from winning on the
// name alone, so the rest of the query decides the ranking (#720).
describe('project-name down-weighting in path relevance (#720)', () => {
it('derives the project name from go.mod / package.json, skipping short names', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-projname-'));
try {
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/SuperBizAgent\n\ngo 1.21\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: '@acme/superbizagent-web' }));
const tokens = deriveProjectNameTokens(dir);
expect(tokens.has('superbizagent')).toBe(true);
expect(tokens.has('superbizagentweb')).toBe(true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('drops a project-name query word from path scoring when other words remain', () => {
const proj = new Set(['superbizagent']);
// Without the project name dropped, the frontend path wins on it (+5).
// With it dropped, only "backend" is left — and it doesn't match this path.
const withDrop = scorePathRelevance('SuperBizAgentFrontend/app.js', 'SuperBizAgent backend', proj);
const noDrop = scorePathRelevance('SuperBizAgentFrontend/app.js', 'SuperBizAgent backend');
expect(withDrop).toBeLessThan(noDrop);
expect(withDrop).toBe(0);
});
it('keeps the project-name word when it is the ONLY query word (bare query still scores)', () => {
const proj = new Set(['superbizagent']);
expect(scorePathRelevance('SuperBizAgentFrontend/app.js', 'SuperBizAgent', proj)).toBe(5);
});
it('does not affect a query that omits the project name', () => {
const proj = new Set(['superbizagent']);
const path0 = 'internal/controller/chat/chat.go';
expect(scorePathRelevance(path0, 'controller chat', proj)).toBe(
scorePathRelevance(path0, 'controller chat')
);
});
});
describe('Context ranking — common-word precision & confidence', () => {
let testDir: string;
let cg: CodeGraph;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ctxrank-'));
// The corroborated target: a capture-flow screen whose NAME alone matches
// three query terms (capture + intro + screen), and which lives under a
// matching directory.
const captureDir = path.join(testDir, 'src', 'app', 'capture');
fs.mkdirSync(captureDir, { recursive: true });
fs.writeFileSync(
path.join(captureDir, 'intro.tsx'),
`export function CaptureIntroScreen() {
// Onboarding screen shown before the user selects flat or standing object capture.
return null;
}
`
);
// The trap: an unrelated constant literally named FLAT, in a totally
// different area. "flat" in a prose query exact-matches it.
const scriptsDir = path.join(testDir, 'scripts', 'dataset');
fs.mkdirSync(scriptsDir, { recursive: true });
fs.writeFileSync(
path.join(scriptsDir, 'download.ts'),
`export const FLAT = 'freiburg_flat_dataset';
export function downloadDataset(name: string): string { return name; }
`
);
cg = CodeGraph.initSync(testDir, {
config: { include: ['**/*.ts', '**/*.tsx'], exclude: [] },
});
await cg.indexAll();
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
it('does not let a common-word exact match (FLAT) outrank a corroborated symbol', async () => {
const sg = await cg.findRelevantContext(
'capture intro onboarding screen flat object'
);
const rootNames = sg.roots.map((id) => sg.nodes.get(id)?.name);
// The corroborated capture screen surfaces as an entry point...
expect(rootNames).toContain('CaptureIntroScreen');
// ...and the trap constant is never the lead result (the bug we fixed).
expect(rootNames[0]).not.toBe('FLAT');
const capIdx = rootNames.indexOf('CaptureIntroScreen');
const flatIdx = rootNames.indexOf('FLAT');
if (flatIdx >= 0) expect(capIdx).toBeLessThan(flatIdx);
// And it's confidently answered (we located a corroborated symbol).
expect(sg.confidence).toBe('high');
});
it('flags low confidence and emits the handoff when only common words match', async () => {
const query = 'flat object thing';
const sg = await cg.findRelevantContext(query);
expect(sg.confidence).toBe('low');
const md = await cg.buildContext(query, { format: 'markdown' });
expect(typeof md).toBe('string');
expect(md as string).toContain(LOW_CONFIDENCE_MARKER);
// The handoff routes to the precise tools rather than claiming completeness.
expect(md as string).toMatch(/codegraph_explore/);
});
it('does not emit the handoff for a precise, distinctive-symbol query', async () => {
const sg = await cg.findRelevantContext('CaptureIntroScreen');
expect(sg.confidence).toBe('high');
const md = await cg.buildContext('CaptureIntroScreen', { format: 'markdown' });
expect(md as string).not.toContain(LOW_CONFIDENCE_MARKER);
});
});
+374
View File
@@ -0,0 +1,374 @@
/**
* Context Builder Tests
*
* Tests for the context building functionality.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
describe('Context Builder', () => {
let testDir: string;
let cg: CodeGraph;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-context-test-'));
// Create a sample codebase
const srcDir = path.join(testDir, 'src');
fs.mkdirSync(srcDir);
// Create a payment service file
fs.writeFileSync(
path.join(srcDir, 'payment.ts'),
`/**
* Payment Service
* Handles payment processing logic.
*/
export interface PaymentResult {
success: boolean;
transactionId: string;
amount: number;
}
export class PaymentService {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
/**
* Process a payment for the given amount
*/
async processPayment(amount: number): Promise<PaymentResult> {
// Validate amount
if (amount <= 0) {
throw new Error('Invalid amount');
}
// Process payment
const transactionId = this.generateTransactionId();
return {
success: true,
transactionId,
amount,
};
}
private generateTransactionId(): string {
return 'txn_' + Math.random().toString(36).substring(2);
}
}
export function createPaymentService(apiKey: string): PaymentService {
return new PaymentService(apiKey);
}
`
);
// Create a checkout controller file
fs.writeFileSync(
path.join(srcDir, 'checkout.ts'),
`/**
* Checkout Controller
* Handles the checkout flow.
*/
import { PaymentService, PaymentResult } from './payment';
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export class CheckoutController {
private paymentService: PaymentService;
constructor(paymentService: PaymentService) {
this.paymentService = paymentService;
}
/**
* Process checkout for the given cart
*/
async processCheckout(cart: CartItem[]): Promise<PaymentResult> {
const total = this.calculateTotal(cart);
if (total === 0) {
throw new Error('Cart is empty');
}
return this.paymentService.processPayment(total);
}
/**
* Calculate the total price of the cart
*/
calculateTotal(cart: CartItem[]): number {
return cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
`
);
// Create a utilities file
fs.writeFileSync(
path.join(srcDir, 'utils.ts'),
`/**
* Utility functions
*/
export function formatCurrency(amount: number): string {
return '$' + amount.toFixed(2);
}
export function validateEmail(email: string): boolean {
return email.includes('@');
}
`
);
// Initialize CodeGraph
cg = CodeGraph.initSync(testDir, {
config: {
include: ['**/*.ts'],
exclude: [],
},
});
// Index the codebase
await cg.indexAll();
});
afterEach(() => {
if (cg) {
cg.destroy();
}
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
describe('getCode()', () => {
it('should extract code for a node', async () => {
// Find the PaymentService class
const nodes = cg.getNodesByKind('class');
const paymentService = nodes.find((n) => n.name === 'PaymentService');
expect(paymentService).toBeDefined();
const code = await cg.getCode(paymentService!.id);
expect(code).not.toBeNull();
expect(code).toContain('class PaymentService');
expect(code).toContain('processPayment');
});
it('should return null for non-existent node', async () => {
const code = await cg.getCode('non-existent-id');
expect(code).toBeNull();
});
});
describe('findRelevantContext()', () => {
it('should find relevant nodes for a query', async () => {
// Use simple query that matches symbol names (FTS5 treats spaces as AND)
const result = await cg.findRelevantContext('PaymentService');
expect(result.nodes.size).toBeGreaterThan(0);
// Should find payment-related nodes
const nodeNames = Array.from(result.nodes.values()).map((n) => n.name);
expect(
nodeNames.some(
(name) =>
name.toLowerCase().includes('payment') ||
name.toLowerCase().includes('checkout')
)
).toBe(true);
});
it('should include edges in the result', async () => {
const result = await cg.findRelevantContext('checkout', {
traversalDepth: 2,
});
// Should have some edges from traversal
expect(result.edges).toBeDefined();
});
it('should respect maxNodes option', async () => {
const result = await cg.findRelevantContext('function', {
maxNodes: 5,
});
expect(result.nodes.size).toBeLessThanOrEqual(5);
});
});
describe('buildContext()', () => {
it('should build context with markdown format', async () => {
const result = await cg.buildContext('Fix checkout error', {
format: 'markdown',
maxCodeBlocks: 3,
});
expect(typeof result).toBe('string');
const markdown = result as string;
// Should contain markdown structure
expect(markdown).toContain('## Code Context');
expect(markdown).toContain('**Query:** Fix checkout error');
});
it('should build context with JSON format', async () => {
const result = await cg.buildContext('payment processing', {
format: 'json',
});
expect(typeof result).toBe('string');
const parsed = JSON.parse(result as string);
expect(parsed.query).toBe('payment processing');
expect(parsed.nodes).toBeDefined();
expect(Array.isArray(parsed.nodes)).toBe(true);
});
it('should accept object input with title and description', async () => {
const result = await cg.buildContext(
{
title: 'Checkout bug',
description: 'Cart total calculation is wrong',
},
{ format: 'markdown' }
);
expect(typeof result).toBe('string');
expect(result).toContain('Checkout bug: Cart total calculation is wrong');
});
it('should include code blocks when requested', async () => {
const result = await cg.buildContext('PaymentService', {
format: 'markdown',
includeCode: true,
maxCodeBlocks: 2,
});
const markdown = result as string;
// Should contain code blocks
expect(markdown).toContain('### Code');
expect(markdown).toContain('```typescript');
});
it('should exclude code blocks when requested', async () => {
const result = await cg.buildContext('payment', {
format: 'markdown',
includeCode: false,
});
const markdown = result as string;
// Should not contain code section
expect(markdown).not.toContain('### Code');
});
it('should include related symbols in compact format', async () => {
const result = await cg.buildContext('checkout', {
format: 'markdown',
maxNodes: 10,
});
const markdown = result as string;
// Compact format uses "Related Symbols" instead of verbose "Related Files"
// and groups symbols by file for compactness
expect(markdown).toContain('### Entry Points');
});
it('should have compact output without verbose stats footer', async () => {
const result = await cg.buildContext('payment', {
format: 'markdown',
});
const markdown = result as string;
// Compact format should NOT have verbose stats footer
expect(markdown).not.toMatch(/\*Context:.*symbols.*relationships.*files/);
// But should still have query
expect(markdown).toContain('**Query:**');
});
});
describe('Context structure', () => {
it('should find entry points from search', async () => {
const result = await cg.buildContext('PaymentService', {
format: 'json',
});
const parsed = JSON.parse(result as string);
expect(parsed.entryPoints).toBeDefined();
expect(parsed.entryPoints.length).toBeGreaterThan(0);
});
it('should traverse graph from entry points', async () => {
const result = await cg.buildContext('CheckoutController', {
format: 'json',
traversalDepth: 2,
});
const parsed = JSON.parse(result as string);
// Should have found related nodes through traversal
const nodeNames = parsed.nodes.map((n: { name: string }) => n.name);
// CheckoutController calls PaymentService, so both should be present
expect(
nodeNames.some((name: string) => name.includes('Checkout'))
).toBe(true);
});
});
describe('Edge cases', () => {
it('should handle empty query', async () => {
const result = await cg.buildContext('', { format: 'markdown' });
expect(typeof result).toBe('string');
});
it('should handle query with no matches', async () => {
const result = await cg.buildContext('xyznonexistent123', {
format: 'json',
});
const parsed = JSON.parse(result as string);
// Should return empty or minimal results
expect(parsed.nodes).toBeDefined();
});
it('should truncate long code blocks', async () => {
const result = await cg.buildContext('PaymentService', {
format: 'markdown',
maxCodeBlockSize: 100,
includeCode: true,
});
const markdown = result as string;
// Long code blocks should be truncated
if (markdown.includes('```typescript')) {
// If there's a code block, check for truncation marker if content was long
// This test validates the truncation logic works
expect(typeof markdown).toBe('string');
}
});
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* Cooperative-yield helper + the async contract of the main-thread resolution
* spans it protects (#1091).
*
* Background: reference resolution and callback-edge synthesis run on the
* indexer's MAIN thread. The #850 liveness watchdog SIGKILLs the process when
* that thread doesn't turn its event loop within the timeout window, because its
* heartbeat is a timer on that same thread. On a large repo those spans run for
* minutes, so they must yield periodically or a VALID index gets killed. These
* tests pin (a) the yielder's budget semantics and (b) that the three long spans
* stayed `async` so they CAN yield — a revert to a synchronous version would
* reintroduce the wedge, and the AsyncFunction assertions fail loudly if so.
*/
import { describe, it, expect } from 'vitest';
import { createYielder, DEFAULT_YIELD_BUDGET_MS } from '../src/resolution/cooperative-yield';
import { synthesizeCallbackEdges } from '../src/resolution/callback-synthesizer';
import { ReferenceResolver } from '../src/resolution/index';
/**
* A `setImmediate` callback runs in the check phase — AFTER the microtask queue
* drains. So if `await maybeYield()` did NOT cross a macrotask boundary (it was
* under budget and returned a synchronously-resolved promise), a `setImmediate`
* scheduled just before it has NOT fired yet. If it DID yield (awaited its own
* `setImmediate`), the earlier `setImmediate` — queued first, FIFO — has fired.
* This makes "did it yield?" a deterministic, non-timing assertion.
*/
async function yieldedDuring(maybeYield: () => Promise<void>): Promise<boolean> {
let macrotaskRan = false;
setImmediate(() => { macrotaskRan = true; });
await maybeYield();
return macrotaskRan;
}
describe('createYielder', () => {
it('does not yield while under the time budget', async () => {
const maybeYield = createYielder(100_000); // effectively never elapses in-test
expect(await yieldedDuring(maybeYield)).toBe(false);
// Repeated calls stay coalesced — still no macrotask boundary crossed.
expect(await yieldedDuring(maybeYield)).toBe(false);
});
it('yields once the budget has elapsed, then resets', async () => {
const maybeYield = createYielder(0); // 0ms budget → every checkpoint yields
expect(await yieldedDuring(maybeYield)).toBe(true);
// Reset: the next checkpoint also yields (budget is measured from the last
// yield, and 0ms has "elapsed" again).
expect(await yieldedDuring(maybeYield)).toBe(true);
});
it('yields after real wall-clock exceeds the budget', async () => {
const maybeYield = createYielder(20);
expect(await yieldedDuring(maybeYield)).toBe(false); // fresh — under budget
const until = Date.now() + 35;
while (Date.now() < until) { /* busy-wait past the 20ms budget */ }
expect(await yieldedDuring(maybeYield)).toBe(true);
});
it('exposes a sane default budget under the watchdog heartbeat cadence', () => {
// The watchdog writes a heartbeat every ~1s at minimum; the yield budget
// must be well under that so a beat can always land between yields.
expect(DEFAULT_YIELD_BUDGET_MS).toBeGreaterThan(0);
expect(DEFAULT_YIELD_BUDGET_MS).toBeLessThan(1000);
});
});
describe('main-thread resolution spans stay async (so they can yield) — #1091', () => {
it('synthesizeCallbackEdges is an async function', () => {
expect(synthesizeCallbackEdges.constructor.name).toBe('AsyncFunction');
});
it('resolveChainedCallsViaConformance is an async function', () => {
expect(ReferenceResolver.prototype.resolveChainedCallsViaConformance.constructor.name).toBe('AsyncFunction');
});
it('resolveDeferredThisMemberRefs is an async function', () => {
expect(ReferenceResolver.prototype.resolveDeferredThisMemberRefs.constructor.name).toBe('AsyncFunction');
});
it('resolveAndPersistBatched is an async function', () => {
expect(ReferenceResolver.prototype.resolveAndPersistBatched.constructor.name).toBe('AsyncFunction');
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* #618 — the "attached to shared daemon" line is benign INFO, but MCP hosts
* render server stderr at error level (and tack on an `undefined` data field),
* so on every session start a healthy attach showed up as `[error] … undefined`.
* It's now gated behind CODEGRAPH_MCP_LOG_ATTACH=1 — silent by default, opt-in
* for debugging. Approach from #640 by @mturac.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { logAttachedDaemon } from '../src/mcp/proxy';
const hello = { pid: 4242, codegraph: '9.9.9' } as any;
describe('daemon attach log gating (#618)', () => {
let spy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
spy = vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as any);
});
afterEach(() => {
spy.mockRestore();
delete process.env.CODEGRAPH_MCP_LOG_ATTACH;
});
it('is silent by default (no [error]/undefined noise in MCP hosts)', () => {
delete process.env.CODEGRAPH_MCP_LOG_ATTACH;
logAttachedDaemon('/tmp/cg.sock', hello);
expect(spy).not.toHaveBeenCalled();
});
it('logs the attach line only when CODEGRAPH_MCP_LOG_ATTACH=1 (opt-in debug)', () => {
process.env.CODEGRAPH_MCP_LOG_ATTACH = '1';
logAttachedDaemon('/tmp/cg.sock', hello);
const out = spy.mock.calls.map((c) => String(c[0])).join('');
expect(out).toContain('Attached to shared daemon on /tmp/cg.sock');
expect(out).toContain('pid 4242');
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* Daemon bind-failure cleanup — issue #974.
*
* A detached daemon acquires the `.codegraph/daemon.pid` lock (via
* `tryAcquireDaemonLock`) BEFORE it binds its socket. If the bind then fails —
* e.g. AF_UNIX is unsupported/unreliable on the filesystem (the WSL2 DrvFs
* hazard behind #974) — `Daemon.start()` must release that lockfile before it
* propagates the error and exits. Otherwise the next launcher reads a stale lock
* pointing at the now-dead pid and the process pileup the issue reported recurs.
*
* We force a deterministic bind failure by planting a *directory* at the socket
* path: `unlinkSync` (the daemon's stale-socket clear) can't remove a directory,
* so it survives and `listen()` fails with EADDRINUSE.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Daemon, tryAcquireDaemonLock, finalizeDaemonExit } from '../src/mcp/daemon';
import { getDaemonPidPath, getDaemonSocketPath } from '../src/mcp/daemon-paths';
const tmpRoots: string[] = [];
afterEach(() => {
while (tmpRoots.length) {
const root = tmpRoots.pop()!;
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best-effort */ }
}
});
describe('Daemon.start() bind failure (#974)', () => {
it.runIf(process.platform !== 'win32')('releases the lockfile it acquired when the socket cannot bind', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-bind-'));
tmpRoots.push(root);
// Acquire the lock exactly as the detached-daemon startup does.
const lock = tryAcquireDaemonLock(root);
expect(lock.kind).toBe('acquired');
const pidPath = getDaemonPidPath(root);
expect(fs.existsSync(pidPath)).toBe(true);
// Make the socket path un-bindable: a directory can't be unlink'd by the
// daemon's stale-socket clear, and listen() on it fails with EADDRINUSE.
const sockPath = getDaemonSocketPath(root);
fs.mkdirSync(sockPath, { recursive: true });
// The tmpdir-fallback socket path can live outside `root`; clean it too.
tmpRoots.push(sockPath);
const daemon = new Daemon(root);
await expect(daemon.start()).rejects.toThrow();
// The lockfile must be gone so the next launcher doesn't spin on a stale lock.
expect(fs.existsSync(pidPath)).toBe(false);
});
});
/**
* Windows shutdown must not force `process.exit()` while the recursive file
* watcher is still tearing down — that aborts the daemon with a libuv
* `UV_HANDLE_CLOSING` assertion (0xC0000409), reproducible when the indexed tree
* contains a nested repo. `finalizeDaemonExit` drains on Windows and exits
* immediately elsewhere; both branches are exercised here by injecting the
* platform + exit fn (so it runs on any host).
*/
describe('finalizeDaemonExit — Windows drains instead of aborting mid-watcher-close', () => {
for (const platform of ['linux', 'darwin'] as const) {
it(`exits immediately on ${platform}`, () => {
const exit = vi.fn();
const backstop = finalizeDaemonExit(platform, exit);
expect(exit).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(0);
expect(backstop).toBeNull();
});
}
it('on win32 defers exit (lets the loop drain), then force-exits via an unref\'d backstop', () => {
vi.useFakeTimers();
const prevExitCode = process.exitCode;
const exit = vi.fn();
try {
const backstop = finalizeDaemonExit('win32', exit);
// No synchronous exit — the process must drain its closing watch handles first.
expect(exit).not.toHaveBeenCalled();
expect(backstop).not.toBeNull();
// Success code is set so a natural drain exits 0.
expect(process.exitCode).toBe(0);
// If a stray handle keeps the loop alive, the backstop still forces exit.
vi.advanceTimersByTime(2_000);
expect(exit).toHaveBeenCalledWith(0);
} finally {
vi.useRealTimers();
process.exitCode = prevExitCode; // don't leak a 0 exit code into the runner
}
});
});
+181
View File
@@ -0,0 +1,181 @@
/**
* Unit coverage for the daemon-side client-liveness primitives (#692, Layer 2).
*
* These back the daemon's defense against a phantom client — one whose process
* died without the socket ever signalling close (a Windows named-pipe hazard).
* The wire parsing and the liveness decision are pure, so they're tested here;
* the full handshake + sweep is exercised end-to-end in `mcp-daemon.test.ts`.
*/
import { describe, it, expect } from 'vitest';
import { Daemon, parseClientHelloLine, peerIsDead } from '../src/mcp/daemon';
describe('parseClientHelloLine', () => {
it('parses a well-formed client-hello', () => {
expect(parseClientHelloLine('{"codegraph_client":1,"pid":1234,"hostPid":56}'))
.toEqual({ pid: 1234, hostPid: 56 });
});
it('accepts a null host pid and a missing host pid', () => {
expect(parseClientHelloLine('{"codegraph_client":1,"pid":1234,"hostPid":null}'))
.toEqual({ pid: 1234, hostPid: null });
expect(parseClientHelloLine('{"codegraph_client":1,"pid":1234}'))
.toEqual({ pid: 1234, hostPid: null });
});
it('returns null for a JSON-RPC message (no marker) so it is treated as data', () => {
expect(parseClientHelloLine('{"jsonrpc":"2.0","id":1,"method":"initialize"}')).toBeNull();
});
it('rejects a wrong-typed marker, a non-numeric pid, and a non-integer marker', () => {
expect(parseClientHelloLine('{"codegraph_client":true,"pid":1}')).toBeNull();
expect(parseClientHelloLine('{"codegraph_client":2,"pid":1}')).toBeNull();
expect(parseClientHelloLine('{"codegraph_client":1,"pid":"1"}')).toBeNull();
});
it('returns null for invalid / empty / non-object JSON', () => {
expect(parseClientHelloLine('not json')).toBeNull();
expect(parseClientHelloLine('')).toBeNull();
expect(parseClientHelloLine('42')).toBeNull();
expect(parseClientHelloLine('null')).toBeNull();
});
});
describe('peerIsDead', () => {
const aliveAll = () => true;
const deadAll = () => false;
const deadOnly = (...pids: number[]) => (pid: number) => !pids.includes(pid);
it('never reaps a client with an unknown pid (no client-hello)', () => {
expect(peerIsDead({ pid: null, hostPid: null }, deadAll)).toBe(false);
expect(peerIsDead({ pid: null, hostPid: 99 }, deadAll)).toBe(false);
});
it('keeps a client whose proxy is alive', () => {
expect(peerIsDead({ pid: 100, hostPid: null }, aliveAll)).toBe(false);
});
it('reaps a client whose proxy process is gone', () => {
expect(peerIsDead({ pid: 100, hostPid: null }, deadOnly(100))).toBe(true);
});
it('reaps when the proxy is alive but its host is gone', () => {
// proxy 100 alive, host 42 dead
expect(peerIsDead({ pid: 100, hostPid: 42 }, deadOnly(42))).toBe(true);
});
it('keeps a client when both proxy and host are alive', () => {
expect(peerIsDead({ pid: 100, hostPid: 42 }, aliveAll)).toBe(false);
});
});
describe('Daemon.reapDeadClients', () => {
// Construct with idleTimeoutMs:0 so dropping the last client doesn't arm a real
// idle timer. The constructor opens no sockets/DB, so this stays a fast unit test.
const makeDaemon = () => new Daemon('/tmp/codegraph-reap-unit-test', { idleTimeoutMs: 0 }) as any;
const fakeSession = () => ({ stopped: false, stop() { this.stopped = true; } });
it('drops clients with a dead peer and leaves live ones attached', () => {
const d = makeDaemon();
const dead = fakeSession();
const live = fakeSession();
d.clients.add(dead); d.clientPeers.set(dead, { pid: 111, hostPid: null });
d.clients.add(live); d.clientPeers.set(live, { pid: 222, hostPid: null });
const reaped = d.reapDeadClients((pid: number) => pid !== 111); // 111 dead, 222 alive
expect(reaped).toBe(1);
expect(dead.stopped).toBe(true);
expect(d.clients.has(dead)).toBe(false);
expect(d.clientPeers.has(dead)).toBe(false); // peer record cleaned up too
expect(d.clients.has(live)).toBe(true);
});
it('never reaps a client with an unknown pid (no client-hello)', () => {
const d = makeDaemon();
const s = fakeSession();
d.clients.add(s); d.clientPeers.set(s, { pid: null, hostPid: null });
expect(d.reapDeadClients(() => false)).toBe(0); // everything "dead", but pid unknown
expect(d.clients.has(s)).toBe(true);
});
it('reaps a client whose host pid is gone even if its proxy pid is alive', () => {
const d = makeDaemon();
const s = fakeSession();
d.clients.add(s); d.clientPeers.set(s, { pid: 100, hostPid: 42 });
expect(d.reapDeadClients((pid: number) => pid !== 42)).toBe(1); // proxy 100 alive, host 42 dead
expect(d.clients.has(s)).toBe(false);
});
});
// The inactivity backstop (#692) must reap a phantom daemon but NEVER a
// live-but-quiet session — reaping the latter silently degraded that session
// (and any others sharing the daemon) to an in-process engine, and on a real
// machine it fired far more often on live sessions than on actual phantoms.
describe('Daemon.backstopShouldExit', () => {
// maxIdleMs small; idleTimeoutMs:0 so a sweep that empties the set doesn't arm
// a real timer. Force the inactivity window open by backdating lastActivityAt.
const makeDaemon = () => {
const d = new Daemon('/tmp/codegraph-backstop-unit-test', { idleTimeoutMs: 0, maxIdleMs: 1000 }) as any;
d.lastActivityAt = Date.now() - 60_000; // long past the 1000ms window
return d;
};
const fakeSession = () => ({ stopped: false, stop() { this.stopped = true; } });
it('does NOT reap while a provably-alive client stays connected (the fix)', () => {
const d = makeDaemon();
const live = fakeSession();
d.clients.add(live); d.clientPeers.set(live, { pid: 222, hostPid: null });
expect(d.backstopShouldExit(() => true)).toBe(false); // 222 alive → keep the daemon
expect(d.clients.has(live)).toBe(true);
});
it('reaps when only an unknown-pid client remains (the phantom the sweep cannot catch)', () => {
const d = makeDaemon();
const phantom = fakeSession();
d.clients.add(phantom); d.clientPeers.set(phantom, { pid: null, hostPid: null });
// Unknown pid → the sweep leaves it, and after the window it's a probable phantom.
expect(d.backstopShouldExit(() => false)).toBe(true);
});
it('protects a live session even when a phantom is also connected', () => {
const d = makeDaemon();
const live = fakeSession();
const phantom = fakeSession();
d.clients.add(live); d.clientPeers.set(live, { pid: 222, hostPid: null });
d.clients.add(phantom); d.clientPeers.set(phantom, { pid: null, hostPid: null });
// 222 alive, phantom unknown → ANY alive keeps the daemon; the live one wins.
expect(d.backstopShouldExit((pid: number) => pid === 222)).toBe(false);
expect(d.clients.has(live)).toBe(true);
});
it('sweeps a dead-peer client first; if that empties the set it does not exit', () => {
const d = makeDaemon();
const dead = fakeSession();
d.clients.add(dead); d.clientPeers.set(dead, { pid: 111, hostPid: null });
// 111 dead → swept by backstopShouldExit; empty set → idle timer owns it, no backstop exit.
expect(d.backstopShouldExit(() => false)).toBe(false);
expect(d.clients.has(dead)).toBe(false);
expect(dead.stopped).toBe(true);
});
it('does not exit before the inactivity window elapses', () => {
const d = makeDaemon();
d.lastActivityAt = Date.now(); // fresh — inside the 1000ms window
const phantom = fakeSession();
d.clients.add(phantom); d.clientPeers.set(phantom, { pid: null, hostPid: null });
expect(d.backstopShouldExit(() => false)).toBe(false);
expect(d.clients.has(phantom)).toBe(true); // not even swept yet
});
it('does not exit with zero clients (the idle timer owns that case)', () => {
const d = makeDaemon();
expect(d.backstopShouldExit(() => false)).toBe(false);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import {
formatUptime,
buildPickItems,
runDaemonPicker,
STOP_ALL,
CANCEL,
type PickerDeps,
} from '../src/mcp/daemon-manager';
import type { DaemonRecord, StopResult } from '../src/mcp/daemon-registry';
const rec = (root: string, pid: number, startedAt: number): DaemonRecord => ({
root, pid, version: '1.0.0', socketPath: `${root}/.codegraph/daemon.sock`, startedAt,
});
describe('formatUptime', () => {
it('formats seconds / minutes / hours', () => {
expect(formatUptime(45_000)).toBe('45s');
expect(formatUptime(12 * 60_000)).toBe('12m');
expect(formatUptime((3 * 60 + 5) * 60_000)).toBe('3h 5m');
});
});
describe('buildPickItems', () => {
const old = rec('/p/old', 1, 1000);
const fresh = rec('/p/new', 2, 2000);
const cwd = rec('/p/cwd', 3, 500);
it('orders newest-first and appends Stop all + Cancel', () => {
const items = buildPickItems([old, fresh], null, 3000);
expect(items.map((i) => i.value)).toEqual(['/p/new', '/p/old', STOP_ALL, CANCEL]);
expect(items[0].hint).toContain('pid 2');
expect(items[0].hint).toContain('Running');
});
it('omits Stop all for a single daemon (but keeps Cancel)', () => {
expect(buildPickItems([old], null, 3000).map((i) => i.value)).toEqual(['/p/old', CANCEL]);
});
it('floats the current project to the top, auto-selected and labelled', () => {
const items = buildPickItems([old, fresh, cwd], '/p/cwd', 3000);
expect(items[0].value).toBe('/p/cwd');
expect(items[0].label).toContain('(current project)');
expect(items.slice(1, 3).map((i) => i.value)).toEqual(['/p/new', '/p/old']); // rest newest-first
});
});
describe('runDaemonPicker', () => {
// A fake registry whose list shrinks as daemons are stopped (like the real one).
function harness(initial: DaemonRecord[], choices: unknown[]) {
let daemons = [...initial];
const stopped: string[] = [];
const notes: string[] = [];
let doneMsg = '';
let i = 0;
const CANCEL_SYMBOL = Symbol('cancel');
const deps: PickerDeps = {
list: () => daemons,
stop: async (root): Promise<StopResult> => {
daemons = daemons.filter((d) => d.root !== root);
stopped.push(root);
return { root, pid: 0, outcome: 'term' };
},
stopAll: async (): Promise<StopResult[]> => {
const all = daemons.map((d) => ({ root: d.root, pid: d.pid, outcome: 'term' as const }));
daemons = [];
stopped.push('ALL');
return all;
},
cwdRoot: null,
now: () => 5000,
select: async () => choices[i++],
isCancel: (v) => v === CANCEL_SYMBOL,
note: (m) => notes.push(m),
done: (m) => { doneMsg = m; },
};
return { deps, stopped, notes, getDone: () => doneMsg, CANCEL_SYMBOL };
}
it('stops the chosen daemon, then re-prompts and exits on Cancel', async () => {
const h = harness([rec('/p/a', 1, 1), rec('/p/b', 2, 2)], ['/p/b', CANCEL]);
await runDaemonPicker(h.deps);
expect(h.stopped).toEqual(['/p/b']);
expect(h.getDone()).toContain('Cancelled');
});
it('keeps stopping until none remain', async () => {
const h = harness([rec('/p/a', 1, 1), rec('/p/b', 2, 2)], ['/p/a', '/p/b']);
await runDaemonPicker(h.deps);
expect(h.stopped).toEqual(['/p/a', '/p/b']);
expect(h.getDone()).toContain('All daemons stopped');
});
it('Stop all stops everything in one shot', async () => {
const h = harness([rec('/p/a', 1, 1), rec('/p/b', 2, 2)], [STOP_ALL]);
await runDaemonPicker(h.deps);
expect(h.stopped).toEqual(['ALL']);
expect(h.getDone()).toBe('Done.');
});
it('Cancel (and Esc/Ctrl-C) stop nothing', async () => {
const h1 = harness([rec('/p/a', 1, 1)], [CANCEL]);
await runDaemonPicker(h1.deps);
expect(h1.stopped).toEqual([]);
expect(h1.getDone()).toContain('Cancelled');
const h2 = harness([rec('/p/a', 1, 1)], [/* will use the cancel symbol */]);
h2.deps.select = async () => h2.CANCEL_SYMBOL;
await runDaemonPicker(h2.deps);
expect(h2.stopped).toEqual([]);
expect(h2.getDone()).toContain('Cancelled');
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
getRegistryDir,
isProcessAlive,
registerDaemon,
deregisterDaemon,
listDaemons,
type DaemonRecord,
} from '../src/mcp/daemon-registry';
/** A pid that's guaranteed dead: spawn a trivial process, let it exit, reap it. */
async function deadPid(): Promise<number> {
const child = spawn(process.execPath, ['-e', 'process.exit(0)']);
const pid = child.pid!;
await new Promise<void>((r) => child.on('exit', () => r()));
await new Promise((r) => setTimeout(r, 50)); // let the OS reap it
return pid;
}
function rec(root: string, pid: number, startedAt = Date.now()): DaemonRecord {
return { root, pid, version: '1.0.0', socketPath: `${root}/.codegraph/daemon.sock`, startedAt };
}
describe('daemon-registry', () => {
let tmpHome: string;
let prevHome: string | undefined;
let prevUserProfile: string | undefined;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-reg-home-'));
prevHome = process.env.HOME;
prevUserProfile = process.env.USERPROFILE;
process.env.HOME = tmpHome; // os.homedir() honors HOME (POSIX) ...
process.env.USERPROFILE = tmpHome; // ... and USERPROFILE (Windows)
// Sanity: the registry must resolve under our temp home, or the test would
// pollute the real ~/.codegraph.
expect(getRegistryDir().startsWith(tmpHome)).toBe(true);
});
afterEach(() => {
if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome;
if (prevUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prevUserProfile;
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('isProcessAlive', () => {
it('is true for our own process and false for junk/dead pids', async () => {
expect(isProcessAlive(process.pid)).toBe(true);
expect(isProcessAlive(0)).toBe(false);
expect(isProcessAlive(-1)).toBe(false);
expect(isProcessAlive(NaN)).toBe(false);
expect(isProcessAlive(await deadPid())).toBe(false);
});
});
it('listDaemons returns [] when nothing is registered (no dir yet)', () => {
expect(listDaemons()).toEqual([]);
});
it('register → list shows a live daemon; deregister removes it', () => {
registerDaemon(rec('/proj/a', process.pid));
const live = listDaemons();
expect(live).toHaveLength(1);
expect(live[0].root).toBe('/proj/a');
expect(live[0].pid).toBe(process.pid);
deregisterDaemon('/proj/a');
expect(listDaemons()).toEqual([]);
});
it('prunes records whose process is dead', async () => {
const dead = await deadPid();
registerDaemon(rec('/proj/dead', dead));
registerDaemon(rec('/proj/live', process.pid));
const live = listDaemons();
expect(live).toHaveLength(1);
expect(live[0].root).toBe('/proj/live');
// The dead record's file was deleted as a side effect.
const remaining = fs.readdirSync(getRegistryDir()).filter((f) => f.endsWith('.json'));
expect(remaining).toHaveLength(1);
});
it('peeking with prune:false leaves dead records on disk', async () => {
const dead = await deadPid();
registerDaemon(rec('/proj/dead', dead));
expect(listDaemons({ prune: false })).toEqual([]); // dead is filtered from results
// ...but the file survives for the caller to inspect.
expect(fs.readdirSync(getRegistryDir()).filter((f) => f.endsWith('.json'))).toHaveLength(1);
});
it('lists multiple live daemons newest-first', () => {
registerDaemon(rec('/proj/old', process.pid, 1000));
registerDaemon(rec('/proj/new', process.pid, 2000));
const live = listDaemons();
expect(live.map((d) => d.root)).toEqual(['/proj/new', '/proj/old']);
});
});
+246
View File
@@ -0,0 +1,246 @@
/**
* Daemon support on socket-incapable filesystems — issue #997 (and the adjacent
* #974 WSL2 DrvFs hazard).
*
* A project on an ExFAT/FAT external volume (or some network mounts / WSL2 DrvFs)
* breaks the daemon at TWO points, BOTH surfacing as ENOTSUP (verified on a real
* macOS fskit ExFAT volume):
*
* 1. Lock acquisition `link()`s a temp file onto `.codegraph/daemon.pid` for
* race-free exclusivity (#411). ExFAT has no hard links, so this throws
* first — before the socket is ever reached. The fix falls back to an
* O_EXCL create (`acquireLockViaExclusiveOpen`).
* 2. The socket `listen()` then throws ENOTSUP regardless of path length, so
* the old length-only tmpdir fallback never triggered. The fix makes the
* socket path an ORDERED candidate list (in-project, then a deterministic
* tmpdir path); the daemon binds the first that works and the proxy connects
* the first that answers, so both converge on the fallback with zero
* coordination.
*
* Both failures report a DIFFERENT errno per OS — ENOTSUP (macOS), EPERM (Linux),
* EISDIR (Windows) — so the fix deliberately does NOT gate on an enumerated set:
* the lock falls back on ANY non-EEXIST link error, the socket relocates on ANY
* non-EADDRINUSE bind error. These tests pin that policy (incl. a deliberately
* unanticipated errno), the candidate list, the candidate-walk binder, and the
* exclusive-open lock primitive. (Throwaway scripts drove the full daemon end-to-
* end on a real macOS ExFAT image, a Linux FAT loopback mount, and a Windows
* exFAT VHD — relocate, serve a real client, rewrite the pidfile — none of which
* can run in CI.)
*/
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import {
getDaemonPidPath,
getDaemonSocketCandidates,
getDaemonSocketPath,
} from '../src/mcp/daemon-paths';
import type { DaemonLockInfo } from '../src/mcp/daemon-paths';
import { decodeLockInfo } from '../src/mcp/daemon-paths';
import {
acquireLockViaExclusiveOpen,
bindFirstUsableSocket,
tryAcquireDaemonLock,
} from '../src/mcp/daemon';
const POSIX = process.platform !== 'win32';
const tmpFiles: string[] = [];
const tmpDirs: string[] = [];
afterEach(() => {
while (tmpFiles.length) {
try { fs.rmSync(tmpFiles.pop()!, { force: true }); } catch { /* best-effort */ }
}
while (tmpDirs.length) {
try { fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true }); } catch { /* best-effort */ }
}
});
/** A stand-in net.Server — bindFirstUsableSocket only ever passes it through. */
const fakeServer = (tag: string): net.Server => ({ tag } as unknown as net.Server);
/** Build an ErrnoException carrying a specific code, like a real listen() error. */
function errno(code: string): NodeJS.ErrnoException {
const e = new Error(`listen ${code}`) as NodeJS.ErrnoException;
e.code = code;
return e;
}
describe('getDaemonSocketCandidates (#997)', () => {
it.runIf(POSIX)('returns [in-project, tmpdir] for a normal short path', () => {
const root = path.join(os.tmpdir(), 'cg-cand-short');
const candidates = getDaemonSocketCandidates(root);
expect(candidates).toHaveLength(2);
expect(candidates[0]).toBe(path.join(root, '.codegraph', 'daemon.sock'));
expect(candidates[1]!.startsWith(os.tmpdir())).toBe(true);
expect(path.basename(candidates[1]!)).toMatch(/^codegraph-[0-9a-f]{16}\.sock$/);
});
it.runIf(POSIX)('drops straight to [tmpdir] when the in-project path is too long', () => {
// A deep root pushes `.codegraph/daemon.sock` past the POSIX socket limit.
const root = path.join('/tmp', 'x'.repeat(120));
const candidates = getDaemonSocketCandidates(root);
expect(candidates).toHaveLength(1);
expect(candidates[0]!.startsWith(os.tmpdir())).toBe(true);
});
it.runIf(POSIX)('is deterministic and project-scoped: same root → same tmpdir fallback', () => {
const root = path.join(os.tmpdir(), 'cg-cand-determinism');
const a = getDaemonSocketCandidates(root);
const b = getDaemonSocketCandidates(root);
expect(a).toEqual(b);
// A different root yields a different (hashed) tmpdir fallback.
const other = getDaemonSocketCandidates(root + '-other');
expect(other[other.length - 1]).not.toBe(a[a.length - 1]);
});
it.runIf(!POSIX)('returns a single named pipe on Windows', () => {
const candidates = getDaemonSocketCandidates('C:/dev/proj');
expect(candidates).toHaveLength(1);
expect(candidates[0]!.startsWith('\\\\.\\pipe\\codegraph-')).toBe(true);
});
it('getDaemonSocketPath returns the preferred candidate (index 0)', () => {
const root = path.join(os.tmpdir(), 'cg-cand-primary');
expect(getDaemonSocketPath(root)).toBe(getDaemonSocketCandidates(root)[0]);
});
});
describe('bindFirstUsableSocket (#997)', () => {
it('binds the first candidate when it works, without relocating', async () => {
const tried: string[] = [];
const relocations: string[] = [];
const result = await bindFirstUsableSocket(
['/proj/.codegraph/daemon.sock', '/tmp/fallback.sock'],
(p) => { tried.push(p); return Promise.resolve(fakeServer(p)); },
{ onRelocate: (from, to) => relocations.push(`${from}->${to}`) },
);
expect(result.socketPath).toBe('/proj/.codegraph/daemon.sock');
expect(tried).toEqual(['/proj/.codegraph/daemon.sock']); // never touched the fallback
expect(relocations).toEqual([]);
});
it('relocates to the tmpdir fallback when the in-project bind throws ENOTSUP', async () => {
const tried: string[] = [];
const relocations: Array<[string, string, string]> = [];
const result = await bindFirstUsableSocket(
['/exfat/proj/.codegraph/daemon.sock', '/tmp/fallback.sock'],
(p) => {
tried.push(p);
if (p.includes('/exfat/')) return Promise.reject(errno('ENOTSUP'));
return Promise.resolve(fakeServer(p));
},
{ onRelocate: (from, to, code) => relocations.push([from, to, code]) },
);
expect(result.socketPath).toBe('/tmp/fallback.sock');
expect(tried).toEqual(['/exfat/proj/.codegraph/daemon.sock', '/tmp/fallback.sock']);
expect(relocations).toEqual([
['/exfat/proj/.codegraph/daemon.sock', '/tmp/fallback.sock', 'ENOTSUP'],
]);
});
it('does NOT relocate on EADDRINUSE — it propagates even with a fallback present', async () => {
const tried: string[] = [];
await expect(
bindFirstUsableSocket(
['/proj/.codegraph/daemon.sock', '/tmp/fallback.sock'],
(p) => { tried.push(p); return Promise.reject(errno('EADDRINUSE')); },
),
).rejects.toMatchObject({ code: 'EADDRINUSE' });
expect(tried).toEqual(['/proj/.codegraph/daemon.sock']); // fallback never tried
});
it('propagates a capability error on the LAST candidate (nowhere left to go)', async () => {
// When tmpdir itself can't host a socket, the single-candidate long-path list
// (or the exhausted tail of a longer one) has no fallback — the daemon must
// surface the error so the launcher drops to direct mode (#974).
await expect(
bindFirstUsableSocket(
['/tmp/only.sock'],
() => Promise.reject(errno('ENOTSUP')),
),
).rejects.toMatchObject({ code: 'ENOTSUP' });
});
it('walks past multiple unusable candidates to the first that binds', async () => {
const tried: string[] = [];
const result = await bindFirstUsableSocket(
['/a.sock', '/b.sock', '/c.sock'],
(p) => {
tried.push(p);
if (p === '/a.sock') return Promise.reject(errno('ENOTSUP'));
if (p === '/b.sock') return Promise.reject(errno('EACCES'));
return Promise.resolve(fakeServer(p));
},
);
expect(result.socketPath).toBe('/c.sock');
expect(tried).toEqual(['/a.sock', '/b.sock', '/c.sock']);
});
it('relocates on an UNEXPECTED errno too — the policy is "anything but EADDRINUSE", not a fixed list', async () => {
// ExFAT/FAT report different bind errnos per OS (ENOTSUP macOS, EPERM Linux),
// so we must NOT gate relocation on an enumerated set — a code we never
// anticipated must still fall through to tmpdir. 'EWEIRD' stands in for any
// such surprise.
const result = await bindFirstUsableSocket(
['/odd/proj/.codegraph/daemon.sock', '/tmp/fallback.sock'],
(p) => p.includes('/odd/') ? Promise.reject(errno('EWEIRD')) : Promise.resolve(fakeServer(p)),
);
expect(result.socketPath).toBe('/tmp/fallback.sock');
});
});
describe('lock acquisition without hard links (#997)', () => {
// The hard-link-FAILS path (link() → O_EXCL fallback) can't be forced on a
// normal FS — fs.linkSync's namespace export is non-configurable, so it can't
// be spied. It's proven instead end-to-end on real ExFAT/FAT/exFAT volumes
// (macOS ENOTSUP, Linux EPERM, Windows EISDIR — all acquire via the fallback).
// Here we just guard that the refactored catch block didn't break the normal
// link path: a clean acquire, and a second caller correctly sees it held.
it.runIf(POSIX)('tryAcquireDaemonLock still acquires on a normal FS, and a second caller is told it is taken', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-lock-'));
tmpDirs.push(root);
const first = tryAcquireDaemonLock(root);
expect(first.kind).toBe('acquired');
const pidPath = getDaemonPidPath(root);
expect(fs.existsSync(pidPath)).toBe(true);
expect(decodeLockInfo(fs.readFileSync(pidPath, 'utf8'))?.pid).toBe(process.pid);
const second = tryAcquireDaemonLock(root); // link() → EEXIST → taken
expect(second.kind).toBe('taken');
if (second.kind === 'taken') expect(second.existing?.pid).toBe(process.pid);
});
it.runIf(POSIX)('acquireLockViaExclusiveOpen creates the pidfile with a complete, parseable record', () => {
const pidPath = path.join(os.tmpdir(), `cg-excl-${process.pid}-${Date.now()}.pid`);
tmpFiles.push(pidPath);
const info: DaemonLockInfo = {
pid: 4242,
version: '9.9.9-test',
socketPath: '/tmp/whatever.sock',
startedAt: 1_700_000_000_000,
};
const acquired = acquireLockViaExclusiveOpen(pidPath, info);
expect(acquired).toBe(true);
// The file is non-empty and decodes back to exactly what we wrote — i.e. no
// empty-file window left behind for a reader to mistake for a corrupt lock.
expect(decodeLockInfo(fs.readFileSync(pidPath, 'utf8'))).toEqual(info);
});
it.runIf(POSIX)('acquireLockViaExclusiveOpen is exclusive: the second caller loses (EEXIST → false)', () => {
const pidPath = path.join(os.tmpdir(), `cg-excl2-${process.pid}-${Date.now()}.pid`);
tmpFiles.push(pidPath);
const winner: DaemonLockInfo = { pid: 1, version: 'a', socketPath: '/s1', startedAt: 1 };
const loser: DaemonLockInfo = { pid: 2, version: 'b', socketPath: '/s2', startedAt: 2 };
expect(acquireLockViaExclusiveOpen(pidPath, winner)).toBe(true);
expect(acquireLockViaExclusiveOpen(pidPath, loser)).toBe(false); // does not clobber
// The winner's record is intact — the loser never overwrote it.
expect(decodeLockInfo(fs.readFileSync(pidPath, 'utf8'))).toEqual(winner);
});
});
+361
View File
@@ -0,0 +1,361 @@
/**
* 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.
* 4. `insertEdges` validates endpoints from the DB, not stale node cache.
*/
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 { runMigrations, getCurrentVersion } from '../src/db/migrations';
import { Node, Edge } 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('deleteResolvedReferences (chunking)', () => {
let dir: string;
let db: DatabaseConnection;
let q: QueryBuilder;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-delref-'));
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('deletes unresolved refs for more ids than the SQLite parameter limit (#1001)', () => {
// Regression: this method bound every id as one parameter in a single
// IN (...), so passing more ids than SQLITE_MAX_VARIABLE_NUMBER (32766 on
// the bundled node:sqlite) threw "too many SQL variables". Use 33000 to
// clear that ceiling. from_node_id has a FK to nodes, so insert nodes first.
const nodes = Array.from({ length: 33000 }, (_, i) => makeNode(`n${i}`));
q.insertNodes(nodes);
q.insertUnresolvedRefsBatch(
nodes.map((n) => ({
fromNodeId: n.id,
referenceName: 'someName',
referenceKind: 'calls',
line: 1,
column: 0,
}))
);
expect(q.getUnresolvedReferencesCount()).toBe(33000);
const ids = nodes.map((n) => n.id);
expect(() => q.deleteResolvedReferences(ids)).not.toThrow();
expect(q.getUnresolvedReferencesCount()).toBe(0);
});
it('handles an empty input array', () => {
expect(() => q.deleteResolvedReferences([])).not.toThrow();
});
});
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('insertEdges endpoint validation', () => {
let dir: string;
let db: DatabaseConnection;
let q: QueryBuilder;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-edges-'));
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('skips edges with missing endpoints instead of failing the whole batch', () => {
q.insertNodes([makeNode('source'), makeNode('target'), makeNode('other')]);
expect(() =>
q.insertEdges([
{ source: 'source', target: 'target', kind: 'calls' },
{ source: 'source', target: 'missing-target', kind: 'calls' },
{ source: 'missing-source', target: 'other', kind: 'references' },
])
).not.toThrow();
const edges = q.getOutgoingEdges('source');
expect(edges).toHaveLength(1);
expect(edges[0]).toMatchObject({ source: 'source', target: 'target', kind: 'calls' });
});
it('does not trust stale cached nodes when validating edge endpoints', () => {
q.insertNodes([makeNode('source'), makeNode('target')]);
expect(q.getNodeById('target')!.id).toBe('target');
db.getDb().prepare('DELETE FROM nodes WHERE id = ?').run('target');
expect(() =>
q.insertEdges([{ source: 'source', target: 'target', kind: 'calls' }])
).not.toThrow();
expect(q.getOutgoingEdges('source')).toEqual([]);
});
});
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', async () => {
await expect(db.runMaintenance()).resolves.toBeUndefined();
});
it('runs without throwing after writes', async () => {
const q = new QueryBuilder(db.getDb());
q.insertNodes([makeNode('n1'), makeNode('n2')]);
await expect(db.runMaintenance()).resolves.toBeUndefined();
});
it('swallows failures rather than propagating (best-effort)', async () => {
// Close the DB so the underlying handle would normally throw on any
// exec(). runMaintenance (worker on its own connection, or the in-line
// fallback) must still not propagate.
db.close();
await expect(db.runMaintenance()).resolves.toBeUndefined();
});
});
// The edges table carried no UNIQUE constraint, so `insertEdge`'s
// `INSERT OR IGNORE` had nothing to conflict on and silently admitted
// byte-identical duplicate rows when two passes emitted the same edge (#1034).
// A UNIQUE identity index — `(source, target, kind, IFNULL(line,-1),
// IFNULL(col,-1))` — makes OR IGNORE actually dedup.
describe('edge identity uniqueness (#1034)', () => {
let dir: string;
let db: DatabaseConnection;
let q: QueryBuilder;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-edge-uniq-'));
db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
q = new QueryBuilder(db.getDb());
q.insertNodes([makeNode('A'), makeNode('B')]);
});
afterEach(() => {
db.close();
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
});
const edgeCount = () =>
(db.getDb().prepare('SELECT count(*) AS c FROM edges').get() as { c: number }).c;
const mk = (over: Partial<Edge> = {}): Edge => ({
source: 'A',
target: 'B',
kind: 'references',
line: 153,
column: 12,
metadata: { resolvedBy: 'exact-match' },
...over,
});
it('a fresh database has the identity index', () => {
const idx = db
.getDb()
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_edges_identity'")
.get();
expect(idx).toBeTruthy();
});
it('collapses byte-identical edges to a single row', () => {
q.insertEdges([mk(), mk(), mk()]);
expect(edgeCount()).toBe(1);
});
it('dedups even when only the metadata differs (same structural identity)', () => {
q.insertEdges([mk({ metadata: { resolvedBy: 'exact-match' } }), mk({ metadata: { resolvedBy: 'import' } })]);
expect(edgeCount()).toBe(1);
});
it('keeps edges that differ in line/col — distinct call sites are not duplicates', () => {
q.insertEdges([mk({ column: 12 }), mk({ column: 99 }), mk({ line: 200, column: 1 })]);
expect(edgeCount()).toBe(3);
});
it('dedups coordinate-less edges, folding NULL line/col via IFNULL', () => {
q.insertEdges([mk({ line: undefined, column: undefined }), mk({ line: undefined, column: undefined })]);
expect(edgeCount()).toBe(1);
});
it('dedups across separate insert calls (storage constraint, not a per-batch dedup)', () => {
q.insertEdges([mk()]);
q.insertEdges([mk()]);
expect(edgeCount()).toBe(1);
});
});
describe('migration v6: dedup edges + add identity index on upgrade (#1034)', () => {
it('collapses pre-existing duplicate rows, keeps distinct ones, and restores the constraint', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-mig6-'));
const db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
const raw = db.getDb();
const q = new QueryBuilder(raw);
q.insertNodes([makeNode('A'), makeNode('B')]);
// Recreate a pre-v6 database: without the identity index, `INSERT OR IGNORE`
// admits duplicates. Revert the recorded version so migration v6 will re-run.
raw.exec('DROP INDEX IF EXISTS idx_edges_identity');
raw.prepare('DELETE FROM schema_versions WHERE version >= 6').run();
q.insertEdges([
{ source: 'A', target: 'B', kind: 'references', line: 153, column: 12, metadata: { resolvedBy: 'exact-match' } },
{ source: 'A', target: 'B', kind: 'references', line: 153, column: 12, metadata: { resolvedBy: 'exact-match' } },
{ source: 'A', target: 'B', kind: 'calls', line: 200, column: 4 },
]);
const count = () => (raw.prepare('SELECT count(*) AS c FROM edges').get() as { c: number }).c;
expect(count()).toBe(3); // duplicate admitted while the index was absent
runMigrations(raw, 5);
expect(count()).toBe(2); // duplicate collapsed, the distinct `calls` edge kept
expect(getCurrentVersion(raw)).toBe(8);
const idx = raw
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_edges_identity'")
.get();
expect(idx).toBeTruthy();
// The constraint now holds — re-inserting the duplicate is a no-op.
q.insertEdges([
{ source: 'A', target: 'B', kind: 'references', line: 153, column: 12, metadata: { resolvedBy: 'x' } },
]);
expect(count()).toBe(2);
db.close();
fs.rmSync(dir, { recursive: true, force: true });
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Deleted-but-open DB inode self-heal (issue #925).
*
* A long-lived process (the MCP daemon) opens `.codegraph/codegraph.db` and
* holds the file descriptor for its whole life. If `.codegraph/` is removed and
* recreated AT THE SAME PATH while it's running — `git worktree remove <p>` then
* `git worktree add <p>` + `codegraph init`, or `rm -rf .codegraph` + re-init —
* the held fd points at the now-unlinked inode and can never see the new index.
* Queries then return the pre-removal snapshot until the process restarts; the
* CLI (a fresh process) reads the new inode and diverges.
*
* The deleted-but-open-inode hazard is POSIX file semantics (an open file can't
* be unlinked on Windows, and st_ino is unreliable there), so the recreate
* repros are gated to non-Windows; `isReplacedOnDisk` is verified to stay false
* on Windows.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DatabaseConnection } from '../src/db';
import { getCodeGraphDir } from '../src/directory';
import CodeGraph from '../src/index';
const posixOnly = it.runIf(process.platform !== 'win32');
const windowsOnly = it.runIf(process.platform === 'win32');
describe('DatabaseConnection.isReplacedOnDisk (issue #925)', () => {
let dir: string;
let dbPath: string;
let conn: DatabaseConnection;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-925-db-'));
dbPath = path.join(dir, 'codegraph.db');
conn = DatabaseConnection.initialize(dbPath);
});
afterEach(() => {
try { conn.close(); } catch { /* may already be closed */ }
fs.rmSync(dir, { recursive: true, force: true });
});
it('is false for the file it opened (any platform)', () => {
expect(conn.isReplacedOnDisk()).toBe(false);
});
posixOnly('becomes true once a DIFFERENT inode lives at the same path', () => {
// Unlink the file we hold open, then create a fresh file at the same path —
// a new inode. The held connection should now report itself replaced.
fs.rmSync(dbPath);
fs.writeFileSync(dbPath, 'not really a db, but a different inode');
expect(conn.isReplacedOnDisk()).toBe(true);
});
posixOnly('is false while the file is momentarily absent (mid-recreate)', () => {
// Nothing to reopen onto yet — don't claim "replaced" until a new file lands.
fs.rmSync(dbPath);
expect(conn.isReplacedOnDisk()).toBe(false);
});
windowsOnly('never fires on Windows (no usable inode / open files cannot be unlinked)', () => {
expect(conn.isReplacedOnDisk()).toBe(false);
});
});
describe('CodeGraph.reopenIfReplaced (issue #925)', () => {
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-925-cg-'));
fs.mkdirSync(path.join(root, 'src'));
fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export function fooOld() { return 1; }\n');
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
posixOnly('heals a held connection after the index is removed and recreated at the same path', async () => {
// The "server" opens and holds the DB for its lifetime.
const server = CodeGraph.initSync(root);
await server.indexAll();
expect(server.searchNodes('fooOld').length).toBeGreaterThan(0);
expect(server.searchNodes('fooNew').length).toBe(0);
// Simulate `git worktree remove` + re-add (or rm -rf .codegraph + init):
// a NEW index inode at the same path, carrying a renamed symbol, written by
// a separate instance (mirrors a fresh `codegraph init` process).
fs.rmSync(getCodeGraphDir(root), { recursive: true, force: true });
fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export function fooNew() { return 2; }\n');
const fresh = CodeGraph.initSync(root);
await fresh.indexAll();
fresh.destroy();
// Pre-heal: the held fd still serves the pre-removal snapshot.
expect(server.searchNodes('fooNew').length).toBe(0);
expect(server.searchNodes('fooOld').length).toBeGreaterThan(0);
// Heal in place — the SAME instance now reads the live inode.
expect(server.reopenIfReplaced()).toBe(true);
expect(server.searchNodes('fooNew').length).toBeGreaterThan(0);
expect(server.searchNodes('fooOld').length).toBe(0);
// Idempotent: nothing changed since, so a second call is a no-op.
expect(server.reopenIfReplaced()).toBe(false);
server.destroy();
});
posixOnly('is a no-op (returns false) when the index has not been replaced', async () => {
const server = CodeGraph.initSync(root);
await server.indexAll();
expect(server.reopenIfReplaced()).toBe(false);
server.destroy();
});
});
+609
View File
@@ -0,0 +1,609 @@
/**
* 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);
});
it('returns true for a contrib module with empty require (composer name/type)', () => {
const ctx = makeContext({
readFile: (f) =>
f === 'composer.json'
? JSON.stringify({
name: 'drupal/admin_toolbar',
type: 'drupal-module',
require: {},
})
: null,
});
expect(drupalResolver.detect(ctx)).toBe(true);
});
it('returns true via the *.info.yml fallback when composer.json is absent', () => {
const ctx = makeContext({
readFile: () => null,
getAllFiles: () => [
'mymodule/mymodule.info.yml',
'mymodule/mymodule.routing.yml',
],
});
expect(drupalResolver.detect(ctx)).toBe(true);
});
it('returns false for a stray *.info.yml with no Drupal PHP/route file', () => {
const ctx = makeContext({
readFile: () => null,
getAllFiles: () => ['some/unrelated.info.yml'],
});
expect(drupalResolver.detect(ctx)).toBe(false);
});
});
describe('drupalResolver.claimsReference', () => {
it('claims FQCN handler refs and hook names the pre-filter would drop', () => {
expect(drupalResolver.claimsReference!('\\Drupal\\m\\Form\\SettingsForm')).toBe(true);
expect(drupalResolver.claimsReference!('\\Drupal\\m\\Controller\\C:setNoJsCookie')).toBe(true);
expect(drupalResolver.claimsReference!('hook_form_alter')).toBe(true);
});
it('does not claim ordinary identifiers or entity-handler dotted refs', () => {
expect(drupalResolver.claimsReference!('someHelperFunction')).toBe(false);
expect(drupalResolver.claimsReference!('comment.default')).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();
});
it('resolves a single-colon controller-service ref (Class:method)', () => {
const methodNode = {
id: 'method:nojs1',
kind: 'method' as const,
name: 'setNoJsCookie',
qualifiedName: 'BigPipeController::setNoJsCookie',
filePath: 'core/modules/big_pipe/src/Controller/BigPipeController.php',
language: 'php' as const,
startLine: 10,
endLine: 20,
startColumn: 0,
endColumn: 0,
updatedAt: 0,
};
const classNode = {
id: 'class:nojs2',
kind: 'class' as const,
name: 'BigPipeController',
qualifiedName: 'BigPipeController',
filePath: 'core/modules/big_pipe/src/Controller/BigPipeController.php',
language: 'php' as const,
startLine: 5,
endLine: 30,
startColumn: 0,
endColumn: 0,
updatedAt: 0,
};
const ctx = makeContext({
getNodesByName: (name) => (name === 'BigPipeController' ? [classNode] : []),
getNodesInFile: () => [classNode, methodNode],
});
const ref = {
fromNodeId: 'route:x',
referenceName: '\\Drupal\\big_pipe\\Controller\\BigPipeController:setNoJsCookie',
referenceKind: 'references' as const,
line: 1,
column: 0,
filePath: 'big_pipe.routing.yml',
language: 'yaml' as const,
};
const resolved = drupalResolver.resolve(ref, ctx);
expect(resolved).not.toBeNull();
expect(resolved!.targetNodeId).toBe('method:nojs1');
});
});
// ---------------------------------------------------------------------------
// 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();
});
});
+393
View File
@@ -0,0 +1,393 @@
/**
* Dynamic-boundary surfacing (#687).
*
* When the flow an agent asked codegraph_explore about does NOT fully connect,
* the Flow section announces WHERE the static path ends — the dynamic-dispatch
* site (computed member call, getattr, typed bus, runtime-keyed emit), with
* candidate targets when a key is statically visible — instead of silently
* showing nothing. Deterministic, query-time only, no graph mutation, and a
* fully connected flow must never produce the section.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
import { scanDynamicDispatch } from '../src/mcp/dynamic-boundaries';
// ---------------------------------------------------------------------------
// Unit: the scanner
// ---------------------------------------------------------------------------
describe('scanDynamicDispatch', () => {
it('detects a computed member call with a literal key', () => {
const body = `function go(p) {\n table['save'](p);\n}`;
const m = scanDynamicDispatch(body, 'typescript', 10);
expect(m).toHaveLength(1);
expect(m[0]!.form).toBe('computed-call');
expect(m[0]!.key).toBe('save');
expect(m[0]!.line).toBe(11); // absolute: body starts at file line 10
expect(m[0]!.snippet).toContain("table['save'](p)");
});
it('detects a computed member call with a runtime key (no key extracted)', () => {
const body = `dispatch(action) {\n this.handlers[action.type](action.payload);\n}`;
const m = scanDynamicDispatch(body, 'typescript', 1);
expect(m).toHaveLength(1);
expect(m[0]!.form).toBe('computed-call');
expect(m[0]!.key).toBeUndefined();
});
it('does not fire on dispatch shapes inside comments or strings', () => {
const body = [
'function safe() {',
" // this.handlers[action.type](payload) — commented out",
' const doc = "call handlers[key](p) to dispatch";',
' return 1;',
'}',
].join('\n');
expect(scanDynamicDispatch(body, 'typescript', 1)).toHaveLength(0);
});
it('does not treat plain indexing or array literals as dispatch', () => {
const body = `function f(xs) {\n const a = xs[0];\n const b = [1, 2, 3];\n return a + b[1];\n}`;
expect(scanDynamicDispatch(body, 'typescript', 1)).toHaveLength(0);
});
it('detects python getattr immediate-call', () => {
const body = `def run(self, name):\n return getattr(self, name)(1)`;
const m = scanDynamicDispatch(body, 'python', 5);
expect(m).toHaveLength(1);
expect(m[0]!.form).toBe('getattr-call');
});
it('detects two-step getattr only when the assigned name is called later', () => {
const called = `def process(self, kind, p):\n handler = getattr(self, 'handle_' + kind)\n return handler(p)`;
const m = scanDynamicDispatch(called, 'python', 1);
expect(m).toHaveLength(1);
expect(m[0]!.form).toBe('getattr-assign');
expect(m[0]!.key).toBe('handle_'); // the literal prefix — enough to shortlist
const notCalled = `def peek(self, kind):\n handler = getattr(self, 'handle_' + kind)\n return handler`;
expect(scanDynamicDispatch(notCalled, 'python', 1)).toHaveLength(0);
});
it('detects ruby send with a symbol key', () => {
const body = `def run(name)\n target.send(:handle_save, 1)\nend`;
const m = scanDynamicDispatch(body, 'ruby', 1);
expect(m).toHaveLength(1);
expect(m[0]!.form).toBe('ruby-send');
expect(m[0]!.key).toBe('handle_save');
});
it('detects typed message dispatch and marks the key as a type', () => {
const body = `public async Task<int> Create(CreateCmd c) {\n return await _mediator.Send(new CreateTodoItemCommand(c));\n}`;
const m = scanDynamicDispatch(body, 'csharp', 1);
expect(m).toHaveLength(1);
expect(m[0]!.form).toBe('typed-bus');
expect(m[0]!.key).toBe('CreateTodoItemCommand');
expect(m[0]!.keyIsType).toBe(true);
});
it('detects runtime-keyed emit but not literal-keyed emit', () => {
const runtime = `notify(name, data) {\n this.emitter.emit(name, data);\n}`;
const m = scanDynamicDispatch(runtime, 'typescript', 1);
expect(m).toHaveLength(1);
expect(m[0]!.form).toBe('var-key-dispatch');
// Literal keys are the edge synthesizer's territory — not a boundary.
const literal = `notify(data) {\n this.emitter.emit('saved', data);\n}`;
expect(scanDynamicDispatch(literal, 'typescript', 1)).toHaveLength(0);
});
it('dedupes repeated same-form/same-key sites and counts the extras', () => {
const body = [
'route(a) {',
' this.table[a.type](a.p);',
' this.table[a.kind](a.p);',
' this.table[a.name](a.p);',
'}',
].join('\n');
const m = scanDynamicDispatch(body, 'typescript', 1);
expect(m).toHaveLength(1);
expect(m[0]!.moreSites).toBe(2);
});
it('detects reflective dispatch with a literal method name as key', () => {
const body = `public void run(Object o) {\n o.getClass().getMethod("handlePing").invoke(o);\n}`;
const m = scanDynamicDispatch(body, 'java', 1);
expect(m.length).toBeGreaterThanOrEqual(1);
expect(m[0]!.form).toBe('reflection');
expect(m[0]!.key).toBe('handlePing');
});
});
// ---------------------------------------------------------------------------
// Integration: codegraph_explore output
// ---------------------------------------------------------------------------
describe('codegraph_explore — dynamic boundaries', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
const setup = async (files: Record<string, string>, include: string[]) => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-boundary-'));
const src = path.join(testDir, 'src');
fs.mkdirSync(src, { recursive: true });
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(src, name), content);
}
cg = CodeGraph.initSync(testDir, { config: { include, exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
};
afterEach(() => {
if (cg) cg.destroy();
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
it('announces the boundary site and shortlists the keyed candidate', async () => {
await setup({
'router.ts': [
'type Handler = (p: unknown) => void;',
'export class Router {',
' private table: Record<string, Handler> = {};',
' add(key: string, fn: Handler) { this.table[key] = fn; }',
' routeSave(payload: unknown) {',
" this.table['save'](payload);",
' }',
'}',
].join('\n'),
'handlers.ts': [
"import { Router } from './router';",
'export function onSave(payload: unknown) { return payload; }',
'export function wire(r: Router) { r.add("save", onSave); }',
].join('\n'),
}, ['**/*.ts']);
const res = await handler.execute('codegraph_explore', { query: 'routeSave onSave' });
const text = res.content[0].text as string;
expect(text).toContain('**Dynamic boundaries');
expect(text).toContain('computed member call');
expect(text).toMatch(/router\.ts:6/); // the exact dispatch site
expect(text).toContain('candidates for key `save`');
expect(text).toContain('onSave');
expect(text).toContain('← you named this');
// Honesty constraint: never steer the agent to Read.
expect(text).not.toMatch(/\buse Read\b/i);
});
it('announces a runtime-keyed boundary with no candidate list', async () => {
await setup({
'bus.ts': [
'type Action = { type: string; payload?: unknown };',
'type Handler = (p: unknown) => void;',
'export class Bus {',
' private table: Record<string, Handler> = {};',
' route(action: Action) {',
' this.table[action.type](action.payload);',
' }',
'}',
].join('\n'),
'handlers.ts': 'export function onSave(payload: unknown) { return payload; }',
}, ['**/*.ts']);
const res = await handler.execute('codegraph_explore', { query: 'route onSave' });
const text = res.content[0].text as string;
expect(text).toContain('**Dynamic boundaries');
expect(text).toContain('computed member call');
expect(text).not.toContain('candidates for key'); // runtime key → no shortlist to claim
});
it('surfaces the boundary even when the other symbol is not in the graph', async () => {
await setup({
'bus.ts': [
'type Action = { type: string; payload?: unknown };',
'type Handler = (p: unknown) => void;',
'export class Bus {',
' private table: Record<string, Handler> = {};',
' route(action: Action) {',
' this.table[action.type](action.payload);',
' }',
'}',
].join('\n'),
}, ['**/*.ts']);
// `processPayment` does not exist anywhere — only `route` resolves.
const res = await handler.execute('codegraph_explore', { query: 'route processPayment' });
const text = res.content[0].text as string;
expect(text).toContain('**Dynamic boundaries');
});
it('renders a direct synthesized emit→handler hop as a dynamic-dispatch link (#687 criterion 1)', async () => {
// Custom EventBus with a LITERAL key: the event-emitter synthesizer
// bridges emit→handler, but the 2-node chain was invisible — too short
// for the Flow section and skipped by the links section as "in-chain".
await setup({
'bus.ts': [
'type Handler = (p: unknown) => void;',
'export class EventBus {',
' private listeners: Record<string, Handler[]> = {};',
' on(event: string, fn: Handler) { (this.listeners[event] ??= []).push(fn); }',
' emit(event: string, payload: unknown) { for (const fn of this.listeners[event] ?? []) fn(payload); }',
'}',
'export const bus = new EventBus();',
].join('\n'),
'billing.ts': [
"import { bus } from './bus';",
'export function settleInvoice(payload: unknown) { return payload; }',
"bus.on('invoice.settled', settleInvoice);",
].join('\n'),
'checkout.ts': [
"import { bus } from './bus';",
'export function completeCheckout(order: unknown) {',
" bus.emit('invoice.settled', order);",
'}',
].join('\n'),
}, ['**/*.ts']);
const res = await handler.execute('codegraph_explore', { query: 'completeCheckout settleInvoice' });
const text = res.content[0].text as string;
expect(text).toContain('**Dynamic-dispatch links among your symbols');
expect(text).toMatch(/completeCheckout → settleInvoice/);
expect(text).toContain('invoice.settled');
// Connected via the synthesized edge — no boundary to announce.
expect(text).not.toContain('**Dynamic boundaries');
});
it('never adds the section to a fully connected flow', async () => {
await setup({
'pipeline.ts': [
'export function stepOne() { return stepTwo(); }',
'export function stepTwo() { return stepThree(); }',
'export function stepThree() { return 3; }',
].join('\n'),
}, ['**/*.ts']);
const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
const text = res.content[0].text as string;
expect(text).toContain('**Flow');
expect(text).not.toContain('**Dynamic boundaries');
});
it('python getattr dispatch surfaces with a prefix-key candidate', async () => {
await setup({
'service.py': [
'class Service:',
' def handle_save(self, payload):',
' return payload',
'',
' def process(self, kind, payload):',
" handler = getattr(self, 'handle_' + kind)",
' return handler(payload)',
].join('\n'),
}, ['**/*.py']);
const res = await handler.execute('codegraph_explore', { query: 'process handle_save' });
const text = res.content[0].text as string;
expect(text).toContain('**Dynamic boundaries');
expect(text).toContain('getattr');
expect(text).toContain('handle_save');
});
});
// ---------------------------------------------------------------------------
// Integration: interface/registry dispatch (a named method has many impls)
// ---------------------------------------------------------------------------
describe('codegraph_explore — interface dispatch', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
const setup = async (files: Record<string, string>, include: string[]) => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-iface-'));
const src = path.join(testDir, 'src');
fs.mkdirSync(src, { recursive: true });
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(src, name), content);
}
cg = CodeGraph.initSync(testDir, { config: { include, exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
};
afterEach(() => {
if (cg) cg.destroy();
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
// 9 classes implement INodeType, each with execute(); a runtime registry lookup
// dispatches to one. The agent names the static entry + `execute`, which can't
// resolve to a single impl — the boundary IS the answer.
const nodeFamily = (n: number) => {
const names = ['Http', 'Set', 'If', 'Merge', 'Code', 'Webhook', 'Cron', 'Func', 'NoOp', 'Switch', 'Wait', 'Filter'];
return [
'export interface INodeType { execute(): unknown; }',
...names.slice(0, n).map((nm, i) => `export class ${nm}Node implements INodeType { execute() { return ${i}; } }`),
].join('\n');
};
const engine = [
"import { registry } from './registry';",
'export class WorkflowExecute {',
' processRunExecutionData() { return this.runNode(); }',
' runNode() { return this.executeNode(); }',
' executeNode() {',
" const nodeType = registry.get('http');",
' return nodeType.execute();',
' }',
'}',
].join('\n');
const registry = [
"import type { INodeType } from './nodes';",
'class Registry {',
' private m: Record<string, INodeType> = {};',
' get(k: string): INodeType { return this.m[k]!; }',
'}',
'export const registry = new Registry();',
].join('\n');
it('announces the interface, the TRUE implementer count, and sample targets', async () => {
await setup({ 'nodes.ts': nodeFamily(9), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']);
const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
const text = res.content[0].text as string;
expect(text).toContain('**Interface dispatch (a named method has many implementations)');
expect(text).toMatch(/`execute` → runtime dispatch to \*\*9\*\* types implementing `INodeType`/);
// a couple of concrete targets, with file:line
expect(text).toMatch(/\b\w+Node\.execute` \(/);
// never steer to Read
expect(text).not.toMatch(/\buse Read\b/i);
});
it('stays SILENT on a fully connected flow with no polymorphic family', async () => {
await setup({
'pipeline.ts': [
'export function stepOne() { return stepTwo(); }',
'export function stepTwo() { return stepThree(); }',
'export function stepThree() { return 3; }',
].join('\n'),
}, ['**/*.ts']);
const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
const text = res.content[0].text as string;
expect(text).toContain('**Flow');
expect(text).not.toContain('**Interface dispatch');
});
it('stays SILENT when the interface family is below the polymorphism threshold (3 impls)', async () => {
await setup({ 'nodes.ts': nodeFamily(3), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']);
const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
const text = res.content[0].text as string;
expect(text).not.toContain('**Interface dispatch');
});
});
@@ -0,0 +1,190 @@
/**
* Erlang behaviour-callback dispatch bridge.
*
* A behaviour module declares `-callback fn/N`, implementers declare
* `-behaviour(B)` and export the callbacks, and the framework dispatches
* through a variable module (`Handler:init(...)`, `Mod:handle_thing(...)`) — a
* dynamic hop extraction deliberately leaves silent. This bridges each
* `Var:fn(args)` site to every in-repo implementer of the ONE behaviour that
* declares (fn, site-arity), and proves the precision gates: a same-named
* function in a non-implementer module contributes no edge, an arity mismatch
* contributes no edge, and a (fn, arity) declared by TWO behaviours bails
* entirely.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('erlang-behaviour synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'erlang-behaviour-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
async function synthEdges(d: string): Promise<any[]> {
const cg = await CodeGraph.init(d, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.name source, s.file_path sf, t.name target, t.file_path tf,
json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'erlang-behaviour'`
)
.all();
cg.destroy();
return rows;
}
it('bridges Var:fn(...) dispatch to every implementer, gated on behaviour + export + arity', async () => {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'worker_behaviour.erl'),
`-module(worker_behaviour).
-callback handle_thing(Arg :: term()) -> ok | {error, term()}.
-callback init(list()) -> {ok, term()}.
-export([dispatch/2]).
dispatch(Mod, Arg) ->
Mod:handle_thing(Arg).
`
);
// Two real implementers, exporting the callback.
fs.writeFileSync(
path.join(dir, 'src', 'worker_a.erl'),
`-module(worker_a).
-behaviour(worker_behaviour).
-export([handle_thing/1, init/1]).
handle_thing(X) -> {ok, X}.
init(_) -> {ok, state}.
`
);
fs.writeFileSync(
path.join(dir, 'src', 'worker_b.erl'),
`-module(worker_b).
-behaviour(worker_behaviour).
-export([handle_thing/1, init/1]).
handle_thing(X) -> {done, X}.
init(_) -> {ok, state}.
`
);
// Defines + exports the same function name but does NOT implement the behaviour.
fs.writeFileSync(
path.join(dir, 'src', 'freeloader.erl'),
`-module(freeloader).
-export([handle_thing/1]).
handle_thing(X) -> X.
`
);
// A second dispatcher in another module, plus an arity-mismatched site and a
// macro-module site — neither of the latter two may produce edges.
fs.writeFileSync(
path.join(dir, 'src', 'runner.erl'),
`-module(runner).
-export([run/2, wrong/2, self_call/1]).
run(Mod, Arg) ->
Mod:handle_thing(Arg).
wrong(Mod, Arg) ->
Mod:handle_thing(Arg, extra).
self_call(X) ->
?MODULE:handle_thing(X).
`
);
const rows = await synthEdges(dir);
const targets = (src: string) =>
rows.filter((r) => r.source === src).map((r) => `${path.basename(r.tf)}:${r.target}`).sort();
// Both dispatch sites link both implementers — and only them (no freeloader).
expect(targets('dispatch')).toEqual(['worker_a.erl:handle_thing', 'worker_b.erl:handle_thing']);
expect(targets('run')).toEqual(['worker_a.erl:handle_thing', 'worker_b.erl:handle_thing']);
// Arity mismatch (handle_thing/2 undeclared) and ?MODULE sites: nothing.
expect(targets('wrong')).toEqual([]);
expect(targets('self_call')).toEqual([]);
// Provenance metadata names the contract.
expect(rows.every((r) => r.via === 'worker_behaviour:handle_thing/1')).toBe(true);
});
it('bails when two behaviours declare the same callback name and arity', async () => {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
for (const b of ['left_behaviour', 'right_behaviour']) {
fs.writeFileSync(
path.join(dir, 'src', `${b}.erl`),
`-module(${b}).
-callback common_cb(term()) -> ok.
`
);
}
fs.writeFileSync(
path.join(dir, 'src', 'impl_left.erl'),
`-module(impl_left).
-behaviour(left_behaviour).
-export([common_cb/1]).
common_cb(X) -> X.
`
);
fs.writeFileSync(
path.join(dir, 'src', 'caller.erl'),
`-module(caller).
-export([go/2]).
go(Mod, X) ->
Mod:common_cb(X).
`
);
const rows = await synthEdges(dir);
expect(rows).toEqual([]);
});
it('does not link an implementer whose callback is not exported', async () => {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'hook_behaviour.erl'),
`-module(hook_behaviour).
-callback on_event(term()) -> ok.
-export([fire/2]).
fire(Mod, Ev) ->
Mod:on_event(Ev).
`
);
fs.writeFileSync(
path.join(dir, 'src', 'private_impl.erl'),
`-module(private_impl).
-behaviour(hook_behaviour).
-export([start/0]).
start() -> ok.
on_event(_Ev) -> ok.
`
);
fs.writeFileSync(
path.join(dir, 'src', 'public_impl.erl'),
`-module(public_impl).
-behaviour(hook_behaviour).
-export([on_event/1]).
on_event(Ev) -> {seen, Ev}.
`
);
const rows = await synthEdges(dir);
expect(rows.map((r) => path.basename(r.tf))).toEqual(['public_impl.erl']);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { CodeGraph } from '../../src/index.js';
import { scoreSearchNodes, scoreFindRelevantContext } from './scoring.js';
import { testCases } from './test-cases.js';
import type { EvalReport, EvalResult } from './types.js';
const codebasePath = process.env.EVAL_CODEBASE || process.argv[2];
if (!codebasePath) {
console.error('Usage: EVAL_CODEBASE=/path/to/codebase npx tsx __tests__/evaluation/runner.ts');
console.error(' or: npx tsx __tests__/evaluation/runner.ts /path/to/codebase');
process.exit(1);
}
const resolvedPath = path.resolve(codebasePath);
if (!fs.existsSync(path.join(resolvedPath, '.codegraph', 'codegraph.db'))) {
console.error(`No .codegraph/codegraph.db found at ${resolvedPath}`);
process.exit(1);
}
let codegraphSha = 'unknown';
try {
codegraphSha = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
} catch {}
console.log(`\nCodeGraph Eval — ${path.basename(resolvedPath)}`);
console.log(`Codebase: ${resolvedPath}`);
console.log(`Commit: ${codegraphSha}`);
console.log(`Cases: ${testCases.length}`);
console.log('');
async function run() {
const cg = CodeGraph.openSync(resolvedPath);
const results: EvalResult[] = [];
for (const tc of testCases) {
const start = performance.now();
if (tc.api === 'searchNodes') {
const searchResults = cg.searchNodes(tc.query, {
limit: 10,
kinds: tc.kinds,
...(tc.options as Record<string, unknown>),
});
const latency = performance.now() - start;
const result = scoreSearchNodes(tc.id, tc.expectedSymbols, searchResults, latency);
results.push(result);
} else {
const subgraph = await cg.findRelevantContext(tc.query, {
searchLimit: 8,
traversalDepth: 3,
maxNodes: 80,
minScore: 0.2,
...(tc.options as Record<string, unknown>),
});
const latency = performance.now() - start;
const result = scoreFindRelevantContext(tc.id, tc.expectedSymbols, subgraph, latency);
results.push(result);
}
}
cg.close();
// Print results table
const maxIdLen = Math.max(...results.map((r) => r.caseId.length));
for (const r of results) {
const status = r.pass ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
const id = r.caseId.padEnd(maxIdLen);
const recall = `recall=${r.recall.toFixed(2)}`;
const extra =
r.edgeDensity !== undefined
? `density=${r.edgeDensity.toFixed(2)}`
: `mrr=${r.mrr.toFixed(2)}`;
const latency = `${Math.round(r.latencyMs)}ms`;
console.log(` ${id} ${status} ${recall} ${extra} ${latency}`);
if (r.missedSymbols.length > 0) {
console.log(` ${' '.repeat(maxIdLen)} missed: ${r.missedSymbols.join(', ')}`);
}
}
// Summary
const passed = results.filter((r) => r.pass).length;
const failed = results.length - passed;
const meanRecall = results.reduce((s, r) => s + r.recall, 0) / results.length;
const mrrResults = results.filter((r) => r.mrr > 0 || r.caseId.startsWith('search-'));
const meanMRR =
mrrResults.length > 0 ? mrrResults.reduce((s, r) => s + r.mrr, 0) / mrrResults.length : 0;
console.log('');
const summaryColor = failed === 0 ? '\x1b[32m' : '\x1b[33m';
console.log(
`${summaryColor}SUMMARY: ${passed}/${results.length} passed | recall=${meanRecall.toFixed(2)} | mrr=${meanMRR.toFixed(2)}\x1b[0m`
);
// Save JSON report
const report: EvalReport = {
timestamp: new Date().toISOString(),
codebasePath: resolvedPath,
codegraphSha,
summary: { total: results.length, passed, failed, meanRecall, meanMRR },
results,
};
const resultsDir = path.join(__dirname, 'results');
fs.mkdirSync(resultsDir, { recursive: true });
const reportFile = path.join(
resultsDir,
`${new Date().toISOString().replace(/[:.]/g, '-')}.json`
);
fs.writeFileSync(reportFile, JSON.stringify(report, null, 2));
console.log(`\nReport saved: ${reportFile}`);
process.exit(failed > 0 ? 1 : 0);
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
+82
View File
@@ -0,0 +1,82 @@
import type { EvalResult } from './types.js';
export const PASS_THRESHOLD = 0.5;
export function scoreSearchNodes(
caseId: string,
expectedSymbols: string[],
results: Array<{ node: { name: string }; score: number }>,
latencyMs: number
): EvalResult {
const expectedLower = expectedSymbols.map((s) => s.toLowerCase());
const resultNames = results.map((r) => r.node.name.toLowerCase());
const found: string[] = [];
const missed: string[] = [];
let firstRank = 0;
for (let i = 0; i < expectedLower.length; i++) {
const idx = resultNames.indexOf(expectedLower[i]);
if (idx !== -1) {
found.push(expectedSymbols[i]);
if (firstRank === 0) firstRank = idx + 1;
} else {
missed.push(expectedSymbols[i]);
}
}
const recall = expectedSymbols.length > 0 ? found.length / expectedSymbols.length : 0;
const mrr = firstRank > 0 ? 1 / firstRank : 0;
return {
caseId,
pass: recall >= PASS_THRESHOLD,
recall,
mrr,
foundSymbols: found,
missedSymbols: missed,
latencyMs,
};
}
export function scoreFindRelevantContext(
caseId: string,
expectedSymbols: string[],
subgraph: { nodes: Map<string, { name: string }>; edges: unknown[]; roots: string[] },
latencyMs: number
): EvalResult {
const expectedLower = new Set(expectedSymbols.map((s) => s.toLowerCase()));
const nodeNames = new Set<string>();
for (const node of subgraph.nodes.values()) {
nodeNames.add(node.name.toLowerCase());
}
const found: string[] = [];
const missed: string[] = [];
for (const sym of expectedSymbols) {
if (nodeNames.has(sym.toLowerCase())) {
found.push(sym);
} else {
missed.push(sym);
}
}
const recall = expectedSymbols.length > 0 ? found.length / expectedSymbols.length : 0;
const nodeCount = subgraph.nodes.size;
const edgeCount = subgraph.edges.length;
const edgeDensity = nodeCount > 0 ? edgeCount / nodeCount : 0;
return {
caseId,
pass: recall >= PASS_THRESHOLD,
recall,
mrr: 0,
foundSymbols: found,
missedSymbols: missed,
nodeCount,
edgeCount,
edgeDensity,
latencyMs,
};
}
+93
View File
@@ -0,0 +1,93 @@
import type { EvalTestCase } from './types.js';
export const testCases: EvalTestCase[] = [
// === searchNodes: Symbol Lookup Precision ===
{
id: 'search-class-exact',
query: 'TransportService',
api: 'searchNodes',
expectedSymbols: ['TransportService'],
kinds: ['class'],
},
{
id: 'search-method-qualified',
query: 'TransportService sendRequest',
api: 'searchNodes',
expectedSymbols: ['sendRequest'],
kinds: ['method'],
},
{
id: 'search-interface',
query: 'ActionListener',
api: 'searchNodes',
expectedSymbols: ['ActionListener'],
kinds: ['interface'],
},
{
id: 'search-enum',
query: 'RestStatus',
api: 'searchNodes',
expectedSymbols: ['RestStatus'],
kinds: ['enum'],
},
{
id: 'search-exception',
query: 'SearchPhaseExecutionException',
api: 'searchNodes',
expectedSymbols: ['SearchPhaseExecutionException'],
kinds: ['class'],
},
{
id: 'search-nested-class',
query: 'Engine Index',
api: 'searchNodes',
expectedSymbols: ['Index'],
kinds: ['class'],
},
// === findRelevantContext: Exploration Quality ===
{
id: 'explore-rest-layer',
query: 'How does the REST layer handle HTTP requests?',
api: 'findRelevantContext',
expectedSymbols: ['RestController', 'RestHandler', 'BaseRestHandler', 'RestRequest'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
{
id: 'explore-search-execution',
query: 'How does search execution work from request to shard?',
api: 'findRelevantContext',
expectedSymbols: ['ShardSearchRequest', 'SearchShardsRequest', 'SearchShardsGroup'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
{
id: 'explore-bulk-indexing',
query: 'How does bulk indexing work?',
api: 'findRelevantContext',
expectedSymbols: ['TransportBulkAction', 'BulkRequest', 'BulkResponse'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
{
id: 'explore-shard-allocation',
query: 'How does shard rebalancing and allocation work?',
api: 'findRelevantContext',
expectedSymbols: ['AllocationService', 'BalancedShardsAllocator'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
{
id: 'explore-transport-search',
query: 'How does TransportService connect to SearchTransportService?',
api: 'findRelevantContext',
expectedSymbols: ['TransportService', 'SearchTransportService'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
{
id: 'explore-engine-implementations',
query: 'What are the Engine implementations for indexing?',
api: 'findRelevantContext',
expectedSymbols: ['InternalEngine', 'ReadOnlyEngine', 'Engine'],
options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 },
},
];
+37
View File
@@ -0,0 +1,37 @@
import type { NodeKind } from '../../src/types.js';
export interface EvalTestCase {
id: string;
query: string;
api: 'searchNodes' | 'findRelevantContext';
expectedSymbols: string[];
kinds?: NodeKind[];
options?: Record<string, unknown>;
}
export interface EvalResult {
caseId: string;
pass: boolean;
recall: number;
mrr: number;
foundSymbols: string[];
missedSymbols: string[];
nodeCount?: number;
edgeCount?: number;
edgeDensity?: number;
latencyMs: number;
}
export interface EvalReport {
timestamp: string;
codebasePath: string;
codegraphSha: string;
summary: {
total: number;
passed: number;
failed: number;
meanRecall: number;
meanMRR: number;
};
results: EvalResult[];
}
+165
View File
@@ -0,0 +1,165 @@
/**
* `codegraph.json` `exclude` — keep paths out of the index even when git-TRACKED
* (#999).
*
* The escape hatch for a committed vendor/theme/SDK directory (a checked-in
* Metronic theme under `static/`) that `.gitignore` cannot drop because git
* tracks it. Two layers under test:
* 1. Loader: parse/validate/cache, mirroring the `includeIgnored` loader.
* 2. Behavior: `scanDirectory` drops excluded paths on BOTH the git
* (`git ls-files`) and non-git (filesystem walk) enumeration paths — and
* crucially for TRACKED files, which is the whole point.
*
* Invariant: every loader failure mode degrades to the zero-config default
* (exclude nothing), never a throw.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { execFileSync } from 'node:child_process';
import { loadExcludePatterns, loadExtensionOverrides, loadIncludeIgnoredPatterns, clearProjectConfigCache } from '../src/project-config';
import { scanDirectory } from '../src/extraction';
describe('exclude loader (codegraph.json)', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-exclude-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const writeConfig = (obj: unknown) =>
fs.writeFileSync(
path.join(dir, 'codegraph.json'),
typeof obj === 'string' ? obj : JSON.stringify(obj)
);
it('returns an empty list when there is no codegraph.json (the default)', () => {
expect(loadExcludePatterns(dir)).toEqual([]);
});
it('loads a well-formed pattern array', () => {
writeConfig({ exclude: ['static/', '**/vendor/**'] });
expect(loadExcludePatterns(dir)).toEqual(['static/', '**/vendor/**']);
});
it('trims whitespace and drops blank / non-string entries', () => {
writeConfig({ exclude: [' static/ ', '', ' ', 42, null, 'vendor/'] });
expect(loadExcludePatterns(dir)).toEqual(['static/', 'vendor/']);
});
it('ignores a non-array exclude value without throwing', () => {
writeConfig({ exclude: 'static/' });
expect(loadExcludePatterns(dir)).toEqual([]);
});
it('ignores malformed JSON without throwing', () => {
writeConfig('{ not: valid json ');
expect(loadExcludePatterns(dir)).toEqual([]);
});
it('coexists with extensions and includeIgnored in one file (shared single parse)', () => {
writeConfig({ extensions: { '.foo': 'typescript' }, includeIgnored: ['pkgs/'], exclude: ['static/'] });
expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript' });
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['pkgs/']);
expect(loadExcludePatterns(dir)).toEqual(['static/']);
});
it('picks up a changed config (mtime-invalidated cache)', () => {
writeConfig({ exclude: ['static/'] });
expect(loadExcludePatterns(dir)).toEqual(['static/']);
writeConfig({ exclude: ['assets/'] });
const future = new Date(Date.now() + 2000);
fs.utimesSync(path.join(dir, 'codegraph.json'), future, future);
expect(loadExcludePatterns(dir)).toEqual(['assets/']);
});
it('drops the patterns again when the config file is removed', () => {
writeConfig({ exclude: ['static/'] });
expect(loadExcludePatterns(dir)).toEqual(['static/']);
fs.rmSync(path.join(dir, 'codegraph.json'));
expect(loadExcludePatterns(dir)).toEqual([]);
});
});
describe('exclude behavior — scanDirectory drops excluded paths (#999)', () => {
let dir: string;
const mk = (rel: string, content = 'export const x = 1;\n') => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content);
};
const writeConfig = (obj: unknown) =>
fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify(obj));
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-exclude-scan-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const gitInit = () => {
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['add', '-A'], { cwd: dir });
execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'x'], { cwd: dir });
};
it('keeps a TRACKED excluded dir out of the index (git path) — the core fix', () => {
mk('app/main.ts');
mk('static/theme/widget1.js');
mk('static/theme/widget2.js');
gitInit(); // static/ is now git-TRACKED — .gitignore could not drop it
// Sanity: without exclude the tracked theme IS indexed.
let files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
expect(files).toContain('app/main.ts');
expect(files.some((f) => f.startsWith('static/'))).toBe(true);
// With exclude the tracked theme is gone, app code stays.
writeConfig({ exclude: ['static/'] });
clearProjectConfigCache();
files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
expect(files).toContain('app/main.ts');
expect(files.some((f) => f.startsWith('static/'))).toBe(false);
});
it('excludes a tracked dir on the non-git filesystem-walk path too', () => {
mk('app/main.ts');
mk('static/theme/widget1.js');
// No git init → scanDirectory falls back to the filesystem walk.
writeConfig({ exclude: ['static/'] });
clearProjectConfigCache();
const files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
expect(files).toContain('app/main.ts');
expect(files.some((f) => f.startsWith('static/'))).toBe(false);
});
it('supports a double-star glob', () => {
mk('src/a.ts');
mk('packages/x/vendor/lib1.js');
mk('packages/y/vendor/lib2.js');
gitInit();
writeConfig({ exclude: ['**/vendor/**'] });
clearProjectConfigCache();
const files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
expect(files).toContain('src/a.ts');
expect(files.some((f) => f.includes('/vendor/'))).toBe(false);
});
it('is a no-op with no exclude config (everything indexed)', () => {
mk('app/main.ts');
mk('static/theme/widget1.js');
gitInit();
const files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
expect(files).toContain('app/main.ts');
expect(files.some((f) => f.startsWith('static/'))).toBe(true);
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* codegraph_explore blast-radius section.
*
* explore now appends a compact, always-on "Blast radius" for the entry
* symbols: who depends on each (locations only — no source) and which test
* files cover it, so the agent knows what to update/verify before editing
* without a separate impact call. Symbols with no dependents are skipped, and
* the section is omitted entirely when nothing qualifies.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
describe('codegraph_explore — blast radius', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-blast-'));
const src = path.join(testDir, 'src');
fs.mkdirSync(src, { recursive: true });
// `target` is depended on by a sibling (caller) and a test file.
fs.writeFileSync(
path.join(src, 'feature.ts'),
`export function target() { return 1; }\n` +
`export function caller() { return target(); }\n`,
);
fs.writeFileSync(
path.join(src, 'feature.test.ts'),
`import { target } from './feature';\n` +
`export function checkTarget() { return target(); }\n`,
);
// A leaf with no dependents — must NOT show up in the blast radius.
fs.writeFileSync(
path.join(src, 'leaf.ts'),
`export function lonelyLeaf() { return 42; }\n`,
);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
it('lists dependents (locations only) and covering tests for an entry symbol', async () => {
const res = await handler.execute('codegraph_explore', { query: 'target' });
const text = res.content[0].text;
expect(text).toContain('**Blast radius');
expect(text).toContain('`target`');
expect(text).toMatch(/caller/); // a caller count is reported
// It names WHERE (the caller file) — not the caller's source body.
expect(text).toContain('feature.ts');
// Test coverage is surfaced (either the covering test file, or the warning).
expect(text).toMatch(/tests:.*feature\.test\.ts|no covering tests/);
});
it('omits symbols that have no dependents from the blast radius', async () => {
const res = await handler.execute('codegraph_explore', { query: 'lonelyLeaf' });
const text = res.content[0].text;
// lonelyLeaf has zero callers — it must never appear under a blast-radius bullet.
expect(text).not.toMatch(/Blast radius[\s\S]*`lonelyLeaf`/);
});
});
@@ -0,0 +1,122 @@
/**
* codegraph_explore — multi-term corroboration tier (cross-layer monorepo ranking).
*
* BEHAVIOURAL coverage for the `isCorroborated` tier in handleExplore's file sort:
* a backend file that is BOTH an entry/central file AND matched by >=2 DISTINCT
* query terms must be surfaced (rendered as a `#### <path>` source section) for a
* backend-flow query in a multi-layer repo — not displaced by a denser frontend
* layer. The tier exists because explore's primary file ranker is graph-centrality
* (Random-Walk-with-Restart) mass, which — seeded from text matches that skew to
* the bigger, internally dense layer — can bury a query-matching backend file under
* an off-topic cluster. The entry/central GUARD keeps the tier safe: an INCIDENTAL
* multi-term file that is neither entry nor central is NOT promoted, so it cannot
* displace a graph-central answer file (the regression a blunt hits-only tier caused
* on excalidraw, where `binding.ts`/`elbowArrow.ts` displaced `renderNewElementScene`).
*
* NOTE: the full directus-scale burial (where frontend RWR mass exceeds a
* query-matching backend file) is an EMERGENT property of thousands of real frontend
* symbols — a self-contained fixture can't reach the cluster size past
* findRelevantContext's retrieval cap. That regression is isolated by the
* deterministic ranking harness on real indexes (directus/n8n/excalidraw), where the
* api/ service moves from "absent/mentioned" to "sourced" with no control regression.
* These tests lock the user-visible behaviour the tier guarantees on a fixture.
*/
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';
/** Paths that explore rendered as full-body ``**`<path>`** —`` source sections.
* Headers are bold labels, not ATX headings (issue #778). */
function sourcedFiles(text: string): string[] {
const out: string[] = [];
for (const line of text.split('\n')) {
const m = line.match(/^\*\*`(.+?)`\*\* —/);
if (m) out.push(m[1].trim());
}
return out;
}
describe('codegraph_explore — multi-term corroboration tier', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-corrob-'));
// --- The large, internally DENSE frontend layer ---------------------------
// Many `app/` files whose SYMBOLS all match the word "item" and form a tight
// call mesh, so Random-Walk-with-Restart mass (seeded from those text matches)
// concentrates here. They are NOT the answer to a backend query — but at scale
// their cluster mass out-ranks the call-isolated backend file.
// "item" is a PATH token (app/item/...) so FTS (token-based, not substring)
// retrieves every file for the query term "item" — matching directus's `app/`
// tree where "item" is a real path/symbol token, not a camelCase fragment.
const appItem = path.join(testDir, 'app', 'item');
fs.mkdirSync(appItem, { recursive: true });
const N = 30;
for (let i = 0; i < N; i++) {
const next = (i + 1) % N;
const prev = (i + N - 1) % N;
// Each file imports two neighbours → a dense mesh of `references`/`calls`.
// snake_case so FTS tokenizes "item" out of the symbol name (camelCase would
// leave `itemview0` as a single unmatchable token).
fs.writeFileSync(path.join(appItem, `view${i}.ts`),
`import { item_view_${next} } from './view${next}';\n` +
`import { item_view_${prev} } from './view${prev}';\n` +
`export function item_view_${i}() {\n` +
` return item_view_${next}() + item_view_${prev}();\n` +
`}\n`);
}
// --- The small, call-ISOLATED backend file (the answer) -------------------
// Its PATH matches TWO distinct query terms (api/item/service.ts → item +
// service), so it IS a search root (an entry file) with file-term-hits >=2 —
// but its generic SYMBOLS don't text-match, and nothing in the frontend mesh
// calls it, so it gets no RWR inflow and its restart mass is diluted across the
// large frontend seed set. This is the directus shape: ItemsService is
// search-relevant by name/path yet call-isolated from the frontend seed cluster,
// so RWR alone buries it under the mesh. Only the corroboration tier (path/name
// matches >=2 query terms AND it's an entry file) keeps it in.
const apiItem = path.join(testDir, 'api', 'item');
fs.mkdirSync(apiItem, { recursive: true });
fs.writeFileSync(path.join(apiItem, 'service.ts'),
`export class DataService {\n` +
` read() { return this.load(); }\n` +
` load(): string[] { return []; }\n` +
`}\n`);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
it('sources the corroborated backend file alongside a denser frontend cluster in a multi-layer repo', async () => {
const res = await handler.execute('codegraph_explore', { query: 'item service' });
const text = res.content[0].text;
const sourced = sourcedFiles(text);
// The backend service — matched by item+service and a search root — must
// be rendered, not truncated out by the frontend mesh's graph mass.
expect(sourced).toContain('api/item/service.ts');
});
it('still leads with the backend file when the query names its symbol directly', async () => {
// A query naming the backend symbol directly: the answer is the DataService
// file; the frontend mesh stays subordinate (it matches only "item").
const res = await handler.execute('codegraph_explore', { query: 'DataService read load' });
const text = res.content[0].text;
const sourced = sourcedFiles(text);
expect(sourced).toContain('api/item/service.ts');
// The named backend file leads — it is not displaced by the frontend layer.
expect(sourced[0]).toBe('api/item/service.ts');
});
});
@@ -0,0 +1,125 @@
/**
* codegraph_explore — NL-stopword collision guard (named-symbol seeding).
*
* handleExplore's named-symbol seeding treats every identifier-shaped query
* token as "a symbol the agent named" and grants its definition the
* named-FIRST sort tier. But explore also takes natural-language questions,
* whose ordinary English words collide with real callables: on this repo the
* query "…check the latest version…" exact-matched the lone `check()` method
* of an unrelated WAL-valve class, which then outranked (and, within the
* per-repo file budget, fully displaced) the upgrade module the corroboration
* ranking had correctly scored — so the agent fell back to Read/Grep.
*
* The guard: a shape-precise token (camelCase, PascalCase, snake_case,
* qualified) seeds unconditionally — it is an unambiguous symbol reference.
* A BARE lowercase word seeds only definitions whose file another query token
* co-names (that other token is itself a symbol defined in the same file, the
* "check drain fire" sibling-bag shape) — which an incidental English-word
* collision never is.
*/
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';
/** Paths explore rendered as full-body ``**`<path>`** —`` source sections, in order. */
function sourcedFiles(text: string): string[] {
const out: string[] = [];
for (const line of text.split('\n')) {
const m = line.match(/^\*\*`(.+?)`\*\* —/);
if (m) out.push(m[1].trim());
}
return out;
}
describe('codegraph_explore — NL-stopword collision guard', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stopword-'));
// --- The collision file: an unrelated class whose methods are ordinary
// English words ("check", "drain", "fire" — the only defs of those names).
// Substantive bodies + an internal call mesh so it isn't skipped as a stub.
const dbDir = path.join(testDir, 'src', 'db');
fs.mkdirSync(dbDir, { recursive: true });
fs.writeFileSync(path.join(dbDir, 'valve.ts'),
`export class Valve {\n` +
` private open = false;\n` +
` check(): void {\n` +
` if (this.open) {\n` +
` this.fire();\n` +
` }\n` +
` }\n` +
` fire(): void {\n` +
` this.drain();\n` +
` this.open = false;\n` +
` }\n` +
` drain(): void {\n` +
` this.open = true;\n` +
` }\n` +
`}\n`);
// --- The answer file: the upgrade module the query is actually about.
// Its path and symbol names match the query's topic terms (upgrade,
// latest, version), so the corroboration ranking scores it — the bug was
// the collision file's named tier sorting ABOVE it anyway.
const upDir = path.join(testDir, 'src', 'upgrade');
fs.mkdirSync(upDir, { recursive: true });
fs.writeFileSync(path.join(upDir, 'updater.ts'),
`export function normalizeVersion(v: string): string {\n` +
` return v.startsWith('v') ? v : 'v' + v;\n` +
`}\n` +
`export function resolveLatestVersion(): string {\n` +
` return normalizeVersion('9.9.9');\n` +
`}\n` +
`export function runUpgrade(): string {\n` +
` const latest = resolveLatestVersion();\n` +
` return latest;\n` +
`}\n`);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
async function explore(query: string): Promise<string> {
const res = await handler.execute('codegraph_explore', { query });
expect(res.isError).toBeFalsy();
return res.content[0]!.text;
}
it('a bare English word ("check") does not tier its namesake above the corroborated answer file', async () => {
const text = await explore('how does the upgrade flow check the latest version');
const files = sourcedFiles(text);
const updater = files.findIndex((f) => f.endsWith('updater.ts'));
const valve = files.findIndex((f) => f.endsWith('valve.ts'));
// The upgrade module must render, and must rank above the collision file
// (pre-guard, valve.ts held the named-FIRST tier and sorted on top).
expect(updater).toBeGreaterThanOrEqual(0);
if (valve !== -1) expect(updater).toBeLessThan(valve);
});
it('a sibling bag of bare words ("check drain fire") still tiers their shared file first', async () => {
const text = await explore('check drain fire');
const files = sourcedFiles(text);
// Every token is co-named by the others in valve.ts — genuine bare-name
// symbol bags must keep the named tier.
expect(files[0]).toMatch(/valve\.ts$/);
});
it('a shape-precise token (camelCase) seeds unconditionally', async () => {
const text = await explore('runUpgrade');
const files = sourcedFiles(text);
expect(files[0]).toMatch(/updater\.ts$/);
});
});
+280
View File
@@ -0,0 +1,280 @@
/**
* 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, normalizeQuerySpelling, 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('caps medium-large projects at the inline tool-result ceiling (~24k) so the result is never externalized', () => {
// A bigger single response gets externalized by the host to a file the agent
// Reads back (a 35k vscode explore did exactly that in the n=4 A/B) — adding a
// read AND cache-write cost. So large repos get MORE CALLS (getExploreBudget),
// not a fatter single response; the output cap stays under the inline limit.
const large = getExploreOutputBudget(10000);
expect(large.maxOutputChars).toBeLessThanOrEqual(25000);
expect(large.maxOutputChars).toBeGreaterThanOrEqual(20000);
});
it('uses tier breakpoints matching getExploreBudget so call-count and output-budget agree on a project', () => {
// Very-tiny tier (<150 files) gets a tighter cap than small (150-499) —
// paired with tool gating to handle the MCP-overhead-dominates regime.
const tier0a = getExploreOutputBudget(50);
const tier0b = getExploreOutputBudget(149);
expect(tier0a.maxOutputChars).toBe(tier0b.maxOutputChars);
const tier1a = getExploreOutputBudget(150);
const tier1b = getExploreOutputBudget(499);
expect(tier1a.maxOutputChars).toBe(tier1b.maxOutputChars);
// The <500 explore-call budget covers both very-tiny and small.
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);
// Small tiers step up (13k → 18k → 24k); medium and large SHARE the ~24k
// inline ceiling — scaling with repo size now lives in the CALL budget
// (getExploreBudget), not in a fatter single response.
expect(tier0a.maxOutputChars).not.toBe(tier1a.maxOutputChars); // <150 vs <500
expect(tier1a.maxOutputChars).not.toBe(tier2a.maxOutputChars); // <500 vs <5000
expect(tier2a.maxOutputChars).toBe(tier3a.maxOutputChars); // <5000 == <15000 (inline cap)
expect(getExploreBudget(5000)).toBeGreaterThan(getExploreBudget(4999)); // calls scale instead
});
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 medium+ tiers — small tiers drop it to maximize body density', () => {
// ITER2: relationships dropped on <500 tiers; on tiny repos the
// per-call payload is the cost driver, so even "cheap" structural
// signal adds up across follow-up turns. Re-enabled at ≥500 where
// body budgets are roomy enough to absorb the 1-2KB overhead.
expect(getExploreOutputBudget(50).includeRelationships).toBe(false);
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)', () => {
// 149 -> very-tiny, 150 -> small
expect(getExploreOutputBudget(149).maxOutputChars).toBe(getExploreOutputBudget(50).maxOutputChars);
expect(getExploreOutputBudget(150).maxOutputChars).toBe(getExploreOutputBudget(200).maxOutputChars);
// 499 -> small, 500 -> medium
expect(getExploreOutputBudget(499).maxOutputChars).toBe(getExploreOutputBudget(200).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);
});
});
describe('normalizeQuerySpelling (Erlang mod:fn/arity)', () => {
it('rewrites Erlang-native symbol spellings to pipeline shapes', () => {
expect(normalizeQuerySpelling('cowboy_stream_h:request_process/3'))
.toBe('cowboy_stream_h.request_process');
expect(normalizeQuerySpelling('ejabberd_router:route/1 do_route/1 session'))
.toBe('ejabberd_router.route do_route session');
expect(normalizeQuerySpelling('init/2 handle_call/3')).toBe('init handle_call');
});
it('leaves query-language field prefixes and other spellings alone', () => {
expect(normalizeQuerySpelling('kind:function lang:erlang route'))
.toBe('kind:function lang:erlang route');
expect(normalizeQuerySpelling('path:src/api name:auth')).toBe('path:src/api name:auth');
expect(normalizeQuerySpelling('Foo::bar baz')).toBe('Foo::bar baz');
expect(normalizeQuerySpelling('https://example.com/docs')).toBe('https://example.com/docs');
expect(normalizeQuerySpelling('meeting at 12:30')).toBe('meeting at 12:30');
expect(normalizeQuerySpelling('src/2fa/handler.ts')).toBe('src/2fa/handler.ts');
});
it('maps Lua colon-method spelling onto the qualified form', () => {
expect(normalizeQuerySpelling('logger:log message')).toBe('logger.log message');
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* codegraph_explore — the "Found N symbols across M files." header reflects the
* CURATED answer actually rendered, not the raw candidate gather (#1046).
*
* A broad natural-language query FTS-matches a huge pool of symbols ("status",
* "publish", "api" hit a large fraction of any API-heavy repo), but only a
* handful of files clear the relevance gate + budget and render with source.
* The header used to report `subgraph.nodes.size` / `fileGroups.size` — the raw
* pool (260 symbols / 124 files on a 636-file repo) — which read as "wade
* through 260 results" even though the correctly-ranked answer was the few files
* below. It now reports only the files whose source survives in the output.
*
* The locked invariant: the header's file count EQUALS the number of rendered
* `**`<path>`**` source sections. Pre-fix that failed whenever the gather
* exceeded what rendered (here: 8 disconnected "noise" files are gathered but
* gated out), so this fixture discriminates the fix from the old behaviour.
*/
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';
/** Files explore rendered as ``**`<path>`**`` source sections (issue #778: bold
* labels, not ATX headings). */
function renderedSourceFiles(text: string): string[] {
const out: string[] = [];
for (const line of text.split('\n')) {
const m = line.match(/^\*\*`(.+?)`\*\*/);
if (m) out.push(m[1].trim());
}
return out;
}
function headerFileCount(text: string): number | null {
const m = text.match(/Found \d+ symbols? across (\d+) files?\./);
return m ? parseInt(m[1], 10) : null;
}
describe('codegraph_explore — curated result count (#1046)', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-count-'));
// The real, connected flow — its symbols call each other, so it clears the
// relevance gate and renders. snake_case so FTS tokenizes "status" out of
// the names (camelCase would leave one unmatchable token).
fs.writeFileSync(path.join(testDir, 'flow.ts'),
`export function publish_status() { return build_status(); }\n` +
`export function build_status() { return send_status(); }\n` +
`export function send_status() { return 'ok'; }\n`);
// Disconnected "noise" files: each defines ONE symbol that text-matches the
// query word "status" but calls nothing in the flow. They ARE gathered into
// the subgraph by FTS (so the OLD header counted them), but score too low to
// render — exactly the breadth that inflated the count.
for (let i = 0; i < 8; i++) {
fs.writeFileSync(path.join(testDir, `status_widget_${i}.ts`),
`export function status_widget_${i}() { return ${i}; }\n`);
}
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
it('header file count equals the number of rendered source sections', async () => {
const res = await handler.execute('codegraph_explore', { query: 'publish status' });
const text = res.content[0].text;
const headerFiles = headerFileCount(text);
const rendered = renderedSourceFiles(text);
expect(headerFiles).not.toBeNull();
// The core honesty invariant — the header counts what's shown, not the gather.
expect(headerFiles).toBe(rendered.length);
// The flow file is the answer and must be among the rendered files.
expect(rendered).toContain('flow.ts');
// Curation actually happened: far fewer than the 9 gathered files (1 flow +
// 8 noise) are reported. Pre-fix this was the inflated gather count.
expect(headerFiles!).toBeLessThan(5);
// And the sentinel placeholder never leaks into the rendered header.
expect(text).not.toContain('codegraph-explore-summary');
});
});
@@ -0,0 +1,86 @@
/**
* Regression: codegraph_explore must SURFACE a synthesized edge whose endpoints are
* `constant` nodes (RTK thunk→thunk), on a SMALL repo.
*
* `buildFlowFromNamedSymbols` historically filtered its "named" set to CALLABLE kinds
* (method/function/component/constructor), excluding `constant`. RTK thunks are
* `export const X = createAsyncThunk(...)`, so a thunk→thunk hop is constant→constant —
* it never entered the flow scan and surfaced nowhere on the Flow path. The kind-agnostic
* "### Relationships" section would have caught it, but that is disabled below 500 files.
* Net: on a small RTK app the synthesized edge existed in the graph yet was invisible to
* the agent. The fix feeds a `dynNamed` set (named non-callable endpoints that participate
* in a heuristic edge) to the tier-independent "**Dynamic-dispatch links**" scan. This test
* pins it on a deliberately tiny (<150-file) fixture so the Relationships gate is OFF and
* the dynamic-dispatch-links path is the ONLY thing that can surface the hop.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
// Assertions read RAW codegraph_explore output; managed offload would replace it. Disable
// it for this file so the suite is hermetic regardless of dev-machine config, then restore.
let _prevOffloadDisable: string | undefined;
beforeAll(() => { _prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; });
afterAll(() => {
if (_prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE;
else process.env.CODEGRAPH_OFFLOAD_DISABLE = _prevOffloadDisable;
});
describe('codegraph_explore — synthesized constant→constant edges surface on small repos', () => {
let dir: string;
let cg: CodeGraph;
let handler: ToolHandler;
afterEach(() => {
if (cg) cg.destroy();
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
});
it('surfaces an RTK thunk→thunk hop (both `constant`) in the Dynamic-dispatch links section', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'explore-thunk-surface-'));
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
);
fs.writeFileSync(
path.join(dir, 'thunks.ts'),
`import { createAsyncThunk } from '@reduxjs/toolkit';
export const deepThunk = createAsyncThunk('app/deep', async (n: number) => n * 2);
export const innerThunk = createAsyncThunk('app/inner', async (n: number, { dispatch }) => {
return dispatch(deepThunk(n));
});
export const outerThunk = createAsyncThunk('app/outer', async (n: number, { dispatch }) => {
await dispatch(innerThunk(n));
});
`
);
cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
// Precondition: the endpoints really are `constant` nodes — the exact kind the old
// CALLABLE-only flow scan dropped (if extraction ever classed them as functions the
// test would pass vacuously, so assert the case we actually fixed).
const db = (cg as any).db.db;
const outerKind = db.prepare(`SELECT kind FROM nodes WHERE name = 'outerThunk' LIMIT 1`).get()?.kind;
expect(outerKind).toBe('constant');
handler = new ToolHandler(cg);
const res = await handler.execute('codegraph_explore', { query: 'outerThunk innerThunk' });
const text = res.content[0].text as string;
// The synthesized hop now surfaces (was invisible: both endpoints `constant` AND the
// small-repo Relationships section is off).
expect(text).toContain('**Dynamic-dispatch links among your symbols');
expect(text).toMatch(/outerThunk\s+→\s+innerThunk/);
// It reads as a dynamic-dispatch bridge with its wiring site, not a bare `calls`.
expect(text).toMatch(/dynamic: redux thunk @/);
expect(text).not.toMatch(/outerThunk\s+→\s+innerThunk\s+\[calls\]/);
});
});
+207
View File
@@ -0,0 +1,207 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
import { expoModulesResolver } from '../src/resolution/frameworks/expo-modules';
describe('Expo Modules framework extractor', () => {
it('extracts AsyncFunction / Function / Property literals as method nodes', () => {
const source = `
import ExpoModulesCore
public class HapticsModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoHaptics")
AsyncFunction("notificationAsync") { (notificationType: NotificationType) in
// body
}
AsyncFunction("impactAsync") { (style: ImpactStyle) in
// body
}
Function("synchronousThing") {
return 1
}
Property("isAvailable") {
return true
}
}
}
`;
const result = expoModulesResolver.extract?.('ios/HapticsModule.swift', source);
expect(result).toBeDefined();
const names = result!.nodes.map((n) => n.name);
expect(names).toEqual(
expect.arrayContaining(['notificationAsync', 'impactAsync', 'synchronousThing', 'isAvailable'])
);
expect(result!.nodes.every((n) => n.kind === 'method')).toBe(true);
expect(result!.nodes.every((n) => n.qualifiedName.includes('ExpoHaptics.'))).toBe(true);
});
it('falls back to the class name when the Module has no Name("X") literal', () => {
const source = `
public class BareModule: Module {
public func definition() -> ModuleDefinition {
Function("doX") { return 1 }
}
}
`;
const result = expoModulesResolver.extract?.('ios/BareModule.swift', source);
// BareModule is used as the qualifier since there's no Name() literal.
expect(result!.nodes[0]?.qualifiedName).toContain('BareModule.doX');
});
it('returns no nodes for a Swift file that is not an Expo Module', () => {
const source = `
class Helper {
func doX() { }
}
`;
const result = expoModulesResolver.extract?.('Helper.swift', source);
expect(result?.nodes).toHaveLength(0);
});
it('also extracts from Kotlin module files', () => {
const source = `
class FooModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoFoo")
AsyncFunction("doAsync") { name: String -> name.uppercase() }
Function("doSync") { 42 }
}
}
`;
const result = expoModulesResolver.extract?.('FooModule.kt', source);
expect(result?.nodes.length).toBe(2);
expect(result?.nodes.map((n) => n.name).sort()).toEqual(['doAsync', 'doSync']);
expect(result?.nodes.every((n) => n.language === 'kotlin')).toBe(true);
});
});
describe('Expo Modules end-to-end — JS caller → native AsyncFunction', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-modules-fixture-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('JS callsite of a literal AsyncFunction("name") resolves to the native impl node', async () => {
fs.writeFileSync(
path.join(dir, 'package.json'),
'{"dependencies":{"expo-modules-core":"^1.0.0"}}'
);
fs.mkdirSync(path.join(dir, 'ios'));
fs.writeFileSync(
path.join(dir, 'ios', 'HapticsModule.swift'),
`
import ExpoModulesCore
public class HapticsModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoHaptics")
AsyncFunction("uniqueExpoHapticCall") { in /* … */ }
}
}
`
);
fs.mkdirSync(path.join(dir, 'src'));
fs.writeFileSync(
path.join(dir, 'src', 'index.ts'),
`
import { requireNativeModule } from 'expo-modules-core';
const Haptics = requireNativeModule('ExpoHaptics');
export async function impactAsync() {
return await Haptics.uniqueExpoHapticCall();
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
// The native method node should exist.
const native = db
.prepare(
"SELECT * FROM nodes WHERE kind='method' AND name='uniqueExpoHapticCall' AND id LIKE 'expo-module:%'"
)
.all();
expect(native).toHaveLength(1);
// And the JS callsite should produce a call edge targeting it.
const callEdge = db
.prepare(
`SELECT t.name target, t.id target_id
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE e.kind = 'calls'
AND s.file_path LIKE '%index.ts'
AND t.name = 'uniqueExpoHapticCall'`
)
.all();
cg.close?.();
expect(callEdge.length).toBeGreaterThanOrEqual(1);
expect(callEdge[0].target_id.startsWith('expo-module:')).toBe(true);
});
it('extracts GENERIC-typed Kotlin AsyncFunction<T> and pairs the iOS + Android impls', async () => {
fs.writeFileSync(
path.join(dir, 'package.json'),
'{"dependencies":{"expo-modules-core":"^1.0.0"}}'
);
fs.mkdirSync(path.join(dir, 'ios'));
fs.writeFileSync(
path.join(dir, 'ios', 'BatteryModule.swift'),
`import ExpoModulesCore
public class BatteryModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoBattery")
AsyncFunction("getBatteryLevelAsync") { () -> Float in return 1.0 }
}
}
`
);
fs.mkdirSync(path.join(dir, 'android'));
fs.writeFileSync(
path.join(dir, 'android', 'BatteryModule.kt'),
`import expo.modules.kotlin.modules.Module
class BatteryModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoBattery")
AsyncFunction<Float>("getBatteryLevelAsync") { 1.0f }
}
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
// The Android (Kotlin) GENERIC AsyncFunction<Float> is extracted — before the
// fix the `<Float>` defeated the regex and it was silently dropped.
const kt = db.prepare(
"SELECT * FROM nodes WHERE name='getBatteryLevelAsync' AND language='kotlin' AND id LIKE 'expo-module:%'"
).all();
expect(kt).toHaveLength(1);
// The iOS (Swift) and Android (Kotlin) impls of the same JS method are linked
// to each other, so a JS call that resolves to one platform reaches the other.
const pair = db.prepare(
`SELECT count(*) c FROM edges e
JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
WHERE s.name='getBatteryLevelAsync' AND t.name='getBatteryLevelAsync'
AND s.language != t.language`
).get();
cg.close?.();
expect(pair.c).toBeGreaterThanOrEqual(2); // swift->kotlin AND kotlin->swift
});
});
+157
View File
@@ -0,0 +1,157 @@
/**
* Custom extension → language mapping (#906).
*
* A project can map non-standard file extensions to a supported language via a
* committed `codegraph.json` at the repo root, so files that would otherwise be
* silently skipped get indexed under the right grammar. These tests cover the
* two choke-point functions (detectLanguage / isSourceFile) honoring an override
* map, the loader's validation/normalization/caching of `codegraph.json`, and a
* full index proving a custom-extension file is actually extracted — while the
* zero-config path stays byte-identical (the file is NOT indexed without config).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
import { detectLanguage, isSourceFile } from '../src/extraction/grammars';
import { loadExtensionOverrides, clearProjectConfigCache } from '../src/project-config';
describe('custom extension → language mapping (#906)', () => {
describe('detectLanguage / isSourceFile overrides argument', () => {
it('maps a custom extension only when present in the overrides', () => {
expect(detectLanguage('a/b.foo')).toBe('unknown');
expect(isSourceFile('a/b.foo')).toBe(false);
expect(detectLanguage('a/b.foo', undefined, { '.foo': 'typescript' })).toBe('typescript');
expect(isSourceFile('a/b.foo', { '.foo': 'typescript' })).toBe(true);
});
it('lets a user mapping take precedence over a built-in extension', () => {
expect(detectLanguage('x.h')).toBe('c');
expect(detectLanguage('x.h', undefined, { '.h': 'cpp' })).toBe('cpp');
});
it('is byte-identical to zero-config behavior when no overrides are passed', () => {
expect(detectLanguage('x.ts')).toBe('typescript');
expect(detectLanguage('x.py')).toBe('python');
expect(isSourceFile('x.ts')).toBe(true);
expect(isSourceFile('x.unknownext')).toBe(false);
});
});
describe('loadExtensionOverrides (codegraph.json)', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-extmap-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const writeConfig = (obj: unknown) =>
fs.writeFileSync(
path.join(dir, 'codegraph.json'),
typeof obj === 'string' ? obj : JSON.stringify(obj)
);
it('returns an empty map when there is no codegraph.json', () => {
expect(loadExtensionOverrides(dir)).toEqual({});
});
it('loads and validates a well-formed extensions map', () => {
writeConfig({ extensions: { '.foo': 'typescript', '.bar': 'python' } });
expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript', '.bar': 'python' });
});
it('normalizes keys (adds a leading dot, lowercases)', () => {
writeConfig({ extensions: { foo: 'lua', '.BAR': 'go' } });
expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'lua', '.bar': 'go' });
});
it('skips entries whose target is not a supported language', () => {
writeConfig({ extensions: { '.foo': 'typescript', '.bad': 'pyhton', '.x': 'unknown' } });
expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript' });
});
it('skips multi-part and otherwise unusable extension keys', () => {
writeConfig({ extensions: { '.d.ts': 'typescript', 'a/b': 'go', '.': 'lua', '.ok': 'rust' } });
expect(loadExtensionOverrides(dir)).toEqual({ '.ok': 'rust' });
});
it('ignores malformed JSON without throwing', () => {
writeConfig('{ not: valid json ');
expect(loadExtensionOverrides(dir)).toEqual({});
});
it('ignores a non-object extensions field', () => {
writeConfig({ extensions: 'nope' });
expect(loadExtensionOverrides(dir)).toEqual({});
});
it('picks up a changed config (mtime-invalidated cache)', () => {
writeConfig({ extensions: { '.foo': 'typescript' } });
expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript' });
writeConfig({ extensions: { '.foo': 'go' } });
// Force a distinct mtime in case the filesystem clock is coarse.
const future = new Date(Date.now() + 2000);
fs.utimesSync(path.join(dir, 'codegraph.json'), future, future);
expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'go' });
});
});
describe('indexAll honors codegraph.json end-to-end', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-extmap-idx-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
const indexAndQuery = async () => {
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const nodes = db
.prepare('SELECT name, kind, file_path, language FROM nodes WHERE file_path = ?')
.all('widget.foo');
const files = db
.prepare('SELECT path, language FROM files WHERE path = ?')
.all('widget.foo');
cg.close?.();
return { nodes, files };
};
const SOURCE = 'export function widgetHandler(x: number): number { return x + 1; }\n';
it('indexes a custom-extension file mapped to a supported language', async () => {
write('codegraph.json', JSON.stringify({ extensions: { '.foo': 'typescript' } }));
write('widget.foo', SOURCE);
const { nodes, files } = await indexAndQuery();
expect(files.length).toBe(1);
expect(files[0].language).toBe('typescript');
expect(nodes.some((n: any) => n.name === 'widgetHandler' && n.language === 'typescript')).toBe(true);
});
it('does NOT index the same file without codegraph.json (zero-config preserved)', async () => {
write('widget.foo', SOURCE);
const { nodes, files } = await indexAndQuery();
expect(files.length).toBe(0);
expect(nodes.length).toBe(0);
});
});
});
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
import { fabricViewResolver } from '../src/resolution/frameworks/fabric';
describe('Fabric view component extractor (codegenNativeComponent specs)', () => {
it('extracts a component node + prop nodes from a Native*.ts spec', () => {
const source = `
'use client';
import { codegenNativeComponent } from 'react-native';
import type { ViewProps, CodegenTypes as CT, ColorValue } from 'react-native';
type TapEvent = Readonly<{ x: number; y: number }>;
export interface NativeProps extends ViewProps {
color?: ColorValue;
onTap?: CT.DirectEventHandler<TapEvent>;
caption?: string;
}
export default codegenNativeComponent<NativeProps>('MyView', {});
`;
const result = fabricViewResolver.extract?.('src/MyViewNativeComponent.ts', source);
expect(result).toBeDefined();
const componentNodes = result!.nodes.filter((n) => n.kind === 'component');
const propNodes = result!.nodes.filter((n) => n.kind === 'property');
expect(componentNodes).toHaveLength(1);
expect(componentNodes[0]?.name).toBe('MyView');
expect(propNodes.map((n) => n.name).sort()).toEqual(['caption', 'color', 'onTap']);
});
it('returns nothing for a file without codegenNativeComponent', () => {
const source = `export const x = 1;`;
const result = fabricViewResolver.extract?.('plain.ts', source);
expect(result?.nodes).toHaveLength(0);
});
it('handles a spec with no NativeProps interface (rare but valid)', () => {
const source = `
import { codegenNativeComponent } from 'react-native';
export default codegenNativeComponent('BareComponent');
`;
const result = fabricViewResolver.extract?.('Bare.ts', source);
// Component node exists; no prop nodes.
const components = result!.nodes.filter((n) => n.kind === 'component');
const props = result!.nodes.filter((n) => n.kind === 'property');
expect(components).toHaveLength(1);
expect(components[0]?.name).toBe('BareComponent');
expect(props).toHaveLength(0);
});
});
describe('Fabric end-to-end: JSX consumer → Fabric component → native class', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-fixture-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('connects <MyView/> JSX to the native ObjC class via Fabric synthesizer', async () => {
fs.writeFileSync(
path.join(dir, 'package.json'),
'{"dependencies":{"react-native":"^0.73"}}'
);
// Fabric spec.
fs.mkdirSync(path.join(dir, 'spec'));
fs.writeFileSync(
path.join(dir, 'spec', 'MyViewNativeComponent.ts'),
`import { codegenNativeComponent } from 'react-native';
import type { ViewProps } from 'react-native';
export interface NativeProps extends ViewProps { color?: string; }
export default codegenNativeComponent<NativeProps>('MyView');`
);
// Native iOS implementation — class named with the `View` suffix
// convention.
fs.mkdirSync(path.join(dir, 'ios'));
fs.writeFileSync(
path.join(dir, 'ios', 'MyView.mm'),
`@interface MyViewView : UIView
@end
@implementation MyViewView
- (void)setColor:(NSString *)c { /* … */ }
@end`
);
// JSX consumer.
fs.mkdirSync(path.join(dir, 'src'));
fs.writeFileSync(
path.join(dir, 'src', 'App.tsx'),
`import React from 'react';
import MyView from '../spec/MyViewNativeComponent';
export function App() {
return <MyView color="red"/>;
}`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
// 1. The Fabric component node exists.
const componentRows = db
.prepare("SELECT id, name, kind FROM nodes WHERE id LIKE 'fabric-component:%' AND name='MyView'")
.all();
expect(componentRows).toHaveLength(1);
// 2. The native class node exists.
const nativeRows = db
.prepare("SELECT id, name FROM nodes WHERE kind='class' AND language='objc' AND name='MyViewView'")
.all();
expect(nativeRows).toHaveLength(1);
// 3. Fabric synthesizer bridges component → native class.
const bridgeRows = db
.prepare(
`SELECT s.name comp, t.name native FROM edges e
JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
WHERE json_extract(e.metadata,'$.synthesizedBy')='fabric-native-impl'
AND s.name='MyView' AND t.name='MyViewView'`
)
.all();
expect(bridgeRows).toHaveLength(1);
// 4. JSX synthesizer links the App function → the Fabric component
// (jsx-render edge keyed on the tag name 'MyView').
const jsxRows = db
.prepare(
`SELECT s.name caller, t.name comp FROM edges e
JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
WHERE json_extract(e.metadata,'$.synthesizedBy')='jsx-render'
AND t.id LIKE 'fabric-component:%' AND t.name='MyView'`
)
.all();
cg.close?.();
expect(jsxRows.length).toBeGreaterThanOrEqual(1);
expect(jsxRows[0].caller).toBe('App');
// The full flow: App (TSX) → MyView (fabric-component) → MyViewView (ObjC native class)
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { EventEmitter } from 'events';
import { describeFatal, installFatalHandlers } from '../src/bin/fatal-handler';
/**
* Regression coverage for #850 (and the related #799): a fault that reaches the
* process-wide handler must NOT be swallowed-and-kept-running, and rendering it
* must NEVER touch `error.stack` — the lazy stack getter is what can wedge a
* core in a V8 source-position loop.
*/
describe('describeFatal', () => {
it('renders name + message for an Error', () => {
expect(describeFatal(new TypeError('boom'))).toBe('TypeError: boom');
});
it('falls back to the name when the message is empty', () => {
expect(describeFatal(new Error(''))).toBe('Error');
});
it('stringifies non-Error values', () => {
expect(describeFatal('a string reason')).toBe('a string reason');
expect(describeFatal(42)).toBe('42');
expect(describeFatal(null)).toBe('null');
expect(describeFatal(undefined)).toBe('undefined');
});
it('NEVER reads error.stack (the #850 hang lives in the lazy stack getter)', () => {
const err = new Error('boom');
let stackAccessed = false;
Object.defineProperty(err, 'stack', {
configurable: true,
get() {
// Simulates the pathological case: formatting the stack never returns.
stackAccessed = true;
throw new Error('stack formatting wedged');
},
});
const rendered = describeFatal(err);
expect(stackAccessed).toBe(false);
expect(rendered).toBe('Error: boom');
expect(rendered).not.toMatch(/\bat\b/); // no stack frames leaked in
});
it('never throws on a value with a hostile toString', () => {
const hostile = {
toString() {
throw new Error('no stringification for you');
},
};
expect(describeFatal(hostile)).toBe('<unstringifiable value>');
});
});
describe('installFatalHandlers', () => {
function harness() {
const target = new EventEmitter();
const writes: string[] = [];
const exits: number[] = [];
installFatalHandlers({
target,
write: (line) => writes.push(line),
exit: (code) => {
exits.push(code);
},
});
return { target, writes, exits };
}
it('logs a bounded line and exits non-zero on an uncaught exception', () => {
const { target, writes, exits } = harness();
target.emit('uncaughtException', new RangeError('kaboom'));
expect(writes).toEqual(['[CodeGraph] Uncaught exception: RangeError: kaboom\n']);
expect(exits).toEqual([1]);
});
it('logs a bounded line and exits non-zero on an unhandled rejection', () => {
const { target, writes, exits } = harness();
target.emit('unhandledRejection', 'promise went sideways');
expect(writes).toEqual(['[CodeGraph] Unhandled rejection: promise went sideways\n']);
expect(exits).toEqual([1]);
});
it('still exits — without touching the stack — when stack formatting would wedge', () => {
const { target, writes, exits } = harness();
const err = new Error('wedged');
Object.defineProperty(err, 'stack', {
configurable: true,
get() {
throw new Error('stack formatting wedged');
},
});
// Must not throw or hang: the handler renders message-only and exits.
expect(() => target.emit('uncaughtException', err)).not.toThrow();
expect(writes).toEqual(['[CodeGraph] Uncaught exception: Error: wedged\n']);
expect(exits).toEqual([1]);
});
});
+526
View File
@@ -0,0 +1,526 @@
/**
* 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, codeGraphDirName, isCodeGraphDataDir } from '../src/directory';
import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } 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 });
}
}
/** 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('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');
// Ignore everything in .codegraph/ except this file itself, so transient
// files (db, daemon.pid, sockets, logs) never show up in git. (#492, #484)
expect(content).toContain('*');
expect(content).toContain('!.gitignore');
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();
});
});
// recreate() backs `codegraph index`: it discards the existing DB and returns
// a fresh, empty instance rather than DELETE-clearing in place — the path that
// recovers a poisoned/oversized prior index without wedging (#1067).
describe('Recreate (#1067)', () => {
it('returns a fresh, empty, usable instance', async () => {
const cg = CodeGraph.initSync(tempDir);
// Give the DB some content so "empty afterwards" is meaningful.
fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export function f() { return 1; }\n');
await cg.indexAll();
expect(cg.getStats().nodeCount).toBeGreaterThan(0);
cg.close();
const fresh = await CodeGraph.recreate(tempDir);
try {
// Empty graph, but a working instance: re-indexing repopulates it.
expect(fresh.getStats().nodeCount).toBe(0);
const result = await fresh.indexAll();
expect(result.success).toBe(true);
expect(fresh.getStats().nodeCount).toBeGreaterThan(0);
} finally {
fresh.close();
}
});
it('discards the old database file rather than emptying it in place', async () => {
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.close();
// Stamp a sentinel into the existing DB header. PRAGMA user_version is
// untouched by DELETE, so an in-place clear() would preserve it — but a
// from-scratch recreate cannot. (An inode-equality check is unreliable:
// ext4/overlayfs recycle the inode number after unlink+recreate, so a
// "new inode" assertion false-fails on Linux while passing on macOS.)
const dbPath = getDatabasePath(tempDir);
const stamp = DatabaseConnection.open(dbPath);
stamp.getDb().pragma('user_version = 4242');
stamp.close();
const fresh = await CodeGraph.recreate(tempDir);
fresh.close();
// The file exists, and the sentinel is gone — proof the old DB was
// discarded and rebuilt, not row-DELETE'd in place (the path that wedged
// on a poisoned graph, #1067).
expect(fs.existsSync(dbPath)).toBe(true);
const check = DatabaseConnection.open(dbPath);
const userVersion = pragmaValue(check.getDb().pragma('user_version'), 'user_version');
check.close();
expect(Number(userVersion)).not.toBe(4242);
});
it('throws a clear error when the project is not initialized', async () => {
await expect(CodeGraph.recreate(tempDir)).rejects.toThrow(/not initialized/i);
});
});
describe('removeDatabaseFiles (#1067)', () => {
it('deletes the database and its -wal/-shm sidecars', () => {
const cg = CodeGraph.initSync(tempDir);
cg.close();
const dbPath = getDatabasePath(tempDir);
// Materialise the WAL sidecars so we can prove they're cleaned up too.
fs.writeFileSync(dbPath + '-wal', 'x');
fs.writeFileSync(dbPath + '-shm', 'x');
expect(fs.existsSync(dbPath)).toBe(true);
removeDatabaseFiles(dbPath);
expect(fs.existsSync(dbPath)).toBe(false);
expect(fs.existsSync(dbPath + '-wal')).toBe(false);
expect(fs.existsSync(dbPath + '-shm')).toBe(false);
});
it('is a no-op (does not throw) when the files are already gone', () => {
const dbPath = getDatabasePath(tempDir);
expect(fs.existsSync(dbPath)).toBe(false);
expect(() => removeDatabaseFiles(dbPath)).not.toThrow();
});
});
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);
});
it('upgrades a stale pre-wildcard .gitignore in place (issue #788)', () => {
const cg = CodeGraph.initSync(tempDir);
cg.close();
const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore');
// A .gitignore written by an older version (<= 0.9.9): an explicit
// allowlist that never ignored daemon.pid, so the daemon's runtime
// pidfile got committed.
const staleV099 =
'# 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, staleV099, 'utf-8');
// Opening the project runs validateDirectory, which self-heals.
const cg2 = CodeGraph.openSync(tempDir);
cg2.close();
const upgraded = fs.readFileSync(gitignorePath, 'utf-8');
expect(upgraded).toContain('\n*\n'); // wildcard ignores everything…
expect(upgraded).toContain('!.gitignore'); // …except this file
expect(upgraded).not.toContain('.dirty'); // old explicit list is gone
});
it('leaves a user-customized .codegraph/.gitignore untouched', () => {
const cg = CodeGraph.initSync(tempDir);
cg.close();
const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore');
// No CodeGraph header → user-authored → must not be rewritten.
const custom = '# my own rules\n*.db\n!keep-this.json\n';
fs.writeFileSync(gitignorePath, custom, 'utf-8');
const cg2 = CodeGraph.openSync(tempDir);
cg2.close();
expect(fs.readFileSync(gitignorePath, 'utf-8')).toBe(custom);
});
});
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(8);
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([]);
});
});
// Two environments that share one working tree (Windows-native + WSL) must not
// share one `.codegraph/`. CODEGRAPH_DIR overrides the data directory name so
// each side keeps its own index in the same tree (issue #636).
describe('CODEGRAPH_DIR override (#636)', () => {
const saved = process.env.CODEGRAPH_DIR;
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-dirname-'));
});
afterEach(() => {
if (saved === undefined) delete process.env.CODEGRAPH_DIR;
else process.env.CODEGRAPH_DIR = saved;
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('codeGraphDirName()', () => {
it('defaults to .codegraph when unset', () => {
delete process.env.CODEGRAPH_DIR;
expect(codeGraphDirName()).toBe('.codegraph');
});
it('honors a valid override', () => {
process.env.CODEGRAPH_DIR = '.codegraph-win';
expect(codeGraphDirName()).toBe('.codegraph-win');
});
// Anything that isn't a plain segment could escape the project root or
// clobber it, so it's ignored in favor of the default.
it.each(['foo/bar', 'a\\b', '..', '../x', '.', '/abs/path', ' ', ''])(
'falls back to .codegraph for invalid value %j',
(bad) => {
process.env.CODEGRAPH_DIR = bad;
expect(codeGraphDirName()).toBe('.codegraph');
}
);
});
describe('isCodeGraphDataDir()', () => {
it('matches the default, the active override, and .codegraph-* siblings', () => {
process.env.CODEGRAPH_DIR = '.codegraph-win';
expect(isCodeGraphDataDir('.codegraph')).toBe(true); // the other env's dir
expect(isCodeGraphDataDir('.codegraph-win')).toBe(true); // active override
expect(isCodeGraphDataDir('.codegraph-wsl')).toBe(true); // any sibling
});
it('does not match unrelated directories', () => {
delete process.env.CODEGRAPH_DIR;
for (const name of ['src', 'node_modules', '.git', 'codegraph', '.codegraphextra']) {
expect(isCodeGraphDataDir(name)).toBe(false);
}
});
});
it('init writes the index under the overridden directory, not .codegraph', () => {
process.env.CODEGRAPH_DIR = '.codegraph-win';
const cg = CodeGraph.initSync(tempDir);
try {
expect(fs.existsSync(path.join(tempDir, '.codegraph-win', 'codegraph.db'))).toBe(true);
expect(fs.existsSync(path.join(tempDir, '.codegraph'))).toBe(false);
expect(getCodeGraphDir(tempDir)).toBe(path.join(tempDir, '.codegraph-win'));
expect(CodeGraph.isInitialized(tempDir)).toBe(true);
} finally {
cg.close();
}
});
it('two index dirs coexist in one tree and the override side skips the sibling', async () => {
// WSL side: default `.codegraph`, with a source file.
delete process.env.CODEGRAPH_DIR;
fs.writeFileSync(path.join(tempDir, 'app.ts'), 'export function onlyReal() {}\n');
const wsl = await CodeGraph.init(tempDir, { index: true });
wsl.close();
// Windows side: override dir, same tree. Plant a decoy source file INSIDE
// the WSL data dir — the override-side index must not pick it up.
process.env.CODEGRAPH_DIR = '.codegraph-win';
fs.writeFileSync(path.join(tempDir, '.codegraph', 'decoy.ts'), 'export function decoyLeak() {}\n');
const win = await CodeGraph.init(tempDir, { index: true });
try {
expect(fs.existsSync(path.join(tempDir, '.codegraph', 'codegraph.db'))).toBe(true);
expect(fs.existsSync(path.join(tempDir, '.codegraph-win', 'codegraph.db'))).toBe(true);
expect(win.searchNodes('onlyReal').length).toBeGreaterThan(0);
expect(win.searchNodes('decoyLeak')).toEqual([]); // sibling data dir not indexed
} finally {
win.close();
}
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
/**
* Front-load hook project resolution (#964).
*
* The Claude `UserPromptSubmit` front-load hook must inject CodeGraph context
* for the RIGHT project — including the monorepo case where the agent's cwd is
* an un-indexed workspace root and the index lives in a sub-project. These test
* `planFrontload` / `findIndexedSubprojectRoots` directly (the hook's decision
* logic), since the end-to-end hook is validated by a live agent run, not a
* unit test.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens } from '../src/directory';
/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
function mkIndexed(dir: string): string {
fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
fs.writeFileSync(path.join(dir, '.codegraph', 'codegraph.db'), '');
return dir;
}
/** A workspace-root manifest so the down-scan gate (looksLikeProjectRoot) passes. */
function mkWorkspaceRoot(dir: string): string {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'package.json'), '{"private":true,"workspaces":["packages/*"]}');
return dir;
}
describe('planFrontload — front-load hook project resolution (#964)', () => {
let tmp: string;
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('cwd is itself indexed → front-load cwd (the common single-project case)', () => {
mkIndexed(tmp);
const plan = planFrontload(tmp, 'how does login work');
expect(plan.exploreRoot).toBe(tmp);
expect(plan.viaSubScan).toBe(false);
expect(plan.nudgeProjects).toEqual([]);
});
it('a nested file under an indexed project resolves up to that project', () => {
mkIndexed(tmp);
const nested = path.join(tmp, 'src', 'deep');
fs.mkdirSync(nested, { recursive: true });
expect(planFrontload(nested, 'trace the flow').exploreRoot).toBe(tmp);
});
it('un-indexed workspace root with ONE indexed sub-project → front-load it (the #964 case)', () => {
mkWorkspaceRoot(tmp);
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
const plan = planFrontload(tmp, 'how does the request get handled');
expect(plan.exploreRoot).toBe(api);
expect(plan.viaSubScan).toBe(true);
expect(plan.nudgeProjects).toEqual([]);
});
it('multiple indexed sub-projects, prompt names one by path → front-load it, nudge the rest', () => {
mkWorkspaceRoot(tmp);
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
const plan = planFrontload(tmp, 'in packages/api, how does the handler validate the token?');
expect(plan.exploreRoot).toBe(api);
expect(plan.viaSubScan).toBe(true);
expect(plan.nudgeProjects).toEqual([web]);
});
it('multiple indexed sub-projects, prompt names one by package name → front-load it', () => {
mkWorkspaceRoot(tmp);
mkIndexed(path.join(tmp, 'packages', 'api'));
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
const plan = planFrontload(tmp, 'how does the web frontend render the dashboard?');
expect(plan.exploreRoot).toBe(web);
});
it('multiple indexed sub-projects, NO clear match → nudge the full list, do not guess', () => {
mkWorkspaceRoot(tmp);
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
const plan = planFrontload(tmp, 'how does authentication work end to end?');
expect(plan.exploreRoot).toBeNull();
expect(plan.viaSubScan).toBe(true);
expect(plan.nudgeProjects.sort()).toEqual([api, web].sort());
});
it('un-indexed dir that is NOT a workspace root → no-op (guards $HOME-style crawls)', () => {
// Indexed project exists below, but cwd has no manifest, so the down-scan is skipped.
mkIndexed(path.join(tmp, 'some', 'project'));
const plan = planFrontload(tmp, 'how does it work');
expect(plan.exploreRoot).toBeNull();
expect(plan.nudgeProjects).toEqual([]);
});
it('nothing indexed anywhere → no-op', () => {
mkWorkspaceRoot(tmp);
fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true });
const plan = planFrontload(tmp, 'how does it work');
expect(plan.exploreRoot).toBeNull();
expect(plan.nudgeProjects).toEqual([]);
});
});
describe('findIndexedSubprojectRoots', () => {
let tmp: string;
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-subscan-'))); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('finds indexed projects a couple levels down and skips node_modules/.git', () => {
mkIndexed(path.join(tmp, 'packages', 'api'));
mkIndexed(path.join(tmp, 'services', 'auth'));
// Decoys that must NOT be scanned into.
mkIndexed(path.join(tmp, 'node_modules', 'dep'));
mkIndexed(path.join(tmp, '.git', 'x'));
const found = findIndexedSubprojectRoots(tmp).map((p) => path.relative(tmp, p)).sort();
expect(found).toEqual([path.join('packages', 'api'), path.join('services', 'auth')].sort());
});
it('does not descend INTO an indexed project (a project\'s sub-dirs are not separate projects)', () => {
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
mkIndexed(path.join(api, 'submodule')); // nested index under an already-indexed project
const found = findIndexedSubprojectRoots(tmp);
expect(found).toEqual([api]);
});
it('respects the depth bound', () => {
mkIndexed(path.join(tmp, 'a', 'b', 'c', 'd', 'e', 'deep'));
expect(findIndexedSubprojectRoots(tmp, { maxDepth: 2 })).toEqual([]);
});
});
describe('hasStructuralKeyword — keyword signal fires the hook directly (#994)', () => {
it('English keywords match with word boundaries so "flow" ≠ "flower"', () => {
expect(hasStructuralKeyword('how does article publish work')).toBe(true);
expect(hasStructuralKeyword('where is the token validated')).toBe(true);
expect(hasStructuralKeyword('trace the request flow')).toBe(true);
expect(hasStructuralKeyword('what calls parseToken')).toBe(true);
expect(hasStructuralKeyword('water the flower')).toBe(false); // "flow" in "flower"
});
it('Chinese keywords match WITHOUT `\\b` — the #994 fix (were silently dropped)', () => {
expect(hasStructuralKeyword('介绍文章发布流程')).toBe(true); // introduce / flow
expect(hasStructuralKeyword('登录是如何实现的')).toBe(true); // how / implement
expect(hasStructuralKeyword('这个函数的调用链')).toBe(true); // call (chain)
expect(hasStructuralKeyword('支付模块依赖哪些服务')).toBe(true); // depend
expect(hasStructuralKeyword('修复这个拼写错误')).toBe(false); // "fix this typo"
});
it('a bare code-token is NOT a keyword — it needs graph verification', () => {
expect(hasStructuralKeyword('看看 get_user 这段逻辑')).toBe(false);
expect(hasStructuralKeyword('I really love JavaScript')).toBe(false);
});
});
describe('hasStructuralKeyword — Latin-script languages, Cyrillic, JA/KO (#1126)', () => {
it('French structural prompts fire — including the prompts from the report', () => {
expect(hasStructuralKeyword('comment marche la state machine des commandes ?')).toBe(true);
expect(hasStructuralKeyword("explique l'architecture du module de stock")).toBe(true);
expect(hasStructuralKeyword('qui appelle cette fonction de parsing ?')).toBe(true);
expect(hasStructuralKeyword('de quoi dépend le module de paiement ?')).toBe(true);
});
it('accented keyword edges match — ASCII `\\b` could never bound "où"', () => {
expect(hasStructuralKeyword('où est validé le token ?')).toBe(true);
expect(hasStructuralKeyword("d'où vient cette valeur ?")).toBe(true);
});
it('Spanish / Portuguese / German / Italian fire', () => {
expect(hasStructuralKeyword('¿cómo funciona la máquina de estados de pedidos?')).toBe(true);
expect(hasStructuralKeyword('¿qué rompe este cambio?')).toBe(true);
expect(hasStructuralKeyword('como funciona a máquina de estados dos pedidos?')).toBe(true);
expect(hasStructuralKeyword('qual é a arquitetura do módulo de estoque?')).toBe(true);
expect(hasStructuralKeyword('wie funktioniert die Zustandsmaschine für Bestellungen?')).toBe(true);
expect(hasStructuralKeyword('wovon hängt das Zahlungsmodul ab?')).toBe(true);
expect(hasStructuralKeyword('come funziona la macchina a stati degli ordini?')).toBe(true);
expect(hasStructuralKeyword('spiegami la struttura del modulo ordini')).toBe(true);
});
it('Russian / Japanese / Korean / traditional Chinese fire', () => {
expect(hasStructuralKeyword('как работает конечный автомат заказов?')).toBe(true);
expect(hasStructuralKeyword('от чего зависит модуль оплаты?')).toBe(true);
expect(hasStructuralKeyword('注文のステートマシンの仕組みを説明して')).toBe(true);
expect(hasStructuralKeyword('この関数の呼び出しの流れは?')).toBe(true);
expect(hasStructuralKeyword('주문 상태 머신은 어떻게 작동하나요?')).toBe(true);
expect(hasStructuralKeyword('訂單狀態機的架構是怎麼實現的?')).toBe(true);
});
it('English derived forms fire — "architecture"/"dependencies" failed the old exact-word list', () => {
expect(hasStructuralKeyword('explain the architecture of the stock module')).toBe(true);
expect(hasStructuralKeyword('what are the dependencies of the parser?')).toBe(true);
});
it('second-tier languages fire — VI/TR/ID/PL/UA/NL/CS/RO/HU/EL/SV/NO/FI/HI', () => {
expect(hasStructuralKeyword('state machine của đơn hàng hoạt động thế nào?')).toBe(true); // Vietnamese
expect(hasStructuralKeyword('sipariş durum makinesi nasıl çalışıyor?')).toBe(true); // Turkish
expect(hasStructuralKeyword('bu fonksiyonun bağımlılıkları neler?')).toBe(true); // Turkish (stem)
expect(hasStructuralKeyword('bagaimana cara kerja mesin status pesanan?')).toBe(true); // Indonesian
expect(hasStructuralKeyword('jak działa maszyna stanów zamówień?')).toBe(true); // Polish
expect(hasStructuralKeyword('co wywołuje tę funkcję?')).toBe(true); // Polish (stem)
expect(hasStructuralKeyword('як працює кінцевий автомат замовлень?')).toBe(true); // Ukrainian
expect(hasStructuralKeyword('від чого залежить модуль оплати?')).toBe(true); // Ukrainian (stem)
expect(hasStructuralKeyword('hoe werkt de state machine van bestellingen?')).toBe(true); // Dutch
expect(hasStructuralKeyword('jak funguje stavový automat objednávek?')).toBe(true); // Czech
expect(hasStructuralKeyword('cum funcționează mașina de stări a comenzilor?')).toBe(true); // Romanian
expect(hasStructuralKeyword('hogyan működik a rendelések állapotgépe?')).toBe(true); // Hungarian
expect(hasStructuralKeyword('πώς λειτουργεί η μηχανή καταστάσεων παραγγελιών;')).toBe(true); // Greek
expect(hasStructuralKeyword('hur fungerar orderns tillståndsmaskin?')).toBe(true); // Swedish
expect(hasStructuralKeyword('hvordan fungerer ordrenes tilstandsmaskin?')).toBe(true); // Norwegian/Danish
expect(hasStructuralKeyword('miten tilausten tilakone toimii?')).toBe(true); // Finnish
expect(hasStructuralKeyword('ऑर्डर स्टेट मशीन कैसे काम करती है?')).toBe(true); // Hindi
});
it('RTL scripts and Thai fire — proclitics/unsegmented text uses substring matching', () => {
expect(hasStructuralKeyword('كيف تعمل آلة حالات الطلبات؟')).toBe(true); // Arabic
expect(hasStructuralKeyword('وكيف يعتمد هذا على قاعدة البيانات؟')).toBe(true); // Arabic, proclitic و attached
expect(hasStructuralKeyword('ماشین وضعیت سفارش‌ها چگونه کار می‌کند؟')).toBe(true); // Farsi
expect(hasStructuralKeyword('איך עובדת מכונת המצבים של ההזמנות?')).toBe(true); // Hebrew
expect(hasStructuralKeyword('สถาปัตยกรรมของระบบทำงานอย่างไร')).toBe(true); // Thai
});
it('terms that collide with English or code words are deliberately excluded', () => {
expect(hasStructuralKeyword('pad the buffer with zeros')).toBe(false); // NL pad=path skipped
expect(hasStructuralKeyword('declare a var for the count')).toBe(false); // SV var=where skipped
expect(hasStructuralKeyword('refresh the token')).toBe(false); // CS tok=flow skipped
expect(hasStructuralKeyword('run the llama model locally')).toBe(false); // ES bare llama skipped
expect(hasStructuralKeyword('come back to this later')).toBe(false); // IT bare come skipped
});
it('stems match only at word start — no mid-word false positives', () => {
expect(hasStructuralKeyword('restructure this paragraph')).toBe(false); // "structur" mid-word
expect(hasStructuralKeyword('an independent module')).toBe(false); // "depend" mid-word
expect(hasStructuralKeyword('water the flower')).toBe(false); // unchanged guarantee
});
it('bounded stems reject ordinary-English completions (#1138)', () => {
expect(hasStructuralKeyword('he has a callus on his palm')).toBe(false);
expect(hasStructuralKeyword('a lovely calligraphy font')).toBe(false);
expect(hasStructuralKeyword('Connecticut is a state')).toBe(false);
expect(hasStructuralKeyword('connective tissue damage')).toBe(false);
expect(hasStructuralKeyword('she is very affectionate')).toBe(false);
expect(hasStructuralKeyword('Tracey went home early')).toBe(false);
});
it('bounded stems keep every structural derived form (#1138)', () => {
// call
expect(hasStructuralKeyword('list the callers of parseToken')).toBe(true);
expect(hasStructuralKeyword('what callbacks fire on save')).toBe(true);
expect(hasStructuralKeyword('is submitOrder callable from the worker')).toBe(true);
expect(hasStructuralKeyword('find every call site of dispose')).toBe(true);
expect(hasStructuralKeyword('who called setupRouter')).toBe(true);
// trace ("tracing" is covered by the exact-word list — the e drops)
expect(hasStructuralKeyword('trace the request')).toBe(true);
expect(hasStructuralKeyword('we traced it to the cache layer')).toBe(true);
expect(hasStructuralKeyword('add tracing to the pipeline')).toBe(true);
// affect / connect
expect(hasStructuralKeyword('which modules are affected by this change')).toBe(true);
expect(hasStructuralKeyword('how do the connections get pooled')).toBe(true);
expect(hasStructuralKeyword('the connector registers itself at boot')).toBe(true);
});
it('non-structural prose stays a no-op in every covered language', () => {
expect(hasStructuralKeyword('corrige cette faute de frappe')).toBe(false); // FR "fix this typo"
expect(hasStructuralKeyword('arregla este error tipográfico')).toBe(false); // ES
expect(hasStructuralKeyword('behebe diesen Tippfehler')).toBe(false); // DE
expect(hasStructuralKeyword('исправь эту опечатку')).toBe(false); // RU
expect(hasStructuralKeyword('このタイプミスを直して')).toBe(false); // JA
expect(hasStructuralKeyword('이 오타를 수정해줘')).toBe(false); // KO
expect(hasStructuralKeyword('sửa lỗi chính tả này')).toBe(false); // VI
expect(hasStructuralKeyword('bu yazım hatasını düzelt')).toBe(false); // TR
expect(hasStructuralKeyword('popraw tę literówkę')).toBe(false); // PL
expect(hasStructuralKeyword('صحح هذا الخطأ الإملائي')).toBe(false); // AR
});
});
describe('extractCodeTokens — candidate symbols the hook verifies against the graph', () => {
it('pulls camelCase / PascalCase / snake_case / call / member tokens', () => {
expect(extractCodeTokens('prepareArticlePublish 的调用链')).toContain('prepareArticlePublish');
expect(extractCodeTokens('看看 get_user 这段逻辑')).toContain('get_user'); // snake_case
expect(extractCodeTokens('render() 在哪触发')).toContain('render'); // call form
expect(extractCodeTokens('user.login 做了什么').sort()).toEqual(['login', 'user']); // member access
expect(extractCodeTokens('看看 UserService')).toContain('UserService'); // PascalCase class kept
});
it('a tech brand is extracted as a CANDIDATE — the hooks graph check is what rejects it', () => {
// This is the #994 follow-up: "JavaScript" is identifier-shaped, so it surfaces
// here as a candidate; the hook only fires if it's a real symbol in the index.
expect(extractCodeTokens('I really love JavaScript')).toEqual(['JavaScript']);
expect(extractCodeTokens('thoughts on GitHub vs GitLab').sort()).toEqual(['GitHub', 'GitLab']);
});
it('ordinary prose and doc/data filenames yield no tokens', () => {
expect(extractCodeTokens('fix typo in readme')).toEqual([]);
expect(extractCodeTokens('fix the typo in README.md')).toEqual([]); // doc filename excluded
expect(extractCodeTokens('bump the version in package.json')).toEqual([]);
expect(extractCodeTokens('water the flower')).toEqual([]);
});
});
describe('isStructuralPrompt — cheap candidate gate (keyword OR code-token)', () => {
it('fires on a keyword prompt in any language', () => {
expect(isStructuralPrompt('how does article publish work')).toBe(true);
expect(isStructuralPrompt('介绍文章发布流程')).toBe(true);
});
it('fires on a code-token prompt with no keyword', () => {
expect(isStructuralPrompt('看看 get_user 这段逻辑')).toBe(true);
expect(isStructuralPrompt('where is prepareArticlePublish 定义')).toBe(true);
expect(isStructuralPrompt('user.login 做了什么')).toBe(true);
});
it('a tech brand passes the CHEAP gate as a candidate — the hook then graph-verifies it', () => {
// Layering, not a bug: isStructuralPrompt is shape-only, so a token-shaped brand
// is a candidate here; the hook rejects it as a non-symbol (proven by the CLI e2e).
expect(isStructuralPrompt('I really love JavaScript')).toBe(true);
});
it('non-structural prose stays a no-op — in either language', () => {
expect(isStructuralPrompt('fix typo in readme')).toBe(false);
expect(isStructuralPrompt('修复这个拼写错误')).toBe(false);
expect(isStructuralPrompt('water the flower')).toBe(false);
expect(isStructuralPrompt('')).toBe(false);
});
});
+790
View File
@@ -0,0 +1,790 @@
/**
* Function-as-value capture tests (#756) — registration-linking for callbacks.
*
* A function name used as a VALUE (passed as an argument, assigned to a
* field/function pointer, placed in a struct/object initializer or function
* table) must produce a `references` edge from the registration site to the
* function, so `callers`/`impact` surface where a callback is wired up.
*
* Safety properties verified here, per the dynamic-dispatch discipline
* ("a wrong edge is worse than none"):
* - decoy: an ambiguous cross-file name (no import, ≥2 definitions) → NO edge
* - same-file priority: a same-file definition beats a same-named decoy
* - kind filter: a class/variable passed as a value never gets a
* function-ref edge
* - self: a function passing itself → no self-loop
* - drain: all resolvable function_ref rows leave unresolved_refs (no
* batched-resolver runaway), and re-index is idempotent
*/
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import type { Edge } from '../src/types';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});
/** Incoming edges to `name`'s node that came from function-as-value capture. */
function fnRefEdgesInto(cg: CodeGraph, name: string): Edge[] {
const targets = cg.getNodesByName(name);
const edges: Edge[] = [];
for (const t of targets) {
for (const e of cg.getIncomingEdges(t.id)) {
if (e.kind === 'references' && e.metadata?.fnRef === true) {
edges.push(e);
}
}
}
return edges;
}
/** Names of the source nodes of the given edges, sorted. */
function sourceNames(cg: CodeGraph, edges: Edge[]): string[] {
const names: string[] = [];
for (const e of edges) {
const n = cg.getNode(e.source);
if (n) names.push(n.name);
}
return names.sort();
}
describe('Function-as-value capture (#756)', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('C: registration sites produce references edges (the #756 scenario)', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-c-'));
fs.writeFileSync(
path.join(tmpDir, 'driver.c'),
[
'struct ops { void (*recv_cb)(int); void (*send_cb)(int); };',
'typedef void (*cb_t)(int);',
'',
'static void my_recv_cb(int x) { (void)x; }',
'static void my_send_cb(int x) { (void)x; }',
'',
'void register_handler(void (*cb)(int)) { cb(1); }',
'',
'void direct_caller(void) { my_recv_cb(5); }',
'',
'void arg_registrar(void) { register_handler(my_recv_cb); }',
'void addr_registrar(void) { register_handler(&my_recv_cb); }',
'void assign_registrar(struct ops *o) { o->recv_cb = my_recv_cb; }',
'',
'static struct ops global_ops = { .recv_cb = my_recv_cb, .send_cb = my_send_cb };',
'static cb_t cb_table[] = { my_recv_cb, my_send_cb };',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const intoRecv = fnRefEdgesInto(cg, 'my_recv_cb');
expect(sourceNames(cg, intoRecv)).toEqual([
'addr_registrar',
'arg_registrar',
'assign_registrar',
'driver.c', // file-scope: designated init + positional table (deduped per source)
]);
// The direct call is still a `calls` edge — unchanged by this feature.
const recv = cg.getNodesByName('my_recv_cb')[0]!;
const callEdges = cg
.getIncomingEdges(recv.id)
.filter((e) => e.kind === 'calls');
expect(sourceNames(cg, callEdges)).toEqual(['direct_caller']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('TypeScript: arg / object / array / member / assignment forms', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ts-'));
fs.writeFileSync(
path.join(tmpDir, 'main.ts'),
[
'export function targetCb(x: number): void { console.log(x); }',
'function registerHandler(cb: (x: number) => void): void { cb(1); }',
'',
'export function argRegistrar(): void { registerHandler(targetCb); }',
'export function timerRegistrar(): void { setTimeout(targetCb, 100); }',
'export function objRegistrar(): unknown { return { recv: targetCb }; }',
'export function arrRegistrar(): unknown { return [targetCb]; }',
'',
'class Emitter { cb: ((x: number) => void) | null = null; }',
'export function assignRegistrar(e: Emitter): void { e.cb = targetCb; }',
'',
'interface Btn { on(ev: string, cb: () => void): void; }',
'export class Comp {',
' handleClick(): void {}',
' wire(btn: Btn): void { btn.on("click", this.handleClick); }',
'}',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
expect(sourceNames(cg, fnRefEdgesInto(cg, 'targetCb'))).toEqual([
'argRegistrar',
'arrRegistrar',
'assignRegistrar',
'objRegistrar',
'timerRegistrar',
]);
// `this.handleClick` resolves class-scoped (#808): the target must be a
// method of the ENCLOSING class, in the same file.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'handleClick'))).toEqual(['wire']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('resolves an imported callback across files via its import', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-import-'));
fs.writeFileSync(
path.join(tmpDir, 'handlers.ts'),
'export function onMessage(x: number): void { console.log(x); }\n'
);
fs.writeFileSync(
path.join(tmpDir, 'wiring.ts'),
[
"import { onMessage } from './handlers';",
'export function wire(bus: { on(cb: (x: number) => void): void }): void {',
' bus.on(onMessage);',
'}',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const edges = fnRefEdgesInto(cg, 'onMessage');
expect(sourceNames(cg, edges)).toContain('wire');
// The edge must target the handlers.ts definition.
const target = cg.getNode(edges[0]!.target);
expect(target?.filePath.endsWith('handlers.ts')).toBe(true);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('DECOY: ambiguous cross-file name without an import resolves to NO edge', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-decoy-'));
// Two same-named functions in different files…
fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export function process(x: number): void {}\n');
fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'export function process(x: number): void {}\n');
// …and a registrar that names `process` WITHOUT importing it. The name
// still passes the extraction gate only if imported/defined here — it is
// neither, so this asserts the gate; even if it leaked through, the
// ambiguity rule (unique-only cross-file) must yield no edge.
fs.writeFileSync(
path.join(tmpDir, 'c.ts'),
'export function wire(bus: { on(cb: unknown): void }, process: unknown): void { bus.on(process); }\n'
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const edges = fnRefEdgesInto(cg, 'process');
expect(sourceNames(cg, edges)).not.toContain('wire');
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('SAME-FILE PRIORITY: a same-file definition beats a same-named decoy elsewhere', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-samefile-'));
fs.writeFileSync(path.join(tmpDir, 'decoy.c'), 'void my_cb(int x) { (void)x; }\n');
fs.writeFileSync(
path.join(tmpDir, 'real.c'),
[
'static void my_cb(int x) { (void)x; }',
'void register_handler(void (*cb)(int)) { cb(1); }',
'void wire(void) { register_handler(my_cb); }',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const wires = fnRefEdgesInto(cg, 'my_cb').filter((e) => {
const src = cg.getNode(e.source);
return src?.name === 'wire';
});
expect(wires).toHaveLength(1);
const target = cg.getNode(wires[0]!.target);
expect(target?.filePath.endsWith('real.c')).toBe(true);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('KIND FILTER: a class passed as a value gets no function-ref edge', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-kind-'));
fs.writeFileSync(
path.join(tmpDir, 'main.ts'),
[
'export class Strategy { run(): void {} }',
'export function consume(x: unknown): void { void x; }',
'export function wire(): void { consume(Strategy); }',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const strategy = cg.getNodesByName('Strategy').find((n) => n.kind === 'class')!;
const fnRef = cg
.getIncomingEdges(strategy.id)
.filter((e) => e.metadata?.fnRef === true);
expect(fnRef).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('SELF: a function registering itself produces no self-loop', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-self-'));
fs.writeFileSync(
path.join(tmpDir, 'main.ts'),
[
'declare function schedule(cb: () => void): void;',
'export function retry(): void { schedule(retry); }',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const retry = cg.getNodesByName('retry')[0]!;
const selfLoops = cg
.getIncomingEdges(retry.id)
.filter((e) => e.source === retry.id && e.metadata?.fnRef === true);
expect(selfLoops).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('C++: &Cls::method member pointers resolve scoped; bare ids are free-function-only', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-cpp-'));
fs.writeFileSync(
path.join(tmpDir, 'widget.cpp'),
[
'struct Widget {',
' void on_click(int x);',
'};',
'void Widget::on_click(int x) { (void)x; }',
'struct Decoy {',
' void on_click(int x);',
'};',
'void Decoy::on_click(int x) { (void)x; }',
'void free_cb(int x) { (void)x; }',
'void bare_fn(int x) { (void)x; }',
'void reg(void* p) { (void)p; }',
'void wire() {',
' auto p = &Widget::on_click;', // qualified — must hit Widget, not Decoy
' reg(p);',
' reg(&free_cb);', // explicit address-of — captured
' reg(bare_fn);', // bare id in args — NOT captured for C++ (addressOfOnly)
'}',
// A method named like a local: passing the LOCAL must not resolve to
// the method (cpp args accept only explicit & forms).
'struct Buf { char* out(); };',
'void copy_to(void* out_) { (void)out_; }',
'void caller(char* out) { copy_to(out); }',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
// Qualified member pointer resolves to Widget::on_click specifically.
const onClicks = cg.getNodesByName('on_click');
const widgetOnClick = onClicks.find((n) => n.qualifiedName.includes('Widget'))!;
const decoyOnClick = onClicks.find((n) => n.qualifiedName.includes('Decoy'))!;
const intoWidget = cg
.getIncomingEdges(widgetOnClick.id)
.filter((e) => e.metadata?.fnRef === true);
expect(intoWidget).toHaveLength(1);
expect(cg.getNode(intoWidget[0]!.source)?.name).toBe('wire');
expect(
cg.getIncomingEdges(decoyOnClick.id).filter((e) => e.metadata?.fnRef === true)
).toHaveLength(0);
// Explicit &fn resolves; bare identifier in C++ args does NOT (the
// generic-name collision class: fmt's `begin`/`out`/`size` params).
expect(sourceNames(cg, fnRefEdgesInto(cg, 'free_cb'))).toContain('wire');
expect(fnRefEdgesInto(cg, 'bare_fn')).toHaveLength(0);
// The local `out` param must NOT produce an edge to Buf::out.
const outMethod = cg.getNodesByName('out').find((n) => n.kind === 'method');
if (outMethod) {
expect(
cg.getIncomingEdges(outMethod.id).filter((e) => e.metadata?.fnRef === true)
).toHaveLength(0);
}
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('Pascal: := event wiring, @addr and bare args', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pas-'));
fs.writeFileSync(
path.join(tmpDir, 'main.pas'),
[
'unit Main;',
'interface',
'type',
' TCallback = procedure(X: Integer);',
' THolder = class',
' public',
' OnFire: TCallback;',
' procedure Wire;',
' end;',
'procedure TargetCb(X: Integer);',
'procedure RegisterHandler(Cb: TCallback);',
'procedure ArgRegistrar;',
'procedure AddrRegistrar;',
'implementation',
'procedure TargetCb(X: Integer);',
'begin',
' WriteLn(X);',
'end;',
'procedure RegisterHandler(Cb: TCallback);',
'begin',
' Cb(1);',
'end;',
'procedure ArgRegistrar;',
'begin',
' RegisterHandler(TargetCb);',
'end;',
'procedure AddrRegistrar;',
'begin',
' RegisterHandler(@TargetCb);',
'end;',
'procedure THolder.Wire;',
'begin',
' OnFire := TargetCb;',
'end;',
'end.',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
expect(sourceNames(cg, fnRefEdgesInto(cg, 'TargetCb'))).toEqual([
'AddrRegistrar',
'ArgRegistrar',
'Wire',
]);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('THIS-MEMBER SCOPING: this.X resolves only to the enclosing class, never elsewhere', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-thisx-'));
fs.writeFileSync(
path.join(tmpDir, 'main.ts'),
[
'declare const bus: { on(ev: string, cb: () => void): void };',
// Decoy: a same-named method on an UNRELATED class.
'export class Decoy { refresh(): void {} }',
'export class Panel {',
' views: number[] = [];', // property (post-#808), shares no name
' refresh(): void {}',
' wire(): void {',
' bus.on("update", this.refresh);', // → Panel::refresh, not Decoy::refresh
' bus.on("data", this.views as never);', // property → NO edge
' bus.on("gone", this.missing as never);', // unknown member → NO edge
' }',
'}',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const refreshes = cg.getNodesByName('refresh');
const panelRefresh = refreshes.find((n) => n.qualifiedName.includes('Panel'))!;
const decoyRefresh = refreshes.find((n) => n.qualifiedName.includes('Decoy'))!;
const intoPanel = cg
.getIncomingEdges(panelRefresh.id)
.filter((e) => e.metadata?.fnRef === true);
expect(intoPanel).toHaveLength(1);
expect(cg.getNode(intoPanel[0]!.source)?.name).toBe('wire');
expect(
cg.getIncomingEdges(decoyRefresh.id).filter((e) => e.metadata?.fnRef === true)
).toHaveLength(0);
// The property and the unknown member produce nothing.
const views = cg.getNodesByName('views').find((n) => n.kind === 'property');
if (views) {
expect(
cg.getIncomingEdges(views.id).filter((e) => e.metadata?.fnRef === true)
).toHaveLength(0);
}
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('INHERITED this.X: resolves on a supertype via the second pass, never on unrelated classes', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-inherit-'));
fs.writeFileSync(
path.join(tmpDir, 'base.ts'),
'export class FormBase { handleSubmit(): void {} }\n'
);
fs.writeFileSync(
path.join(tmpDir, 'unrelated.ts'),
'export class Unrelated { handleSubmit(): void {} }\n'
);
fs.writeFileSync(
path.join(tmpDir, 'login.ts'),
[
"import { FormBase } from './base';",
'declare const bus: { on(ev: string, cb: () => void): void };',
'export class LoginForm extends FormBase {',
' wire(): void { bus.on("submit", this.handleSubmit); }',
'}',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const handleSubmits = cg.getNodesByName('handleSubmit');
const baseM = handleSubmits.find((n) => n.qualifiedName.includes('FormBase'))!;
const unrelatedM = handleSubmits.find((n) => n.qualifiedName.includes('Unrelated'))!;
const intoBase = cg.getIncomingEdges(baseM.id).filter((e) => e.metadata?.fnRef === true);
expect(intoBase).toHaveLength(1);
expect(cg.getNode(intoBase[0]!.source)?.name).toBe('wire');
expect(
cg.getIncomingEdges(unrelatedM.id).filter((e) => e.metadata?.fnRef === true)
).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('JAVA: Type::method cross-file, this::/super:: scoped, variable:: yields nothing', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-java-'));
fs.writeFileSync(
path.join(tmpDir, 'Handlers.java'),
[
'package com.example;',
'public class Handlers {',
' public static void onMessage(int x) { System.out.println(x); }',
'}',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, 'BaseForm.java'),
['package com.example;', 'public class BaseForm {', ' void baseHandler(int x) {}', '}'].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, 'Main.java'),
[
'package com.example;',
'import com.example.Handlers;',
'import java.util.function.IntConsumer;',
'public class Main extends BaseForm {',
' static void registerHandler(IntConsumer cb) { cb.accept(1); }',
' void run0() {}',
' void crossFile() { registerHandler(Handlers::onMessage); }',
' void thisRef() { registerHandler(this::run0); }',
' void superRef() { registerHandler(super::baseHandler); }',
' void varRef(Main m) { registerHandler(m::run0); }',
'}',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
expect(sourceNames(cg, fnRefEdgesInto(cg, 'onMessage'))).toEqual(['crossFile']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'baseHandler'))).toEqual(['superRef']);
// this::run0 resolves class-scoped; m::run0 (variable receiver) must NOT
// add a second edge — exactly one source.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'run0'))).toEqual(['thisRef']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('KOTLIN: companion-object refs resolve cross-file without imports; decoy companion untouched', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ktcomp-'));
// Same package, no imports — the Java/Kotlin reality the name gate can't
// see, which is why qualified `Type::member` candidates skip it.
fs.writeFileSync(
path.join(tmpDir, 'Handlers.kt'),
[
'class KtHandlers {',
' companion object {',
' fun handle(x: Int) {}',
' }',
'}',
'class Decoy {',
' companion object {',
' fun handle(x: Int) {}',
' }',
'}',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, 'Wirer.kt'),
[
'fun register(cb: Any) {}',
'class Wirer {',
' fun wire() { register(KtHandlers::handle) }',
'}',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const handles = cg.getNodesByName('handle');
const target = handles.find((n) => n.qualifiedName.includes('KtHandlers'))!;
const decoy = handles.find((n) => n.qualifiedName.includes('Decoy'))!;
const into = cg.getIncomingEdges(target.id).filter((e) => e.metadata?.fnRef === true);
expect(into).toHaveLength(1);
expect(cg.getNode(into[0]!.source)?.name).toBe('wire');
expect(cg.getIncomingEdges(decoy.id).filter((e) => e.metadata?.fnRef === true)).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('SWIFT SCOPING: bare ids hit only the enclosing types methods; top-level bare hits functions only', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-swiftscope-'));
fs.writeFileSync(
path.join(tmpDir, 'main.swift'),
[
'func register(_ cb: (Int) -> Void) { cb(1) }',
'class Monitor {',
' func report(_ x: Int) {}',
' func wire() { register(report) }', // implicit self → Monitor::report
'}',
'class Other {',
// `report` here is a PARAMETER; Monitor::report must not win.
' func use(report: (Int) -> Void) { register(report) }',
'}',
'func topLevel() { register(report) }', // no implicit self → no method target
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const edges = fnRefEdgesInto(cg, 'report');
expect(sourceNames(cg, edges)).toEqual(['wire']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-'));
// Handler defined in its own file…
fs.writeFileSync(path.join(tmpDir, 't_string.c'), 'void getCommand(int c) { (void)c; }\n');
// …and registered in a table in ANOTHER file, with no import mechanism (C).
fs.writeFileSync(
path.join(tmpDir, 'server.c'),
[
'struct cmd { const char *name; void (*proc)(int); };',
'static struct cmd commandTable[] = {',
' { "get", getCommand },',
'};',
].join('\n')
);
// Ambiguity safety: two files define dupCmd; a third table references it →
// NO edge (unique-or-drop).
fs.writeFileSync(path.join(tmpDir, 'dup_a.c'), 'void dupCmd(int c) { (void)c; }\n');
fs.writeFileSync(path.join(tmpDir, 'dup_b.c'), 'void dupCmd(int c) { (void)c; }\n');
fs.writeFileSync(
path.join(tmpDir, 'other.c'),
[
'struct cmd2 { void (*proc)(int); };',
'static struct cmd2 otherTable[] = { { dupCmd } };',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
// Cross-file unique handler resolves from the table's file.
const intoGet = fnRefEdgesInto(cg, 'getCommand');
expect(sourceNames(cg, intoGet)).toEqual(['server.c']);
const target = cg.getNode(intoGet[0]!.target);
expect(target?.filePath.endsWith('t_string.c')).toBe(true);
// Ambiguous handler resolves to NOTHING — silent beats wrong.
expect(fnRefEdgesInto(cg, 'dupCmd')).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('PHP: HOF string callables, [$this,…] and [Cls::class,…] arrays; non-HOF strings ignored', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-php-'));
fs.writeFileSync(
path.join(tmpDir, 'handlers.php'),
"<?php\nfunction cmp_items($a, $b) { return $a <=> $b; }\n"
);
fs.writeFileSync(
path.join(tmpDir, 'main.php'),
[
'<?php',
'class Saver {',
' public function onSave($x) {}',
' public function wire() {',
" register_shutdown_function([$this, 'onSave']);",
' }',
'}',
'class Loader {',
' public static function load($cls) {}',
'}',
'function sorter($items) {',
" usort($items, 'cmp_items');", // known HOF, cross-file string → edge
" spl_autoload_register([Loader::class, 'load']);",
" some_random_fn('cmp_items');", // NOT a known HOF → no edge
' return $items;',
'}',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
// Exactly ONE source for cmp_items: the usort site, not some_random_fn.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'cmp_items'))).toEqual(['sorter']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'onSave'))).toEqual(['wire']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'load'))).toEqual(['sorter']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('RUBY HOOKS: before_action/rescue_from symbols resolve class-scoped incl. inherited; validates is excluded', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-rubyhooks-'));
fs.writeFileSync(
path.join(tmpDir, 'posts_controller.rb'),
[
'class ApplicationController',
' def authenticate; end',
'end',
'',
'class PostsController < ApplicationController',
' before_action :authenticate', // inherited → ApplicationController
' after_save :reindex',
' validates :title, presence: true', // attributes, NOT methods → no edge
' rescue_from StandardError, with: :render_500',
'',
' def reindex; end',
' def render_500; end',
' def title; end',
'end',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const auth = fnRefEdgesInto(cg, 'authenticate');
expect(auth).toHaveLength(1);
expect(cg.getNode(auth[0]!.target)?.qualifiedName).toContain('ApplicationController');
expect(fnRefEdgesInto(cg, 'reindex')).toHaveLength(1);
expect(fnRefEdgesInto(cg, 'render_500')).toHaveLength(1);
// `validates :title` names an attribute — the same-named METHOD must
// get no registration edge.
expect(fnRefEdgesInto(cg, 'title')).toHaveLength(0);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-'));
fs.writeFileSync(
path.join(tmpDir, 'main.c'),
[
'static void cb_a(int x) { (void)x; }',
'void reg(void (*cb)(int)) { cb(1); }',
'void wire(void) { reg(cb_a); }',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const stats1 = cg.getStats();
// No function_ref rows may linger for resolvable names — the batched
// resolver must have drained them (delete keyed on the ORIGINAL stored
// ref; the #760 runaway came from violating that).
const db = (cg as unknown as { db: { prepare(sql: string): { all(): unknown[] } } }).db;
let leftover: unknown[] = [];
try {
leftover = db
.prepare("SELECT * FROM unresolved_refs WHERE reference_kind = 'function_ref'")
.all();
} catch {
// If internals aren't reachable this guard is covered by the edge
// assertions below.
}
expect(leftover).toHaveLength(0);
// Re-index: identical node/edge counts (idempotent, no accumulation).
await cg.indexAll();
const stats2 = cg.getStats();
expect(stats2.totalNodes).toBe(stats1.totalNodes);
expect(stats2.totalEdges).toBe(stats1.totalEdges);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'cb_a'))).toEqual(['wire']);
} finally {
cg.destroy();
tmpDir = undefined;
}
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* Regression coverage for the generated-file detector that drives
* symbol-disambiguation down-ranking. Locked here because the suffix
* list is a contract: if a future edit drops `.pb.go`, the cosmos-sdk
* trace endpoint regresses to the gRPC stub (see
* `project_go_multi_module_audit` memory + the audit in #N/A).
*/
import { describe, it, expect } from 'vitest';
import { isGeneratedFile } from '../src/extraction/generated-detection';
describe('isGeneratedFile', () => {
it('classifies Go protobuf / gRPC / pulsar / mock outputs as generated', () => {
expect(isGeneratedFile('api/cosmos/bank/v1beta1/tx_grpc.pb.go')).toBe(true);
expect(isGeneratedFile('x/bank/types/tx.pb.go')).toBe(true);
expect(isGeneratedFile('api/cosmos/bank/v1beta1/tx.pulsar.go')).toBe(true);
// cosmos-sdk uses `<base>_mocks.go`; mockgen's default is `mock_<src>.go`;
// many projects use `<base>_mock.go`. All three are mockgen output.
expect(isGeneratedFile('x/auth/testutil/expected_keepers_mocks.go')).toBe(true);
expect(isGeneratedFile('internal/foo_mock.go')).toBe(true);
expect(isGeneratedFile('mock_keeper.go')).toBe(true);
});
it('does not flag the hand-written keeper as generated', () => {
expect(isGeneratedFile('x/bank/keeper/msg_server.go')).toBe(false);
expect(isGeneratedFile('x/bank/keeper/send.go')).toBe(false);
});
it('catches common cross-language codegen suffixes', () => {
expect(isGeneratedFile('app/foo.generated.ts')).toBe(true);
expect(isGeneratedFile('app/foo.generated.tsx')).toBe(true);
expect(isGeneratedFile('proto/bar_pb2.py')).toBe(true);
expect(isGeneratedFile('proto/bar_pb2_grpc.py')).toBe(true);
expect(isGeneratedFile('lib/baz.pb.cc')).toBe(true);
expect(isGeneratedFile('lib/baz.pb.h')).toBe(true);
expect(isGeneratedFile('lib/quux.g.dart')).toBe(true);
expect(isGeneratedFile('lib/quux.freezed.dart')).toBe(true);
});
it('leaves ordinary source files alone', () => {
expect(isGeneratedFile('src/index.ts')).toBe(false);
expect(isGeneratedFile('src/components/Foo.tsx')).toBe(false);
expect(isGeneratedFile('lib/main.dart')).toBe(false);
expect(isGeneratedFile('cmd/server/main.go')).toBe(false);
expect(isGeneratedFile('app/db.py')).toBe(false);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
/**
* End-to-end synthesizer test for the gin middleware chain.
*
* `(*Context).Next` runs the handler chain by slice index
* (`c.handlers[c.index](c)`) — a computed dispatch tree-sitter can't resolve, so
* `callees(Next)` would otherwise dead-end at the `len()` helper. Handlers are
* registered via `.Use(...)` / `.GET("/path", h)`. Verify the synthesizer links
* `Next` → each registered NAMED HandlerFunc, captures the wiring site, and
* skips inline (anonymous) closures.
*/
describe('gin middleware-chain synthesizer', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gin-chain-fixture-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('links Context.Next to handlers registered via Use/GET and skips inline closures', async () => {
fs.writeFileSync(path.join(dir, 'go.mod'), 'module ginapp\n\ngo 1.21\n');
// gin-core shape: the dynamic-dispatch chain driver + registration surface.
fs.writeFileSync(
path.join(dir, 'gin.go'),
`package gin
type HandlerFunc func(*Context)
type HandlersChain []HandlerFunc
type Context struct {
handlers HandlersChain
index int8
}
func (c *Context) Next() {
c.index++
for c.index < int8(len(c.handlers)) {
c.handlers[c.index](c)
c.index++
}
}
type Engine struct {
Handlers HandlersChain
}
func (e *Engine) Use(middleware ...HandlerFunc) {
e.Handlers = append(e.Handlers, middleware...)
}
func (e *Engine) GET(path string, handlers ...HandlerFunc) {}
`
);
// registration site: named middleware + named route handler + an inline closure.
fs.writeFileSync(
path.join(dir, 'app.go'),
`package gin
func Logger(c *Context) {}
func Recovery(c *Context) {}
func getUser(c *Context) {}
func setup() {
e := &Engine{}
e.Use(Logger, Recovery)
e.GET("/users", getUser)
e.GET("/inline", func(c *Context) {})
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.name source_name, s.kind source_kind, t.name target_name,
json_extract(e.metadata,'$.via') via,
json_extract(e.metadata,'$.registeredAt') registeredAt
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'gin-middleware-chain'`
)
.all();
cg.close?.();
// Every edge originates from the chain dispatcher Context.Next.
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r: any) => r.source_name === 'Next' && r.source_kind === 'method')).toBe(true);
// Exactly the three NAMED handlers are linked — the inline closure (4th
// registration) is anonymous and must be skipped.
const targets = new Set(rows.map((r: any) => r.target_name));
expect(targets).toEqual(new Set(['Logger', 'Recovery', 'getUser']));
// The wiring site (`.Use`/`.GET` call) is surfaced for the agent.
const logger = rows.find((r: any) => r.target_name === 'Logger');
expect(logger.via).toBe('Logger');
expect(logger.registeredAt).toMatch(/app\.go:\d+/);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Git Sync Hooks Tests
*
* Covers installing/removing the opt-in commit/merge/checkout hooks that
* keep the index fresh when the live watcher is disabled (issue #199).
* Exercises real git repos in temp dirs — no mocking.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
installGitSyncHook,
removeGitSyncHook,
isSyncHookInstalled,
isGitRepo,
DEFAULT_SYNC_HOOKS,
} from '../src/sync/git-hooks';
function gitInit(dir: string): void {
execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' });
}
function isExecutable(file: string): boolean {
if (process.platform === 'win32') return true; // mode bits not meaningful
return (fs.statSync(file).mode & 0o111) !== 0;
}
describe('git sync hooks', () => {
let repo: string;
beforeEach(() => {
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-githooks-'));
});
afterEach(() => {
if (fs.existsSync(repo)) fs.rmSync(repo, { recursive: true, force: true });
});
it('installs all default hooks, executable, invoking codegraph sync', () => {
gitInit(repo);
const result = installGitSyncHook(repo);
expect(result.installed.sort()).toEqual([...DEFAULT_SYNC_HOOKS].sort());
expect(result.skipped).toBeUndefined();
for (const hook of DEFAULT_SYNC_HOOKS) {
const file = path.join(repo, '.git', 'hooks', hook);
expect(fs.existsSync(file)).toBe(true);
const body = fs.readFileSync(file, 'utf8');
expect(body).toContain('codegraph sync');
expect(body).toContain('command -v codegraph'); // no-op when not on PATH
expect(isExecutable(file)).toBe(true);
}
expect(isSyncHookInstalled(repo)).toBe(true);
});
it('is idempotent — re-install does not duplicate the block', () => {
gitInit(repo);
installGitSyncHook(repo);
installGitSyncHook(repo);
const body = fs.readFileSync(path.join(repo, '.git', 'hooks', 'post-commit'), 'utf8');
const occurrences = body.split('# >>> codegraph sync hook >>>').length - 1;
expect(occurrences).toBe(1);
});
it('preserves a pre-existing user hook and appends our block', () => {
gitInit(repo);
const file = path.join(repo, '.git', 'hooks', 'post-commit');
fs.writeFileSync(file, '#!/bin/sh\necho "my custom hook"\n', { mode: 0o755 });
installGitSyncHook(repo, ['post-commit']);
const body = fs.readFileSync(file, 'utf8');
expect(body).toContain('echo "my custom hook"');
expect(body).toContain('codegraph sync');
});
it('remove strips our block; deletes a hook that was only ours', () => {
gitInit(repo);
installGitSyncHook(repo, ['post-commit']);
const file = path.join(repo, '.git', 'hooks', 'post-commit');
expect(fs.existsSync(file)).toBe(true);
const result = removeGitSyncHook(repo, ['post-commit']);
expect(result.installed).toEqual(['post-commit']);
expect(fs.existsSync(file)).toBe(false); // was ours-only → deleted
expect(isSyncHookInstalled(repo)).toBe(false);
});
it('remove keeps user content when the hook is shared', () => {
gitInit(repo);
const file = path.join(repo, '.git', 'hooks', 'post-commit');
fs.writeFileSync(file, '#!/bin/sh\necho "keep me"\n', { mode: 0o755 });
installGitSyncHook(repo, ['post-commit']);
removeGitSyncHook(repo, ['post-commit']);
expect(fs.existsSync(file)).toBe(true);
const body = fs.readFileSync(file, 'utf8');
expect(body).toContain('echo "keep me"');
expect(body).not.toContain('codegraph sync');
});
it('honors core.hooksPath', () => {
gitInit(repo);
const customHooks = path.join(repo, '.husky');
fs.mkdirSync(customHooks);
execFileSync('git', ['config', 'core.hooksPath', '.husky'], { cwd: repo, stdio: 'ignore' });
const result = installGitSyncHook(repo, ['post-commit']);
expect(result.hooksDir).toBe(customHooks);
expect(fs.existsSync(path.join(customHooks, 'post-commit'))).toBe(true);
// The default .git/hooks dir should NOT have received the hook.
expect(fs.existsSync(path.join(repo, '.git', 'hooks', 'post-commit'))).toBe(false);
});
it('skips cleanly when not a git repository', () => {
expect(isGitRepo(repo)).toBe(false);
const result = installGitSyncHook(repo);
expect(result.installed).toEqual([]);
expect(result.hooksDir).toBeNull();
expect(result.skipped).toMatch(/not a git repository/);
expect(isSyncHookInstalled(repo)).toBe(false);
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* Glyph fallback / Unicode-support detection.
*
* Pinned because the matrix is small and the consequence of regression
* is highly visible: shimmer-worker output on Windows mojibakes when
* UTF-8 glyphs are written via `fs.writeSync` (see #168). The detection
* + ASCII fallback is the contract that prevents this.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
supportsUnicode,
getGlyphs,
UNICODE_GLYPHS,
ASCII_GLYPHS,
_resetGlyphsCache,
} from '../src/ui/glyphs';
function withEnv(patch: Record<string, string | undefined>, fn: () => void): void {
const saved: Record<string, string | undefined> = {};
const savedPlatform = process.platform;
for (const key of Object.keys(patch)) {
saved[key] = process.env[key];
if (patch[key] === undefined) delete process.env[key];
else process.env[key] = patch[key];
}
_resetGlyphsCache();
try {
fn();
} finally {
for (const key of Object.keys(saved)) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
Object.defineProperty(process, 'platform', { value: savedPlatform });
_resetGlyphsCache();
}
}
function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value });
}
describe('supportsUnicode', () => {
let originalPlatform: NodeJS.Platform;
beforeEach(() => {
originalPlatform = process.platform;
_resetGlyphsCache();
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
_resetGlyphsCache();
});
it('returns false on Windows by default (mojibake-prone consoles)', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
setPlatform('win32');
expect(supportsUnicode()).toBe(false);
});
});
it('returns true on macOS by default', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
setPlatform('darwin');
expect(supportsUnicode()).toBe(true);
});
});
it('returns true on Linux by default', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
setPlatform('linux');
expect(supportsUnicode()).toBe(true);
});
});
it('returns false on Linux kernel console (TERM=linux)', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: 'linux' }, () => {
setPlatform('linux');
expect(supportsUnicode()).toBe(false);
});
});
it('respects CODEGRAPH_UNICODE=1 on Windows (opt-in escape hatch)', () => {
withEnv({ CODEGRAPH_UNICODE: '1', CODEGRAPH_ASCII: undefined }, () => {
setPlatform('win32');
expect(supportsUnicode()).toBe(true);
});
});
it('respects CODEGRAPH_ASCII=1 on macOS (opt-out escape hatch)', () => {
withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('darwin');
expect(supportsUnicode()).toBe(false);
});
});
it('CODEGRAPH_ASCII takes precedence over CODEGRAPH_UNICODE', () => {
withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: '1' }, () => {
setPlatform('darwin');
expect(supportsUnicode()).toBe(false);
});
});
});
describe('getGlyphs', () => {
let originalPlatform: NodeJS.Platform;
beforeEach(() => {
originalPlatform = process.platform;
_resetGlyphsCache();
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
_resetGlyphsCache();
});
it('returns ASCII glyphs on Windows', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('win32');
const g = getGlyphs();
expect(g).toBe(ASCII_GLYPHS);
expect(g.ok).toBe('[OK]');
expect(g.rail).toBe('|');
expect(g.phaseDone).toBe('*');
expect(g.dash).toBe('-');
});
});
it('returns Unicode glyphs on macOS', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('darwin');
const g = getGlyphs();
expect(g).toBe(UNICODE_GLYPHS);
expect(g.ok).toBe('✓');
expect(g.rail).toBe('│');
expect(g.phaseDone).toBe('◆');
expect(g.dash).toBe('—');
});
});
it('caches the result so repeated calls return the same object', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('darwin');
expect(getGlyphs()).toBe(getGlyphs());
});
});
});
describe('Glyph sets', () => {
it('ASCII and Unicode sets cover the same keys', () => {
expect(Object.keys(ASCII_GLYPHS).sort()).toEqual(Object.keys(UNICODE_GLYPHS).sort());
});
it('ASCII glyphs are all 7-bit ASCII', () => {
for (const [key, value] of Object.entries(ASCII_GLYPHS)) {
const flat = Array.isArray(value) ? value.join('') : value;
for (let i = 0; i < flat.length; i++) {
const codepoint = flat.charCodeAt(i);
expect(codepoint, `ASCII_GLYPHS.${key} contains non-ASCII char U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`).toBeLessThan(128);
}
}
});
it('ASCII spinner has the same frame count as the Unicode spinner', () => {
expect(ASCII_GLYPHS.spinner.length).toBe(UNICODE_GLYPHS.spinner.length);
});
});
+181
View File
@@ -0,0 +1,181 @@
/**
* GoFrame route → controller-method coverage (#747), end to end.
*
* GoFrame binds routes reflectively, so the route declared in a request type's
* `g.Meta` tag has no static edge to the controller method that serves it, and
* the method name is NOT derivable from the request type (`DeptSearchReq` is
* served by `List`). This indexes a fixture through the full pipeline and
* checks: the `g.Meta` tags become route nodes; each route joins to its handler
* by the request type in the method signature (the naming-mismatch case
* included); a response (`mime`-only) `g.Meta` makes no route; a route with no
* handler is left unlinked (silent beats wrong); and the response type never
* produces a spurious edge.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('GoFrame route synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'goframe-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('joins each g.Meta route to its controller method by the request-type signature', async () => {
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/app\n\nrequire github.com/gogf/gf/v2 v2.7.0\n');
fs.mkdirSync(path.join(dir, 'api', 'system'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'api', 'system', 'dept.go'),
`package system
import "github.com/gogf/gf/v2/frame/g"
type DeptSearchReq struct {
g.Meta \`path:"/dept/list" tags:"Dept" method:"get" summary:"list"\`
DeptName string
}
type DeptSearchRes struct {
g.Meta \`mime:"application/json"\`
List []string
}
type DeptAddReq struct {
g.Meta \`path:"/dept/add" method:"post"\`
Name string
}
type DeptAddRes struct{}
// A declared route whose handler does not exist in this codebase.
type OrphanReq struct {
g.Meta \`path:"/orphan" method:"get"\`
}
type OrphanRes struct{}
`
);
fs.mkdirSync(path.join(dir, 'internal', 'controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'internal', 'controller', 'dept.go'),
`package controller
import (
"context"
"example.com/app/api/system"
)
type sysDeptController struct{}
// NB: method name (List) differs from the request type (DeptSearchReq) — the join
// must be by signature, not name.
func (c *sysDeptController) List(ctx context.Context, req *system.DeptSearchReq) (res *system.DeptSearchRes, err error) {
return helper(ctx)
}
func (c *sysDeptController) Add(ctx context.Context, req *system.DeptAddReq) (res *system.DeptAddRes, err error) {
return
}
// Returns the response type but takes no request type — must NOT be linked.
func helper(ctx context.Context) (res *system.DeptSearchRes, err error) {
return
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const routes = db.prepare(`SELECT name FROM nodes WHERE kind='route' ORDER BY name`).all();
const edges = db
.prepare(
`SELECT json_extract(e.metadata,'$.route') route, json_extract(e.metadata,'$.requestType') reqType,
e.kind, t.name target_name, t.kind target_kind
FROM edges e JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'goframe-route'
ORDER BY route`
)
.all();
cg.close?.();
// Three routes from path-bearing g.Meta; the mime-only response g.Meta makes none.
expect(routes.map((r: any) => r.name)).toEqual(['GET /dept/list', 'GET /orphan', 'POST /dept/add']);
// Two route→handler edges — the orphan route stays unlinked (silent beats wrong).
expect(edges).toHaveLength(2);
const byRoute = Object.fromEntries(edges.map((e: any) => [e.route, e]));
// Naming mismatch resolved by signature: GET /dept/list → List.
expect(byRoute['GET /dept/list'].target_name).toBe('List');
expect(byRoute['GET /dept/list'].reqType).toBe('DeptSearchReq');
expect(byRoute['POST /dept/add'].target_name).toBe('Add');
// It is a dynamic-dispatch `calls` hop to a real method, never to the helper.
expect(edges.every((e: any) => e.kind === 'calls' && e.target_kind === 'method')).toBe(true);
expect(edges.some((e: any) => e.target_name === 'helper')).toBe(false);
expect(byRoute['GET /orphan']).toBeUndefined();
});
it('disambiguates identical bare request types across modules by package qualifier', async () => {
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/app\n\nrequire github.com/gogf/gf/v2 v2.7.0\n');
// Two modules that BOTH define `type ListReq struct` — the collision a large
// GoFrame app has dozens of. The package qualifier in the handler signature
// (`*cash.ListReq` vs `*order.ListReq`) is the only thing that tells them apart.
for (const mod of ['cash', 'order']) {
fs.mkdirSync(path.join(dir, 'api', mod), { recursive: true });
fs.writeFileSync(
path.join(dir, 'api', mod, `${mod}.go`),
`package ${mod}
import "github.com/gogf/gf/v2/frame/g"
type ListReq struct {
g.Meta \`path:"/${mod}/list" method:"get"\`
}
type ListRes struct{}
`
);
fs.mkdirSync(path.join(dir, 'internal', 'controller', mod), { recursive: true });
fs.writeFileSync(
path.join(dir, 'internal', 'controller', mod, `${mod}.go`),
`package ${mod}
import (
"context"
"example.com/app/api/${mod}"
)
type c${mod} struct{}
func (c *c${mod}) List(ctx context.Context, req *${mod}.ListReq) (res *${mod}.ListRes, err error) {
return
}
`
);
}
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT json_extract(e.metadata,'$.route') route, t.file_path handler_file
FROM edges e JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'goframe-route'
ORDER BY route`
)
.all();
cg.close?.();
expect(rows).toHaveLength(2);
// Each route binds to ITS OWN module's handler, never the other's.
const byRoute = Object.fromEntries(rows.map((r: any) => [r.route, r.handler_file]));
expect(byRoute['GET /cash/list']).toContain('controller/cash/');
expect(byRoute['GET /order/list']).toContain('controller/order/');
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* readGrammarWasmBytes + bytes-based grammar loading (#1231, Phase 2.1).
*
* The orchestrator pre-reads each needed grammar's WASM once on the main
* thread and hands the bytes to every parse worker, so a worker respawn loads
* grammars from memory instead of re-reading them from a (possibly slow) disk.
* These tests pin that the byte reader resolves the same artifacts the loader
* would, and that web-tree-sitter genuinely accepts the bytes.
*/
import { describe, it, expect } from 'vitest';
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
import { readGrammarWasmBytes } from '../src/extraction/grammars';
describe('readGrammarWasmBytes', () => {
it('reads bytes for a tree-sitter-wasms grammar and a vendored grammar', async () => {
const bytes = await readGrammarWasmBytes(['typescript', 'lua']);
expect(bytes.typescript).toBeInstanceOf(Uint8Array); // from tree-sitter-wasms
expect(bytes.typescript.byteLength).toBeGreaterThan(10_000);
expect(bytes.lua).toBeInstanceOf(Uint8Array); // vendored under src/extraction/wasm/
expect(bytes.lua.byteLength).toBeGreaterThan(10_000);
});
it('expands delegating languages to the grammars they need (svelte → ts/js)', async () => {
const bytes = await readGrammarWasmBytes(['svelte']);
expect(Object.keys(bytes).sort()).toEqual(['javascript', 'typescript']);
});
it('omits languages without a WASM grammar instead of failing', async () => {
const bytes = await readGrammarWasmBytes(['yaml', 'unknown']);
expect(Object.keys(bytes)).toEqual([]);
});
it('produces bytes web-tree-sitter can load into a working parser', async () => {
await Parser.init();
const bytes = await readGrammarWasmBytes(['javascript']);
const language = await WasmLanguage.load(bytes.javascript);
const parser = new Parser();
parser.setLanguage(language);
const tree = parser.parse('function hello() { return 1; }');
expect(tree!.rootNode.hasError).toBe(false);
expect(tree!.rootNode.toString()).toContain('function_declaration');
});
});
+615
View File
@@ -0,0 +1,615 @@
/**
* Graph Query Tests
*
* Tests for graph traversal and query functionality.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { Node, Edge } from '../src/types';
import { GraphTraverser } from '../src/graph/traversal';
describe('Graph Queries', () => {
let testDir: string;
let cg: CodeGraph;
beforeEach(async () => {
// Create temp directory
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-graph-test-'));
// Create test files with relationships
const srcDir = path.join(testDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
// Create base class
fs.writeFileSync(
path.join(srcDir, 'base.ts'),
`
export class BaseClass {
protected value: number;
constructor(value: number) {
this.value = value;
}
getValue(): number {
return this.value;
}
}
export interface Printable {
print(): void;
}
`
);
// Create derived class
fs.writeFileSync(
path.join(srcDir, 'derived.ts'),
`
import { BaseClass, Printable } from './base';
export class DerivedClass extends BaseClass implements Printable {
private name: string;
constructor(value: number, name: string) {
super(value);
this.name = name;
}
print(): void {
console.log(this.getName(), this.getValue());
}
getName(): string {
return this.name;
}
}
`
);
// Create utility functions
fs.writeFileSync(
path.join(srcDir, 'utils.ts'),
`
export function formatValue(value: number): string {
return value.toFixed(2);
}
export function processValue(value: number): number {
const formatted = formatValue(value);
return parseFloat(formatted);
}
export function doubleValue(value: number): number {
return value * 2;
}
// Unused function (dead code)
function unusedHelper(): void {
console.log('never called');
}
`
);
// Create main file that uses everything
fs.writeFileSync(
path.join(srcDir, 'main.ts'),
`
import { DerivedClass } from './derived';
import { processValue, doubleValue } from './utils';
function main(): void {
const obj = new DerivedClass(10, 'test');
obj.print();
const result = processValue(doubleValue(obj.getValue()));
console.log(result);
}
export { main };
`
);
// Initialize and index
cg = CodeGraph.initSync(testDir, {
config: {
include: ['src/**/*.ts'],
exclude: [],
},
});
await cg.indexAll();
cg.resolveReferences();
});
afterEach(() => {
if (cg) {
cg.destroy();
}
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
describe('traverse()', () => {
it('should traverse graph from a starting node', () => {
const nodes = cg.getNodesByKind('function');
const mainFunc = nodes.find((n) => n.name === 'main');
if (!mainFunc) {
console.log('main function not found, skipping test');
return;
}
const subgraph = cg.traverse(mainFunc.id, {
maxDepth: 2,
direction: 'outgoing',
});
expect(subgraph.nodes.size).toBeGreaterThan(0);
expect(subgraph.roots).toContain(mainFunc.id);
});
it('should respect maxDepth option', () => {
const nodes = cg.getNodesByKind('function');
const mainFunc = nodes.find((n) => n.name === 'main');
if (!mainFunc) {
return;
}
const shallow = cg.traverse(mainFunc.id, { maxDepth: 1 });
const deep = cg.traverse(mainFunc.id, { maxDepth: 3 });
expect(deep.nodes.size).toBeGreaterThanOrEqual(shallow.nodes.size);
});
it('should support incoming direction', () => {
const nodes = cg.getNodesByKind('function');
const formatValue = nodes.find((n) => n.name === 'formatValue');
if (!formatValue) {
return;
}
const subgraph = cg.traverse(formatValue.id, {
maxDepth: 2,
direction: 'incoming',
});
expect(subgraph.nodes.size).toBeGreaterThan(0);
});
});
describe('getContext()', () => {
it('should return context for a node', () => {
const nodes = cg.getNodesByKind('class');
const derivedClass = nodes.find((n) => n.name === 'DerivedClass');
if (!derivedClass) {
console.log('DerivedClass not found, skipping test');
return;
}
const context = cg.getContext(derivedClass.id);
expect(context.focal).toBeDefined();
expect(context.focal.id).toBe(derivedClass.id);
expect(context.ancestors).toBeDefined();
expect(context.children).toBeDefined();
expect(context.incomingRefs).toBeDefined();
expect(context.outgoingRefs).toBeDefined();
});
it('should throw for non-existent node', () => {
expect(() => cg.getContext('non-existent-id')).toThrow('Node not found');
});
});
describe('getCallGraph()', () => {
it('should return call graph for a function', () => {
const nodes = cg.getNodesByKind('function');
const processValue = nodes.find((n) => n.name === 'processValue');
if (!processValue) {
console.log('processValue not found, skipping test');
return;
}
const callGraph = cg.getCallGraph(processValue.id, 2);
expect(callGraph.nodes.size).toBeGreaterThan(0);
expect(callGraph.nodes.has(processValue.id)).toBe(true);
});
});
describe('getTypeHierarchy()', () => {
it('should return type hierarchy for a class', () => {
const nodes = cg.getNodesByKind('class');
const derivedClass = nodes.find((n) => n.name === 'DerivedClass');
if (!derivedClass) {
return;
}
const hierarchy = cg.getTypeHierarchy(derivedClass.id);
expect(hierarchy.nodes.size).toBeGreaterThan(0);
expect(hierarchy.nodes.has(derivedClass.id)).toBe(true);
});
it('should return empty subgraph for non-existent node', () => {
const hierarchy = cg.getTypeHierarchy('non-existent-id');
expect(hierarchy.nodes.size).toBe(0);
expect(hierarchy.edges.length).toBe(0);
});
});
describe('findUsages()', () => {
it('should find usages of a symbol', () => {
const nodes = cg.getNodesByKind('class');
const baseClass = nodes.find((n) => n.name === 'BaseClass');
if (!baseClass) {
return;
}
const usages = cg.findUsages(baseClass.id);
// Should find at least the extends relationship
expect(usages).toBeDefined();
expect(Array.isArray(usages)).toBe(true);
});
});
describe('getCallers() and getCallees()', () => {
it('should get callers of a function', () => {
const nodes = cg.getNodesByKind('function');
const formatValue = nodes.find((n) => n.name === 'formatValue');
if (!formatValue) {
return;
}
const callers = cg.getCallers(formatValue.id);
// processValue calls formatValue
expect(Array.isArray(callers)).toBe(true);
});
it('should get callees of a function', () => {
const nodes = cg.getNodesByKind('function');
const processValue = nodes.find((n) => n.name === 'processValue');
if (!processValue) {
return;
}
const callees = cg.getCallees(processValue.id);
expect(Array.isArray(callees)).toBe(true);
});
it('treats class instantiation as a caller/callee of the class (#774)', () => {
// main() does `new DerivedClass(10, 'test')`. Constructing a class is
// calling its constructor, so main is a caller of DerivedClass and
// DerivedClass is a callee of main. Before #774 the `instantiates` edge
// was excluded from the caller/callee traversal, so `callers <Class>`
// returned the importing file (or nothing) and missed every
// construction site.
const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass');
const main = cg.getNodesByKind('function').find((n) => n.name === 'main');
expect(derived).toBeDefined();
expect(main).toBeDefined();
const callerNames = cg.getCallers(derived!.id).map((c) => c.node.name);
expect(callerNames).toContain('main');
const calleeNames = cg.getCallees(main!.id).map((c) => c.node.name);
expect(calleeNames).toContain('DerivedClass');
});
});
describe('getImpactRadius()', () => {
it('should calculate impact radius', () => {
const nodes = cg.getNodesByKind('function');
const formatValue = nodes.find((n) => n.name === 'formatValue');
if (!formatValue) {
return;
}
const impact = cg.getImpactRadius(formatValue.id, 3);
expect(impact.nodes.size).toBeGreaterThan(0);
expect(impact.nodes.has(formatValue.id)).toBe(true);
});
it('does not drag in sibling members via the structural contains edge (#536)', () => {
const getName = cg.getNodesByKind('method').find((n) => n.name === 'getName');
const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass');
expect(getName).toBeDefined();
expect(derived).toBeDefined();
const impact = cg.getImpactRadius(getName!.id, 3);
// The containing class must NOT be pulled into impact just because it
// *contains* getName — climbing that contains edge would re-expand every
// sibling method and explode impact for a leaf symbol. (#536)
expect(impact.nodes.has(derived!.id)).toBe(false);
});
});
describe('findPath()', () => {
it('should find path between connected nodes', () => {
const stats = cg.getStats();
if (stats.nodeCount < 2) {
return;
}
const functions = cg.getNodesByKind('function');
if (functions.length < 2) {
return;
}
// Try to find any path
const processValue = functions.find((n) => n.name === 'processValue');
const formatValue = functions.find((n) => n.name === 'formatValue');
if (processValue && formatValue) {
const path = cg.findPath(processValue.id, formatValue.id);
// Path might exist or might not depending on edge direction
expect(path === null || Array.isArray(path)).toBe(true);
}
});
it('should return null for disconnected nodes', () => {
// Create two nodes that definitely don't have a path
const path = cg.findPath('non-existent-1', 'non-existent-2');
expect(path).toBeNull();
});
});
describe('getAncestors() and getChildren()', () => {
it('should get ancestors of a node', () => {
const methods = cg.getNodesByKind('method');
const printMethod = methods.find((n) => n.name === 'print');
if (!printMethod) {
return;
}
const ancestors = cg.getAncestors(printMethod.id);
// Should have class and file as ancestors
expect(Array.isArray(ancestors)).toBe(true);
});
it('should get children of a node', () => {
const classes = cg.getNodesByKind('class');
const derivedClass = classes.find((n) => n.name === 'DerivedClass');
if (!derivedClass) {
return;
}
const children = cg.getChildren(derivedClass.id);
// Should have methods as children
expect(Array.isArray(children)).toBe(true);
});
});
describe('File dependency analysis', () => {
// Regression: getFileDependents/getFileDependencies used to follow
// ONLY `imports` edges, which in this engine are same-file (a file → its
// own local import declarations). That made both return [] for EVERY file,
// so `codegraph affected` found no dependents on any language/framework.
// They must follow the cross-file symbol graph instead (calls / references
// / instantiates / extends / implements / ...).
it('reports cross-file dependencies via the symbol graph, not just imports', () => {
const deps = cg.getFileDependencies('src/main.ts');
// main() instantiates DerivedClass (derived.ts) and calls
// processValue/doubleValue (utils.ts) — both are real dependencies.
expect(deps).toContain('src/utils.ts');
expect(deps).toContain('src/derived.ts');
});
it('reports cross-file dependents via the symbol graph, not just imports', () => {
// utils.ts is used by main.ts (processValue/doubleValue calls); the old
// imports-only implementation returned [] here.
expect(cg.getFileDependents('src/utils.ts')).toContain('src/main.ts');
});
it('counts extends/implements as a dependency edge', () => {
// derived.ts extends BaseClass / implements Printable, both in base.ts.
expect(cg.getFileDependencies('src/derived.ts')).toContain('src/base.ts');
expect(cg.getFileDependents('src/base.ts')).toContain('src/derived.ts');
});
it('never lists a file as its own dependent or dependency', () => {
for (const f of ['src/main.ts', 'src/utils.ts', 'src/base.ts', 'src/derived.ts']) {
expect(cg.getFileDependents(f)).not.toContain(f);
expect(cg.getFileDependencies(f)).not.toContain(f);
}
});
});
describe('findCircularDependencies()', () => {
it('should detect circular dependencies', () => {
const cycles = cg.findCircularDependencies();
// Our test files don't have circular deps
expect(Array.isArray(cycles)).toBe(true);
});
});
describe('findDeadCode()', () => {
it('should find dead code', () => {
const deadCode = cg.findDeadCode(['function']);
expect(Array.isArray(deadCode)).toBe(true);
// unusedHelper should be detected
const hasUnused = deadCode.some((n) => n.name === 'unusedHelper');
// Note: This depends on extraction properly detecting function scope
expect(deadCode.length).toBeGreaterThanOrEqual(0);
});
});
describe('getNodeMetrics()', () => {
it('should return metrics for a node', () => {
const functions = cg.getNodesByKind('function');
const func = functions[0];
if (!func) {
return;
}
const metrics = cg.getNodeMetrics(func.id);
expect(metrics).toHaveProperty('incomingEdgeCount');
expect(metrics).toHaveProperty('outgoingEdgeCount');
expect(metrics).toHaveProperty('callCount');
expect(metrics).toHaveProperty('callerCount');
expect(metrics).toHaveProperty('childCount');
expect(metrics).toHaveProperty('depth');
expect(typeof metrics.incomingEdgeCount).toBe('number');
expect(typeof metrics.outgoingEdgeCount).toBe('number');
});
});
});
// =============================================================================
// Traversal edge-completeness & node-limit regressions (#1086#1090)
//
// These drive GraphTraverser directly against an in-memory graph (the same
// approach the reporter used), so the exact parallel-edge / high-degree
// topologies can be constructed deterministically without round-tripping
// through extraction.
// =============================================================================
/** Minimal Node stub — the traversal code only reads id/kind/name. */
function tNode(id: string, kind: Node['kind'] = 'function'): Node {
return {
id,
kind,
name: id,
qualifiedName: id,
filePath: `src/${id}.ts`,
language: 'typescript',
startLine: 1,
endLine: 10,
startColumn: 0,
endColumn: 0,
} as unknown as Node;
}
/** Build a GraphTraverser over a fixed node/edge set, honoring the `kinds` filter. */
function tGraph(nodes: Node[], edges: Edge[]): GraphTraverser {
const byId = new Map(nodes.map((n) => [n.id, n]));
const q = {
getNodeById: (id: string) => byId.get(id) ?? null,
getNodesByIds: (ids: readonly string[]) => {
const m = new Map<string, Node>();
for (const id of ids) {
const n = byId.get(id);
if (n) m.set(id, n);
}
return m;
},
getOutgoingEdges: (source: string, kinds?: string[]) =>
edges.filter((e) => e.source === source && (!kinds || kinds.includes(e.kind))),
getIncomingEdges: (target: string, kinds?: string[]) =>
edges.filter((e) => e.target === target && (!kinds || kinds.includes(e.kind))),
};
return new GraphTraverser(q as never);
}
describe('Traversal edge-completeness & limits (#1086#1090)', () => {
it('traverseBFS keeps every parallel edge to the same target (#1090)', () => {
// A reaches B via both `calls` and `references` — two distinct edges.
const edges: Edge[] = [
{ source: 'A', target: 'B', kind: 'calls', line: 1 },
{ source: 'A', target: 'B', kind: 'references', line: 2 },
];
const sub = tGraph([tNode('A'), tNode('B')], edges).traverseBFS('A', { direction: 'outgoing' });
const ab = sub.edges.filter((e) => e.source === 'A' && e.target === 'B');
// Pre-fix: only the higher-priority `calls` edge survived; `references` was dropped.
expect(ab.map((e) => e.kind).sort()).toEqual(['calls', 'references']);
expect(sub.nodes.has('B')).toBe(true);
});
it('traverseBFS keeps two same-kind edges on different lines (#1090)', () => {
const edges: Edge[] = [
{ source: 'A', target: 'B', kind: 'calls', line: 3 },
{ source: 'A', target: 'B', kind: 'calls', line: 7 },
];
const sub = tGraph([tNode('A'), tNode('B')], edges).traverseBFS('A', { direction: 'outgoing' });
expect(sub.edges.filter((e) => e.source === 'A' && e.target === 'B')).toHaveLength(2);
});
it('traverseBFS does not overshoot opts.limit on a high-degree node (#1087)', () => {
const neighbors = ['B', 'C', 'D', 'E', 'F'];
const nodes = [tNode('A'), ...neighbors.map((n) => tNode(n))];
const edges: Edge[] = neighbors.map((n) => ({ source: 'A', target: n, kind: 'calls' as const }));
const sub = tGraph(nodes, edges).traverseBFS('A', { limit: 3, direction: 'outgoing' });
// Pre-fix: all 5 neighbors were added in one pass → 6 nodes despite limit 3.
expect(sub.nodes.size).toBeLessThanOrEqual(3);
});
it('traverseDFS does not overshoot opts.limit on a high-degree node (#1088)', () => {
const neighbors = ['B', 'C', 'D', 'E', 'F'];
const nodes = [tNode('A'), ...neighbors.map((n) => tNode(n))];
const edges: Edge[] = neighbors.map((n) => ({ source: 'A', target: n, kind: 'calls' as const }));
const sub = tGraph(nodes, edges).traverseDFS('A', { limit: 2, direction: 'outgoing' });
expect(sub.nodes.size).toBeLessThanOrEqual(2);
});
it('getCallers returns each caller once when reached via multiple edges (#1086)', () => {
// Y calls X at two sites and also references it — three incoming edges.
const edges: Edge[] = [
{ source: 'Y', target: 'X', kind: 'calls', line: 1 },
{ source: 'Y', target: 'X', kind: 'calls', line: 2 },
{ source: 'Y', target: 'X', kind: 'references', line: 3 },
];
const callers = tGraph([tNode('X'), tNode('Y')], edges).getCallers('X'); // default maxDepth = 1
// Pre-fix: Y appeared three times (depth guard returned before visited.add).
expect(callers.map((c) => c.node.id)).toEqual(['Y']);
});
it('getCallees returns each callee once when reached via multiple edges (#1086)', () => {
const edges: Edge[] = [
{ source: 'X', target: 'Y', kind: 'calls', line: 1 },
{ source: 'X', target: 'Y', kind: 'calls', line: 2 },
];
const callees = tGraph([tNode('X'), tNode('Y')], edges).getCallees('X');
expect(callees.map((c) => c.node.id)).toEqual(['Y']);
});
it('getImpactRadius keeps a direct edge into a node already collected via another path (#1089)', () => {
// Class P contains method M. Q calls both M and P. Reaching M first collects
// Q; the pre-fix `!nodes.has()` gate then dropped the direct Q→P edge.
const nodes = [tNode('P', 'class'), tNode('M', 'method'), tNode('Q')];
const edges: Edge[] = [
{ source: 'P', target: 'M', kind: 'contains' },
{ source: 'Q', target: 'M', kind: 'calls', line: 1 },
{ source: 'Q', target: 'P', kind: 'calls', line: 2 },
];
const sub = tGraph(nodes, edges).getImpactRadius('P', 2);
expect(sub.nodes.has('Q')).toBe(true);
expect(sub.edges.some((e) => e.source === 'Q' && e.target === 'M' && e.kind === 'calls')).toBe(true);
// The regression: this direct dependency edge used to vanish.
expect(sub.edges.some((e) => e.source === 'Q' && e.target === 'P' && e.kind === 'calls')).toBe(true);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import {
splitIdentifierSegments,
extractProseCandidates,
normalizeProseWord,
segmentLookupVariants,
} from '../src/search/identifier-segments';
describe('splitIdentifierSegments — symbol names → prose words', () => {
it('splits camelCase / PascalCase at humps', () => {
expect(splitIdentifierSegments('OrderStateMachine')).toEqual(['order', 'state', 'machine']);
expect(splitIdentifierSegments('userId')).toEqual(['user', 'id']);
});
it('handles acronym runs — HTML stays one segment', () => {
expect(splitIdentifierSegments('parseHTMLDocument')).toEqual(['parse', 'html', 'document']);
expect(splitIdentifierSegments('HTMLParser')).toEqual(['html', 'parser']);
});
it('keeps digits glued to their word', () => {
expect(splitIdentifierSegments('base64Encode')).toEqual(['base64', 'encode']);
expect(splitIdentifierSegments('parseHTML5Doc')).toEqual(['parse', 'html5', 'doc']);
});
it('splits snake_case, kebab-case, and dotted file names', () => {
expect(splitIdentifierSegments('snake_case_name')).toEqual(['snake', 'case', 'name']);
expect(splitIdentifierSegments('MAX_RETRY_COUNT')).toEqual(['max', 'retry', 'count']);
expect(splitIdentifierSegments('checkout.service.ts')).toEqual(['checkout', 'service', 'ts']);
expect(splitIdentifierSegments('state-machine')).toEqual(['state', 'machine']);
});
it('drops sub-minimum and digit-only fragments, dedupes', () => {
expect(splitIdentifierSegments('x')).toEqual([]);
expect(splitIdentifierSegments('42')).toEqual([]);
expect(splitIdentifierSegments('getData_getData')).toEqual(['get', 'data']);
});
});
describe('extractProseCandidates — prompt prose → lookup words', () => {
it('keeps content words, drops short function words, in any Latin language', () => {
expect(extractProseCandidates('comment marche la state machine des commandes ?')).toEqual([
'comment', 'marche', 'state', 'machine', 'commandes',
]);
});
it('strips diacritics so loanwords meet ASCII identifier segments', () => {
expect(extractProseCandidates('la résolution des références')).toEqual(['resolution', 'references']);
expect(normalizeProseWord('Übersicht')).toBe('ubersicht');
});
it("splits on apostrophes — l'architecture keeps the noun", () => {
expect(extractProseCandidates("explique l'architecture du module de stock")).toEqual([
'explique', 'architecture', 'module', 'stock',
]);
});
it('caps candidates and skips unsegmented-script sentence runs', () => {
const many = Array.from({ length: 25 }, (_, i) => `distinctword${String.fromCharCode(97 + i)}`).join(' ');
expect(extractProseCandidates(many)).toHaveLength(16);
// A no-spaces CJK sentence is one giant run — over the length ceiling, skipped.
expect(extractProseCandidates('請解釋一下這個訂單狀態機的整體運作流程與架構設計方式')).toEqual([]);
// Short CJK runs pass through as candidates — no script filter; the graph
// verification tier rejects them (identifiers are almost never CJK).
expect(extractProseCandidates('修复这个拼写错误')).toEqual(['修复这个拼写错误']);
});
it('drops digit-only and sub-4-char words', () => {
expect(extractProseCandidates('fix the bug in v2 at 1234')).toEqual([]);
});
});
describe('segmentLookupVariants — light plural folding', () => {
it('folds trailing s/es so plurals hit singular segments', () => {
expect(segmentLookupVariants('services')).toContain('service');
expect(segmentLookupVariants('machines')).toContain('machine');
expect(segmentLookupVariants('classes')).toContain('class');
});
it('bare-s plurals no longer mint a bogus -es sibling (#1145)', () => {
expect(segmentLookupVariants('services')).toEqual(['services', 'service']);
expect(segmentLookupVariants('machines')).toEqual(['machines', 'machine']);
});
it('unambiguous sibilant-es plurals no longer mint a bogus -s sibling (#1145)', () => {
expect(segmentLookupVariants('classes')).toEqual(['classes', 'class']);
expect(segmentLookupVariants('hashes')).toEqual(['hashes', 'hash']);
});
it('a trailing -ss is a singular, not a plural — no strip (#1145)', () => {
expect(segmentLookupVariants('class')).toEqual(['class']);
expect(segmentLookupVariants('process')).toEqual(['process']);
});
it('ambiguous endings emit BOTH candidate keys — a wrong exclusive guess would lose the real match', () => {
expect(segmentLookupVariants('caches')).toEqual(['caches', 'cach', 'cache']); // cache + s
expect(segmentLookupVariants('databases')).toEqual(['databases', 'databas', 'database']); // database + s
});
it('never strips a word below the minimum', () => {
expect(segmentLookupVariants('bus')).toEqual(['bus']);
expect(segmentLookupVariants('boxes')).toEqual(['boxes']); // -es strip would go sub-minimum
});
});
+262
View File
@@ -0,0 +1,262 @@
/**
* `codegraph.json` `include` — force first-party source INTO the index even when
* `.gitignore` would drop it.
*
* The whitelist `includeIgnored` never was: that one only revives *embedded git
* repos* inside ignored dirs (#622/#699), so pure source gitignored out of Git
* (the SVN+Git dual-VCS case — committed to SVN, `.gitignore`d so it never lands
* in Git) had no way in. Three layers under test:
* 1. Loader: parse/validate/cache, mirroring the `exclude` loader.
* 2. Behavior: `scanDirectory` adds included paths on BOTH the git
* (`git ls-files`) and non-git (filesystem walk) enumeration paths.
* 3. Scope: `buildScopeIgnore` (the watcher's source of truth) treats an
* included file — and the gitignored dirs leading to it — as not-ignored.
*
* Invariants: an explicit `exclude` still wins; built-in default-ignored dirs
* (`node_modules`, …) are never resurfaced; every loader failure mode degrades
* to the zero-config default (force nothing in), never a throw.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { execFileSync } from 'node:child_process';
import {
loadIncludePatterns,
loadExcludePatterns,
loadExtensionOverrides,
loadIncludeIgnoredPatterns,
clearProjectConfigCache,
} from '../src/project-config';
import { scanDirectory, buildScopeIgnore } from '../src/extraction';
describe('include loader (codegraph.json)', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-include-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const writeConfig = (obj: unknown) =>
fs.writeFileSync(
path.join(dir, 'codegraph.json'),
typeof obj === 'string' ? obj : JSON.stringify(obj)
);
it('returns an empty list when there is no codegraph.json (the default)', () => {
expect(loadIncludePatterns(dir)).toEqual([]);
});
it('loads a well-formed pattern array', () => {
writeConfig({ include: ['Tools/', 'Local/**'] });
expect(loadIncludePatterns(dir)).toEqual(['Tools/', 'Local/**']);
});
it('trims whitespace and drops blank / non-string entries', () => {
writeConfig({ include: [' Tools/ ', '', ' ', 42, null, 'Local/'] });
expect(loadIncludePatterns(dir)).toEqual(['Tools/', 'Local/']);
});
it('ignores a non-array include value without throwing', () => {
writeConfig({ include: 'Tools/' });
expect(loadIncludePatterns(dir)).toEqual([]);
});
it('ignores malformed JSON without throwing', () => {
writeConfig('{ not: valid json ');
expect(loadIncludePatterns(dir)).toEqual([]);
});
it('coexists with extensions / includeIgnored / exclude in one file (shared single parse)', () => {
writeConfig({
extensions: { '.foo': 'typescript' },
includeIgnored: ['pkgs/'],
exclude: ['static/'],
include: ['Tools/'],
});
expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript' });
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['pkgs/']);
expect(loadExcludePatterns(dir)).toEqual(['static/']);
expect(loadIncludePatterns(dir)).toEqual(['Tools/']);
});
it('picks up a changed config (mtime-invalidated cache)', () => {
writeConfig({ include: ['Tools/'] });
expect(loadIncludePatterns(dir)).toEqual(['Tools/']);
writeConfig({ include: ['Local/'] });
const future = new Date(Date.now() + 2000);
fs.utimesSync(path.join(dir, 'codegraph.json'), future, future);
expect(loadIncludePatterns(dir)).toEqual(['Local/']);
});
it('drops the patterns again when the config file is removed', () => {
writeConfig({ include: ['Tools/'] });
expect(loadIncludePatterns(dir)).toEqual(['Tools/']);
fs.rmSync(path.join(dir, 'codegraph.json'));
expect(loadIncludePatterns(dir)).toEqual([]);
});
});
describe('include behavior — scanDirectory force-indexes gitignored source', () => {
let dir: string;
const mk = (rel: string, content = 'export const x = 1;\n') => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content);
};
const writeConfig = (obj: unknown) =>
fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify(obj));
const scan = () => scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-include-scan-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const gitInit = () => {
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['add', '-A'], { cwd: dir });
execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'x'], { cwd: dir });
};
it('indexes a .gitignored source dir when include opts it in (git path) — the core fix', () => {
mk('app/main.ts');
mk('Tools/gen.py', 'def gen():\n return 1\n');
fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n'); // SVN-only source, kept out of Git
gitInit(); // Tools/ is gitignored → NOT tracked
// Sanity: without include the gitignored source is invisible.
let files = scan();
expect(files).toContain('app/main.ts');
expect(files.some((f) => f.startsWith('Tools/'))).toBe(false);
// With include the gitignored source is forced in, app code still there.
writeConfig({ include: ['Tools/'] });
clearProjectConfigCache();
files = scan();
expect(files).toContain('app/main.ts');
expect(files).toContain('Tools/gen.py');
});
it('forces gitignored source in on the non-git filesystem-walk path too', () => {
mk('app/main.ts');
mk('Tools/gen.py', 'def gen():\n return 1\n');
fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n');
// No git init → scanDirectory falls back to the filesystem walk (which still
// honours .gitignore), so Tools/ must be re-added by include.
writeConfig({ include: ['Tools/'] });
clearProjectConfigCache();
const files = scan();
expect(files).toContain('app/main.ts');
expect(files).toContain('Tools/gen.py');
});
it('supports a recursive ** glob and nested dirs', () => {
mk('src/a.ts');
mk('Local/ts/a.ts');
mk('Local/ts/nested/b.ts');
fs.writeFileSync(path.join(dir, '.gitignore'), 'Local/\n');
gitInit();
writeConfig({ include: ['Local/**'] });
clearProjectConfigCache();
const files = scan();
expect(files).toContain('Local/ts/a.ts');
expect(files).toContain('Local/ts/nested/b.ts');
});
it('lets an explicit exclude win over include', () => {
mk('Tools/keep.py', 'def k():\n return 1\n');
mk('Tools/secret/drop.py', 'def d():\n return 1\n');
fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n');
gitInit();
writeConfig({ include: ['Tools/'], exclude: ['Tools/secret/'] });
clearProjectConfigCache();
const files = scan();
expect(files).toContain('Tools/keep.py');
expect(files.some((f) => f.startsWith('Tools/secret/'))).toBe(false);
});
it('prunes an explicitly-excluded subtree under an included dir (a frontend own deps stay out)', () => {
// The real-world case: an SVN-committed frontend is force-included, but its
// own vendored deps live in a NON-default-named dir (`third_party/`) the
// built-in ignore list does not cover, so it is excluded explicitly. The
// whole subtree - nested files and all - must stay out, while sibling source
// stays in.
mk('Local/frontend/src/app.ts');
mk('Local/frontend/src/util.ts');
mk('Local/frontend/third_party/lib/a.ts');
mk('Local/frontend/third_party/lib/nested/b.ts');
fs.writeFileSync(path.join(dir, '.gitignore'), 'Local/\n');
gitInit();
writeConfig({ include: ['Local/frontend/'], exclude: ['Local/frontend/third_party/'] });
clearProjectConfigCache();
const files = scan();
expect(files).toContain('Local/frontend/src/app.ts');
expect(files).toContain('Local/frontend/src/util.ts');
expect(files.some((f) => f.startsWith('Local/frontend/third_party/'))).toBe(false);
});
it('never resurrects a built-in default-ignored dir (node_modules) via include', () => {
mk('src/a.ts');
mk('node_modules/pkg/index.js');
gitInit();
// Even explicitly opting node_modules in must not pull it into the graph.
writeConfig({ include: ['node_modules/'] });
clearProjectConfigCache();
const files = scan();
expect(files).toContain('src/a.ts');
expect(files.some((f) => f.startsWith('node_modules/'))).toBe(false);
});
it('is a no-op with no include config (gitignored source stays out)', () => {
mk('app/main.ts');
mk('Tools/gen.py', 'def gen():\n return 1\n');
fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n');
gitInit();
const files = scan();
expect(files).toContain('app/main.ts');
expect(files.some((f) => f.startsWith('Tools/'))).toBe(false);
});
});
describe('include scope — buildScopeIgnore keeps included paths watchable', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-include-scope-'));
clearProjectConfigCache();
execFileSync('git', ['init', '-q'], { cwd: dir });
fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\nOther/\n');
fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({ include: ['Tools/'] }));
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
it('does not ignore an included file, nor the gitignored dir leading to it', () => {
const scope = buildScopeIgnore(dir);
// The included file and its (gitignored) directory are watchable.
expect(scope.ignores('Tools/gen.py')).toBe(false);
expect(scope.ignores('Tools/')).toBe(false);
// A different gitignored dir that was NOT opted in stays ignored.
expect(scope.ignores('Other/')).toBe(true);
expect(scope.ignores('Other/x.py')).toBe(true);
});
it('still ignores everything when no include is configured', () => {
fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({}));
clearProjectConfigCache();
const scope = buildScopeIgnore(dir);
expect(scope.ignores('Tools/gen.py')).toBe(true);
expect(scope.ignores('Tools/')).toBe(true);
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* `codegraph.json` `includeIgnored` loader (#970, #976 / #622, #699).
*
* Parsing, validation, and mtime-caching of the opt-in patterns that re-include
* gitignored directories for embedded-repo discovery. The behavioral end of this
* feature (scanDirectory / discoverEmbeddedRepoRoots / sync honoring the patterns)
* lives in `multi-repo-workspace.test.ts`; these are the loader unit tests,
* mirroring the `extensions` loader coverage in `extension-mapping.test.ts`.
*
* Invariant under test: every failure mode degrades to the zero-config default
* (empty patterns → `.gitignore` fully respected), never a throw.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { loadIncludeIgnoredPatterns, loadExtensionOverrides, clearProjectConfigCache, addIncludeIgnoredPatterns } from '../src/project-config';
describe('includeIgnored loader (codegraph.json)', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-includeignored-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const writeConfig = (obj: unknown) =>
fs.writeFileSync(
path.join(dir, 'codegraph.json'),
typeof obj === 'string' ? obj : JSON.stringify(obj)
);
it('returns an empty list when there is no codegraph.json (the default)', () => {
expect(loadIncludeIgnoredPatterns(dir)).toEqual([]);
});
it('loads a well-formed pattern array', () => {
writeConfig({ includeIgnored: ['packages/', 'services/'] });
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['packages/', 'services/']);
});
it('trims whitespace and drops blank / non-string entries', () => {
writeConfig({ includeIgnored: [' packages/ ', '', ' ', 42, null, 'services/'] });
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['packages/', 'services/']);
});
it('ignores a non-array includeIgnored value without throwing', () => {
writeConfig({ includeIgnored: 'packages/' });
expect(loadIncludeIgnoredPatterns(dir)).toEqual([]);
});
it('ignores malformed JSON without throwing', () => {
writeConfig('{ not: valid json ');
expect(loadIncludeIgnoredPatterns(dir)).toEqual([]);
});
it('returns [] when the field is absent but other config is present', () => {
writeConfig({ extensions: { '.foo': 'typescript' } });
expect(loadIncludeIgnoredPatterns(dir)).toEqual([]);
});
it('coexists with extensions in one file (shared single parse)', () => {
writeConfig({ extensions: { '.foo': 'typescript' }, includeIgnored: ['vendor/'] });
expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript' });
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['vendor/']);
});
it('picks up a changed config (mtime-invalidated cache)', () => {
writeConfig({ includeIgnored: ['packages/'] });
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['packages/']);
writeConfig({ includeIgnored: ['services/'] });
// Force a distinct mtime in case the filesystem clock is coarse.
const future = new Date(Date.now() + 2000);
fs.utimesSync(path.join(dir, 'codegraph.json'), future, future);
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['services/']);
});
it('drops the patterns again when the config file is removed', () => {
writeConfig({ includeIgnored: ['packages/'] });
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['packages/']);
fs.rmSync(path.join(dir, 'codegraph.json'));
expect(loadIncludeIgnoredPatterns(dir)).toEqual([]);
});
});
describe('addIncludeIgnoredPatterns (codegraph.json writer, #1156)', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-addincludeignored-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const readConfig = () => JSON.parse(fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf-8'));
it('creates codegraph.json when none exists', () => {
expect(addIncludeIgnoredPatterns(dir, ['mtc-a/', 'mtc-b/'])).toBe(2);
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['mtc-a/', 'mtc-b/']);
});
it('merges into an existing list, preserving other keys and de-duping', () => {
fs.writeFileSync(
path.join(dir, 'codegraph.json'),
JSON.stringify({ extensions: { '.foo': 'typescript' }, includeIgnored: ['mtc-a/'] }),
);
expect(addIncludeIgnoredPatterns(dir, ['mtc-a/', 'mtc-b/'])).toBe(1); // only mtc-b/ is new
const parsed = readConfig();
expect(parsed.includeIgnored).toEqual(['mtc-a/', 'mtc-b/']);
expect(parsed.extensions).toEqual({ '.foo': 'typescript' }); // untouched
});
it('is idempotent — re-adding the same patterns adds nothing', () => {
addIncludeIgnoredPatterns(dir, ['mtc-a/']);
expect(addIncludeIgnoredPatterns(dir, ['mtc-a/'])).toBe(0);
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['mtc-a/']);
});
it('replaces a non-array includeIgnored value rather than crashing', () => {
fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({ includeIgnored: 'oops' }));
expect(addIncludeIgnoredPatterns(dir, ['mtc-a/'])).toBe(1);
expect(loadIncludeIgnoredPatterns(dir)).toEqual(['mtc-a/']);
});
it('refuses to clobber a malformed existing codegraph.json (throws, leaves file intact)', () => {
const bad = '{ not: valid json ';
fs.writeFileSync(path.join(dir, 'codegraph.json'), bad);
expect(() => addIncludeIgnoredPatterns(dir, ['mtc-a/'])).toThrow();
expect(fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf-8')).toBe(bad);
});
it('writes pretty-printed, newline-terminated JSON', () => {
addIncludeIgnoredPatterns(dir, ['mtc-a/']);
const raw = fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf-8');
expect(raw.endsWith('\n')).toBe(true);
expect(raw).toContain('\n "includeIgnored"'); // 2-space indent
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* Regression coverage for issue #874: `codegraph index` produced 0 nodes / 0
* edges while `codegraph init` worked, and appeared to wipe the graph.
*
* Root cause: `index` ran a full extraction against the already-populated DB
* without clearing it first. Every file's content hash still matched, so the
* orchestrator skipped re-inserting all of them, and the run reported its delta
* (after - before = 0) as "0 nodes, 0 edges". The fix makes `index` a true full
* rebuild — clear, then re-index — so it produces the same complete result as a
* fresh `init`.
*
* Exercised end-to-end against the built binary so the CLI wiring (not just the
* library) is covered.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { DatabaseConnection } from '../src/db';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
/** Normalize a PRAGMA read across return shapes (array | object | scalar). */
function pragmaValue(raw: unknown, key: string): unknown {
const row = Array.isArray(raw) ? raw[0] : raw;
if (row !== null && typeof row === 'object') return (row as Record<string, unknown>)[key];
return row;
}
function runCodegraph(args: string[], cwd: string): string {
return execFileSync(process.execPath, [BIN, ...args], {
cwd,
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
}
function graphCounts(dir: string): { nodes: number; edges: number } {
const cg = CodeGraph.openSync(dir);
try {
const stats = cg.getStats();
return { nodes: stats.nodeCount, edges: stats.edgeCount };
} finally {
cg.close();
}
}
describe('codegraph index — full re-index keeps the graph populated (#874)', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-cmd-'));
// A couple of files with a call edge so there is a non-trivial graph to
// (fail to) reproduce.
fs.writeFileSync(
path.join(tempDir, 'a.ts'),
`export function greet(name: string) { return hello(name); }\n` +
`export function hello(n: string) { return 'hi ' + n; }\n`,
);
fs.writeFileSync(
path.join(tempDir, 'b.ts'),
`import { greet } from './a';\nexport function main() { return greet('world'); }\n`,
);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('reproduces init\'s node/edge counts instead of emptying the index', () => {
runCodegraph(['init'], tempDir);
const afterInit = graphCounts(tempDir);
expect(afterInit.nodes).toBeGreaterThan(0);
expect(afterInit.edges).toBeGreaterThan(0);
const out = runCodegraph(['index'], tempDir);
const afterIndex = graphCounts(tempDir);
// The graph is still fully populated — `index` rebuilt it, it did not wipe it.
expect(afterIndex.nodes).toBe(afterInit.nodes);
expect(afterIndex.edges).toBe(afterInit.edges);
// ...and the CLI reported the real counts, never the misleading "0 nodes".
expect(out).not.toMatch(/\b0 nodes, 0 edges\b/);
expect(out).toMatch(new RegExp(`\\b${afterInit.nodes} nodes\\b`));
});
it('is idempotent: a second index does not grow the graph', () => {
runCodegraph(['init'], tempDir);
runCodegraph(['index'], tempDir);
const first = graphCounts(tempDir);
runCodegraph(['index'], tempDir);
const second = graphCounts(tempDir);
// A clean rebuild each time — no duplicate (re-resolved) edges accumulating
// across runs (the C# "+18 edges" symptom in the report).
expect(second.nodes).toBe(first.nodes);
expect(second.edges).toBe(first.edges);
});
it('--quiet path also rebuilds a populated graph', () => {
runCodegraph(['init'], tempDir);
const afterInit = graphCounts(tempDir);
runCodegraph(['index', '--quiet'], tempDir);
const afterIndex = graphCounts(tempDir);
expect(afterIndex.nodes).toBe(afterInit.nodes);
expect(afterIndex.edges).toBe(afterInit.edges);
});
});
/**
* Regression coverage for issue #1067: a full re-index must RECOVER an existing
* oversized/stale index from earlier versions, not wedge on it.
*
* Root cause: `index` opened the old database and DELETE-d every row to clear
* it. With FTS triggers firing per deleted node, a pre-fix poisoned graph (an
* ignored gitlink corpus scanned into ~1.6M nodes + a multi-GB WAL, #1065) took
* well over the 60s liveness-watchdog window to clear, so the process was
* SIGKILLed before scanning even began and the bad state could never be rebuilt
* away. The fix discards (unlinks) the database files and re-initializes a fresh
* one — O(1) regardless of size — so `index` recovers any prior state.
*/
describe('codegraph index — recovers a stale/oversized prior index (#1067)', () => {
let tempDir: string;
const dbPath = (dir: string) => path.join(dir, '.codegraph', 'codegraph.db');
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-recover-'));
fs.writeFileSync(
path.join(tempDir, 'a.ts'),
`export function greet(name: string) { return hello(name); }\n` +
`export function hello(n: string) { return 'hi ' + n; }\n`,
);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('rebuilds to the current disk state, discarding content for files that no longer exist', () => {
// Stand in for the "old graph indexed an ignored corpus" shape: index a tree
// that also has a junk/ directory, then delete junk/ from disk so the DB now
// carries stale nodes for paths that should no longer be indexed.
const junkDir = path.join(tempDir, 'junk');
fs.mkdirSync(junkDir);
for (let i = 0; i < 12; i++) {
fs.writeFileSync(path.join(junkDir, `j${i}.ts`), `export function j${i}() { return ${i}; }\n`);
}
runCodegraph(['init'], tempDir);
const withJunk = graphCounts(tempDir);
// Remove the corpus from disk. The DB still holds its nodes — the stale,
// oversized prior state #1067 is about.
fs.rmSync(junkDir, { recursive: true, force: true });
runCodegraph(['index'], tempDir);
const recovered = graphCounts(tempDir);
// The rebuild reflects only what's on disk now — the junk nodes are gone…
expect(recovered.nodes).toBeLessThan(withJunk.nodes);
// …and the result is identical to a fresh init of the same (now-smaller) tree.
const fresh = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-fresh-'));
try {
fs.copyFileSync(path.join(tempDir, 'a.ts'), path.join(fresh, 'a.ts'));
runCodegraph(['init'], fresh);
const freshCounts = graphCounts(fresh);
expect(recovered.nodes).toBe(freshCounts.nodes);
expect(recovered.edges).toBe(freshCounts.edges);
} finally {
fs.rmSync(fresh, { recursive: true, force: true });
}
});
// The fix rebuilds a fresh DB rather than DELETE-ing rows in place. Prove it
// with a header sentinel: PRAGMA user_version survives an in-place clear but
// not a from-scratch recreate. (An inode check is unreliable — ext4/overlayfs
// recycle the inode number after unlink+recreate.)
it('rebuilds a fresh database rather than clearing the old one in place', () => {
runCodegraph(['init'], tempDir);
const stamp = DatabaseConnection.open(dbPath(tempDir));
stamp.getDb().pragma('user_version = 4242');
stamp.close();
runCodegraph(['index'], tempDir);
const check = DatabaseConnection.open(dbPath(tempDir));
const userVersion = pragmaValue(check.getDb().pragma('user_version'), 'user_version');
check.close();
// Sentinel gone → `index` discarded the old DB and rebuilt it, the path that
// avoids the per-row FTS delete wedge on a poisoned graph (#1067).
expect(Number(userVersion)).not.toBe(4242);
// …and the graph is intact afterwards.
const counts = graphCounts(tempDir);
expect(counts.nodes).toBeGreaterThan(0);
expect(counts.edges).toBeGreaterThan(0);
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* `index` / `init` command supervision regression test (#999, secondary issues).
*
* `codegraph index` runs in a child re-exec'd with `--liftoff-only` whose parent
* blocks in `spawnSync` and so cannot forward a signal — when the parent shim is
* killed the indexer used to keep running, orphaned, pinning a CPU core. The
* `#850` liveness watchdog and `#277` ppid watchdog were also wired only into
* `serve`, never `index`/`init`. `installCommandSupervision` (src/bin/
* command-supervision.ts) closes both gaps; this proves the orphan half end to
* end: a process running it self-terminates once its parent dies.
*
* Windows is excluded — `process.kill(pid, 'SIGKILL')` doesn't deliver SIGKILL
* there and the reparenting semantics the ppid watchdog relies on are POSIX-only
* (same exclusion as mcp-ppid-watchdog.test.ts).
*/
import { describe, it, expect, afterEach } from 'vitest';
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const SUPERVISION = path.resolve(__dirname, '../dist/bin/command-supervision.js');
function isAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
function waitForExit(pid: number, timeoutMs: number): Promise<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')('index/init orphan supervision (#999)', () => {
let wrapper: ChildProcessWithoutNullStreams | null = null;
let childPid: number | null = null;
afterEach(() => {
if (wrapper && !wrapper.killed) {
try { wrapper.kill('SIGKILL'); } catch { /* already gone */ }
}
if (childPid !== null && isAlive(childPid)) {
try { process.kill(childPid, 'SIGKILL'); } catch { /* already gone */ }
}
wrapper = null;
childPid = null;
});
it("self-terminates when its parent is SIGKILL'd mid-index", async () => {
const stderrLog = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'cg-index-orphan-')),
'child.stderr.log',
);
// The child stands in for a running indexer: it installs the SAME command
// supervision `index`/`init` install, then idles on a ref'd timer so it
// stays alive until the watchdog (not the timer) takes it down.
// CODEGRAPH_NO_WATCHDOG=1 isolates the ppid (orphan) path from the liveness
// child; CODEGRAPH_PPID_POLL_MS=200 keeps it responsive in test.
const childSrc = `
const { installCommandSupervision } = require(${JSON.stringify(SUPERVISION)});
installCommandSupervision('index');
process.stdout.write('UP ' + process.pid + '\\n');
setInterval(() => {}, 60000);
`;
// The wrapper spawns the child detached (so it's reparented to init when the
// wrapper dies, not killed with it), waits for it to report its pid + install
// the watchdog, relays the pid, then idles until SIGKILL'd.
const wrapperSrc = `
const { spawn } = require('child_process');
const fs = require('fs');
const errFd = fs.openSync(${JSON.stringify(stderrLog)}, 'a');
const child = spawn(process.execPath, ['-e', ${JSON.stringify(childSrc)}], {
stdio: ['ignore', 'pipe', errFd],
env: { ...process.env, CODEGRAPH_NO_WATCHDOG: '1', CODEGRAPH_PPID_POLL_MS: '200', CODEGRAPH_WASM_RELAUNCHED: '1' },
detached: true,
});
child.unref();
child.stdout.on('data', (d) => {
const m = /UP (\\d+)/.exec(d.toString());
if (m) process.stdout.write(JSON.stringify({ pid: Number(m[1]) }) + '\\n');
});
setInterval(() => {}, 60000);
`;
wrapper = spawn(process.execPath, ['-e', wrapperSrc], {
stdio: ['pipe', 'pipe', 'inherit'],
}) as ChildProcessWithoutNullStreams;
const { pid } = await new Promise<{ pid: number }>((resolve, reject) => {
let buf = '';
const timer = setTimeout(() => reject(new Error('child did not report its pid in time')), 10000);
wrapper!.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString('utf8');
const m = buf.match(/\{"pid":(\d+)\}/);
if (m) { clearTimeout(timer); resolve({ pid: parseInt(m[1], 10) }); }
});
wrapper!.on('exit', () => { clearTimeout(timer); reject(new Error('wrapper exited before reporting pid')); });
});
childPid = pid;
expect(isAlive(childPid)).toBe(true);
// SIGKILL the wrapper — no cleanup runs, just like killing the parent shim.
// The child is reparented to init; only its ppid watchdog can take it down.
wrapper.kill('SIGKILL');
const exited = await waitForExit(childPid, 5000);
const stderr = fs.existsSync(stderrLog) ? fs.readFileSync(stderrLog, 'utf-8') : '<none>';
expect(
exited,
`child (pid=${childPid}) did not self-terminate within 5s after parent SIGKILL.\nstderr:\n${stderr}`,
).toBe(true);
// Confirm it died from the parent-death path, not some other cause.
expect(stderr).toMatch(/Parent process exited.*aborting/);
}, 20000);
});
+113
View File
@@ -0,0 +1,113 @@
/**
* install.sh version-prune tests (issue #1074).
*
* The standalone installer keeps each release in its own `versions/<v>` dir and
* — before this fix — never removed the old ones, so they piled up (~50 MB of
* vendored Node runtime each) across upgrades. `install.sh` now prunes every
* `versions/*` dir except the one it just installed.
*
* Rather than duplicate the shell (which would drift from the shipped script),
* these tests extract the REAL prune block from `install.sh` — between its
* `CODEGRAPH_PRUNE_OLD_VERSIONS` markers — and run it under `sh` against a temp
* fixture, with `$INSTALL_DIR` / `$dest` injected. No network, no download.
*
* POSIX only: the block is `/bin/sh`. Windows installs overwrite a single dir in
* place (install.ps1) and never reach this code, so there's nothing to prune.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const INSTALL_SH = path.join(__dirname, '..', 'install.sh');
const START = '# >>> CODEGRAPH_PRUNE_OLD_VERSIONS';
const END = '# <<< CODEGRAPH_PRUNE_OLD_VERSIONS';
/** Pull the exact prune block out of the shipped install.sh (no duplication). */
function extractPruneBlock(): string {
const lines = fs.readFileSync(INSTALL_SH, 'utf8').split('\n');
const i = lines.findIndex((l) => l.trim() === START);
const j = lines.findIndex((l) => l.trim() === END);
if (i < 0 || j < 0 || j <= i) {
throw new Error('CODEGRAPH_PRUNE_OLD_VERSIONS markers not found in install.sh');
}
return lines.slice(i + 1, j).join('\n');
}
/** Single-quote a path for safe interpolation into the sh script. */
function shq(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
/** Run the real prune block with INSTALL_DIR/dest set, return code + stdout. */
function runPrune(installDir: string, dest: string): { code: number; stdout: string } {
const script = `set -eu\nINSTALL_DIR=${shq(installDir)}\ndest=${shq(dest)}\n${extractPruneBlock()}\n`;
const r = spawnSync('sh', ['-c', script], { encoding: 'utf8' });
return { code: r.status ?? -1, stdout: r.stdout ?? '' };
}
/** Create a versions/<v>/bin dir with a dummy launcher, like a real bundle. */
function seedVersion(installDir: string, version: string): string {
const dir = path.join(installDir, 'versions', version);
fs.mkdirSync(path.join(dir, 'bin'), { recursive: true });
fs.writeFileSync(path.join(dir, 'bin', 'codegraph'), '#!/bin/sh\n');
return dir;
}
describe.skipIf(process.platform === 'win32')('install.sh version prune (#1074)', () => {
let installDir: string;
beforeEach(() => {
installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-prune-'));
});
afterEach(() => {
fs.rmSync(installDir, { recursive: true, force: true });
});
it('removes older version dirs and keeps only the just-installed one', () => {
seedVersion(installDir, 'v1.1.2');
seedVersion(installDir, 'v1.1.3');
const dest = seedVersion(installDir, 'v1.1.4');
fs.symlinkSync(dest, path.join(installDir, 'current'));
const { code, stdout } = runPrune(installDir, dest);
expect(code).toBe(0);
const remaining = fs.readdirSync(path.join(installDir, 'versions')).sort();
expect(remaining).toEqual(['v1.1.4']);
expect(stdout).toContain('Removed 2 older version(s)');
// The `current` symlink (outside versions/) is never globbed → untouched.
expect(fs.existsSync(path.join(installDir, 'current'))).toBe(true);
expect(fs.realpathSync(path.join(installDir, 'current'))).toBe(fs.realpathSync(dest));
});
it('is a silent no-op when the just-installed version is the only one', () => {
const dest = seedVersion(installDir, 'v1.1.4');
const { code, stdout } = runPrune(installDir, dest);
expect(code).toBe(0);
expect(fs.readdirSync(path.join(installDir, 'versions'))).toEqual(['v1.1.4']);
expect(stdout).not.toContain('Removed');
});
it('does not error when there is no versions/ dir yet', () => {
const dest = path.join(installDir, 'versions', 'v1.1.4'); // never created
const { code, stdout } = runPrune(installDir, dest);
expect(code).toBe(0);
expect(stdout).not.toContain('Removed');
});
it('reports the count when several older versions are present', () => {
for (const v of ['v1.0.0', 'v1.1.0', 'v1.1.1', 'v1.1.2', 'v1.1.3']) seedVersion(installDir, v);
const dest = seedVersion(installDir, 'v1.1.4');
const { code, stdout } = runPrune(installDir, dest);
expect(code).toBe(0);
expect(fs.readdirSync(path.join(installDir, 'versions'))).toEqual(['v1.1.4']);
expect(stdout).toContain('Removed 5 older version(s)');
});
});
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
/**
* Installer Tests
*
* Tests for installer config-writer fixes:
* - readJsonFile error handling
*
* (The CLAUDE.md instructions block is no longer written — see issue
* #529. The marker-based install/uninstall self-heal is covered in
* `installer-targets.test.ts`.)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// We test the exported functions from config-writer
import {
writeMcpConfig,
} from '../src/installer/config-writer';
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-installer-test-'));
}
function cleanupTempDir(dir: string): void {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe('Installer Config Writer', () => {
let origCwd: string;
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
origCwd = process.cwd();
process.chdir(tempDir);
});
afterEach(() => {
process.chdir(origCwd);
cleanupTempDir(tempDir);
});
describe('readJsonFile error handling', () => {
it('should return empty object for non-existent file', () => {
// writeMcpConfig reads .mcp.json - if it doesn't exist, it should create it
writeMcpConfig('local');
const mcpJson = path.join(tempDir, '.mcp.json');
expect(fs.existsSync(mcpJson)).toBe(true);
const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8'));
expect(content.mcpServers).toBeDefined();
expect(content.mcpServers.codegraph).toBeDefined();
});
it('should handle corrupted JSON by creating backup', () => {
// Create a corrupted .mcp.json
const mcpJson = path.join(tempDir, '.mcp.json');
fs.writeFileSync(mcpJson, '{ this is not valid json !!!');
// Suppress console.warn during test
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Should not throw - gracefully handles corruption
writeMcpConfig('local');
// Should have warned
expect(warnSpy).toHaveBeenCalled();
const warnMsg = warnSpy.mock.calls[0][0];
expect(warnMsg).toContain('Warning');
// Backup should exist
expect(fs.existsSync(mcpJson + '.backup')).toBe(true);
// Original backup content should be the corrupted content
const backup = fs.readFileSync(mcpJson + '.backup', 'utf-8');
expect(backup).toContain('this is not valid json');
// New file should be valid JSON with codegraph config
const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8'));
expect(content.mcpServers.codegraph).toBeDefined();
warnSpy.mockRestore();
});
it('should preserve existing valid config when adding codegraph', () => {
const mcpJson = path.join(tempDir, '.mcp.json');
fs.writeFileSync(mcpJson, JSON.stringify({
mcpServers: { other: { command: 'other-tool' } },
customField: 'preserved',
}, null, 2));
writeMcpConfig('local');
const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8'));
expect(content.mcpServers.codegraph).toBeDefined();
expect(content.mcpServers.other).toBeDefined();
expect(content.customField).toBe('preserved');
});
});
});
+272
View File
@@ -0,0 +1,272 @@
/**
* End-to-end pipeline integration tests
*
* Exercises the full happy path that unit tests cover in isolation:
* init → indexAll → resolveReferences → searchNodes/getCallers/buildContext → sync
*
* Also covers two error paths that were previously uncovered:
* - Indexing a file that contains a syntactically invalid snippet
* (parse errors must not abort the batch).
* - Sync correctly applies adds + modifies + removes in a single pass.
*
* A synthetic ~120-file project is generated per test (5k files would
* dwarf the test runner; 120 files of varied TS shape is enough to
* stress the resolver and graph layers without slowing the suite to a
* crawl).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../../src/index';
function createTempDir(prefix = 'codegraph-int-'): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanupTempDir(dir: string): void {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
/**
* Generate a synthetic TypeScript project with the given module count.
* Each module exports a function that calls the previous module's
* function so that the resolver has real import edges + call edges to
* resolve. The first module is a leaf; the last is the root.
*/
function generateSyntheticProject(root: string, moduleCount: number): void {
const srcDir = path.join(root, 'src');
fs.mkdirSync(srcDir, { recursive: true });
// Leaf module — no imports.
fs.writeFileSync(
path.join(srcDir, `mod0.ts`),
`export function fn0(x: number): number { return x + 1; }\n` +
`export class Mod0 { ping(): string { return 'mod0'; } }\n`
);
for (let i = 1; i < moduleCount; i++) {
const prev = i - 1;
fs.writeFileSync(
path.join(srcDir, `mod${i}.ts`),
`import { fn${prev}, Mod${prev} } from './mod${prev}';\n` +
`export function fn${i}(x: number): number { return fn${prev}(x) + 1; }\n` +
`export class Mod${i} extends Mod${prev} {\n` +
` call${i}(): number { return fn${i}(${i}); }\n` +
`}\n`
);
}
// Entry point file.
fs.writeFileSync(
path.join(srcDir, 'index.ts'),
`import { fn${moduleCount - 1}, Mod${moduleCount - 1} } from './mod${moduleCount - 1}';\n` +
`export function entry(): number {\n` +
` const m = new Mod${moduleCount - 1}();\n` +
` return fn${moduleCount - 1}(0) + m.call${moduleCount - 1}();\n` +
`}\n`
);
}
describe('Integration: full pipeline', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('runs init → index → resolve → search → callers → context → sync', async () => {
const MODULE_COUNT = 120;
generateSyntheticProject(tempDir, MODULE_COUNT);
// ── init ──────────────────────────────────────────────────────
const cg = await CodeGraph.init(tempDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
try {
// ── indexAll ────────────────────────────────────────────────
const indexResult = await cg.indexAll();
// Synthetic project: MODULE_COUNT mod files + 1 index file.
expect(indexResult.filesIndexed).toBeGreaterThanOrEqual(MODULE_COUNT);
const statsAfterIndex = cg.getStats();
expect(statsAfterIndex.fileCount).toBeGreaterThanOrEqual(MODULE_COUNT);
expect(statsAfterIndex.nodeCount).toBeGreaterThan(MODULE_COUNT * 2);
// ── resolveReferences ────────────────────────────────────────
// Many call-site edges are wired up during extraction itself, so
// the unresolved-reference queue may already be drained by the
// time we get here. We assert that resolve completes cleanly and
// returns a well-formed result; downstream callers/callees
// assertions verify the graph is actually populated.
cg.reinitializeResolver();
const resolution = cg.resolveReferences();
expect(resolution).toBeDefined();
expect(resolution.stats).toBeDefined();
expect(typeof resolution.stats.total).toBe('number');
expect(typeof resolution.stats.resolved).toBe('number');
// ── searchNodes ──────────────────────────────────────────────
const entryResults = cg.searchNodes('entry', { limit: 10 });
expect(entryResults.length).toBeGreaterThan(0);
const entryNode = entryResults.find((r) => r.node.name === 'entry');
expect(entryNode).toBeDefined();
const midResults = cg.searchNodes(`fn50`, { limit: 10 });
expect(midResults.find((r) => r.node.name === 'fn50')).toBeDefined();
// ── getCallers / getCallees ──────────────────────────────────
const fn0Results = cg.searchNodes('fn0', { limit: 5 });
const fn0Node = fn0Results.find((r) => r.node.name === 'fn0');
expect(fn0Node).toBeDefined();
const callers = cg.getCallers(fn0Node!.node.id);
// fn0 is called by fn1 (at least). After resolution this should
// be wired up.
expect(Array.isArray(callers)).toBe(true);
// ── buildContext ─────────────────────────────────────────────
const context = await cg.buildContext('entry function chain', {
maxNodes: 10,
format: 'markdown',
});
expect(typeof context).toBe('string');
expect((context as string).length).toBeGreaterThan(0);
// ── sync (add + modify + remove in one pass) ─────────────────
// Add: a new file referencing entry().
fs.writeFileSync(
path.join(tempDir, 'src', 'consumer.ts'),
`import { entry } from './index';\nexport const result = entry();\n`
);
// Modify: change mod0.
fs.writeFileSync(
path.join(tempDir, 'src', 'mod0.ts'),
`export function fn0(x: number): number { return x + 2; }\n` +
`export function newHelper(): string { return 'new'; }\n` +
`export class Mod0 { ping(): string { return 'mod0v2'; } }\n`
);
// Remove: drop mod1 — note this will leave dangling imports in
// mod2, which the resolver should tolerate.
fs.unlinkSync(path.join(tempDir, 'src', 'mod1.ts'));
const syncResult = await cg.sync();
expect(syncResult.filesAdded).toBeGreaterThanOrEqual(1);
expect(syncResult.filesModified).toBeGreaterThanOrEqual(1);
expect(syncResult.filesRemoved).toBeGreaterThanOrEqual(1);
// New symbol must now be findable; removed file's symbols gone.
expect(cg.searchNodes('newHelper').length).toBeGreaterThan(0);
// Removed file should no longer appear in the indexed file list.
// (FTS prefix matching makes name-based assertions unreliable here —
// Mod10/Mod11/… all start with "Mod1" — so we check the file set
// instead.)
const filesAfterSync = cg.getNodesInFile('src/mod1.ts');
expect(filesAfterSync).toHaveLength(0);
} finally {
cg.destroy();
}
}, 60_000);
it('keeps indexing files when one file has a parse error', async () => {
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
// Valid files
fs.writeFileSync(
path.join(srcDir, 'good1.ts'),
`export function good1(): number { return 1; }\n`
);
fs.writeFileSync(
path.join(srcDir, 'good2.ts'),
`export function good2(): number { return 2; }\n`
);
// Intentionally broken file — unclosed brace, stray tokens.
fs.writeFileSync(
path.join(srcDir, 'broken.ts'),
`export function broken(\n this is { not valid typescript at all\n`
);
const cg = await CodeGraph.init(tempDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
try {
const result = await cg.indexAll();
// The two good files must still be indexed regardless of the
// broken one. Tree-sitter is error-tolerant so it may still
// extract a partial AST from broken.ts — but the test only
// requires that the batch completes and finds the good symbols.
expect(result.filesIndexed).toBeGreaterThanOrEqual(2);
const good1 = cg.searchNodes('good1');
const good2 = cg.searchNodes('good2');
expect(good1.find((r) => r.node.name === 'good1')).toBeDefined();
expect(good2.find((r) => r.node.name === 'good2')).toBeDefined();
} finally {
cg.destroy();
}
}, 30_000);
it('handles repeated sync calls when nothing has changed', async () => {
generateSyntheticProject(tempDir, 10);
const cg = await CodeGraph.init(tempDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
try {
await cg.indexAll();
const statsBefore = cg.getStats();
const first = await cg.sync();
const second = await cg.sync();
// Subsequent sync with no changes should be a no-op.
expect(first.filesAdded + first.filesModified + first.filesRemoved).toBe(0);
expect(second.filesAdded + second.filesModified + second.filesRemoved).toBe(0);
const statsAfter = cg.getStats();
expect(statsAfter.fileCount).toBe(statsBefore.fileCount);
expect(statsAfter.nodeCount).toBe(statsBefore.nodeCount);
} finally {
cg.destroy();
}
}, 30_000);
it('reports edgesCreated including resolution + synthesizer phases', async () => {
// The synthetic project has cross-file imports, calls, and extends —
// all wired up in the resolution phase, AFTER the orchestrator's
// per-file extraction counter is done. The CLI summary used to read
// only the extraction-phase counter and undercount the graph; this
// test pins the counter to the true DB totals across all phases.
generateSyntheticProject(tempDir, 30);
const cg = await CodeGraph.init(tempDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
try {
const result = await cg.indexAll();
const stats = cg.getStats();
expect(result.success).toBe(true);
expect(result.nodesCreated).toBe(stats.nodeCount);
expect(result.edgesCreated).toBe(stats.edgeCount);
// Sanity: cross-file resolution had something to do — calls/extends
// edges should exist beyond the bare extraction-time contains edges.
const containsOnly = stats.edgesByKind.contains ?? 0;
expect(stats.edgeCount).toBeGreaterThan(containsOnly);
} finally {
cg.destroy();
}
}, 30_000);
});
+96
View File
@@ -0,0 +1,96 @@
/**
* LRUCache unit tests
*
* Covers the eviction guarantees that the resolver relies on:
* - capacity is enforced (never exceeds max)
* - LRU ordering: hot keys survive eviction passes
* - has()/get()/set()/clear() behave like the original Map shape
* - null values are storable (the fileCache uses null for "failed read")
*/
import { describe, it, expect } from 'vitest';
import { LRUCache } from '../../src/resolution/lru-cache';
describe('LRUCache', () => {
it('enforces capacity by evicting the oldest entry on overflow', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.set('d', 4); // evicts 'a'
expect(cache.size).toBe(3);
expect(cache.has('a')).toBe(false);
expect(cache.get('a')).toBeUndefined();
expect(cache.get('b')).toBe(2);
expect(cache.get('c')).toBe(3);
expect(cache.get('d')).toBe(4);
});
it('promotes touched keys to most-recent so they survive eviction', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
// Touch 'a' — it should now be most-recent.
expect(cache.get('a')).toBe(1);
cache.set('d', 4); // evicts the LRU, which is now 'b' (not 'a')
expect(cache.has('a')).toBe(true);
expect(cache.has('b')).toBe(false);
expect(cache.has('c')).toBe(true);
expect(cache.has('d')).toBe(true);
});
it('overwriting an existing key refreshes its recency but does not grow size', () => {
const cache = new LRUCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('a', 99); // 'a' is now most-recent
expect(cache.size).toBe(2);
expect(cache.get('a')).toBe(99);
cache.set('c', 3); // should evict 'b', not 'a'
expect(cache.has('a')).toBe(true);
expect(cache.has('b')).toBe(false);
expect(cache.has('c')).toBe(true);
});
it('stores null values (used by the file content cache)', () => {
const cache = new LRUCache<string, string | null>(2);
cache.set('missing.ts', null);
expect(cache.has('missing.ts')).toBe(true);
expect(cache.get('missing.ts')).toBeNull();
});
it('clear() resets the cache', () => {
const cache = new LRUCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.clear();
expect(cache.size).toBe(0);
expect(cache.has('a')).toBe(false);
});
it('rejects non-positive capacity', () => {
expect(() => new LRUCache(0)).toThrow();
expect(() => new LRUCache(-1)).toThrow();
expect(() => new LRUCache(NaN)).toThrow();
});
it('stays bounded under heavy churn (regression for OOM scenario)', () => {
const cache = new LRUCache<string, number>(100);
for (let i = 0; i < 10_000; i++) {
cache.set(`key${i}`, i);
}
expect(cache.size).toBe(100);
// The last 100 keys should still be present, the rest evicted.
expect(cache.has('key9999')).toBe(true);
expect(cache.has('key9900')).toBe(true);
expect(cache.has('key0')).toBe(false);
});
});
@@ -0,0 +1,109 @@
/**
* MCP tool input-size limits
*
* Regression coverage for the DoS vector: MCP clients can ship
* unbounded payloads (`query`, `task`, `symbol`, `projectPath`,
* `path`, `pattern`). Before the cap, a 100MB string would hit
* the FTS5 layer and pin the server. These tests assert that the
* tool layer rejects oversize inputs early.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../../src/index';
import { ToolHandler } from '../../src/mcp/tools';
describe('MCP input size limits', () => {
let tempDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-limits-'));
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'src', 'a.ts'),
`export function alpha(): number { return 1; }\n`
);
cg = await CodeGraph.init(tempDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('accepts a normal-sized query', async () => {
const result = await handler.execute('codegraph_search', { query: 'alpha' });
expect(result.isError).toBeFalsy();
});
it('rejects an oversize query on codegraph_search', async () => {
const huge = 'a'.repeat(20_000);
const result = await handler.execute('codegraph_search', { query: huge });
expect(result.isError).toBe(true);
expect(result.content[0]!.text).toMatch(/maximum length/i);
});
it('rejects an oversize query on codegraph_explore', async () => {
const huge = 'b'.repeat(50_000);
const result = await handler.execute('codegraph_explore', { query: huge });
expect(result.isError).toBe(true);
expect(result.content[0]!.text).toMatch(/maximum length/i);
});
it('rejects an oversize symbol on codegraph_callers', async () => {
const huge = 'c'.repeat(15_000);
const result = await handler.execute('codegraph_callers', { symbol: huge });
expect(result.isError).toBe(true);
expect(result.content[0]!.text).toMatch(/maximum length/i);
});
it('rejects an oversize symbol on codegraph_impact', async () => {
const huge = 'd'.repeat(11_000);
const result = await handler.execute('codegraph_impact', { symbol: huge });
expect(result.isError).toBe(true);
expect(result.content[0]!.text).toMatch(/maximum length/i);
});
it('rejects an oversize projectPath', async () => {
const hugePath = '/tmp/' + 'x'.repeat(5_000);
const result = await handler.execute('codegraph_search', {
query: 'alpha',
projectPath: hugePath,
});
expect(result.isError).toBe(true);
expect(result.content[0]!.text).toMatch(/projectPath/);
});
it('rejects an oversize path filter on codegraph_files', async () => {
const hugePath = 'src/' + 'y'.repeat(5_000);
const result = await handler.execute('codegraph_files', { path: hugePath });
expect(result.isError).toBe(true);
expect(result.content[0]!.text).toMatch(/path/);
});
it('rejects an oversize glob pattern on codegraph_files', async () => {
const hugePattern = '*'.repeat(5_000);
const result = await handler.execute('codegraph_files', { pattern: hugePattern });
expect(result.isError).toBe(true);
expect(result.content[0]!.text).toMatch(/pattern/);
});
it('rejects a non-string projectPath', async () => {
const result = await handler.execute('codegraph_search', {
query: 'alpha',
projectPath: 12345 as unknown as string,
});
expect(result.isError).toBe(true);
expect(result.content[0]!.text).toMatch(/projectPath/);
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* isTestFile heuristic — test-file detection used to deprioritize test code in
* search/explore ranking.
*
* Regression coverage for the cold-query fix: the heuristic previously only
* knew Java/JS/Python conventions, so Kotlin (`*Test.kt`, `jvmTest/`), Swift
* (`*Tests.swift`), and camelCase test source-set dirs slipped through — which
* let OkHttp's tests flood `codegraph_explore` results on a plain-language
* query. The false-positive guards matter just as much: `latest.kt` /
* `manifest.kt` / a `RealCall.kt` production file must NOT be flagged.
*/
import { describe, it, expect } from 'vitest';
import { isTestFile } from '../src/search/query-utils';
describe('isTestFile', () => {
it('flags Kotlin test files and source sets', () => {
expect(isTestFile('okhttp/src/jvmTest/kotlin/okhttp3/CallTest.kt')).toBe(true);
expect(isTestFile('okhttp/src/commonTest/kotlin/okhttp3/CompressionInterceptorTest.kt')).toBe(true);
expect(isTestFile('app/src/androidTest/java/com/example/FooTest.kt')).toBe(true);
expect(isTestFile('module/src/integrationTest/kotlin/BarSpec.kt')).toBe(true);
});
it('flags Swift test files', () => {
expect(isTestFile('Tests/SessionTests.swift')).toBe(true);
expect(isTestFile('Sources/FooTest.swift')).toBe(true);
});
it('still flags the previously-supported conventions', () => {
expect(isTestFile('foo/test_bar.py')).toBe(true);
expect(isTestFile('pkg/bar_test.go')).toBe(true);
expect(isTestFile('src/foo.test.ts')).toBe(true);
expect(isTestFile('src/foo.spec.ts')).toBe(true);
expect(isTestFile('com/example/FooTest.java')).toBe(true);
expect(isTestFile('com/example/FooTestCase.java')).toBe(true);
expect(isTestFile('project/__tests__/foo.ts')).toBe(true);
expect(isTestFile('project/tests/foo.rb')).toBe(true);
});
it('does NOT flag production files that merely contain "test" lowercase', () => {
// The fix is capital-led so camelCase boundaries distinguish these.
expect(isTestFile('src/latest/loader.kt')).toBe(false);
expect(isTestFile('lib/manifest.kt')).toBe(false);
expect(isTestFile('okhttp/src/jvmMain/kotlin/okhttp3/internal/connection/RealCall.kt')).toBe(false);
expect(isTestFile('src/contestEntry.ts')).toBe(false);
expect(isTestFile('pkg/greatest.go')).toBe(false);
});
it('does NOT flag ordinary production source', () => {
expect(isTestFile('src/flask/app.py')).toBe(false);
expect(isTestFile('src/vs/workbench/api/common/extensionHostMain.ts')).toBe(false);
expect(isTestFile('okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt')).toBe(false);
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* `QueryBuilder.iterateNodesByKind` — the streaming scan that fixes the #610
* OOM. The dynamic-edge synthesizers used to `getNodesByKind('function')` /
* `('method')`, materializing every symbol into one array (gigabytes on a
* symbol-dense project → JS-heap OOM). They now iterate. These tests pin the
* two properties that refactor relies on: the streamed set equals the eager
* set, and an open iterator cursor coexists with other queries on the same
* connection.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
describe('iterateNodesByKind (#610 streaming)', () => {
let dir: string;
let cg: CodeGraph;
beforeEach(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-iter-'));
fs.mkdirSync(path.join(dir, 'src'));
fs.writeFileSync(
path.join(dir, 'src', 'a.ts'),
'export function foo() { return 1; }\n' +
'export function bar() { return 2; }\n' +
'export class C { m() { return 3; } n() { return 4; } }\n'
);
cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
});
afterEach(() => {
try { cg.close(); } catch { /* ignore */ }
fs.rmSync(dir, { recursive: true, force: true });
});
it('yields exactly the same nodes as the eager getNodesByKind', () => {
const q = (cg as unknown as { queries: any }).queries;
for (const kind of ['function', 'method', 'class'] as const) {
const eager = q.getNodesByKind(kind).map((n: any) => n.id).sort();
const streamed = [...q.iterateNodesByKind(kind)].map((n: any) => n.id).sort();
expect(streamed).toEqual(eager);
}
// sanity: the fixture actually produced functions + methods to stream
expect([...q.iterateNodesByKind('function')].length).toBeGreaterThan(0);
expect([...q.iterateNodesByKind('method')].length).toBeGreaterThan(0);
});
it('keeps the cursor valid while other queries run mid-iteration', () => {
const q = (cg as unknown as { queries: any }).queries;
let seen = 0;
for (const n of q.iterateNodesByKind('function')) {
// A different prepared statement stepped on the same connection while the
// iterator's cursor is open must not corrupt it.
const again = q.getNodeById(n.id);
expect(again?.id).toBe(n.id);
seen++;
}
expect(seen).toBe(q.getNodesByKind('function').length);
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* Laravel event-dispatch bridge (PHP).
*
* Laravel decouples an event dispatch from its listener(s), linked by the event class:
* `event(new SongLiked($id))` has no static edge to the `handle(SongLiked $e)` that runs it
* (usually a separate `app/Listeners/` file). This bridges each `event(new X(...))` site to every
* listener's `handle` for X, via TWO registration mechanisms: (A) a typed `handle(EventType $e)`
* (auto-discovery, union-split for `A|B`) and (B) the `protected $listen` map in an
* EventServiceProvider (which also covers a listener whose `handle()` is untyped). Queued JOBS
* dispatch via `::dispatch()`/`dispatch()` and their `handle()` takes a service — so only
* `event(new X)` is matched and jobs are excluded.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('laravel-event synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'laravel-event-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
it('bridges event(new X) to listener handles via typed handles, the $listen map, unions, and fan-out; excludes jobs', async () => {
for (const [name, body] of [
['SongLiked', 'public int $id; public function __construct(int $id) { $this->id = $id; }'],
['LibraryChanged', ''],
['ScanDone', ''],
['OwnerTest', ''],
['UserTest', ''],
] as const) {
write(`app/Events/${name}.php`, `<?php\nnamespace App\\Events;\nclass ${name} {\n ${body}\n}\n`);
}
// (A) typed-handle listener — auto-discovery, no $listen entry needed.
write('app/Listeners/LoveTrack.php', `<?php
namespace App\\Listeners;
use App\\Events\\SongLiked;
class LoveTrack {
public function handle(SongLiked $event): void {}
}
`);
// (B) UNTYPED handle — linkable only through the $listen map.
write('app/Listeners/PruneLibrary.php', `<?php
namespace App\\Listeners;
class PruneLibrary {
public function handle(): void {}
}
`);
// Fan-out: two listeners for ScanDone.
write('app/Listeners/WriteScanLog.php', `<?php
namespace App\\Listeners;
use App\\Events\\ScanDone;
class WriteScanLog {
public function handle(ScanDone $event): void {}
}
`);
write('app/Listeners/DeleteStale.php', `<?php
namespace App\\Listeners;
use App\\Events\\ScanDone;
class DeleteStale {
public function handle(ScanDone $event): void {}
}
`);
// Union-typed handle — one listener, two events.
write('app/Listeners/SendsTestNotification.php', `<?php
namespace App\\Listeners;
use App\\Events\\OwnerTest;
use App\\Events\\UserTest;
class SendsTestNotification {
public function handle(OwnerTest|UserTest $event): void {}
}
`);
// A queued JOB — handle takes a service, dispatched via ::dispatch()/dispatch(). Never an edge.
write('app/Jobs/ProcessAudio.php', `<?php
namespace App\\Jobs;
use App\\Services\\AudioService;
class ProcessAudio implements ShouldQueue {
public function handle(AudioService $svc): void {}
}
`);
// The $listen map — registers the untyped PruneLibrary for LibraryChanged.
write('app/Providers/EventServiceProvider.php', `<?php
namespace App\\Providers;
use App\\Events\\LibraryChanged;
use App\\Listeners\\PruneLibrary;
class EventServiceProvider {
protected $listen = [
LibraryChanged::class => [
PruneLibrary::class,
],
];
}
`);
write('app/Services/SongService.php', `<?php
namespace App\\Services;
use App\\Events\\SongLiked;
use App\\Events\\LibraryChanged;
use App\\Events\\ScanDone;
use App\\Events\\OwnerTest;
use App\\Events\\UserTest;
use App\\Jobs\\ProcessAudio;
class SongService {
public function like(int $id): void { event(new SongLiked($id)); }
public function deleteSongs(): void { event(new LibraryChanged()); }
public function scan(): void { event(new ScanDone()); }
public function ownerTest(): void { event(new OwnerTest()); }
public function userTest(): void { event(new UserTest()); }
public function process(): void {
ProcessAudio::dispatch();
dispatch(new ProcessAudio());
}
}
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges = db
.prepare(
`SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'laravel-event'`
)
.all();
const bySrc = (s: string) => edges.filter((r: any) => r.source === s);
const file = (r: any) => /(\w+)\.php$/.exec(r.tf)![1];
expect(edges.length).toBe(6);
expect(edges.every((r: any) => r.target === 'handle')).toBe(true);
// (A) typed handle.
expect(bySrc('like').map((r: any) => [r.via, file(r)])).toEqual([['SongLiked', 'LoveTrack']]);
// (B) untyped handle via the $listen map.
expect(bySrc('deleteSongs').map((r: any) => [r.via, file(r)])).toEqual([['LibraryChanged', 'PruneLibrary']]);
// Fan-out: ScanDone → both listeners.
expect(new Set(bySrc('scan').map(file))).toEqual(new Set(['WriteScanLog', 'DeleteStale']));
// Union split: OwnerTest and UserTest each reach the one listener (separate dispatchers,
// so they aren't deduped to a single source→target edge).
expect(bySrc('ownerTest').map((r: any) => [r.via, file(r)])).toEqual([['OwnerTest', 'SendsTestNotification']]);
expect(bySrc('userTest').map((r: any) => [r.via, file(r)])).toEqual([['UserTest', 'SendsTestNotification']]);
// PRECISION: a queued job (::dispatch / dispatch()) produces nothing.
expect(edges.some((r: any) => r.source === 'process')).toBe(false);
cg.close?.();
});
it('produces no edges in a PHP project with no Laravel events (clean control)', async () => {
write('src/Client.php', `<?php
namespace Acme;
class Client {
public function send(string $url): string { return $url; }
}
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const count = db
.prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'laravel-event'`)
.get();
expect(count.c).toBe(0);
cg.close?.();
});
});
+197
View File
@@ -0,0 +1,197 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import {
parseWatchdogTimeoutMs,
deriveCheckIntervalMs,
installMainThreadWatchdog,
DEFAULT_WATCHDOG_TIMEOUT_MS,
} from '../src/mcp/liveness-watchdog';
describe('config parsing', () => {
it('parseWatchdogTimeoutMs falls back for missing/invalid input', () => {
expect(parseWatchdogTimeoutMs(undefined)).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
expect(parseWatchdogTimeoutMs('not-a-number')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
expect(parseWatchdogTimeoutMs('0')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
expect(parseWatchdogTimeoutMs('-5')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
expect(parseWatchdogTimeoutMs('1500')).toBe(1500);
});
it('deriveCheckIntervalMs stays within [50, 2000] and scales with the timeout', () => {
expect(deriveCheckIntervalMs(60_000)).toBe(2000); // clamped high
expect(deriveCheckIntervalMs(500)).toBe(100); // 500/5
expect(deriveCheckIntervalMs(10)).toBe(50); // clamped low
});
});
describe('installMainThreadWatchdog opt-out', () => {
it('returns null (spawns nothing) when CODEGRAPH_NO_WATCHDOG is set', () => {
const prev = process.env.CODEGRAPH_NO_WATCHDOG;
process.env.CODEGRAPH_NO_WATCHDOG = '1';
try {
expect(installMainThreadWatchdog()).toBeNull();
} finally {
if (prev === undefined) delete process.env.CODEGRAPH_NO_WATCHDOG;
else process.env.CODEGRAPH_NO_WATCHDOG = prev;
}
});
});
/**
* End-to-end: spawn a real process, install the real watchdog (which spawns a
* separate watchdog child), and prove it kills a wedged main thread — including
* the case a worker thread could NOT (a non-allocating loop under heap pressure,
* which strands a same-process worker on V8's global safepoint, #850). Drives
* the built module the way mcp-ppid-watchdog.test.ts drives the built CLI.
*/
describe('liveness watchdog (spawned, real watchdog process)', () => {
const MODULE = path.resolve(__dirname, '../dist/mcp/liveness-watchdog.js');
beforeAll(() => {
if (!fs.existsSync(MODULE)) {
throw new Error(`Build the project first: ${MODULE} is missing (run npm run build).`);
}
});
function runChild(
env: Record<string, string>,
body: string,
hardTimeoutMs: number,
progressPaths?: string[]
): Promise<{ code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null }> {
const src = `
const { installMainThreadWatchdog } = require(${JSON.stringify(MODULE)});
installMainThreadWatchdog(${progressPaths ? JSON.stringify({ progressPaths }) : ''});
${body}
`;
const child = spawn(process.execPath, ['-e', src], {
env: { ...process.env, ...env },
stdio: ['ignore', 'ignore', 'ignore'],
});
return new Promise((resolve) => {
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolve({ code: null, signal: 'TIMEOUT' });
}, hardTimeoutMs);
child.on('exit', (code, signal) => {
clearTimeout(timer);
resolve({ code, signal });
});
});
}
// Assert the watchdog terminated the process. POSIX surfaces the external
// SIGKILL as signal 'SIGKILL'; Windows has no real signals, so the watchdog's
// `process.kill(pid, 'SIGKILL')` maps to TerminateProcess and an observer sees
// signal=null with a non-zero exit code. Either is a kill; the synthetic
// 'TIMEOUT' (the watchdog never fired) is the failure we're guarding against.
function expectKilled(r: { code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null }): void {
expect(r.signal === 'SIGKILL' || (r.signal === null && r.code !== 0 && r.code !== null)).toBe(true);
}
it('SIGKILLs a process whose main thread wedges in a sync loop', async () => {
const r = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
'setTimeout(() => { while (true) {} }, 150);',
8000
);
expectKilled(r);
}, 12000);
it('SIGKILLs a non-allocating wedge under heap pressure (the case worker threads stalled on)', async () => {
const r = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
// ~40MB retained so a GC is likely, then a tight NON-allocating loop — the
// exact shape that deadlocks a same-process worker on the global safepoint.
'const k=[]; for (let i=0;i<40;i++) k.push(Buffer.alloc(1024*1024,i)); global.__k=k; setTimeout(() => { while (true) {} }, 150);',
8000
);
expectKilled(r);
}, 12000);
it('does NOT kill a healthy process that keeps its event loop turning', async () => {
const { code, signal } = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
'const iv = setInterval(() => {}, 50); setTimeout(() => { clearInterval(iv); process.exit(7); }, 1500);',
8000
);
expect(signal).toBeNull(); // never signalled
expect(code).toBe(7); // exited on its own terms
}, 12000);
// --- disk-progress deferral (#1231): a blocked event loop is NOT a wedge
// when the watched DB files keep advancing (a slow synchronous SQLite
// statement on degraded storage). ---
/** Grow `file` every 150ms for `forMs`; resolves when done. */
function growFile(file: string, forMs: number): Promise<void> {
return new Promise((resolve) => {
const iv = setInterval(() => { fs.appendFileSync(file, 'x'.repeat(64)); }, 150);
setTimeout(() => { clearInterval(iv); resolve(); }, forMs);
});
}
it('does NOT kill a blocked loop while the watched files advance (slow store, not a wedge)', async () => {
const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
fs.writeFileSync(tmp, 'seed');
// Base timeout 500ms; the loop blocks for 2.5s (5 timeouts, under the 10×
// cap) while the test process grows the watched file. Old behavior: killed
// at ~500ms. New: deferred, exits on its own with code 5.
const [r] = await Promise.all([
runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
'setTimeout(() => { const end = Date.now() + 2500; while (Date.now() < end) {} process.exit(5); }, 200);',
10_000,
[tmp]
),
growFile(tmp, 3200),
]);
expect(r.signal).toBeNull();
expect(r.code).toBe(5);
}, 15000);
it('still kills a blocked loop when the watched files do NOT advance (a true wedge)', async () => {
const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
fs.writeFileSync(tmp, 'seed');
// Same blocked loop, nobody grows the file: the base timeout kills it long
// before its own exit(5) at 2.5s.
const r = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
'setTimeout(() => { const end = Date.now() + 2500; while (Date.now() < end) {} process.exit(5); }, 200);',
10_000,
[tmp]
);
expectKilled(r);
}, 15000);
it('kills at the hard cap even with ongoing file activity (bounded deferral)', async () => {
const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
fs.writeFileSync(tmp, 'seed');
// Base timeout 300ms ⇒ cap 3s. The loop blocks for 8s with continuous file
// growth: deferral carries it past 300ms but the cap kills it around ~3s,
// well before its own exit(5).
const [r] = await Promise.all([
runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '300' },
'setTimeout(() => { const end = Date.now() + 8000; while (Date.now() < end) {} process.exit(5); }, 200);',
15_000,
[tmp]
),
growFile(tmp, 9000),
]);
expectKilled(r);
}, 20000);
it('does NOT kill a wedged process when CODEGRAPH_NO_WATCHDOG=1', async () => {
const { code, signal } = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500', CODEGRAPH_NO_WATCHDOG: '1' },
'setTimeout(() => { const end = Date.now() + 1500; while (Date.now() < end) {} process.exit(3); }, 150);',
8000
);
// It exits with its OWN code 3 — proving nothing killed it. (Checking only
// signal=null is insufficient on Windows, where a kill also reports null.)
expect(signal).toBeNull();
expect(code).toBe(3);
}, 12000);
});
+156
View File
@@ -0,0 +1,156 @@
/**
* Lombok-generated member synthesis (Java, #912).
*
* Lombok generates getters/setters/builder/equals/hashCode/toString and the
* `log` field at compile time, so they never appear in the source AST. Without
* synthesis they're absent from the index and any `bean.getX()` / `Bean.builder()`
* / `log.info()` call resolves to nothing — call chains break silently. We
* synthesize the mechanical ones from the annotations + fields, mark them
* (`lombok` decorator + a docstring naming the source annotation), and they then
* resolve as ordinary call targets. These tests prove the synthesis, the call
* resolution that motivated it, and the precision boundaries (static fields
* skipped, hand-written members never overridden, a non-Lombok class is clean).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('lombok synthesis', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lombok-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
type Row = { name: string; kind: string; decorators: string | null; docstring: string | null; signature: string | null };
const load = async () => {
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const nodes: Row[] = db.prepare(`SELECT name, kind, decorators, docstring, signature FROM nodes`).all();
const calls: { src: string; tgt: string }[] = db
.prepare(
`SELECT s.name src, t.name tgt FROM edges e
JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE e.kind = 'calls'`
)
.all();
cg.close?.();
return { nodes, calls };
};
const isLombok = (n: Row | undefined) => !!n && (n.decorators ?? '').includes('lombok');
it('synthesizes accessors that resolve as call targets, and the @Slf4j log field', async () => {
write('model/User.java', `package model;
import lombok.Data;
import lombok.Builder;
import lombok.extern.slf4j.Slf4j;
@Data
@Builder
@Slf4j
public class User {
private String name;
private boolean active;
private static final int MAX = 10;
}
`);
write('svc/UserService.java', `package svc;
import model.User;
class UserService {
String describe(User user) {
user.setActive(true);
return user.getName();
}
User make() {
return User.builder();
}
}
`);
const { nodes, calls } = await load();
const byName = (name: string) => nodes.find((n) => n.name === name && isLombok(n));
// Accessors + Data contract + builder are synthesized and marked.
for (const m of ['getName', 'setName', 'isActive', 'setActive', 'builder', 'equals', 'hashCode', 'toString']) {
expect(isLombok(byName(m)), `expected synthesized ${m}`).toBe(true);
}
expect(byName('getName')!.docstring).toMatch(/Lombok-generated/);
expect(byName('getName')!.signature).toBe('String getName()');
expect(byName('isActive')!.signature).toBe('boolean isActive()'); // boolean → is-prefix
expect(byName('builder')!.signature).toContain('static ');
// @Slf4j → a `log` field.
expect(isLombok(nodes.find((n) => n.name === 'log' && n.kind === 'field'))).toBe(true);
// PRECISION: a static field gets no accessor.
expect(nodes.some((n) => n.name === 'getMAX' || n.name === 'getMax')).toBe(false);
// THE FIX: calls to Lombok-generated methods resolve to their synthesized target.
const resolved = (src: string, tgt: string) => calls.some((c) => c.src === src && c.tgt === tgt);
expect(resolved('describe', 'getName')).toBe(true);
expect(resolved('describe', 'setActive')).toBe(true);
expect(resolved('make', 'builder')).toBe(true);
});
it('never overrides a hand-written accessor', async () => {
write('model/Account.java', `package model;
import lombok.Getter;
@Getter
public class Account {
private int balance;
private String owner;
// explicit getter — Lombok skips it, so must we
public int getBalance() { return balance < 0 ? 0 : balance; }
}
`);
const { nodes } = await load();
const getBalance = nodes.filter((n) => n.name === 'getBalance');
expect(getBalance.length).toBe(1); // exactly one, not duplicated
expect(isLombok(getBalance[0])).toBe(false); // the hand-written one survives
// the un-shadowed field still gets its synthesized getter
expect(isLombok(nodes.find((n) => n.name === 'getOwner'))).toBe(true);
});
it('field-level @Getter/@Setter and final-field rules', async () => {
write('model/Box.java', `package model;
import lombok.Getter;
import lombok.Setter;
public class Box {
@Getter @Setter private String label;
@Getter private final long id; // final → getter only, no setter
private int hidden; // no annotation → nothing
}
`);
const { nodes } = await load();
expect(isLombok(nodes.find((n) => n.name === 'getLabel'))).toBe(true);
expect(isLombok(nodes.find((n) => n.name === 'setLabel'))).toBe(true);
expect(isLombok(nodes.find((n) => n.name === 'getId'))).toBe(true);
expect(nodes.some((n) => n.name === 'setId')).toBe(false); // final → no setter
expect(nodes.some((n) => n.name === 'getHidden')).toBe(false); // un-annotated → nothing
});
it('produces no synthesized members for a plain Java class (clean control)', async () => {
write('model/Plain.java', `package model;
public class Plain {
private int value;
public int getValue() { return value; }
public void setValue(int v) { this.value = v; }
}
`);
const { nodes } = await load();
expect(nodes.some((n) => isLombok(n))).toBe(false);
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* MCP catch-up gate — first tool call blocks on the engine's post-open
* filesystem reconcile so it never serves rows for files that were
* deleted (or edited) while no MCP server was running.
*
* Background: `MCPEngine.catchUpSync()` fires `cg.sync()` in the background.
* Before this fix it was fire-and-forget — a tool call could race past it
* and return rows for files that no longer exist on disk. The per-file
* staleness banner (`withStalenessNotice`) couldn't help, because
* `getPendingFiles()` is populated by the watcher, not by catch-up.
*
* The fix: `catchUpSync()` pushes its promise into the `ToolHandler` via
* `setCatchUpGate(p)`; the first `execute()` call awaits the gate and then
* clears it. These tests exercise the gate directly (deterministic) and
* the engine-driven path (proves the engine actually pokes the gate).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
describe('MCP catch-up gate', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-catchup-gate-'));
fs.mkdirSync(path.join(testDir, 'src'));
fs.writeFileSync(
path.join(testDir, 'src', 'survivor.ts'),
'export function survivor() { return 1; }\n',
);
fs.writeFileSync(
path.join(testDir, 'src', 'deleted-later.ts'),
'export function deletedLater() { return 2; }\n',
);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
try { cg.unwatch(); } catch { /* ignore */ }
try { cg.close(); } catch { /* ignore */ }
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
it('awaits the gate before serving the first tool call', async () => {
let gateResolved = false;
const gate = new Promise<void>((resolve) => {
setTimeout(() => { gateResolved = true; resolve(); }, 80);
});
handler.setCatchUpGate(gate);
const res = await handler.execute('codegraph_search', { query: 'survivor' });
expect(gateResolved).toBe(true);
expect(res.isError).toBeFalsy();
expect(res.content[0].text).toMatch(/survivor/);
});
it('drops the gate after first await — second call does not re-wait', async () => {
let awaitCount = 0;
const gate = new Promise<void>((resolve) => {
awaitCount++;
setTimeout(resolve, 20);
});
handler.setCatchUpGate(gate);
await handler.execute('codegraph_search', { query: 'survivor' });
const before = awaitCount;
await handler.execute('codegraph_search', { query: 'survivor' });
// The promise body runs once when constructed; second execute never
// resubscribes to a fresh promise because the gate field was nulled.
expect(awaitCount).toBe(before);
});
it('catch-up reconciles a deleted file before the first tool call sees it', async () => {
// Simulate the empty-project / deleted-files startup case: file is in
// the DB (we indexed it above) but vanishes from disk before the MCP
// server's first query. The catch-up sync, awaited via the gate,
// must remove the row so the first tool call returns no hit.
fs.unlinkSync(path.join(testDir, 'src', 'deleted-later.ts'));
// Push the actual catch-up sync as the gate — same flow the MCP engine
// uses (`cg.sync()` returns a Promise<SyncResult>, the wrapper voids it).
handler.setCatchUpGate(cg.sync().then(() => undefined));
const res = await handler.execute('codegraph_search', { query: 'deletedLater' });
expect(res.isError).toBeFalsy();
const text = res.content[0].text;
expect(text).not.toMatch(/src\/deleted-later\.ts/);
});
it('catch-up that converges the project to 0 files clears all rows', async () => {
// Worst case: every source file is gone between sessions. Without the
// gate, the first tool call serves whatever was in the DB. With the
// gate + the orchestrator's filesystem reconcile, the DB drains.
fs.unlinkSync(path.join(testDir, 'src', 'survivor.ts'));
fs.unlinkSync(path.join(testDir, 'src', 'deleted-later.ts'));
handler.setCatchUpGate(cg.sync().then(() => undefined));
const res = await handler.execute('codegraph_search', { query: 'survivor' });
expect(res.isError).toBeFalsy();
expect(cg.getStats().fileCount).toBe(0);
});
it('does not hang the first call when catch-up runs past the timeout (#905)', async () => {
// The issue #905 hang: on a huge repo the post-open reconcile takes minutes,
// and gating the first tool call on all of it reads as a multi-minute hang.
// With the time-box, the call is served promptly and the reconcile finishes
// in the background.
const prev = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '50';
let timer: NodeJS.Timeout | undefined;
try {
let gateResolved = false;
const gate = new Promise<void>((resolve) => {
timer = setTimeout(() => { gateResolved = true; resolve(); }, 5000);
});
handler.setCatchUpGate(gate);
const started = Date.now();
const res = await handler.execute('codegraph_search', { query: 'survivor' });
const elapsed = Date.now() - started;
expect(res.isError).toBeFalsy();
expect(res.content[0].text).toMatch(/survivor/);
// Served on the timeout (~50ms), NOT after the 5s reconcile.
expect(gateResolved).toBe(false);
expect(elapsed).toBeLessThan(2000);
} finally {
if (timer) clearTimeout(timer);
if (prev === undefined) delete process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
else process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = prev;
}
});
it('CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS=0 restores the unbounded wait', async () => {
const prev = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '0';
try {
let gateResolved = false;
const gate = new Promise<void>((resolve) => {
setTimeout(() => { gateResolved = true; resolve(); }, 80);
});
handler.setCatchUpGate(gate);
const res = await handler.execute('codegraph_search', { query: 'survivor' });
// With the time-box disabled, the call waits for the full reconcile.
expect(gateResolved).toBe(true);
expect(res.isError).toBeFalsy();
} finally {
if (prev === undefined) delete process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
else process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = prev;
}
});
it('gate that rejects does not break the tool call', async () => {
// A catch-up sync failure (lock contention, transient FS error) must
// not poison tool dispatch — the engine logs it, the handler proceeds.
handler.setCatchUpGate(Promise.reject(new Error('simulated sync failure')));
const res = await handler.execute('codegraph_search', { query: 'survivor' });
expect(res.isError).toBeFalsy();
expect(res.content[0].text).toMatch(/survivor/);
});
});
+460
View File
@@ -0,0 +1,460 @@
/**
* Shared MCP daemon — issue #411.
*
* Validates the daemon architecture in `src/mcp/{daemon,proxy,session,index}.ts`
* AFTER the review fixes:
*
* - The daemon is a *detached* background process; every `serve --mcp`
* invocation is a thin proxy to it. Two invocations against one project
* share ONE daemon.
* - Concurrent launchers converge on a single daemon (the must-fix-1
* lockfile-race: an empty-pidfile window used to let a racing candidate
* delete the winner's lock → two daemons).
* - Killing the launcher that spawned the daemon does NOT take the daemon
* down — other attached clients keep working (the must-fix-2 detach: the
* in-process daemon used to die with its launcher's process group and
* orphan on host SIGKILL, regressing #277).
* - A stale lockfile (dead pid) is cleared; `CODEGRAPH_NO_DAEMON=1` opts out;
* the proxy refuses to attach across a version mismatch; the daemon
* idle-times-out after the last client leaves (so a single session can't
* leak a daemon forever).
*
* These tests intentionally spawn real `node dist/bin/codegraph.js` processes
* over real sockets/pipes — the same surface a Claude Code / Cursor / Codex
* install exercises. The daemon logs to `.codegraph/daemon.log` (it has no
* client stderr of its own), so daemon-side assertions read that file.
*
* `realRoot` vs `tempDir`: processes are spawned with the (possibly symlinked)
* `tempDir` as cwd/rootUri — on macOS `os.tmpdir()` lives under `/var`, a
* symlink to `/private/var`, and a spawned child's `process.cwd()` is already
* realpath'd. The daemon canonicalizes the root with `realpathSync`, so all
* path assertions use `realRoot` (the canonical form). That this matches end to
* end is itself the proof the canonicalization works.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { getDaemonSocketPath } from '../src/mcp/daemon-paths';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
interface SpawnedServer {
child: ChildProcessWithoutNullStreams;
stdout: string[];
stderr: string[];
}
function spawnServer(cwd: string, env: NodeJS.ProcessEnv = {}): SpawnedServer {
const child = spawn(process.execPath, [BIN, 'serve', '--mcp'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
// #618: the daemon-attach log line is now off by default; opt the test
// harness into it (CODEGRAPH_MCP_LOG_ATTACH=1) so the attach assertions
// below can still observe a successful attach. A per-test env still wins.
env: { CODEGRAPH_MCP_LOG_ATTACH: '1', ...process.env, ...env },
}) as ChildProcessWithoutNullStreams;
// Swallow spawn/EPIPE errors so killing a child mid-write can't surface as an
// unhandled error that crashes the vitest worker.
child.on('error', () => { /* ignore */ });
child.stdin.on('error', () => { /* ignore */ });
const stdout: string[] = [];
const stderr: string[] = [];
let stdoutBuf = '';
let stderrBuf = '';
child.stdout.on('data', (chunk: Buffer) => {
stdoutBuf += chunk.toString('utf8');
let idx: number;
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
stdout.push(stdoutBuf.slice(0, idx));
stdoutBuf = stdoutBuf.slice(idx + 1);
}
});
child.stderr.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString('utf8');
let idx: number;
while ((idx = stderrBuf.indexOf('\n')) !== -1) {
stderr.push(stderrBuf.slice(0, idx));
stderrBuf = stderrBuf.slice(idx + 1);
}
});
return { child, stdout, stderr };
}
function sendMessage(child: ChildProcessWithoutNullStreams, msg: unknown): void {
try { child.stdin.write(JSON.stringify(msg) + '\n'); } catch { /* child may be gone */ }
}
function sendInitialize(child: ChildProcessWithoutNullStreams, rootUri: string, id: number): void {
sendMessage(child, {
jsonrpc: '2.0',
id,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test', version: '0.0.0' },
rootUri,
},
});
}
/** Find a JSON-RPC response with the given id (result OR error) on stdout. */
function findResponse(stdout: string[], id: number): any | null {
for (const line of stdout) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
if (parsed && parsed.id === id && (parsed.result !== undefined || parsed.error !== undefined)) {
return parsed;
}
} catch { /* not JSON */ }
}
return null;
}
function waitFor<T>(
predicate: () => T | undefined | null | false,
timeoutMs: number,
pollMs = 25,
label = '',
): Promise<T> {
return new Promise((resolve, reject) => {
const started = Date.now();
const tick = () => {
let v: T | undefined | null | false;
try { v = predicate(); } catch (e) { return reject(e); }
if (v) return resolve(v as T);
if (Date.now() - started > timeoutMs) {
// Name the wait: an async stack loses the await site, so an unlabeled
// timeout can't tell WHICH step flaked (the #662 test's recurring
// timeout was undiagnosable for exactly this reason).
return reject(new Error(`Timed out after ${timeoutMs}ms${label ? ` waiting for: ${label}` : ''}`));
}
setTimeout(tick, pollMs);
};
tick();
});
}
function isAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
function readLockPid(root: string): number | null {
try {
const raw = fs.readFileSync(path.join(root, '.codegraph', 'daemon.pid'), 'utf8');
const info = JSON.parse(raw);
return typeof info.pid === 'number' ? info.pid : null;
} catch { return null; }
}
function readDaemonLog(root: string): string {
try { return fs.readFileSync(path.join(root, '.codegraph', 'daemon.log'), 'utf8'); }
catch { return ''; }
}
function countListeningLines(root: string): number {
return readDaemonLog(root).split('\n').filter((l) => l.includes('[CodeGraph daemon] Listening on')).length;
}
function killTree(...procs: ChildProcessWithoutNullStreams[]): void {
for (const p of procs) {
if (!p.killed) { try { p.kill('SIGKILL'); } catch { /* gone */ } }
}
}
async function waitProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
return waitFor(() => !isAlive(pid), timeoutMs).then(() => true).catch(() => false);
}
describe('Shared MCP daemon (issue #411)', () => {
let tempDir: string; // the (possibly symlinked) path processes are spawned with
let realRoot: string; // its canonical form — what the daemon keys paths on
const servers: SpawnedServer[] = [];
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-daemon-'));
const cg = await CodeGraph.init(tempDir);
cg.close();
realRoot = fs.realpathSync(tempDir);
});
afterEach(async () => {
killTree(...servers.map((s) => s.child));
// The daemon is detached (not a tracked child) — reap it explicitly via the
// pid it recorded, so a test can't leak a background daemon. Guard against
// our own pid: the version-mismatch test plants `pid: process.pid` in the
// lockfile, and we must never SIGKILL the vitest worker.
const daemonPid = readLockPid(realRoot);
if (daemonPid && daemonPid !== process.pid && isAlive(daemonPid)) {
try { process.kill(daemonPid, 'SIGKILL'); } catch { /* race */ }
}
await new Promise((r) => setTimeout(r, 50));
servers.length = 0;
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('two invocations share ONE detached daemon; both attach as proxies', async () => {
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' };
const first = spawnServer(tempDir, env);
servers.push(first);
sendInitialize(first.child, `file://${tempDir}`, 1);
const firstResp = await waitFor(() => findResponse(first.stdout, 1), 10000);
expect(firstResp.result.serverInfo.name).toBe('codegraph');
// The launcher is a PROXY (not the daemon itself) — that's the detach fix.
await waitFor(() => first.stderr.some((l) => l.includes('Attached to shared daemon')), 8000);
// A detached daemon came up and recorded itself.
await waitFor(() => fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid')), 8000);
await waitFor(() => countListeningLines(realRoot) >= 1, 8000);
const daemonPid = readLockPid(realRoot);
expect(daemonPid).toBeTruthy();
expect(isAlive(daemonPid!)).toBe(true);
// The socket exists at the path the code computes from the canonical root.
// On Windows the daemon listens on a named pipe (\\.\pipe\...), which isn't
// a filesystem entry — existsSync doesn't apply there, and the "Attached to
// shared daemon" proof above already confirms the proxy reached it.
if (process.platform !== 'win32') {
expect(fs.existsSync(getDaemonSocketPath(realRoot))).toBe(true);
}
// Second invocation attaches as a proxy to the SAME daemon.
const second = spawnServer(tempDir, env);
servers.push(second);
sendInitialize(second.child, `file://${tempDir}`, 2);
const secondResp = await waitFor(() => findResponse(second.stdout, 2), 10000);
expect(secondResp.result.serverInfo.name).toBe('codegraph');
await waitFor(() => second.stderr.some((l) => l.includes('Attached to shared daemon')), 8000);
// Exactly one daemon ever bound, and it's the same pid both attached to.
expect(countListeningLines(realRoot)).toBe(1);
expect(readLockPid(realRoot)).toBe(daemonPid);
}, 40000);
it('concurrent launchers converge on a single daemon (lockfile race — must-fix 1)', async () => {
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' };
// Fire three launchers as close to simultaneously as possible — this is the
// race window where the old code could end up with two daemons.
const procs = [spawnServer(tempDir, env), spawnServer(tempDir, env), spawnServer(tempDir, env)];
procs.forEach((p, i) => { servers.push(p); sendInitialize(p.child, `file://${tempDir}`, i + 1); });
// All three get a valid initialize response...
for (let i = 0; i < procs.length; i++) {
const resp = await waitFor(() => findResponse(procs[i].stdout, i + 1), 12000);
expect(resp.result.serverInfo.name).toBe('codegraph');
}
// ...and all three attached as proxies (none fell back / wedged).
for (const p of procs) {
await waitFor(() => p.stderr.some((l) => l.includes('Attached to shared daemon')), 10000);
}
// The decisive assertion: exactly ONE daemon bound the socket. Losing
// candidates log "already holds the lock; exiting" and never listen.
expect(countListeningLines(realRoot)).toBe(1);
const daemonPid = readLockPid(realRoot);
expect(daemonPid).toBeTruthy();
expect(isAlive(daemonPid!)).toBe(true);
}, 45000);
it('daemon survives the first client dying; a second client keeps working (must-fix 2 / #277)', async () => {
// Idle high so the daemon doesn't reap mid-test; poll fast so proxy 1
// notices its dead parent quickly.
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_PPID_POLL_MS: '200' };
const first = spawnServer(tempDir, env);
servers.push(first);
sendInitialize(first.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(first.stdout, 1), 10000);
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000);
const daemonPid = readLockPid(realRoot)!;
expect(isAlive(daemonPid)).toBe(true);
const second = spawnServer(tempDir, env);
servers.push(second);
sendInitialize(second.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(second.stdout, 1), 10000);
await waitFor(() => second.stderr.some((l) => l.includes('Attached to shared daemon')), 8000);
// Kill the launcher that spawned the daemon. With the old in-process design
// this would take the daemon (and thus the second client) down.
killTree(first.child);
// The daemon is detached — it must still be alive a beat later.
await new Promise((r) => setTimeout(r, 1500));
expect(isAlive(daemonPid)).toBe(true);
// And the second client can still drive a real tool call through it.
sendMessage(second.child, { jsonrpc: '2.0', id: 2, method: 'tools/list' });
const toolsResp = await waitFor(() => findResponse(second.stdout, 2), 10000);
expect(Array.isArray(toolsResp.result.tools)).toBe(true);
expect(toolsResp.result.tools.length).toBeGreaterThan(0);
}, 45000);
it('CODEGRAPH_NO_DAEMON=1 keeps each process independent (no socket/pidfile)', async () => {
const env = { CODEGRAPH_NO_DAEMON: '1' };
const first = spawnServer(tempDir, env);
servers.push(first);
sendInitialize(first.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(first.stdout, 1), 10000);
// Direct mode — no daemon machinery touched.
expect(first.stderr.some((l) => l.includes('Attached to shared daemon'))).toBe(false);
expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid'))).toBe(false);
expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.log'))).toBe(false);
}, 20000);
it('clears a stale (dead-pid) lockfile and a fresh daemon takes over', async () => {
// Plant a lockfile pointing at a definitely-dead pid + the real socket path.
fs.writeFileSync(
path.join(realRoot, '.codegraph', 'daemon.pid'),
JSON.stringify({
pid: 999_999,
version: '0.0.0-fake',
socketPath: getDaemonSocketPath(realRoot),
startedAt: Date.now() - 1000,
}),
);
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' };
const server = spawnServer(tempDir, env);
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
const resp = await waitFor(() => findResponse(server.stdout, 1), 10000).catch((e) => {
throw new Error(`${(e as Error).message}\nstderr:\n${server.stderr.join('\n')}\ndaemon.log:\n${readDaemonLog(realRoot)}`);
});
expect(resp.result.serverInfo.name).toBe('codegraph');
await waitFor(() => countListeningLines(realRoot) >= 1, 10000);
// The pidfile now names a live daemon, not the planted-dead 999999.
const livePid = readLockPid(realRoot);
expect(livePid).not.toBe(999_999);
expect(isAlive(livePid!)).toBe(true);
}, 40000);
it('proxy falls back to direct mode on a daemon version mismatch', async () => {
const net = await import('net');
const sockPath = getDaemonSocketPath(realRoot);
// Plant a live-pid lockfile so the launcher treats the lock as held, and a
// mini-server that answers with a mismatched-version hello.
fs.writeFileSync(
path.join(realRoot, '.codegraph', 'daemon.pid'),
JSON.stringify({ pid: process.pid, version: '0.0.0-mismatch', socketPath: sockPath, startedAt: Date.now() }),
);
const miniServer = net.createServer((sock) => {
sock.write(JSON.stringify({ codegraph: '0.0.0-mismatch', pid: 1, socketPath: sockPath, protocol: 1 }) + '\n');
});
await new Promise<void>((resolve) => miniServer.listen(sockPath, () => resolve()));
try {
const server = spawnServer(tempDir);
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
// Despite the mismatched daemon, the client still gets an initialize
// response — the proxy answers the handshake locally and, refusing to
// attach across the version mismatch, serves the session in-process.
const resp = await waitFor(() => findResponse(server.stdout, 1), 10000);
expect(resp.result.serverInfo.name).toBe('codegraph');
await waitFor(
() => server.stderr.some((l) => l.includes('serving this session in-process')),
6000,
);
} finally {
await new Promise<void>((resolve) => miniServer.close(() => resolve()));
}
}, 30000);
// The over-the-wire client-hello → record → sweep path, and the inactivity
// backstop's liveness gate, are covered by the deterministic unit tests in
// daemon-client-liveness (`reapDeadClients`, `backstopShouldExit`) — a
// raw-socket variant here was flaky under heavy parallel load. What stays
// here is the lifecycle behavior that needs real procs: a live-but-quiet
// client must SURVIVE the inactivity backstop. Reaping it used to silently
// degrade the session (and any others sharing the daemon) to an in-process
// engine; on a real machine the backstop fired on live sessions far more
// often than on the phantoms it exists for. The phantom case it still covers
// (an unknown-pid connection) is the `backstopShouldExit` unit test.
it('does NOT reap a live-but-quiet client on the inactivity backstop (#692)', async () => {
// Backstop short, idle timeout long: with a client connected the idle timer
// never arms, so the inactivity backstop is the only thing that could take
// the daemon down — and it must not, because the client's peer is alive.
const env = { CODEGRAPH_DAEMON_MAX_IDLE_MS: '1200', CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '60000' };
const server = spawnServer(tempDir, env);
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(server.stdout, 1), 10000);
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000);
const daemonPid = readLockPid(realRoot)!;
expect(isAlive(daemonPid)).toBe(true);
// Stay silent well past several backstop windows. The live session's peer is
// provably alive, so the daemon must keep running (and never log a backstop
// shutdown), with its lockfile intact.
await new Promise((r) => setTimeout(r, 4000)); // > 3× maxIdle
expect(isAlive(daemonPid)).toBe(true);
expect(readDaemonLog(realRoot)).not.toContain('inactivity backstop');
expect(readLockPid(realRoot)).toBe(daemonPid);
}, 30000);
it('daemon idle-times-out after the last client disconnects', async () => {
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '800', CODEGRAPH_PPID_POLL_MS: '200' };
const server = spawnServer(tempDir, env);
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(server.stdout, 1), 10000);
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000);
const daemonPid = readLockPid(realRoot)!;
// Close the only client's stdin → proxy exits → daemon refcount hits 0 →
// idle timer fires → daemon exits and cleans up its lockfile.
server.child.stdin.end();
expect(await waitProcessExit(daemonPid, 10000)).toBe(true);
expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid'))).toBe(false);
}, 30000);
it('proxy survives the daemon dying mid-session and keeps serving (#662)', async () => {
// The #662 scenario: an MCP host SIGTERM's the shared daemon while a session
// is live. The proxy must NOT exit (losing CodeGraph for that session) — it
// falls back to an in-process engine and keeps answering.
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_PPID_POLL_MS: '5000' };
const server = spawnServer(tempDir, env);
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(server.stdout, 1), 20000, 25, 'initialize response');
await waitFor(() => server.stderr.some((l) => l.includes('Attached to shared daemon')), 8000, 25, 'daemon attach log');
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000, 25, 'daemon pidfile');
const daemonPid = readLockPid(realRoot)!;
// A warm call goes through the daemon.
sendMessage(server.child, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
try {
await waitFor(() => findResponse(server.stdout, 2), 30000, 25, 'warm tools/call via daemon');
} catch (e) {
// This is the wait that historically flaked — surface WHERE the request
// died: proxy side (stderr) or daemon side (daemon.log).
let daemonLog = '<no daemon.log>';
try { daemonLog = fs.readFileSync(path.join(realRoot, '.codegraph', 'daemon.log'), 'utf8').split('\n').slice(-25).join('\n'); } catch { /* absent */ }
throw new Error(
`${(e as Error).message}\ndaemonAlive=${isAlive(daemonPid)} proxyAlive=${isAlive(server.child.pid!)}\n` +
`--- proxy stderr tail ---\n${server.stderr.slice(-15).join('')}\n--- daemon.log tail ---\n${daemonLog}`
);
}
// Kill the daemon out from under the live proxy.
process.kill(daemonPid, 'SIGTERM');
expect(await waitProcessExit(daemonPid, 8000)).toBe(true);
// The proxy must still be alive and still answer — served in-process now.
expect(isAlive(server.child.pid!)).toBe(true);
await waitFor(() => server.stderr.some((l) => l.includes('serving this session in-process')), 8000, 25, 'in-process failover log');
sendMessage(server.child, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
const resp = await waitFor(() => findResponse(server.stdout, 3), 15000);
expect(resp.result !== undefined || resp.error !== undefined).toBe(true);
expect(isAlive(server.child.pid!)).toBe(true);
}, 45000);
});
+47
View File
@@ -0,0 +1,47 @@
/**
* CODEGRAPH_WATCH_DEBOUNCE_MS env override (issue #403).
*
* Lets users tune the watcher quiet window from MCP-launched configs without
* editing the agent's command line — formatter-on-save chains and large
* generated outputs benefit from a longer window. Clamped to [100ms, 60s];
* out-of-range / non-numeric values fall back to the FileWatcher default
* (2000ms) rather than throwing or silently capping a likely typo.
*/
import { describe, it, expect } from 'vitest';
import { parseDebounceEnv } from '../src/mcp/engine';
describe('parseDebounceEnv', () => {
it('returns undefined for unset / empty values', () => {
expect(parseDebounceEnv(undefined)).toBeUndefined();
expect(parseDebounceEnv('')).toBeUndefined();
expect(parseDebounceEnv(' ')).toBeUndefined();
});
it('accepts integer values inside [100, 60000]', () => {
expect(parseDebounceEnv('100')).toBe(100);
expect(parseDebounceEnv('2000')).toBe(2000);
expect(parseDebounceEnv('5000')).toBe(5000);
expect(parseDebounceEnv('60000')).toBe(60000);
});
it('rejects out-of-range values (returns undefined, lets default win)', () => {
expect(parseDebounceEnv('0')).toBeUndefined();
expect(parseDebounceEnv('50')).toBeUndefined(); // below 100
expect(parseDebounceEnv('99')).toBeUndefined();
expect(parseDebounceEnv('60001')).toBeUndefined(); // above 60s
expect(parseDebounceEnv('-500')).toBeUndefined();
});
it('rejects non-integer / non-numeric values', () => {
expect(parseDebounceEnv('abc')).toBeUndefined();
expect(parseDebounceEnv('500.5')).toBeUndefined();
expect(parseDebounceEnv('NaN')).toBeUndefined();
expect(parseDebounceEnv('Infinity')).toBeUndefined();
});
it('accepts scientific notation that resolves to an in-range integer', () => {
// Number('1e3') === 1000, Number.isInteger(1000) === true. Power users
// who write debounce as 1e3 should not be surprised; the clamp still applies.
expect(parseDebounceEnv('1e3')).toBe(1000);
});
});
@@ -0,0 +1,113 @@
/**
* codegraph_files path-filter normalization (#426)
*
* Stored file paths are project-relative POSIX (e.g. "src/foo.ts"). Some
* agents pass project-root variants like "/", ".", "./" or "" when they want
* "the whole project", and Windows-style backslashes or leading "/" / "./"
* prefixes when they want a subtree. The old filter used a plain
* `startsWith(pathFilter)`, so any of those buried the agent at "no files
* found" and pushed it back to Read/Glob — the exact opencode regression in
* #426. These tests pin every branch of the normalization.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
describe('codegraph_files path normalization', () => {
let tempDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-files-paths-'));
fs.mkdirSync(path.join(tempDir, 'src', 'components'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'tests'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'src', 'index.ts'), `export const x = 1;\n`);
fs.writeFileSync(
path.join(tempDir, 'src', 'components', 'Button.ts'),
`export const Button = () => 1;\n`
);
fs.writeFileSync(path.join(tempDir, 'tests', 'a.test.ts'), `export const t = 1;\n`);
cg = await CodeGraph.init(tempDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
async function listed(pathFilter: string | undefined): Promise<string> {
const result = await handler.execute('codegraph_files', {
...(pathFilter !== undefined ? { path: pathFilter } : {}),
format: 'flat',
includeMetadata: false,
});
expect(result.isError).toBeFalsy();
return result.content[0]!.text as string;
}
// Root-ish filters: every shape an agent might guess for "whole project"
// must list the same files as no filter at all.
for (const rootish of ['/', '.', './', '', '\\', '//', './/']) {
it(`treats path=${JSON.stringify(rootish)} as project root`, async () => {
const output = await listed(rootish);
expect(output).toContain('src/index.ts');
expect(output).toContain('src/components/Button.ts');
expect(output).toContain('tests/a.test.ts');
});
}
it('matches a real subdirectory prefix', async () => {
const output = await listed('src');
expect(output).toContain('src/index.ts');
expect(output).toContain('src/components/Button.ts');
expect(output).not.toContain('tests/a.test.ts');
});
it('tolerates a leading slash on a real subdirectory', async () => {
const output = await listed('/src');
expect(output).toContain('src/index.ts');
expect(output).not.toContain('tests/a.test.ts');
});
it('tolerates a leading "./" on a real subdirectory', async () => {
const output = await listed('./src');
expect(output).toContain('src/index.ts');
expect(output).not.toContain('tests/a.test.ts');
});
it('tolerates a trailing slash on a real subdirectory', async () => {
const output = await listed('src/');
expect(output).toContain('src/index.ts');
expect(output).not.toContain('tests/a.test.ts');
});
it('normalizes Windows backslashes', async () => {
const output = await listed('src\\components');
expect(output).toContain('src/components/Button.ts');
expect(output).not.toContain('src/index.ts');
});
// Old code matched on raw `startsWith`, so a filter "src" would also
// return a sibling like "src-utils/...". The new code requires either an
// exact match or a "<filter>/" boundary, so prefixes don't bleed.
it('does not match sibling directories that share a prefix', async () => {
fs.mkdirSync(path.join(tempDir, 'src-utils'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'src-utils', 'helper.ts'), `export const h = 1;\n`);
await cg.indexAll();
const output = await listed('src');
expect(output).toContain('src/index.ts');
expect(output).not.toContain('src-utils/helper.ts');
});
});
+183
View File
@@ -0,0 +1,183 @@
/**
* MCP `initialize` handshake regression tests.
*
* Issue #172: on slow filesystems (Docker Desktop VirtioFS on macOS, WSL2),
* the MCP server was blocking the initialize response on CodeGraph.open() and
* Parser.init() (web-tree-sitter WASM bootstrap), which could take longer than
* Claude Code's ~30s handshake timeout. The child process stayed alive and
* had received the request, but never sent a response, so tools never
* appeared in the client. The fix sends the initialize response before
* kicking off the heavy init in the background. These tests guard the
* contract that initialize is fast regardless of how much work init does.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
return spawn(process.execPath, [BIN, 'serve', '--mcp'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
// Pin to direct (in-process) mode. #172 is a contract about the in-process
// server's init ordering — the "File watcher active" log this test observes
// is emitted in-process. In daemon mode the watcher runs in the detached
// daemon (logging to .codegraph/daemon.log, not the child's stderr); the
// same response-before-init guarantee lives in the shared session code and
// is covered by mcp-daemon.test.ts. Direct mode also avoids leaking a
// detached daemon from this suite.
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' },
}) as ChildProcessWithoutNullStreams;
}
function sendInitialize(child: ChildProcessWithoutNullStreams, projectPath: string) {
const msg = JSON.stringify({
jsonrpc: '2.0',
id: 0,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'test', version: '0.0.0' },
rootUri: `file://${projectPath}`,
},
});
child.stdin.write(msg + '\n');
}
/**
* Collect stdout lines and stderr text from the child, tagging each piece
* with a monotonic sequence number. Lets us assert ordering between the
* JSON-RPC response (stdout) and side-effect logs (stderr).
*/
function tagStreams(child: ChildProcessWithoutNullStreams) {
const events: Array<{ seq: number; stream: 'stdout' | 'stderr'; text: string }> = [];
let seq = 0;
let stdoutBuf = '';
let stderrBuf = '';
child.stdout.on('data', (chunk) => {
stdoutBuf += chunk.toString('utf8');
let idx;
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, idx);
stdoutBuf = stdoutBuf.slice(idx + 1);
events.push({ seq: seq++, stream: 'stdout', text: line });
}
});
child.stderr.on('data', (chunk) => {
stderrBuf += chunk.toString('utf8');
let idx;
while ((idx = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, idx);
stderrBuf = stderrBuf.slice(idx + 1);
events.push({ seq: seq++, stream: 'stderr', text: line });
}
});
return events;
}
function waitFor<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);
it('answers resources/list and prompts/list with empty lists, not -32601 (issue #621)', async () => {
child = spawnServer(tempDir);
const events = tagStreams(child);
sendInitialize(child, tempDir);
await waitFor(events, (e) => e.stream === 'stdout', 5000); // initialize reply
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'resources/list', params: {} }) + '\n');
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'prompts/list', params: {} }) + '\n');
const replyFor = async (id: number) => {
const ev = await waitFor(events, (e) => {
if (e.stream !== 'stdout') return false;
try { return JSON.parse(e.text).id === id; } catch { return false; }
}, 5000);
return JSON.parse(ev.text);
};
const resources = await replyFor(1);
expect(resources.error).toBeUndefined();
expect(resources.result.resources).toEqual([]);
const prompts = await replyFor(2);
expect(prompts.error).toBeUndefined();
expect(prompts.result.prompts).toEqual([]);
}, 15000);
});
+173
View File
@@ -0,0 +1,173 @@
/**
* PPID watchdog regression test (#277).
*
* On Linux, when an MCP host (Claude Code, opencode, …) is SIGKILL'd by the
* OOM killer / a force-quit / a container teardown, the kernel does NOT
* propagate the death to its `codegraph serve --mcp` child. The child gets
* reparented to init/systemd, its stdin stays half-open in some
* configurations, and the existing `stdin.on('end' | 'close')` handlers
* never fire — the server lingers indefinitely, holding inotify watches,
* file descriptors, and the SQLite WAL.
*
* `src/mcp/index.ts` polls `process.ppid` and shuts down the moment it
* diverges from the value observed at startup. This test stands up a
* four-tier process tree (vitest → wrapper → {stdin-holder, codegraph}) and
* SIGKILL's the wrapper. The stdin-holder is a long-lived sibling whose
* `stdout` pipe is dup'd into codegraph's `stdin`. After the wrapper dies
* the pipe stays open (stdin-holder still owns the write-end), so the
* existing stdin close handlers do **not** fire — the only thing that can
* terminate codegraph then is the PPID watchdog.
*
* Windows is excluded — `process.kill(pid, 'SIGKILL')` does not actually
* deliver SIGKILL there, and the per-OS reparenting semantics the watchdog
* relies on are POSIX-specific.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function waitForExit(pid: number, timeoutMs: number): Promise<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],
// Pin to direct (in-process) mode: this test targets the in-process
// server's PPID watchdog (#277). The detached-daemon/proxy watchdog is
// covered separately in mcp-daemon.test.ts ("daemon survives the first
// client dying"). Without this the spawned process becomes a proxy and
// also spawns a detached daemon that would outlive the test.
env: { ...process.env, CODEGRAPH_PPID_POLL_MS: '200', CODEGRAPH_NO_DAEMON: '1' },
detached: true,
});
child.unref();
setTimeout(() => {
process.stdout.write(JSON.stringify({ pid: child.pid, stdinHolderPid: stdinHolder.pid }) + '\\n');
}, 800);
setInterval(() => {}, 60000);
`;
wrapper = spawn(process.execPath, ['-e', wrapperSrc], {
stdio: ['pipe', 'pipe', 'pipe'],
}) as ChildProcessWithoutNullStreams;
const pids = await new Promise<{ pid: number; stdinHolderPid: number }>((resolve, reject) => {
let buf = '';
const timer = setTimeout(
() => reject(new Error('wrapper did not report PIDs in time')),
10000,
);
wrapper!.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString('utf8');
const m = buf.match(/\{"pid":(\d+),"stdinHolderPid":(\d+)\}/);
if (m) {
clearTimeout(timer);
resolve({ pid: parseInt(m[1], 10), stdinHolderPid: parseInt(m[2], 10) });
}
});
wrapper!.on('exit', () => {
clearTimeout(timer);
reject(new Error('wrapper exited before reporting PIDs'));
});
});
childPid = pids.pid;
stdinHolderPid = pids.stdinHolderPid;
expect(isAlive(childPid)).toBe(true);
expect(isAlive(stdinHolderPid)).toBe(true);
// SIGKILL the wrapper — no cleanup runs, just like a real OOM kill.
// codegraph and the stdin-holder both get reparented to init/systemd.
// Crucially, the pipe between them stays open, so codegraph's stdin
// doesn't close: only the watchdog can take it down.
wrapper.kill('SIGKILL');
// Watchdog runs every 200ms in this test → 5s gives ~25 polls of headroom.
const exited = await waitForExit(childPid, 5000);
const stderrContent = fs.existsSync(stderrLog) ? fs.readFileSync(stderrLog, 'utf-8') : '<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);
});
+104
View File
@@ -0,0 +1,104 @@
/**
* No-default-project → projectPath is `required` in the tool schema (issue #993).
*
* When the MCP server has no default project to fall back to — a gateway server
* started outside any repo, or a monorepo root whose `.codegraph/` indexes live
* only in sub-projects — every tool call MUST carry an explicit `projectPath`.
* `ToolHandler.getTools()` reflects that by marking `projectPath` required in the
* exposed schemas, a high-salience nudge that gets the agent to pass it on the
* first call instead of omitting it (the reported behavior). When a default
* project IS open, projectPath stays optional: a bare call falls back to it.
*
* The change is schema-only — the runtime stays exactly as before: a missing
* projectPath with no default still returns SUCCESS-shaped guidance (never
* `isError`), and a missing projectPath WITH a default still falls back to it.
*/
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ToolHandler, tools } from '../src/mcp/tools';
import { CodeGraph } from '../src';
const ENV = 'CODEGRAPH_MCP_TOOLS';
const exploreOf = (defs: { name: string; inputSchema: { required?: string[] } }[]) =>
defs.find((t) => t.name === 'codegraph_explore')!;
describe('No-default-project requires projectPath in the schema (#993)', () => {
const originalAllowlist = process.env[ENV];
afterEach(() => {
if (originalAllowlist === undefined) delete process.env[ENV];
else process.env[ENV] = originalAllowlist;
});
it('marks projectPath required on codegraph_explore when no default project is loaded', () => {
const explore = exploreOf(new ToolHandler(null).getTools());
expect(explore.inputSchema.required).toContain('projectPath');
// The tool's own required arg is preserved, not replaced.
expect(explore.inputSchema.required).toContain('query');
});
it('requires projectPath on EVERY exposed tool, incl. ones with no prior required list', () => {
// status has no `required` array of its own → it should gain ['projectPath'].
process.env[ENV] = 'explore,node,status';
const got = new ToolHandler(null).getTools();
expect(got.map((t) => t.name).sort()).toEqual([
'codegraph_explore',
'codegraph_node',
'codegraph_status',
]);
for (const t of got) {
expect(t.inputSchema.required ?? []).toContain('projectPath');
}
});
it('does NOT mutate the shared module-level tools array (purity)', () => {
// Marking required must clone — otherwise a no-default session would corrupt
// the schema every later default-project session reuses.
new ToolHandler(null).getTools();
expect(exploreOf(tools).inputSchema.required).toEqual(['query']);
});
it('a missing projectPath with no default is still SUCCESS-shaped guidance, not isError', async () => {
// Schema-only change: the runtime backstop is unchanged. A client that
// ignores `required` still gets the nudge, never a session-souring isError.
const res = await new ToolHandler(null).execute('codegraph_explore', { query: 'anything' });
expect(res.isError).toBeUndefined();
expect(res.content[0]!.text).toMatch(/No CodeGraph project is loaded/);
expect(res.content[0]!.text).toMatch(/projectPath/);
});
});
describe('A default project keeps projectPath OPTIONAL (#993)', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-reqpath-'));
fs.writeFileSync(
path.join(tempDir, 'pay.ts'),
'export function processPayment(amount: number): boolean { return amount > 0; }\n'
);
cg = await CodeGraph.init(tempDir, { index: true });
});
afterEach(() => {
cg.close();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('leaves projectPath optional when a default project is loaded', () => {
const explore = exploreOf(new ToolHandler(cg).getTools());
expect(explore.inputSchema.required).toEqual(['query']);
expect(explore.inputSchema.required).not.toContain('projectPath');
});
it('a bare call (no projectPath) still falls back to the default project', async () => {
const res = await new ToolHandler(cg).execute('codegraph_explore', { query: 'processPayment' });
expect(res.isError).toBeUndefined();
// Resolved against the default project — not the no-default guidance.
expect(res.content[0]!.text).not.toMatch(/No CodeGraph project is loaded/);
expect(res.content[0]!.text).toMatch(/processPayment/);
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* MCP project-resolution regression tests (issue #196).
*
* When an MCP client launches the server outside the project directory AND
* doesn't pass a `rootUri`/`workspaceFolders` in `initialize`, the server used
* to fall straight back to `process.cwd()` — which for many IDE clients is the
* wrong directory. Every tool call without an explicit `projectPath` then
* failed with a misleading "CodeGraph not initialized. Run 'codegraph init'."
*
* The fix: when no explicit path is provided, the server asks the client for
* its workspace root via the spec-blessed `roots/list` request (if the client
* advertised the `roots` capability), and only falls back to cwd otherwise.
* When it still can't resolve, the error now says exactly how to fix it.
*
* These tests drive the real stdio transport via a spawned subprocess — no
* mocking — so they also exercise the new bidirectional request/response path.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
// --no-watch keeps the test deterministic and avoids watcher startup noise.
return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
}) as ChildProcessWithoutNullStreams;
}
/** Parse every JSON-RPC message the server writes to stdout into an array. */
function collectMessages(child: ChildProcessWithoutNullStreams): Array<Record<string, any>> {
const messages: Array<Record<string, any>> = [];
let buf = '';
child.stdout.on('data', (chunk) => {
buf += chunk.toString('utf8');
let idx;
while ((idx = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
try { messages.push(JSON.parse(line)); } catch { /* ignore non-JSON */ }
}
});
return messages;
}
function waitForMessage(
messages: ReadonlyArray<Record<string, any>>,
predicate: (m: Record<string, any>) => boolean,
timeoutMs: number,
): Promise<Record<string, any>> {
return new Promise((resolve, reject) => {
const started = Date.now();
const tick = () => {
const hit = messages.find(predicate);
if (hit) return resolve(hit);
if (Date.now() - started > timeoutMs) {
return reject(new Error(`Timed out. Messages so far: ${JSON.stringify(messages)}`));
}
setTimeout(tick, 20);
};
tick();
});
}
function send(child: ChildProcessWithoutNullStreams, msg: object): void {
child.stdin.write(JSON.stringify(msg) + '\n');
}
const CLIENT_INFO = { name: 'test', version: '0.0.0' };
describe('MCP project resolution via roots/list (issue #196)', () => {
let cwdDir: string; // where the server is launched — has NO .codegraph
let projectDir: string; // the real indexed project the client reports
let child: ChildProcessWithoutNullStreams | null = null;
beforeEach(() => {
cwdDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-cwd-'));
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-proj-'));
});
afterEach(() => {
if (child && !child.killed) {
child.kill('SIGKILL');
child = null;
}
fs.rmSync(cwdDir, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
});
it('resolves the project from the client roots/list when no rootUri is sent', async () => {
const cg = await CodeGraph.init(projectDir);
cg.close();
child = spawnServer(cwdDir);
const messages = collectMessages(child);
// Advertise the roots capability but pass NO rootUri/workspaceFolders.
send(child, {
jsonrpc: '2.0', id: 0, method: 'initialize',
params: { protocolVersion: '2025-11-25', capabilities: { roots: {} }, clientInfo: CLIENT_INFO },
});
await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000);
send(child, { jsonrpc: '2.0', method: 'notifications/initialized' });
// First tool call (no projectPath) drives the server to ask us for roots.
send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
const rootsReq = await waitForMessage(messages, (m) => m.method === 'roots/list', 5000);
expect(typeof rootsReq.id).toBe('string'); // server-initiated id
send(child, {
jsonrpc: '2.0', id: rootsReq.id,
result: { roots: [{ uri: `file://${projectDir}`, name: 'proj' }] },
});
// The status call now succeeds against the resolved project.
const resp = await waitForMessage(messages, (m) => m.id === 1, 8000);
const text = resp.result.content[0].text as string;
expect(text).toContain('CodeGraph Status');
expect(text).not.toContain('No CodeGraph project is loaded');
}, 20000);
it('returns an actionable error when there is no rootUri and no roots capability', async () => {
child = spawnServer(cwdDir);
const messages = collectMessages(child);
send(child, {
jsonrpc: '2.0', id: 0, method: 'initialize',
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: CLIENT_INFO },
});
await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000);
send(child, { jsonrpc: '2.0', method: 'notifications/initialized' });
send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
const resp = await waitForMessage(messages, (m) => m.id === 1, 8000);
const text = resp.result.content[0].text as string;
expect(text).toContain('No CodeGraph project is loaded');
expect(text).toContain('projectPath');
expect(text).toContain('--path');
// Names the directory it actually searched (the wrong cwd) so the user can
// see why detection missed. basename survives any symlink realpath-ing.
expect(text).toContain(path.basename(cwdDir));
// It must not have hung waiting on roots/list — the client never offered it.
expect(messages.some((m) => m.method === 'roots/list')).toBe(false);
}, 20000);
it('honors an explicit rootUri without asking the client for roots', async () => {
const cg = await CodeGraph.init(projectDir);
cg.close();
child = spawnServer(cwdDir);
const messages = collectMessages(child);
send(child, {
jsonrpc: '2.0', id: 0, method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: { roots: {} },
clientInfo: CLIENT_INFO,
rootUri: `file://${projectDir}`,
},
});
await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000);
send(child, { jsonrpc: '2.0', method: 'notifications/initialized' });
send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
const resp = await waitForMessage(messages, (m) => m.id === 1, 8000);
const text = resp.result.content[0].text as string;
expect(text).toContain('CodeGraph Status');
// rootUri is a stronger signal than roots — we never needed to ask.
expect(messages.some((m) => m.method === 'roots/list')).toBe(false);
}, 20000);
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Per-file staleness banner on MCP tool responses (issue #403).
*
* The watcher tracks every file event since the last successful sync; the
* tool dispatcher intersects "files referenced in this response" with that
* pending set and prepends a banner ("⚠️ Some files referenced below were
* edited since the last index sync…") plus an optional footer ("(Note: N
* file(s) elsewhere in this project are pending index sync…)").
*
* No auto-flush, no static wait — the response is instant and the agent
* decides whether to Read the specific stale file. These tests exercise
* the full real path: real CodeGraph index + real ToolHandler.execute().
*
* **Event delivery uses a synthetic seam** (`__emitWatchEventForTests`): the
* real native fs.watch (FSEvents/inotify) delivery is non-deterministic under
* parallel vitest execution and produced a consistent ~30% failure rate on
* these tests when run inside the full suite. The seam drives the watcher's
* pending-set pipeline directly so the tests synthesize file events
* deterministically. The watcher's actual debounce timer (real setTimeout) is
* left untouched.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
import { __emitWatchEventForTests, __setFsWatchForTests } from '../src/sync/watcher';
function waitFor(condition: () => boolean, timeoutMs = 2000, intervalMs = 25): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now();
const tick = () => {
if (condition()) return resolve();
if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out'));
setTimeout(tick, intervalMs);
};
tick();
});
}
describe('MCP staleness banner', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-banner-'));
fs.mkdirSync(path.join(testDir, 'src'));
// Three isolated files with no cross-references — keeps each test's
// "which path does the response mention?" assertion unambiguous. If the
// files shared imports/calls, codegraph_search responses would surface
// multiple file paths and the banner-vs-footer split would be racy.
fs.writeFileSync(
path.join(testDir, 'src', 'alpha-only.ts'),
'export function alphaOnly() { return 1; }\n',
);
fs.writeFileSync(
path.join(testDir, 'src', 'bravo-only.ts'),
'export function bravoOnly() { return 2; }\n',
);
fs.writeFileSync(
path.join(testDir, 'src', 'charlie-only.ts'),
'export function charlieOnly() { return 3; }\n',
);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
__setFsWatchForTests(null); // reset the injected fs.watch seam
try { cg.unwatch(); } catch { /* ignore */ }
try { cg.close(); } catch { /* ignore */ }
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
// Force watch-resource exhaustion at startup so the real watcher degrades
// deterministically on any platform (recursive or per-directory strategy).
const degradeWatcher = () => {
__setFsWatchForTests(() => {
const err = new Error('too many open files') as NodeJS.ErrnoException;
err.code = 'EMFILE';
throw err;
});
const started = cg.watch({ debounceMs: 1000 }); // real (non-inert) watcher
expect(started).toBe(false);
expect(cg.isWatcherDegraded()).toBe(true);
};
it('prepends a stale banner when the response references a pending file', async () => {
// Long debounce so the edit lingers in pendingFiles while we query.
cg.watch({ debounceMs: 4000, inertForTests: true });
await cg.waitUntilWatcherReady();
// Real disk write so a later sync (if it fires) sees the new content,
// plus a synthesized chokidar event so the watcher's pendingFiles set
// updates immediately without waiting on OS-level event delivery.
fs.writeFileSync(
path.join(testDir, 'src', 'alpha-only.ts'),
'export function alphaOnly() { return 99; }\n',
);
__emitWatchEventForTests(testDir, 'src/alpha-only.ts');
// With mocked chokidar this is synchronous — keep the wait just to
// exercise the realistic shape (the watcher's `chokidarReady` gate
// and the small window before the pending-file Map is populated).
await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/alpha-only.ts'));
const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
expect(res.isError).toBeFalsy();
const text = res.content[0].text;
// Banner shape: warning glyph + filename + actionable instruction.
expect(text.startsWith('⚠️')).toBe(true);
expect(text).toContain('src/alpha-only.ts');
expect(text).toMatch(/edited \d+ms ago/);
expect(text).toMatch(/Read them directly/);
// The actual result must still follow the banner.
expect(text).toMatch(/alphaOnly/);
});
it('uses the footer (not the banner) when pending files are not referenced', async () => {
cg.watch({ debounceMs: 4000, inertForTests: true });
await cg.waitUntilWatcherReady();
// Edit bravo-only.ts but search for the alphaOnly symbol, whose hit is
// only in alpha-only.ts. The two files share no imports/calls so the
// response text won't mention bravo-only.ts.
fs.writeFileSync(
path.join(testDir, 'src', 'bravo-only.ts'),
'export function bravoOnly() { return 22; }\n',
);
__emitWatchEventForTests(testDir, 'src/bravo-only.ts');
await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/bravo-only.ts'));
const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
const text = res.content[0].text;
expect(text.startsWith('⚠️')).toBe(false);
expect(text).toMatch(/elsewhere in this project are pending index sync/);
expect(text).toContain('src/bravo-only.ts');
});
it('drops the banner once the sync completes and clears the pending entry', async () => {
cg.watch({ debounceMs: 200, inertForTests: true });
await cg.waitUntilWatcherReady();
fs.writeFileSync(
path.join(testDir, 'src', 'alpha-only.ts'),
'export function alphaOnly() { return 7; }\n',
);
__emitWatchEventForTests(testDir, 'src/alpha-only.ts');
// Wait through debounce (200ms) + sync; pendingFiles drains back to empty.
await waitFor(() => cg.getPendingFiles().length === 0, 3000);
const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
const text = res.content[0].text;
expect(text.startsWith('⚠️')).toBe(false);
expect(text).not.toMatch(/elsewhere in this project are pending index sync/);
});
it('lists pending files under "Pending sync" in codegraph_status', async () => {
cg.watch({ debounceMs: 4000, inertForTests: true });
await cg.waitUntilWatcherReady();
fs.writeFileSync(
path.join(testDir, 'src', 'charlie-only.ts'),
'export function charlieOnly() { return 33; }\n',
);
__emitWatchEventForTests(testDir, 'src/charlie-only.ts');
await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/charlie-only.ts'));
const res = await handler.execute('codegraph_status', {});
const text = res.content[0].text;
expect(text).toContain('**Pending sync:');
expect(text).toContain('src/charlie-only.ts');
// Status embeds the info first-class, so the auto-banner is suppressed.
expect(text.startsWith('⚠️')).toBe(false);
});
it('returns zero pending files when no watcher is active', () => {
expect(cg.getPendingFiles()).toEqual([]);
});
it('prepends a whole-index degraded banner once live watching has permanently stopped (#876)', async () => {
degradeWatcher();
const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
expect(res.isError).toBeFalsy();
const text = res.content[0].text;
expect(text.startsWith('⚠️')).toBe(true);
expect(text).toMatch(/auto-sync is DISABLED/i);
expect(text).toMatch(/Read files directly/i);
expect(text).toContain('OS watch/file limit exhausted'); // the degrade reason
expect(text).toMatch(/alphaOnly/); // the real result still follows the banner
});
it('surfaces the degraded state as its own section in codegraph_status (#876)', async () => {
degradeWatcher();
const res = await handler.execute('codegraph_status', {});
const text = res.content[0].text;
expect(text).toContain('**Auto-sync disabled:');
expect(text).toContain('OS watch/file limit exhausted');
// status renders the notice inline, so the auto-banner is not also prepended.
expect(text.startsWith('⚠️')).toBe(false);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Startup-orphan regression tests (#1185) — spawn-level.
*
* Reproduced bug: an MCP host kills the launcher chain within the server's
* first ~100ms while keeping the stdio pipes open (config probe, instant
* cancel, initialize-timeout teardown; Rust hosts that kill a child without
* dropping its stdio handles hold pipes exactly like this). The server booted
* already reparented, so its PPID-watchdog baseline read 1 (blind forever),
* stdin never EOF'd, and the process lived until the HOST exited — one ~30MB
* node process leaked per occurrence.
*
* These tests exercise the last-resort defense end-to-end on the real built
* binary: a server that receives no MCP traffic shuts itself down when the
* startup-handshake timeout lapses, and a server that got even one message
* is never touched by it.
*
* POSIX-only: the blind spot is a POSIX reparenting artifact (Windows never
* reparents, so its liveness-based check keeps working with a late baseline),
* and the suite avoids the known Windows EPERM teardown quirk of spawned
* `serve --mcp` children holding the temp cwd open.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function spawnServer(cwd: string, handshakeTimeoutMs: number): ChildProcessWithoutNullStreams {
return spawn(process.execPath, [BIN, 'serve', '--mcp'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
// Direct mode: hermetic (no detached daemon to leak from the suite).
// The backstop is armed identically on the proxy path.
CODEGRAPH_NO_DAEMON: '1',
// Single process (skip the --liftoff-only re-exec) so exit-code and
// liveness assertions observe the server itself.
CODEGRAPH_WASM_RELAUNCHED: '1',
// One less helper child; the liveness watchdog is not under test.
CODEGRAPH_NO_WATCHDOG: '1',
CODEGRAPH_TELEMETRY: '0',
DO_NOT_TRACK: '1',
CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS: String(handshakeTimeoutMs),
},
}) as ChildProcessWithoutNullStreams;
}
function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<number | null> {
return new Promise((resolve, reject) => {
if (child.exitCode !== null) { resolve(child.exitCode); return; }
const timer = setTimeout(
() => reject(new Error(`server did not exit within ${timeoutMs}ms`)),
timeoutMs
);
child.on('exit', (code) => { clearTimeout(timer); resolve(code); });
});
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
describe.skipIf(process.platform === 'win32')('startup-orphan backstop (#1185)', () => {
let dir: string;
let child: ChildProcessWithoutNullStreams | null = null;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-orphan-'));
});
afterEach(() => {
if (child && child.exitCode === null) child.kill('SIGKILL');
child = null;
fs.rmSync(dir, { recursive: true, force: true });
});
it('a server that never receives MCP traffic shuts itself down', async () => {
child = spawnServer(dir, 1000);
let stderr = '';
child.stderr.on('data', (c) => { stderr += c.toString(); });
// Keep our pipe ends open the whole time — the abandoned-launch shape:
// no stdin EOF ever arrives; only the backstop can reap the server.
const code = await waitForExit(child, 15_000);
expect(code).toBe(0);
expect(stderr).toContain('No MCP traffic since startup');
}, 20_000);
it('a server that got an initialize is never reaped by the backstop', async () => {
child = spawnServer(dir, 1000);
child.stdin.write(JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 't', version: '0' } },
}) + '\n');
// Well past the 1s backstop window: the first byte disarmed it for good.
await sleep(3000);
expect(child.exitCode).toBeNull();
// Normal lifecycle still intact: closing stdin ends the session.
child.stdin.end();
const code = await waitForExit(child, 10_000);
expect(code).toBe(0);
}, 20_000);
});
+63
View File
@@ -0,0 +1,63 @@
/**
* CODEGRAPH_MCP_TOOLS allowlist — lets an operator (or an A/B harness) trim the
* exposed MCP tool surface without touching the client config. Inert when unset.
* Filtering happens in ListTools (getTools) and is enforced again on execute().
*/
import { describe, it, expect, afterEach } from 'vitest';
import { ToolHandler } from '../src/mcp/tools';
const ENV = 'CODEGRAPH_MCP_TOOLS';
describe('CODEGRAPH_MCP_TOOLS allowlist', () => {
const original = process.env[ENV];
afterEach(() => {
if (original === undefined) delete process.env[ENV];
else process.env[ENV] = original;
});
const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort();
it('exposes ONLY codegraph_explore by default when unset', () => {
delete process.env[ENV];
// The default set (see DEFAULT_MCP_TOOLS) is pared to explore alone — the one
// tool that earns its place (verbatim source grouped by file).
// node/search/callers/callees/impact/files/status stay defined and executable
// but unlisted; CODEGRAPH_MCP_TOOLS re-enables them.
expect(listed()).toEqual(['codegraph_explore']);
});
it('re-enables an unlisted tool via the allowlist (impact)', () => {
process.env[ENV] = 'explore,impact';
expect(listed()).toEqual(['codegraph_explore', 'codegraph_impact']);
});
it('filters ListTools to the allowlisted short names', () => {
process.env[ENV] = 'explore,search,node';
expect(listed()).toEqual(['codegraph_explore', 'codegraph_node', 'codegraph_search']);
});
it('accepts fully-qualified codegraph_ names and ignores whitespace', () => {
process.env[ENV] = ' codegraph_explore , search ';
expect(listed()).toEqual(['codegraph_explore', 'codegraph_search']);
});
it('treats an empty/whitespace value as unset (default surface)', () => {
process.env[ENV] = ' ';
expect(listed()).toEqual(['codegraph_explore']);
});
it('rejects a disabled tool on execute (defense in depth)', async () => {
process.env[ENV] = 'node';
const res = await new ToolHandler(null).execute('codegraph_explore', {});
expect(res.isError).toBe(true);
expect(res.content[0].text).toMatch(/disabled via CODEGRAPH_MCP_TOOLS/);
});
it('lets an allowlisted tool past the guard', async () => {
process.env[ENV] = 'search';
// No CodeGraph attached, so it fails *after* the allowlist guard — the
// "disabled" message must NOT appear, proving the guard passed it through.
const res = await new ToolHandler(null).execute('codegraph_search', { query: 'x' });
expect(res.content[0].text).not.toMatch(/disabled via CODEGRAPH_MCP_TOOLS/);
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* Read-only MCP ToolAnnotations on every codegraph tool (issue #1018).
*
* Every codegraph tool is query-only — it reads the pre-built index and never
* mutates the workspace. Clients gate on this: Cursor's Ask mode refuses any MCP
* tool that doesn't advertise `readOnlyHint: true`, so without annotations the
* codegraph tools were blocked there even though they only read.
*
* These tests pin that the read-only contract is present on the master tool
* array AND survives every transform that builds a `tools/list` response — the
* static proxy surface (`getStaticTools`), the live surface (`getTools`, which
* rewrites codegraph_explore's description via spread), and the no-default-
* project surface (`withRequiredProjectPath`, which clones the schema). A drop in
* any of those would silently re-block the tools in Ask mode.
*/
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ToolHandler, getStaticTools, tools, type ToolDefinition } from '../src/mcp/tools';
import { CodeGraph } from '../src';
const ENV = 'CODEGRAPH_MCP_TOOLS';
const ALL_TOOLS = tools.map((t) => t.name).join(',');
/** Assert a single tool advertises the full read-only contract from #1018. */
function expectReadOnly(tool: ToolDefinition): void {
expect(tool.annotations, `${tool.name} is missing annotations`).toBeDefined();
// The hint Cursor Ask mode (and other clients) gate on.
expect(tool.annotations!.readOnlyHint).toBe(true);
// The exact triplet the issue asks for, plus the honest closed-world hint.
expect(tool.annotations!.destructiveHint).toBe(false);
expect(tool.annotations!.idempotentHint).toBe(true);
expect(tool.annotations!.openWorldHint).toBe(false);
}
describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
const original = process.env[ENV];
afterEach(() => {
if (original === undefined) delete process.env[ENV];
else process.env[ENV] = original;
});
it('every tool in the master array is annotated read-only', () => {
expect(tools.length).toBeGreaterThan(0);
for (const tool of tools) expectReadOnly(tool);
});
it('the static proxy surface carries annotations on every exposed tool', () => {
// getStaticTools() answers tools/list before any project opens (proxy path).
process.env[ENV] = ALL_TOOLS;
const got = getStaticTools();
expect(got.map((t) => t.name).sort()).toEqual(tools.map((t) => t.name).sort());
for (const tool of got) expectReadOnly(tool);
});
it('the no-default-project surface keeps annotations through the schema clone', () => {
// withRequiredProjectPath (null cg) clones each tool's inputSchema — the
// top-level annotations field must ride along on the spread.
process.env[ENV] = ALL_TOOLS;
const got = new ToolHandler(null).getTools();
expect(got.length).toBe(tools.length);
for (const tool of got) {
expectReadOnly(tool);
// Sanity: this IS the clone path (projectPath got marked required).
expect(tool.inputSchema.required ?? []).toContain('projectPath');
}
});
});
describe('Live tool surface keeps annotations with a project open (#1018)', () => {
let tempDir: string;
let cg: CodeGraph;
const original = process.env[ENV];
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-annot-'));
fs.writeFileSync(
path.join(tempDir, 'pay.ts'),
'export function processPayment(amount: number): boolean { return amount > 0; }\n'
);
cg = await CodeGraph.init(tempDir, { index: true });
});
afterEach(() => {
cg.close();
fs.rmSync(tempDir, { recursive: true, force: true });
if (original === undefined) delete process.env[ENV];
else process.env[ENV] = original;
});
it('getTools() keeps annotations, incl. codegraph_explore whose description is rebuilt', () => {
process.env[ENV] = ALL_TOOLS;
const got = new ToolHandler(cg).getTools();
expect(got.length).toBeGreaterThan(0);
for (const tool of got) expectReadOnly(tool);
// explore's description is regenerated with a per-repo budget suffix via
// object spread; the annotation must survive that rewrite.
const explore = got.find((t) => t.name === 'codegraph_explore');
expect(explore).toBeDefined();
expect(explore!.description).toMatch(/Budget: make at most/);
expectReadOnly(explore!);
});
});

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