diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..ea6186f40
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,283 @@
+# AGENTS.md
+
+Canonical project guidance for coding agents working in this repository (Codex/Astra, Claude Code via `@AGENTS.md`, Cursor, etc.).
+
+**Codex size note:** root `AGENTS.md` is intentionally kept near ~35 KiB (critical build/test/arch/retrieval rules retained). Longer validation methodology + the Excalidraw worked example live in `docs/AGENTS.md`. This environment sets `project_doc_max_bytes = 49152` so root (and root+`docs/` when cwd is under `docs/`) are not silently truncated at the 32 KiB default.
+
+## 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 + build the viewer into dist/; chmods dist/bin/codegraph.js
+npm run build:lib # the viewer's components as @colbymchenry/codegraph-ui (ui/dist) — NOT part of `build`
+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.**
+
+One other build step writes into `dist/` and is subject to the same rule: `build:ui` builds the
+browser viewer into `dist/viewer/` (never `dist/ui/` — that's the terminal ui).
+`scripts/check-ui-build.mjs` asserts both `dist/viewer/` and the copied grammars in
+`dist/extraction/wasm/` after every build and inside every release archive — the viewer's syntax
+highlighting reads a file with the same grammar the engine indexed it with, so a missing wasm is an
+unhighlighted screen as well as an extraction gap.
+
+`npm run build:lib` is separate and does NOT run as part of `npm run build`: it compiles the same
+`ui/src` tree a second way, with `svelte-package`, into `ui/dist` — the `@colbymchenry/codegraph-ui`
+component library the Pro app imports (task CG-61). `scripts/check-ui-package.mjs` then prunes the
+standalone app's shell out of it, resolves the extensionless import specifiers `svelte-package`
+leaves behind, and asserts the seam: nothing outside `lib/adapter.js` may reach the network. The
+package is **prepared, not published** — `ui/package.json` carries `"private": true` deliberately,
+and `scripts/pack-npm.sh` only packs a tarball when `CODEGRAPH_PACK_UI=1`.
+
+Tests run as **two vitest projects** (`vitest.workspace.mts`): `engine` (node) and `ui` (jsdom, the
+Svelte plugin, `resolve.conditions: ['browser']`) for the single `__tests__/ui-package.test.ts`.
+`npm test` still runs both. The split is not cosmetic — `browser` is a package-resolution
+condition, and applied globally it hands the engine's suites the browser builds of
+`web-tree-sitter` and friends. The root config (`vitest.config.mts`, `.mts` because the plugin is
+ESM-only and the repo is CJS) is the shared base; note that a workspace project **concatenates**
+the base's `include` with its own, which is why the `ui` project does not `extends` it.
+
+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, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`).
+- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws),
+ `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
+- `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).
+- `src/ui-server/` -- read-only JSON API for the `codegraph ui` browser viewer (`api/`: `node`, `flow`, `map`, `screens`, `steps`, `deadcode`, `trails`, `program`, ...) plus static server; Svelte viewer lives in `ui/` (see `docs/design/codegraph-ui-design-spec.md`). `screens`/`steps`/`program` share one fold (`via`/`when` via `graph/branch-guards.ts`); `api/effects.ts` curates calls that leave the index; `api/route-roots.ts` names where a route's code starts. Derivations rendered by more than one surface belong in `src/graph/`, not `ToolHandler`.
+
+### 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`, `union`.
+- **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 ``/`` 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 3–5 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) | 643–3043 | 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), **React Native native→JS events** (`sendEvent(withName:)` / JVM `emit` → the `addListener` handler, named or inline, `rn-event-channel`), django ORM descriptor. The JS→native direction is a *resolver* (`frameworks/react-native.ts`: `RCT_EXPORT_METHOD`, `RCT_EXTERN_MODULE` Swift shims, TurboModules), which trusts receiver evidence — an alias bound to `NativeModules.X` — over the import resolver. 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 5–7; only completing the flow (adding the jsx-child hop) dropped it to 0–1. **Always close the flow end-to-end and re-measure** — never ship a half-bridged flow.
+
+
+### Validation methodology & worked examples
+
+**Required** for every new language/framework: validate on small/medium/large real repos with >=3 flow prompts; deterministic probes (`scripts/agent-eval/probe-*.mjs`) then agent A/B (`scripts/agent-eval/run-all.sh` / `ab-new-vs-baseline.sh`). Pass bar: ~0 Read/Grep within the explore-call budget, faster than without-codegraph, no control-repo regression.
+
+Full methodology (feedback metrics, CLI contamination guard, Sonnet/`--effort high` model policy, daemon pre-warm), the Excalidraw worked example, and coverage matrix live in:
+- `docs/AGENTS.md` (nested; also loaded when cwd is under `docs/`)
+- `docs/design/dynamic-dispatch-coverage-playbook.md`
+- `docs/design/callback-edge-synthesis.md`
+- `docs/benchmarks/call-sequence-analysis.md` / `docs/benchmarks/agent-eval-feedback-metrics.md`
+
+
+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//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 @ "..."`. For multi-line work, pipe PowerShell over stdin and **refresh PATH from the registry** first (sshd's session has a stale PATH after winget installs):
+ ```
+ ssh colby@10.211.55.3 "powershell -NoProfile -ExecutionPolicy Bypass -Command -" <<'PS'
+ $env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User")
+ Set-Location C:\dev\codegraph
+ PS
+ ```
+- Clone fresh into a **Windows-local** path (`C:\dev\codegraph`) and `npm ci` there — never run npm against the shared Mac repo, since `esbuild`/`rollup` ship platform-specific binaries.
+- Guest toolchain (winget): Node LTS, Git, and the **VC++ ARM64 redistributable** (required by `@rollup/rollup-win32-arm64-msvc`, which vitest pulls in).
+- Fetch a contributor PR head straight from their fork to dodge `pull//head` lag: `git fetch ` then `git checkout -f FETCH_HEAD`.
+- Known pre-existing Windows 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] - ` 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).
+6. **Every release opens with a `### Highlights` block — the only part most people read.** At most ~8 one-line bullets, in plain language for someone who doesn't read code, ordered by what a typical user notices first (new agent/IDE support and setup changes, then answer quality, then reliability), plus a one-sentence upgrade note when a re-index is needed. Write or refresh it in `[Unreleased]` when a release is being prepared — not per PR — and keep the detailed `### New Features` / `### Fixes` entries below it. When `### Fixes` grows past ~15 entries, group them under `####` sub-headings (`Better answers from codegraph_explore`, `Finding your project, live updates, and the CLI`, `Indexing reliability and disk usage`, `Language and framework accuracy`) so a skimmer can find their area.
+
+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 `[]` (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 ` → promotes `[Unreleased]` → `[X.Y.Z] - ` 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.)
+- **Before adding or extending a router, a web framework, or a language's `WHEN` rules, read `docs/design/framework-coverage.md`.** It is the standing answer to "what is supported and what is left" across the three axes (route nodes → Entry points, `navigates` edges → Screens, branch-guard rules → the `WHEN` labels), with what each remaining item needs, the traps that have already cost debugging time, and the queries to re-verify it. Update it in the same change that moves a row.
+- 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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0e4af0b1..0762ecb5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### New Features
+- **Codex and Astra read project guidance from `AGENTS.md`.** The canonical agent guide now lives in `AGENTS.md` (with a nested `docs/AGENTS.md` for long validation notes); `CLAUDE.md` is a thin `@AGENTS.md` wrapper for Claude Code. Codex/Astra no longer miss the old CLAUDE-only instructions.
+
- **A busy screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order between them, so on a hub screen the old rows-by-distance collapsed into one enormous row — the main screen of one app put 89 boxes side by side on a canvas over 28,000px wide, every line a near-horizontal sweep across all of it. The Steps tab now groups a screen's picture by region — the component that owns each handler, named in a small caption over its boxes — with each region a column where a step sits above what it sets in motion, tiled in the screen's own source order. At rest the picture hides only two things: the screen's own fan-out — one line into each region stands in for it — and lines that point back up; every other line draws where it leads, between two regions included, and selecting a step brings out its whole story in the side panel, link by link. A box nothing points at is the screen's own doing — run on render or mount, or from a binding written inline — the key says so, and selecting it lights its line from the screen with what fires it. The same app's widest screen now lays out under 3,500px with every line local, and the whole picture fits on screen when it opens. Endpoints, handlers and the in-order reading are untouched, and nothing needs a re-index: the regions come from the same walk that draws the steps.
- **Where the code chooses, the picture says so once.** A helper that ends `return (await hasSeenWelcome(id)) ? '/home/' : '/welcome/'` sends the app to one of two screens, but the Steps picture drew that as two separate arrows, each carrying the whole condition with one of them negated and both cut off at the same forty characters — and before you clicked anything, neither arrow was labelled at all, so nothing said it was a choice. Now sibling arrows out of one box that are the arms of one `if`, `switch` or ternary are drawn as the choice they are: the condition is written once under the box that decides it, and each arrow out says only which way it is — `yes`, `no`, or a case's own value. They are the only arrows labelled before you select anything, so the picture reads at a glance without becoming a wall of text. A one-sided guard — an early exit, an `if` with only one side drawn — still carries its condition on the arrow, and an arrow that is reached whether or not the condition holds never claims a side. Nothing needs a re-index: the decision is read from the source at request time.
@@ -133,6 +135,16 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
+#### MCP / indexing
+
+- **Watcher scope now matches `git ls-files --exclude-standard` (#1728).** `buildDefaultIgnore` / `buildScopeIgnore` read `.git/info/exclude` and `core.excludesFile` (not only the root `.gitignore`), and seed directories git reports as ignored-untracked so nested `.gitignore` effects prune the live watcher the same way the indexer skips them. Single-file auto-sync was already incremental (`pendingFiles` → scoped `sync({ paths })`); the remaining gap was watching trees git had excluded.
+
+- **Live sync no longer lets the write-ahead log grow without a bound when a reader is holding it open (#1539).** Incremental sync now uses the same writer pause that full indexing already used, and if checkpointing still cannot finish once the log is past its documented size limit — typically because the query pool is reading at the same time — sync stops with a clear error instead of keeping writing until the disk fills. The previous behaviour could leave a multi-tens-of-gigabyte log beside a few-gigabyte index on a large project. Close concurrent readers and retry, or raise `CODEGRAPH_WAL_VALVE_MB` if the limit is too tight for the project.
+
+- **A second `codegraph serve --mcp` on the same project no longer silently kills auto-sync (#1740).** Direct mode (`CODEGRAPH_NO_DAEMON=1` or proxy→in-process fallback) now takes an exclusive `.codegraph/writer.pid` lock; a second writer exits immediately with guidance to stop the other server or unset `CODEGRAPH_NO_DAEMON` so clients share the daemon. The shared daemon already multiplexes N clients onto one watcher — this closes the same-OS dual-direct gap the docs warned about for Windows/WSL but did not guard.
+
+- Indexing no longer checks whether files outside your project exist. A relative import that points above the project directory (`../../something`) made CodeGraph probe that location on disk while resolving it. Nothing outside the project was ever read, and no such file was ever added to the index or linked to, but the check itself should not have happened — such an import now simply resolves to nothing. Symlinks inside your project that point at code kept elsewhere are unaffected and still index as before. Thanks @ErQrYfkrju. (#1631)
+
#### Screens, links and navigation
- **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up.
@@ -210,6 +222,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Kotlin `init { }` blocks and destructuring declarations no longer swallow their code: `init { val cfg = load() }` and `val (a, b) = makePair()` contributed no call edge at all, and now attribute to the enclosing class or file.
- A Kotlin property's accessor body now belongs to the property whichever line it is written on, instead of being dropped (same line) or handed to the enclosing class (own line).
- Kotlin properties that hold a lambda, a SAM callback or an anonymous object — `private val frameListener = CameraFrameListener { … }`, the way Android and MSDK callbacks are almost always declared — now contribute call edges. Previously everything inside such an initializer was dropped, so a function reached only through one of these callbacks looked like it had no callers at all and its blast radius came back far too small. Delegated properties (`by lazy { … }`) and plain initializers (`val x = compute()`) were affected the same way and are fixed too. Re-index with `codegraph index -f` after upgrading to pick up the new edges.
+- **A C macro call written with designated initializers no longer swallows every function after it.** Betaflight resets each config struct with `RESET_CONFIG(type, dst, .field = value, …)`, a shape the C grammar cannot parse; past a hundred or so fields its error recovery ran the enclosing function to the end of the file, the next function vanished from the index and every later one was filed under the first, where name matching then treated it as an unreachable closure. The argument list of such a call is now blanked before parsing, offsets kept, so the file's functions come out with their real extents. On that tree 45 functions in `pid.c` alone moved back to top level and their 117 callers resolve at exact-match confidence. Re-index after upgrading. (#1729)
+- A method called on the result of another call — `d.setdefault(k, []).append(v)`, `make().run()` — no longer produces a call edge to an unrelated top-level function that merely shares the name, in Python and JavaScript/TypeScript. The receiver is kept so the inner call still resolves; the outer method stays unresolved rather than guessed. Re-index after upgrading. (#1683, #1681)
+- A Python call through an imported project module whose name collides with a builtin collection method — `ledger.append(row)` after `from . import ledger` — is no longer dropped as `list.append`. The builtin-method filter now lets the receiver through when it is an imported module that resolves to a file in the project, so `resolveViaImport` can attach the real edge; a stdlib/PyPI receiver (`os.remove`) still produces none. Re-index after upgrading. (#1681, via #1704)
+- **A definition its language makes file-local no longer captures calls from other files.** A C `static` in another source file (`.c`/`.cc`/… — not a header's `static inline`, which is textually included), a Kotlin/Java/C#/Swift/Scala/Dart/PHP `private` member, a Go unexported name in another package, and a Rust non-`pub` item outside its module subtree cannot be what a name in another file means, but name matching accepted them whenever the names agreed: an Android `editor.apply()` onto an unrelated class's `private fun apply`, a JavaScript `fail(...)` onto a Go `func fail`, a Rust `.count()` onto a private `fn count` in another crate, and C USB helpers onto a `static` in a `.c` they never link. Such a target is now declined after the whole name-matching pipeline settles — the reference stays unresolved rather than falling through to a fuzzy namesake. Same-file definitions, a child Rust module reaching its ancestors' private items, and Rust `impl Trait for Type` methods stay resolvable. Re-index after upgrading. (#1730, #1731)
+- **A binding in a module that exports nothing is no longer a cross-file target.** On vite, every `import { defineConfig } from 'vite'` across the playground resolved onto a `const vite = await createServer(…)` sitting at module scope in `playground/ssr-html/test-stacktrace.js` — a file with an import and no export, so that binding is reachable from nowhere but itself. Name matching commits as soon as one candidate survives, and nothing asked whether an import could reach the survivor; that one binding took 157 edges. A JS/TS file holding an `import` and no export of any kind now offers its locals to no other file. Classic scripts, CommonJS (including `exports["x"] = …`), a later `export { … }`, and names contributed through `declare global` are all unaffected. Across vite this removed 320 wrong edges and added 18, each addition a reference that was previously ambiguous rather than newly invented. Re-index after upgrading. (#1719)
+- **A bare call inside a JavaScript or TypeScript method no longer resolves to the method itself.** When a method and a module-scope function share a name, `serialize(this.raw)` written inside `Record.serialize` means the function, but the nearest same-named definition won the tie and the graph recorded the method calling itself. A call written without a receiver can never reach a method in JS/TS, so methods are no longer candidates for it; `this.serialize()` and `other.serialize()` resolve as before. (#1714)
- **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.
diff --git a/CLAUDE.md b/CLAUDE.md
index f91f170a9..217d62d2e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,299 +1,13 @@
# CLAUDE.md
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+Claude Code project guidance for this repository.
-## Project Overview
+Primary instructions live in the canonical agent guide — import it:
-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.
+@AGENTS.md
-Distributed as `@colbymchenry/codegraph` on npm; same binary serves as installer, indexer, and MCP server.
+## Claude-only notes
-## Build, Test, Run
-
-```bash
-npm run build # tsc + copy schema.sql and *.wasm + build the viewer into dist/; chmods dist/bin/codegraph.js
-npm run build:lib # the viewer's components as @colbymchenry/codegraph-ui (ui/dist) — NOT part of `build`
-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.**
-
-One other build step writes into `dist/` and is subject to the same rule: `build:ui` builds the
-browser viewer into `dist/viewer/` (never `dist/ui/` — that's the terminal ui).
-`scripts/check-ui-build.mjs` asserts both `dist/viewer/` and the copied grammars in
-`dist/extraction/wasm/` after every build and inside every release archive — the viewer's syntax
-highlighting reads a file with the same grammar the engine indexed it with, so a missing wasm is an
-unhighlighted screen as well as an extraction gap.
-
-`npm run build:lib` is separate and does NOT run as part of `npm run build`: it compiles the same
-`ui/src` tree a second way, with `svelte-package`, into `ui/dist` — the `@colbymchenry/codegraph-ui`
-component library the Pro app imports (task CG-61). `scripts/check-ui-package.mjs` then prunes the
-standalone app's shell out of it, resolves the extensionless import specifiers `svelte-package`
-leaves behind, and asserts the seam: nothing outside `lib/adapter.js` may reach the network. The
-package is **prepared, not published** — `ui/package.json` carries `"private": true` deliberately,
-and `scripts/pack-npm.sh` only packs a tarball when `CODEGRAPH_PACK_UI=1`.
-
-Tests run as **two vitest projects** (`vitest.workspace.mts`): `engine` (node) and `ui` (jsdom, the
-Svelte plugin, `resolve.conditions: ['browser']`) for the single `__tests__/ui-package.test.ts`.
-`npm test` still runs both. The split is not cosmetic — `browser` is a package-resolution
-condition, and applied globally it hands the engine's suites the browser builds of
-`web-tree-sitter` and friends. The root config (`vitest.config.mts`, `.mts` because the plugin is
-ESM-only and the repo is CJS) is the shared base; note that a workspace project **concatenates**
-the base's `include` with its own, which is why the `ui` project does not `extends` it.
-
-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, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`).
-- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws),
- `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
-- `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).
-- `src/ui-server/` — the `codegraph ui` browser viewer's read-only JSON API (`api/`: one module per endpoint — `node`, `flow`, `map`, `screens`, `steps`, `deadcode`, `trails`…) and static server; the Svelte viewer itself lives in `ui/` (see `docs/design/codegraph-ui-design-spec.md`). `api/screens.ts` (the app as screens and transitions) and `api/steps.ts` (what happens from a screen, an endpoint or a symbol, as typed steps — screens, handlers, native bridge calls and events, store actions, calls that leave the index) share one fold: everything between two boxes is `via`, and the branch guards along it join into `when` (`graph/branch-guards.ts`, read at request time). `api/program.ts` is the SECOND reading of that same walk (spec §3.13.1): the anchor's body as a block tree — items in source order, a fork wherever two sites are arms of one decision, a helper drawn in place, an arm that answers or leaves ending there — which `ui/src/lib/program-model.ts` turns into the canvas's graph of what happens NEXT (a line means "and then", a row down is one more thing already done). It is pure over the records `steps.ts` keeps while it walks (`ProgramSite`), so the rail and the tree can never hold different steps; what makes the fold possible is that a guard names the DECISION it belongs to (`BranchGuard.branch`), not only its own words. Two helpers sit beside them: `api/route-roots.ts` (where a route's code starts — the handler a resolver named, the page a screen file exports, or the route itself for an inline handler; one rule for every framework) and `api/effects.ts` (the curated table of calls that leave the index — database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry — matched on the call **as written**, per language family, plus the model / read-write and the response status). `graph/branch-guards.ts` reads, from one cached tree per file, the conditions a site runs under (each with the branching construct it belongs to and how its arm leaves), the loops it is written inside, what it passes, the call as written (the index keeps only the last segment of a deep member chain), the decorators on a definition and the declared types of a class's members — for JS/TS, Swift, Python, Java, Kotlin, C#, Go and C; a language without rules yields nothing, never a wrong label. `steps.ts`'s `crossing()` reads an edge's `tier` / `channel` marker before the languages, so a synthesized cross-tier hop between two TS files draws as a bridge (an endpoint reached over HTTP — a boundary like another screen, entered with `through=1`) or an event (a job, an event, a message arriving); a Next server action is marked at request time from its `'use server'` directive (`api/when.ts`'s `directive`).
-
-### 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`, `union`.
-- **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 ``/`` 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 3–5 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) | 643–3043 | 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), **React Native native→JS events** (`sendEvent(withName:)` / JVM `emit` → the `addListener` handler, named or inline, `rn-event-channel`), django ORM descriptor. The JS→native direction is a *resolver* (`frameworks/react-native.ts`: `RCT_EXPORT_METHOD`, `RCT_EXTERN_MODULE` Swift shims, TurboModules), which trusts receiver evidence — an alias bound to `NativeModules.X` — over the import resolver. 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 5–7; only completing the flow (adding the jsx-child hop) dropped it to 0–1. **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 ""`): 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`).
- - **Every run also reports three feedback metrics** — residual context occupancy, explore sufficiency (what the agent did NEXT after each explore), and allocation efficiency (share of returned bytes the answer cited) — under each run, plus a side-by-side arm table (`compare-arms.mjs`). Entry point: `docs/benchmarks/agent-eval-feedback-metrics.md`. Reading them: `Read a file we returned` is an allocation miss, `Read a file we did NOT return`/`Grep` is recall; allocation efficiency is **relative** (attribution is by citation) so it is only valid between builds on the same question; occupancy *shares* are Claude Code / 200k and don't transfer to another host — the arm ratio does.
- - **The `codegraph` CLI is blocked in every arm** (`no-cli-shim.sh`: sanitized PATH + a PreToolUse hook, shared by both harnesses). Without it 14 of 15 without-arm runs in one 7-repo pass reached codegraph through Bash. Check the contamination row before believing any number: `CLI calls that RETURNED output` > 0 invalidates the run (in a new-vs-baseline A/B it silently drops calls from all three metrics, since a CLI explore is not a tool call).
- - **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 "" [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 | 115–139s | 9–10 | 10–11 | 0 |
-| Broken (explore-budget regression) | 131–139s | 5–10 | 3–5 | 6–14 |
-| Fixed (budget + msgs + synthesis) | 64–112s | 0–2 | 2–4 | 3–**10** |
-| + trace-first steering | **51–74s** | **0–2** | 0–4 | **3–4** |
-
-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 3–10 to 3–4, 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//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 @ "..."`. For multi-line work, pipe PowerShell over stdin and **refresh PATH from the registry** first (sshd's session has a stale PATH after winget installs):
- ```
- ssh colby@10.211.55.3 "powershell -NoProfile -ExecutionPolicy Bypass -Command -" <<'PS'
- $env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User")
- Set-Location C:\dev\codegraph
- PS
- ```
-- Clone fresh into a **Windows-local** path (`C:\dev\codegraph`) and `npm ci` there — never run npm against the shared Mac repo, since `esbuild`/`rollup` ship platform-specific binaries.
-- Guest toolchain (winget): Node LTS, Git, and the **VC++ ARM64 redistributable** (required by `@rollup/rollup-win32-arm64-msvc`, which vitest pulls in).
-- Fetch a contributor PR head straight from their fork to dodge `pull//head` lag: `git fetch ` then `git checkout -f FETCH_HEAD`.
-- Known pre-existing Windows 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] - ` 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).
-6. **Every release opens with a `### Highlights` block — the only part most people read.** At most ~8 one-line bullets, in plain language for someone who doesn't read code, ordered by what a typical user notices first (new agent/IDE support and setup changes, then answer quality, then reliability), plus a one-sentence upgrade note when a re-index is needed. Write or refresh it in `[Unreleased]` when a release is being prepared — not per PR — and keep the detailed `### New Features` / `### Fixes` entries below it. When `### Fixes` grows past ~15 entries, group them under `####` sub-headings (`Better answers from codegraph_explore`, `Finding your project, live updates, and the CLI`, `Indexing reliability and disk usage`, `Language and framework accuracy`) so a skimmer can find their area.
-
-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 `[]` (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 ` → promotes `[Unreleased]` → `[X.Y.Z] - ` 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.)
-- **Before adding or extending a router, a web framework, or a language's `WHEN` rules, read `docs/design/framework-coverage.md`.** It is the standing answer to "what is supported and what is left" across the three axes (route nodes → Entry points, `navigates` edges → Screens, branch-guard rules → the `WHEN` labels), with what each remaining item needs, the traps that have already cost debugging time, and the queries to re-verify it. Update it in the same change that moves a row.
-- 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.
+- Prefer the repo-root `AGENTS.md` as the source of truth for build/test/architecture/house rules. Edit that file (not this wrapper) when guidance changes.
+- Claude Code can `@`-import nested guides too (e.g. `@docs/AGENTS.md`) when working on design/eval docs; Codex loads nested `AGENTS.md` automatically when the session cwd is under that directory.
+- Do not reintroduce a duplicated `## CodeGraph` MCP tool-guidance block here — `src/mcp/server-instructions.ts` is the single source of truth (issue #529); the installer strips legacy marker blocks on upgrade.
diff --git a/README.md b/README.md
index a73d3b2bc..80fa50696 100644
--- a/README.md
+++ b/README.md
@@ -944,6 +944,8 @@ Framework routing is validated the same way, on a canonical app per framework: E
**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.
+**Two `codegraph serve --mcp` on one project fight over the index / auto-sync stops** — CodeGraph allows one live MCP *writer* per project (the shared background daemon, or a single direct-mode process). Extra clients should proxy to that daemon. If you set `CODEGRAPH_NO_DAEMON=1`, run only one `serve --mcp` for that project; a second instance exits with a clear writer-lock error (see `writer.pid` under `.codegraph/`). Prefer leaving the daemon enabled so multiple MCP hosts share one watcher.
+
**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`).
diff --git a/__tests__/bare-call-no-method.test.ts b/__tests__/bare-call-no-method.test.ts
new file mode 100644
index 000000000..ba5e1d3f8
--- /dev/null
+++ b/__tests__/bare-call-no-method.test.ts
@@ -0,0 +1,148 @@
+/**
+ * In JS/TS a receiver-less call can never bind to a class method: `serialize(x)`
+ * inside `Record.serialize` means the module-scope function, and the method
+ * itself — which the same-file proximity term used to pick, producing a
+ * self-edge — is not a candidate (#1714). `this.serialize(x)` still is.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+
+let tempDir: string;
+let cg: CodeGraph | null = null;
+
+async function callsFromMethod(source: string, methodName: string): Promise {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1714-'));
+ fs.writeFileSync(path.join(tempDir, 'record.ts'), source);
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+ const from = cg.getNodesByKind('method').find((n) => n.name === methodName)!;
+ expect(from).toBeDefined();
+ return cg
+ .getOutgoingEdges(from.id)
+ .filter((e) => e.kind === 'calls')
+ .map((e) => cg!.getNode(e.target))
+ .filter((n): n is NonNullable => !!n)
+ .map((n) => `${n.kind}:${n.qualifiedName ?? n.name}`);
+}
+
+afterEach(() => {
+ cg?.close();
+ cg = null;
+ fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('a receiver-less JS/TS call never binds to a method (#1714)', () => {
+ it('resolves the bare call onto the module-scope function, not the enclosing method', async () => {
+ const callees = await callsFromMethod(
+ [
+ 'function serialize(value: string): string {',
+ ' return value.trim();',
+ '}',
+ '',
+ 'export class Record {',
+ ' constructor(private readonly raw: string) {}',
+ ' serialize(): string {',
+ ' return serialize(this.raw);',
+ ' }',
+ '}',
+ '',
+ ].join('\n'),
+ 'serialize'
+ );
+ expect(callees).toContain('function:serialize');
+ expect(callees).not.toContain('method:Record::serialize');
+ });
+
+ it('keeps `this.serialize()` — a real recursive self-call', async () => {
+ const callees = await callsFromMethod(
+ [
+ 'function serialize(value: string): string {',
+ ' return value.trim();',
+ '}',
+ '',
+ 'export class Record {',
+ ' constructor(private readonly raw: string, private depth = 0) {}',
+ ' serialize(): string {',
+ ' if (this.depth > 0) return this.serialize();',
+ ' return this.raw;',
+ ' }',
+ '}',
+ '',
+ ].join('\n'),
+ 'serialize'
+ );
+ expect(callees).toContain('method:Record::serialize');
+ });
+
+ it('a bare call to a name the file binds itself has no cross-file candidate', async () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1714-'));
+ fs.writeFileSync(path.join(tempDir, 'config.ts'), 'export function resolve(p: string) { return p; }\nexport function transform(c: string) { return c; }\nexport function now() { return 0; }\n');
+ fs.writeFileSync(
+ path.join(tempDir, 'client.ts'),
+ [
+ 'const transform = makeTransform();',
+ 'export function ping(): Promise {',
+ ' return new Promise((resolve, reject) => {',
+ ' setTimeout(() => resolve(), 10);',
+ ' });',
+ '}',
+ 'export function run(options: { now?: () => number }) {',
+ ' const now = options.now || (() => Date.now());',
+ ' return now() + transform("x").length;',
+ '}',
+ '',
+ ].join('\n')
+ );
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+ const targets = cg.getNodesByKind('function').filter((n) => n.filePath === 'config.ts').map((n) => n.id);
+ const callers = cg.getNodesByKind('function').filter((n) => n.filePath === 'client.ts');
+ const crossFile = callers.flatMap((c) => cg!.getOutgoingEdges(c.id)).filter((e) => e.kind === 'calls' && targets.includes(e.target));
+ expect(crossFile).toEqual([]);
+ });
+
+ it('a destructured require or a string mentioning the name is not a local binding', async () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1714-'));
+ fs.writeFileSync(path.join(tempDir, 'public-ip.js'), 'function lookupPublicIPv4() { return "1.2.3.4"; }\nfunction test(name, fn) { return fn(); }\nmodule.exports = { lookupPublicIPv4, test };\n');
+ fs.writeFileSync(
+ path.join(tempDir, 'main.js'),
+ [
+ 'const { lookupPublicIPv4 } = require("./public-ip");',
+ 'const { test } = require("./public-ip");',
+ 'async function prepare() {',
+ ' const ip = await lookupPublicIPv4();',
+ ' test("a test of the thing", () => {});',
+ ' return ip;',
+ '}',
+ 'module.exports = { prepare };',
+ '',
+ ].join('\n')
+ );
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+ const prepare = cg.getNodesByKind('function').find((n) => n.name === 'prepare')!;
+ const names = cg.getOutgoingEdges(prepare.id).filter((e) => e.kind === 'calls').map((e) => cg!.getNode(e.target)?.name);
+ expect(names).toContain('lookupPublicIPv4');
+ expect(names).toContain('test');
+ });
+
+ it('keeps `other.serialize()` — a call through a receiver', async () => {
+ const callees = await callsFromMethod(
+ [
+ 'export class Record {',
+ ' serialize(): string { return ""; }',
+ ' copyOf(other: Record): string {',
+ ' return other.serialize();',
+ ' }',
+ '}',
+ '',
+ ].join('\n'),
+ 'copyOf'
+ );
+ expect(callees).toContain('method:Record::serialize');
+ });
+});
diff --git a/__tests__/call-receiver-no-fabrication.test.ts b/__tests__/call-receiver-no-fabrication.test.ts
new file mode 100644
index 000000000..28412dedd
--- /dev/null
+++ b/__tests__/call-receiver-no-fabrication.test.ts
@@ -0,0 +1,81 @@
+/**
+ * A member call whose receiver is itself a call never fabricates an edge
+ * (#1683, #1681). `d.setdefault(k, []).append(v)` used to lose its receiver at
+ * extraction time, degrade to the bare `append`, and exact-match any top-level
+ * project function of that name — a call edge from an unrelated function,
+ * reproduced in Python and JavaScript alike. The receiver is now kept as
+ * `().`, which nothing name-matches; the inner call resolves
+ * on its own as before.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+let dir: string;
+let cg: CodeGraph;
+
+beforeAll(async () => {
+ await initGrammars();
+ await loadAllGrammars();
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1683-'));
+ fs.mkdirSync(path.join(dir, 'py'));
+ fs.mkdirSync(path.join(dir, 'js'));
+ fs.writeFileSync(path.join(dir, 'py', '__init__.py'), '');
+ fs.writeFileSync(
+ path.join(dir, 'py', 'collect.py'),
+ 'def append(item):\n return item\n\ndef get(key):\n return key\n\ndef make():\n return {}\n\n' +
+ 'def bucket(d, k, v):\n d.setdefault(k, []).append(v)\n return d.items().get(k)\n\n' +
+ 'def fresh():\n return make().get("x")\n'
+ );
+ fs.writeFileSync(
+ path.join(dir, 'js', 'collect.js'),
+ 'function append(item) { return item; }\nfunction run() { return 1; }\nfunction make() { return {}; }\n' +
+ 'function bucket(d, k, v) { d.setdefault(k, []).append(v); make().run(); (0, make)().run(); }\n' +
+ 'module.exports = { append, run, make, bucket };\n'
+ );
+ cg = CodeGraph.initSync(dir);
+ await cg.indexAll();
+});
+
+afterAll(() => {
+ cg.destroy();
+ fs.rmSync(dir, { recursive: true, force: true });
+});
+
+const fn = (name: string, file: string) => cg.getNodesByName(name).find((n) => n.kind === 'function' && n.filePath.endsWith(file))!;
+const calleesOf = (name: string, file: string) =>
+ cg.getCallees(fn(name, file).id).filter(({ edge }) => edge.kind === 'calls').map(({ node }) => node.name).sort();
+// Callers through `calls` edges only — a `module.exports = { run }` value reference is not a call.
+const callersOf = (name: string, file: string) =>
+ cg.getCallers(fn(name, file).id).filter(({ edge }) => edge.kind === 'calls').map(({ node }) => node.name);
+
+describe('call-expression receivers (#1683)', () => {
+ it('Python: no edge from a call-result receiver to a same-named top-level function', () => {
+ expect(calleesOf('bucket', 'collect.py')).toEqual([]);
+ expect(callersOf('append', 'collect.py')).toEqual([]);
+ expect(callersOf('get', 'collect.py')).toEqual([]);
+ // The inner call still resolves on its own; `.get` on its unknown product does not.
+ expect(calleesOf('fresh', 'collect.py')).toEqual(['make']);
+ });
+
+ it('JavaScript: the same shape, and the inner call keeps its edge', () => {
+ expect(callersOf('append', 'collect.js')).toEqual([]);
+ // `make().run()` — what `make` returns is unknown, so `run` is not guessed.
+ expect(callersOf('run', 'collect.js')).toEqual([]);
+ expect(calleesOf('bucket', 'collect.js')).toEqual(['make']);
+ });
+
+ it('encodes the receiver as `().` and drops a receiver with no static callee', () => {
+ const r = extractFromSource('src/x.js', 'function f(d) { d.setdefault("k", []).append(1); make().run(); (0, make)().run(); arr[0]().go(); }');
+ const names = r.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName).sort();
+ // `(0, make)` and `arr[0]` are the inner calls' own refs, unchanged; their chains are dropped.
+ expect(names).toEqual(['(0, make)', 'arr[0]', 'd.setdefault', 'd.setdefault().append', 'make', 'make().run']);
+ const py = extractFromSource('x.py', 'def f(d):\n d.setdefault("k", []).append(1)\n d.items().get(2)\n');
+ expect(py.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName).sort())
+ .toEqual(['d.items', 'd.items().get', 'd.setdefault', 'd.setdefault().append']);
+ });
+});
diff --git a/__tests__/cross-file-visibility.test.ts b/__tests__/cross-file-visibility.test.ts
new file mode 100644
index 000000000..1cb0007c9
--- /dev/null
+++ b/__tests__/cross-file-visibility.test.ts
@@ -0,0 +1,150 @@
+/**
+ * A definition the language makes file-local is not a candidate for a
+ * cross-file name match: a C `static`, a Kotlin `private fun`, a Go unexported
+ * identifier in another package, a Rust non-`pub` item outside its module
+ * subtree. Each case pairs the invisible shape with the visible one of
+ * identical form, so the assertion discriminates on visibility alone.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+
+let tempDir: string;
+let cg: CodeGraph | null = null;
+
+function project(files: Record): void {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-visibility-'));
+ for (const [rel, content] of Object.entries(files)) {
+ const abs = path.join(tempDir, rel);
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
+ fs.writeFileSync(abs, content);
+ }
+}
+
+/** `calls` targets of the function named `caller`, as `file:name` strings. */
+async function calleesOf(caller: string): Promise {
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+ const from = cg.getNodesByKind('function').concat(cg.getNodesByKind('method')).find((n) => n.name === caller)!;
+ expect(from).toBeDefined();
+ return cg
+ .getOutgoingEdges(from.id)
+ .filter((e) => e.kind === 'calls')
+ .map((e) => cg!.getNode(e.target))
+ .filter((n): n is NonNullable => !!n)
+ .map((n) => `${n.filePath}:${n.name}`);
+}
+
+afterEach(() => {
+ cg?.close();
+ cg = null;
+ fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('C: a static function is local to its translation unit', () => {
+ it('does not resolve a call onto a static in another file', async () => {
+ project({
+ 'core.c': 'void coreRun(void)\n{\n usbGetDescriptor();\n}\n',
+ 'usb_audio.c': 'static void usbGetDescriptor(void)\n{\n}\n',
+ });
+ expect(await calleesOf('coreRun')).not.toContain('usb_audio.c:usbGetDescriptor');
+ });
+
+ it('still resolves onto a non-static function in another file', async () => {
+ project({
+ 'core.c': 'void coreRun(void)\n{\n usbGetDescriptor();\n}\n',
+ 'usb_audio.c': 'void usbGetDescriptor(void)\n{\n}\n',
+ });
+ expect(await calleesOf('coreRun')).toContain('usb_audio.c:usbGetDescriptor');
+ });
+
+ it('keeps a static inline defined in a header: it lives in every unit that includes it', async () => {
+ project({
+ 'protocol.h': 'static inline void mav_put_char(char *buf, char c)\n{\n buf[0] = c;\n}\n',
+ 'core.c': '#include "protocol.h"\n\nvoid coreRun(char *b)\n{\n mav_put_char(b, 0);\n}\n',
+ });
+ expect(await calleesOf('coreRun')).toContain('protocol.h:mav_put_char');
+ });
+
+ it('keeps a same-file static, whichever line the keyword is on', async () => {
+ project({
+ 'core.c': 'static void\nhelper(void)\n{\n}\n\nvoid coreRun(void)\n{\n helper();\n}\n',
+ 'other.c': 'static void helper(void)\n{\n}\n',
+ });
+ expect(await calleesOf('coreRun')).toEqual(['core.c:helper']);
+ });
+});
+
+describe('Kotlin: a private function is class- or file-local', () => {
+ it('does not resolve an SDK-style call onto another file\'s private fun', async () => {
+ project({
+ 'Budget.kt': 'class Budget {\n private fun apply(bps: Long): Long = bps\n}\n',
+ 'Main.kt': 'class Main {\n fun onCreate(editor: Editor) {\n editor.apply()\n }\n}\n',
+ });
+ expect(await calleesOf('onCreate')).not.toContain('Budget.kt:apply');
+ });
+
+ it('still resolves onto a public fun in another file', async () => {
+ project({
+ 'Budget.kt': 'class Budget {\n fun apply(bps: Long): Long = bps\n}\n',
+ 'Main.kt': 'class Main {\n fun onCreate(budget: Budget) {\n budget.apply(1L)\n }\n}\n',
+ });
+ expect(await calleesOf('onCreate')).toContain('Budget.kt:apply');
+ });
+});
+
+describe('Go: an unexported identifier is package-local', () => {
+ it('does not resolve a call onto an unexported func in another package', async () => {
+ project({
+ 'cmd/probe/main.go': 'package main\n\nfunc fail(msg string) {}\n',
+ 'server/turn.go': 'package server\n\nfunc Run() {\n\tfail("x")\n}\n',
+ });
+ expect(await calleesOf('Run')).not.toContain('cmd/probe/main.go:fail');
+ });
+
+ it('still resolves within the package and onto an exported func elsewhere', async () => {
+ project({
+ 'server/util.go': 'package server\n\nfunc fail(msg string) {}\n',
+ 'server/turn.go': 'package server\n\nfunc Run() {\n\tfail("x")\n\tReport()\n}\n',
+ 'report/report.go': 'package report\n\nfunc Report() {}\n',
+ });
+ const callees = await calleesOf('Run');
+ expect(callees).toContain('server/util.go:fail');
+ expect(callees).toContain('report/report.go:Report');
+ });
+});
+
+describe('Rust: a non-pub item is visible to its module subtree only', () => {
+ it('does not resolve a sibling module\'s private fn, nor another crate\'s', async () => {
+ project({
+ 'src/main.rs': 'mod util;\nmod net;\nfn main() {}\n',
+ 'src/util.rs': 'fn count() -> usize { 0 }\n',
+ 'src/net.rs': 'pub fn run() -> usize {\n count()\n}\n',
+ });
+ expect(await calleesOf('run')).not.toContain('src/util.rs:count');
+ });
+
+ it('keeps a trait-impl method, which has the trait\'s visibility', async () => {
+ project({
+ 'src/main.rs': 'mod shape;\nmod draw;\nfn main() {}\n',
+ 'src/shape.rs': 'pub struct Circle;\npub trait Area { fn area(&self) -> f64; }\nimpl Area for Circle {\n fn area(&self) -> f64 { 1.0 }\n}\n',
+ 'src/draw.rs': 'use crate::shape::{Area, Circle};\npub fn render(c: &Circle) -> f64 {\n c.area()\n}\n',
+ });
+ expect(await calleesOf('render')).toContain('src/shape.rs:area');
+ });
+
+ it('still resolves a parent module\'s private fn from a child, and any pub fn', async () => {
+ project({
+ 'src/main.rs': 'mod net;\nmod util;\nfn main() {}\n',
+ 'src/net.rs': 'pub mod tcp;\nfn shared() {}\n',
+ 'src/net/tcp.rs': 'use super::shared;\nuse crate::util::exported;\npub fn open() {\n shared();\n exported();\n}\n',
+ 'src/util.rs': 'pub fn exported() {}\n',
+ });
+ const callees = await calleesOf('open');
+ expect(callees).toContain('src/net.rs:shared');
+ expect(callees).toContain('src/util.rs:exported');
+ });
+});
diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts
index 1cc356ee9..9fbdd5932 100644
--- a/__tests__/extraction.test.ts
+++ b/__tests__/extraction.test.ts
@@ -745,6 +745,63 @@ export const fetchData = async () => {
});
});
+describe('Generator Function Extraction (#1741)', () => {
+ const functionNames = (file: string, code: string) =>
+ extractFromSource(file, code)
+ .nodes.filter((n) => n.kind === 'function')
+ .map((n) => n.name)
+ .sort();
+
+ it('extracts function* and async function* declarations in TypeScript', () => {
+ process.env.CODEGRAPH_KERNEL = '0';
+ const code = `
+function plain() { return 1; }
+function* gen() { yield 2; }
+async function asyncFn() { return 3; }
+async function* asyncGen() { yield 4; }
+`;
+ expect(functionNames('gens.ts', code)).toEqual(['asyncFn', 'asyncGen', 'gen', 'plain']);
+ });
+
+ it('extracts function* and async function* declarations in JavaScript', () => {
+ process.env.CODEGRAPH_KERNEL = '0';
+ const code = `
+function plain() { return 1; }
+function* gen() { yield 2; }
+async function asyncFn() { return 3; }
+async function* asyncGen() { yield 4; }
+`;
+ expect(functionNames('gens.js', code)).toEqual(['asyncFn', 'asyncGen', 'gen', 'plain']);
+ });
+
+ it('extracts const-assigned generator and async generator expressions (TS)', () => {
+ process.env.CODEGRAPH_KERNEL = '0';
+ const code = `
+const g = function* () { yield 1; };
+const ag = async function* () { yield 2; };
+export const exportedGen = function* () { yield 3; };
+`;
+ const result = extractFromSource('gen-expr.ts', code);
+ const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name).sort();
+ expect(names).toEqual(['ag', 'exportedGen', 'g']);
+ expect(result.nodes.find((n) => n.name === 'exportedGen')?.isExported).toBe(true);
+ expect(result.nodes.find((n) => n.name === 'g')?.isExported).toBeFalsy();
+ });
+
+ it('extracts const-assigned generator and async generator expressions (JS)', () => {
+ process.env.CODEGRAPH_KERNEL = '0';
+ const code = `
+const g = function* () { yield 1; };
+const ag = async function* () { yield 2; };
+export const exportedGen = function* () { yield 3; };
+`;
+ const result = extractFromSource('gen-expr.js', code);
+ const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name).sort();
+ expect(names).toEqual(['ag', 'exportedGen', 'g']);
+ expect(result.nodes.find((n) => n.name === 'exportedGen')?.isExported).toBe(true);
+ });
+});
+
describe('Type Alias Extraction', () => {
it('should extract exported type aliases in TypeScript', () => {
const code = `
@@ -6048,6 +6105,74 @@ end
});
});
+describe('C++ pure-virtual method nodes (#1727)', () => {
+ // Pure-virtual methods are field_declarations (`virtual int read(int key) = 0;`),
+ // not function_definitions — they previously minted no method node, so calls
+ // through an abstract base and cpp-override synthesis had nothing to attach to.
+ // Java interface methods already get nodes; C++ should behave similarly.
+ it('indexes Store::read from the issue fixture and records the call', () => {
+ const code = `
+class Store {
+public:
+ virtual ~Store() {}
+ virtual int read(int key) = 0;
+};
+
+class DiskStore : public Store {
+public:
+ int read(int key) override { return key + 1; }
+};
+
+class MemStore : public Store {
+public:
+ int read(int key) override { return key + 2; }
+};
+
+int fetch(Store* s, int k) {
+ return s->read(k);
+}
+`;
+ const result = extractFromSource('store.cc', code);
+ const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.qualifiedName);
+ expect(methods).toContain('Store::read');
+ expect(methods).toContain('DiskStore::read');
+ expect(methods).toContain('MemStore::read');
+
+ const baseRead = result.nodes.find((n) => n.qualifiedName === 'Store::read');
+ expect(baseRead?.isAbstract).toBe(true);
+
+ // Call site unresolved ref targets the method name (resolver types the receiver).
+ expect(
+ result.unresolvedReferences.some(
+ (r) => r.referenceKind === 'calls' && (r.referenceName === 'read' || r.referenceName.endsWith('.read') || r.referenceName.endsWith('->read') || r.referenceName === 's.read')
+ )
+ ).toBe(true);
+ });
+
+ it('indexes pure virtuals with pointer/reference return types and operators', () => {
+ const code = `
+class Cloneable {
+public:
+ virtual Cloneable* clone() = 0;
+ virtual const Foo& get() = 0;
+ virtual Cloneable& operator=(const Cloneable&) = 0;
+ int notPure(int x);
+ int data = 0;
+};
+`;
+ const result = extractFromSource('clone.hpp', code);
+ const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
+ expect(methods).toContain('clone');
+ expect(methods).toContain('get');
+ expect(methods).toContain('operator=');
+ // Non-pure prototype and data member must NOT become methods here.
+ expect(methods).not.toContain('notPure');
+ expect(methods).not.toContain('data');
+ expect(result.nodes.find((n) => n.name === 'clone')?.isAbstract).toBe(true);
+ });
+
+});
+
describe('C++ free-function name extraction', () => {
let tempDir: string;
let cg: CodeGraph;
@@ -7530,6 +7655,105 @@ describe('Directory Exclusion', () => {
});
});
+
+describe('Nested .gitignore node_modules exclusion (#1567)', () => {
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = createTempDir();
+ });
+
+ afterEach(() => {
+ cleanupTempDir(tempDir);
+ });
+
+ function plantNodeModules(subproject: string, packages = 80): void {
+ const base = path.join(tempDir, subproject, 'node_modules');
+ for (let i = 0; i < packages; i++) {
+ const pkg = path.join(base, `pkg${i}`);
+ fs.mkdirSync(pkg, { recursive: true });
+ fs.writeFileSync(path.join(pkg, 'index.js'), `module.exports = ${i};`);
+ fs.writeFileSync(path.join(pkg, 'index.d.ts'), 'export const n: number;');
+ if (i % 4 === 0) fs.writeFileSync(path.join(pkg, '.gitignore'), '*.map\n');
+ const nested = path.join(pkg, 'node_modules', `nested${i}`);
+ fs.mkdirSync(nested, { recursive: true });
+ fs.writeFileSync(path.join(nested, 'lib.ts'), 'export const x = 1;');
+ }
+ }
+
+ function initGitRepo(): void {
+ const { execFileSync } = require('child_process') as typeof import('child_process');
+ execFileSync('git', ['init'], { cwd: tempDir, stdio: 'ignore' });
+ execFileSync('git', ['add', '-A'], { cwd: tempDir, stdio: 'ignore' });
+ execFileSync(
+ 'git',
+ ['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'init'],
+ { cwd: tempDir, stdio: 'ignore' },
+ );
+ }
+
+ it('excludes node_modules ignored only by a nested .gitignore (git path)', () => {
+ fs.mkdirSync(path.join(tempDir, 'frontend', 'src'), { recursive: true });
+ fs.mkdirSync(path.join(tempDir, 'extension', 'src'), { recursive: true });
+ fs.writeFileSync(path.join(tempDir, 'frontend', 'src', 'app.ts'), 'export const a = 1;');
+ fs.writeFileSync(path.join(tempDir, 'extension', 'src', 'ext.ts'), 'export const b = 1;');
+ fs.writeFileSync(path.join(tempDir, 'root.ts'), 'export const r = 1;');
+ fs.writeFileSync(path.join(tempDir, '.gitignore'), '*.log\n');
+ fs.writeFileSync(path.join(tempDir, 'frontend', '.gitignore'), '/node_modules\n');
+ fs.writeFileSync(path.join(tempDir, 'extension', '.gitignore'), 'node_modules/\n');
+ plantNodeModules('frontend');
+ plantNodeModules('extension');
+ initGitRepo();
+
+ const files = scanDirectory(tempDir);
+ expect(files.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']);
+ expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
+ });
+
+ it('excludes nested-gitignore node_modules on the filesystem-walk fallback too', () => {
+ fs.mkdirSync(path.join(tempDir, 'frontend', 'src'), { recursive: true });
+ fs.mkdirSync(path.join(tempDir, 'extension', 'src'), { recursive: true });
+ fs.writeFileSync(path.join(tempDir, 'frontend', 'src', 'app.ts'), 'export const a = 1;');
+ fs.writeFileSync(path.join(tempDir, 'extension', 'src', 'ext.ts'), 'export const b = 1;');
+ fs.writeFileSync(path.join(tempDir, 'root.ts'), 'export const r = 1;');
+ fs.writeFileSync(path.join(tempDir, '.gitignore'), '*.log\n');
+ fs.writeFileSync(path.join(tempDir, 'frontend', '.gitignore'), '/node_modules\n');
+ fs.writeFileSync(path.join(tempDir, 'extension', '.gitignore'), 'node_modules/\n');
+ plantNodeModules('frontend', 60);
+ plantNodeModules('extension', 60);
+
+ const files = scanDirectory(tempDir);
+ expect(files.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']);
+ expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
+ });
+
+ it('still excludes when root only lists one subproject node_modules (Boba-like)', () => {
+ fs.mkdirSync(path.join(tempDir, 'frontend', 'src'), { recursive: true });
+ fs.mkdirSync(path.join(tempDir, 'extension', 'src'), { recursive: true });
+ fs.writeFileSync(path.join(tempDir, 'frontend', 'src', 'app.ts'), 'export const a = 1;');
+ fs.writeFileSync(path.join(tempDir, 'extension', 'src', 'ext.ts'), 'export const b = 1;');
+ fs.writeFileSync(path.join(tempDir, 'root.ts'), 'export const r = 1;');
+ fs.writeFileSync(
+ path.join(tempDir, '.gitignore'),
+ ['*.log', 'frontend/node_modules/', 'frontend/.angular/', ''].join('\n'),
+ );
+ fs.writeFileSync(path.join(tempDir, 'frontend', '.gitignore'), '/node_modules\n');
+ fs.writeFileSync(path.join(tempDir, 'extension', '.gitignore'), 'node_modules/\n');
+ plantNodeModules('frontend', 40);
+ plantNodeModules('extension', 40);
+
+ const fsFiles = scanDirectory(tempDir);
+ expect(fsFiles.every((f) => !f.includes('node_modules'))).toBe(true);
+ expect(fsFiles.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']);
+
+ initGitRepo();
+ const gitFiles = scanDirectory(tempDir);
+ expect(gitFiles.every((f) => !f.includes('node_modules'))).toBe(true);
+ expect(gitFiles.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']);
+ });
+});
+
+
describe('Git Submodules', () => {
let tempDir: string;
@@ -7874,6 +8098,82 @@ describe('Nested non-submodule git repos', () => {
expect(ig.ignores('dist/')).toBe(true); // valid rule survives
expect(ig.ignores('src/app.ts')).toBe(false);
});
+
+ it('buildDefaultIgnore honors .git/info/exclude (#1728)', async () => {
+ const { execFileSync } = await import('child_process');
+ const git = (cwd: string, ...args: string[]) =>
+ execFileSync('git', args, { cwd, stdio: 'pipe' });
+
+ const root = path.join(tempDir, 'exclude-root');
+ fs.mkdirSync(root, { recursive: true });
+ git(root, 'init', '-q');
+ fs.writeFileSync(path.join(root, 'src.ts'), 'export const x = 1;\n');
+ fs.mkdirSync(path.join(root, '.claude', 'worktrees', 'agent-1'), { recursive: true });
+ fs.writeFileSync(
+ path.join(root, '.claude', 'worktrees', 'agent-1', 'src.ts'),
+ 'export const w = 1;\n',
+ );
+ // Not in .gitignore — only in info/exclude (the reporter's exact shape).
+ fs.writeFileSync(
+ path.join(root, '.git', 'info', 'exclude'),
+ '**/.claude/worktrees/\n',
+ );
+
+ const ig = buildDefaultIgnore(root);
+ expect(ig.ignores('src.ts')).toBe(false);
+ expect(ig.ignores('.claude/worktrees/agent-1/src.ts')).toBe(true);
+ expect(ig.ignores('.claude/worktrees/')).toBe(true);
+
+ // ScopeIgnore (watcher path) agrees, including via git ignored-dir seeding.
+ const scope = buildScopeIgnore(root);
+ expect(scope.ignores('src.ts')).toBe(false);
+ expect(scope.ignores('.claude/worktrees/agent-1/')).toBe(true);
+ expect(scope.ignores('.claude/worktrees/agent-1/src.ts')).toBe(true);
+ });
+
+ it('buildDefaultIgnore honors core.excludesFile (#1728)', async () => {
+ const { execFileSync } = await import('child_process');
+ const git = (cwd: string, ...args: string[]) =>
+ execFileSync('git', args, { cwd, stdio: 'pipe' });
+
+ const root = path.join(tempDir, 'excludesfile-root');
+ fs.mkdirSync(root, { recursive: true });
+ git(root, 'init', '-q');
+ const globalExcludes = path.join(tempDir, 'global-excludes');
+ fs.writeFileSync(globalExcludes, 'scratch/\n');
+ git(root, 'config', 'core.excludesFile', globalExcludes);
+ fs.mkdirSync(path.join(root, 'scratch'), { recursive: true });
+ fs.writeFileSync(path.join(root, 'scratch', 'tmp.ts'), 'export const t = 1;\n');
+ fs.writeFileSync(path.join(root, 'app.ts'), 'export const a = 1;\n');
+
+ const ig = buildDefaultIgnore(root);
+ expect(ig.ignores('app.ts')).toBe(false);
+ expect(ig.ignores('scratch/')).toBe(true);
+ expect(ig.ignores('scratch/tmp.ts')).toBe(true);
+ });
+
+ it('buildScopeIgnore prunes dirs ignored only by a nested .gitignore (#1728)', async () => {
+ const { execFileSync } = await import('child_process');
+ const git = (cwd: string, ...args: string[]) =>
+ execFileSync('git', args, { cwd, stdio: 'pipe' });
+
+ const root = path.join(tempDir, 'nested-gi-root');
+ fs.mkdirSync(path.join(root, 'pkg', 'build'), { recursive: true });
+ git(root, 'init', '-q');
+ git(root, 'config', 'user.email', 'test@test.com');
+ git(root, 'config', 'user.name', 'Test');
+ fs.writeFileSync(path.join(root, 'pkg', 'app.ts'), 'export const a = 1;\n');
+ fs.writeFileSync(path.join(root, 'pkg', 'build', 'out.ts'), 'export const o = 1;\n');
+ fs.writeFileSync(path.join(root, 'pkg', '.gitignore'), 'build/\n');
+ // Commit only the non-ignored file so git still reports build/ as ignored-other.
+ git(root, 'add', 'pkg/app.ts', 'pkg/.gitignore');
+ git(root, 'commit', '-q', '-m', 'init');
+
+ const scope = buildScopeIgnore(root);
+ expect(scope.ignores('pkg/app.ts')).toBe(false);
+ expect(scope.ignores('pkg/build/')).toBe(true);
+ expect(scope.ignores('pkg/build/out.ts')).toBe(true);
+ });
});
// =============================================================================
@@ -11937,6 +12237,102 @@ describe('C/C++ kernel-port preParse blanks (R7a)', () => {
expect(blankLoneMacroLines(bare)).toBe(bare);
});
+ it('blankCDesignatedMacroArgs empties a designated-initializer macro call, offsets kept (#1729)', async () => {
+ const { blankCDesignatedMacroArgs } = await import('../src/extraction/languages/c-cpp');
+ const src = [
+ 'void resetProfile(profile_t *p)',
+ '{',
+ ' RESET_CONFIG(profile_t, p,',
+ ' .pid = { [PID_ROLL] = PID_ROLL_DEFAULT, [PID_YAW] = { 50, 75 } },',
+ ' .limit = 500, // trailing comma follows',
+ ' );',
+ ' log(.5);',
+ ' OTHER_MACRO(a == b, c);',
+ '}',
+ ].join('\n');
+ const out = blankCDesignatedMacroArgs(src);
+ expect(out.length).toBe(src.length);
+ expect(out.split('\n').length).toBe(src.split('\n').length);
+ expect(out).toContain('RESET_CONFIG(');
+ expect(out).not.toContain('.pid');
+ expect(out).not.toContain('PID_ROLL');
+ // The closing `);` keeps its column; the argument lines are spaces.
+ expect(out.split('\n')[5]).toBe(' );');
+ expect(out.split('\n')[3]).toBe(' '.repeat(src.split('\n')[3].length));
+ // A numeric literal and a comparison are not designators.
+ expect(out).toContain('log(.5);');
+ expect(out).toContain('OTHER_MACRO(a == b, c);');
+ });
+
+ it('a designated-initializer macro call no longer swallows the functions after it (#1729)', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1729-'));
+ try {
+ // Issue fixture: designated-initializer args + trailing comma. Without
+ // blankCDesignatedMacroArgs, tree-sitter-c error recovery extends
+ // `function_definition` to EOF — `g` vanishes and `h` nests as `f::h`.
+ fs.writeFileSync(
+ path.join(dir, 'pid.c'),
+ [
+ 'void f(void)',
+ '{',
+ ' M(a, b,',
+ ' .x = 1,',
+ ' .y = { 1, 2 },',
+ ' );',
+ '}',
+ '',
+ 'void g(void)',
+ '{',
+ '}',
+ '',
+ 'int h(void)',
+ '{',
+ ' return 1;',
+ '}',
+ '',
+ ].join('\n')
+ );
+ const cg = await CodeGraph.init(dir, { index: true });
+ try {
+ const fns = cg.getNodesByKind('function').filter((n) => n.filePath === 'pid.c');
+ const byName = Object.fromEntries(fns.map((n) => [n.name, n]));
+ expect(Object.keys(byName).sort()).toEqual(['f', 'g', 'h']);
+ expect(byName.f!.endLine).toBe(7);
+ expect(byName.g!.qualifiedName).toBe('g');
+ expect(byName.h!.qualifiedName).toBe('h');
+ } finally {
+ cg.close();
+ }
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('a large designated-initializer macro call keeps later functions top-level (#1729)', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1729-large-'));
+ try {
+ // Scale guard for betaflight-sized RESET_CONFIG argument lists.
+ const fields = Array.from({ length: 120 }, (_, i) => ` .field${i} = ${i},`).join('\n');
+ fs.writeFileSync(
+ path.join(dir, 'pid.c'),
+ `void resetProfile(profile_t *p)\n{\n RESET_CONFIG(profile_t, p,\n${fields}\n );\n}\n\nvoid g(void)\n{\n}\n\nint h(void)\n{\n return 1;\n}\n`
+ );
+ const cg = await CodeGraph.init(dir, { index: true });
+ try {
+ const fns = cg.getNodesByKind('function').filter((n) => n.filePath === 'pid.c');
+ const byName = Object.fromEntries(fns.map((n) => [n.name, n]));
+ expect(Object.keys(byName).sort()).toEqual(['g', 'h', 'resetProfile']);
+ expect(byName.resetProfile!.endLine).toBe(125);
+ expect(byName.g!.qualifiedName).toBe('g');
+ expect(byName.h!.qualifiedName).toBe('h');
+ } finally {
+ cg.close();
+ }
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
it('blankCStatementMacroCalls blanks indented iterator macros, keeps the block', async () => {
const { blankCStatementMacroCalls } = await import('../src/extraction/languages/c-cpp');
const src = [
diff --git a/__tests__/fixtures/kernel-parity/torture.cpp b/__tests__/fixtures/kernel-parity/torture.cpp
index aa6e382de..b6b160f7a 100644
--- a/__tests__/fixtures/kernel-parity/torture.cpp
+++ b/__tests__/fixtures/kernel-parity/torture.cpp
@@ -39,6 +39,8 @@ class Session {
public:
void open();
virtual ~Session() {}
+ // #1727 — pure virtual must mint a method node (parity between wasm + kernel).
+ virtual int read(int key) = 0;
};
void Session::open() {}
} // namespace app::net
diff --git a/__tests__/fixtures/kernel-parity/torture.js b/__tests__/fixtures/kernel-parity/torture.js
index 98b6ea4ed..7dad4ce3b 100644
--- a/__tests__/fixtures/kernel-parity/torture.js
+++ b/__tests__/fixtures/kernel-parity/torture.js
@@ -79,3 +79,11 @@ export default {
const eagerConfig = loadConfig();
const handlerMap = { onSave: () => persist(eagerConfig), onLoad: loadConfig() };
const lazyList = [() => persist(eagerConfig)];
+// --- call-expression receivers (#1683) ----------------------------------------
+function bucketChains(d, k, v) {
+ d.setdefault(k, []).append(v);
+ make().run();
+ (0, make)().run();
+ arr[0]().go();
+ obj.make().run().again();
+}
diff --git a/__tests__/fixtures/kernel-parity/torture.py b/__tests__/fixtures/kernel-parity/torture.py
index 8ef3e5885..001d64f49 100644
--- a/__tests__/fixtures/kernel-parity/torture.py
+++ b/__tests__/fixtures/kernel-parity/torture.py
@@ -53,3 +53,11 @@ INIT_EAGER = helper()
INIT_LAMBDA = lambda: target_cb()
INIT_MAP = {"a": helper()}
init_a, init_b = helper(), view()
+
+# --- call receivers (#1683) ---------------------------------------------------
+def bucket_chains(d, k, v):
+ d.setdefault(k, []).append(v)
+ d.items().get(k)
+ make().run()
+ (lambda: make)()().run()
+ obj.make().run().again()
diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts
index 3df4f2d88..4e59a7385 100644
--- a/__tests__/frameworks-integration.test.ts
+++ b/__tests__/frameworks-integration.test.ts
@@ -3,6 +3,10 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
+import { DatabaseConnection, getDatabasePath } from '../src/db';
+import { QueryBuilder } from '../src/db/queries';
+import { createResolver } from '../src/resolution';
+import type { Node } from '../src/types';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
beforeAll(async () => {
@@ -10,6 +14,54 @@ beforeAll(async () => {
await loadAllGrammars();
});
+describe('Express middleware imports', () => {
+ it('does not resolve package imports into license headings', async () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-express-doc-import-'));
+ let cg: CodeGraph | undefined;
+ try {
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { express: '*', cors: '*' } }));
+ fs.writeFileSync(path.join(tmpDir, 'LICENSE.md'), '# cors\n\n# host-validation-middleware\n');
+ fs.writeFileSync(path.join(tmpDir, 'local.js'), 'export function localMiddleware() {}\n');
+ fs.writeFileSync(path.join(tmpDir, 'server.js'), [
+ "import corsMiddleware from 'cors'",
+ "import { hostValidationMiddleware as originalHostValidationMiddleware } from 'host-validation-middleware'",
+ "import { localMiddleware } from './local.js'",
+ 'localMiddleware()',
+ ].join('\n'));
+ cg = await CodeGraph.init(tmpDir, { index: true });
+ const local = cg.getNodesByKind('function').find((n) => n.name === 'localMiddleware');
+ expect(local).toBeDefined();
+ expect(cg.getIncomingEdges(local!.id).some((e) => e.kind === 'imports')).toBe(true);
+ expect(cg.getIncomingEdges(local!.id).some((e) => e.kind === 'calls')).toBe(true);
+ cg.close();
+ cg = undefined;
+ const db = DatabaseConnection.open(getDatabasePath(tmpDir));
+ try {
+ const queries = new QueryBuilder(db.getDb());
+ for (const name of ['cors', 'host-validation-middleware']) {
+ queries.insertNode({
+ id: `heading:${name}`, name, qualifiedName: `LICENSE.md#${name}`,
+ kind: 'module', language: 'markdown' as Node['language'], filePath: 'LICENSE.md',
+ startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0,
+ });
+ }
+ const resolver = createResolver(tmpDir, queries);
+ for (const referenceName of ['cors', 'corsMiddleware', 'host-validation-middleware']) {
+ expect(resolver.resolveOne({
+ fromNodeId: 'file:server.js', referenceName, referenceKind: 'imports',
+ filePath: 'server.js', language: 'javascript', line: 1, column: 0,
+ })).toBeNull();
+ }
+ } finally {
+ db.close();
+ }
+ } finally {
+ cg?.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ });
+});
+
describe('Django end-to-end framework extraction', () => {
let tmpDir: string | undefined;
afterEach(() => {
@@ -301,6 +353,61 @@ describe('C++ end-to-end — virtual override synthesis', () => {
cg.close();
});
+
+ it('indexes pure-virtual base methods and bridges overrides (#1727)', async () => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-pure-'));
+ fs.writeFileSync(
+ path.join(tmpDir, 'store.cc'),
+ 'class Store {\n' +
+ 'public:\n' +
+ ' virtual ~Store() {}\n' +
+ ' virtual int read(int key) = 0;\n' +
+ '};\n' +
+ 'class DiskStore : public Store {\n' +
+ 'public:\n' +
+ ' int read(int key) override { return key + 1; }\n' +
+ '};\n' +
+ 'class MemStore : public Store {\n' +
+ 'public:\n' +
+ ' int read(int key) override { return key + 2; }\n' +
+ '};\n' +
+ 'int fetch(Store* s, int k) {\n' +
+ ' return s->read(k);\n' +
+ '}\n'
+ );
+
+ const cg = CodeGraph.initSync(tmpDir);
+ await cg.indexAll();
+
+ const storeRead = cg
+ .getNodesByKind('method')
+ .find((n) => n.qualifiedName === 'Store::read');
+ expect(storeRead, 'Store::read pure virtual must be a method node').toBeDefined();
+ expect(storeRead!.isAbstract).toBe(true);
+
+ const diskRead = cg
+ .getNodesByKind('method')
+ .find((n) => n.qualifiedName === 'DiskStore::read');
+ const memRead = cg
+ .getNodesByKind('method')
+ .find((n) => n.qualifiedName === 'MemStore::read');
+ expect(diskRead).toBeDefined();
+ expect(memRead).toBeDefined();
+
+ // cpp-override synthesis: base pure virtual → each override
+ const out = cg.getOutgoingEdges(storeRead!.id).filter((e) => e.kind === 'calls');
+ const targets = out.map((e) => e.target);
+ expect(targets).toContain(diskRead!.id);
+ expect(targets).toContain(memRead!.id);
+
+ // Call through abstract base resolves onto Store::read
+ const fetch = cg.getNodesByKind('function').find((n) => n.name === 'fetch');
+ expect(fetch).toBeDefined();
+ const callees = cg.getCallees(fetch!.id).map((c) => c.node.qualifiedName);
+ expect(callees).toContain('Store::read');
+
+ cg.close();
+ });
});
describe('Java end-to-end — field-injected bean trace (issue #389)', () => {
diff --git a/__tests__/mcp-writer-lock.test.ts b/__tests__/mcp-writer-lock.test.ts
new file mode 100644
index 000000000..be68f944c
--- /dev/null
+++ b/__tests__/mcp-writer-lock.test.ts
@@ -0,0 +1,128 @@
+/**
+ * Issue #1740 — concurrent direct-mode serve --mcp must fail fast on the
+ * second writer instead of silently degrading auto-sync.
+ */
+
+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 { getWriterPidPath } from '../src/mcp/writer-lock';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+function sleep(ms: number): Promise {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+function spawnMcp(
+ cwd: string,
+ env: NodeJS.ProcessEnv,
+): { child: ChildProcessWithoutNullStreams; getStderr: () => string } {
+ const child = spawn(process.execPath, [BIN, 'serve', '--mcp'], {
+ cwd,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: { ...process.env, ...env },
+ }) as ChildProcessWithoutNullStreams;
+ child.on('error', () => {});
+ child.stdin.on('error', () => {});
+ let stderr = '';
+ child.stderr.on('data', (c: Buffer) => { stderr += c.toString('utf8'); });
+ child.stdout.on('data', () => {});
+ return { child, getStderr: () => stderr };
+}
+
+describe('issue #1740 — direct-mode writer lock', () => {
+ let tempDir: string;
+ let realRoot: string;
+ const children: ChildProcessWithoutNullStreams[] = [];
+
+ beforeEach(async () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg1740-mcp-'));
+ realRoot = fs.realpathSync(tempDir);
+ fs.mkdirSync(path.join(realRoot, 'src'));
+ fs.writeFileSync(path.join(realRoot, 'src/a.ts'), 'export function a() { return 1; }\n');
+ const cg = await CodeGraph.init(realRoot);
+ await cg.indexAll();
+ cg.close();
+ });
+
+ afterEach(async () => {
+ for (const c of children) {
+ try { c.kill('SIGTERM'); } catch { /* ignore */ }
+ }
+ children.length = 0;
+ await sleep(300);
+ try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ }
+ });
+
+ it('second CODEGRAPH_NO_DAEMON serve --mcp exits with writer-lock error', async () => {
+ const env = {
+ CODEGRAPH_NO_DAEMON: '1',
+ CODEGRAPH_MCP_DEBUG: '1',
+ CODEGRAPH_NO_WATCHDOG: '1',
+ CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS: '0',
+ // Avoid wasm --liftoff-only re-exec so lock.pid matches the spawned pid.
+ CODEGRAPH_NO_RELAUNCH: '1',
+ CODEGRAPH_WASM_RELAUNCHED: '1',
+ };
+ const first = spawnMcp(realRoot, env);
+ children.push(first.child);
+
+ const lockPath = getWriterPidPath(realRoot);
+ const deadline = Date.now() + 10000;
+ while (Date.now() < deadline && !fs.existsSync(lockPath)) {
+ await sleep(50);
+ }
+ expect(fs.existsSync(lockPath)).toBe(true);
+ expect(first.child.exitCode).toBeNull();
+
+ const second = spawnMcp(realRoot, env);
+ children.push(second.child);
+
+ const code = await new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(second.child.exitCode), 10000);
+ second.child.on('close', (c) => {
+ clearTimeout(timer);
+ resolve(c);
+ });
+ });
+
+ expect(code).toBe(1);
+ expect(second.getStderr()).toMatch(/writer lock held/i);
+ expect(second.getStderr()).toMatch(/CODEGRAPH_NO_DAEMON/);
+ expect(first.child.exitCode).toBeNull();
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as { pid: number };
+ expect(lock.pid).toBe(first.child.pid);
+ }, 20000);
+
+ it('default daemon mode still allows two proxies to share one writer', async () => {
+ const env = {
+ CODEGRAPH_MCP_LOG_ATTACH: '1',
+ CODEGRAPH_NO_WATCHDOG: '1',
+ CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS: '0',
+ CODEGRAPH_NO_RELAUNCH: '1',
+ CODEGRAPH_WASM_RELAUNCHED: '1',
+ };
+ const a = spawnMcp(realRoot, env);
+ const b = spawnMcp(realRoot, env);
+ children.push(a.child, b.child);
+
+ const lockPath = getWriterPidPath(realRoot);
+ const deadline = Date.now() + 15000;
+ while (Date.now() < deadline && !fs.existsSync(lockPath)) {
+ await sleep(50);
+ }
+ expect(fs.existsSync(lockPath)).toBe(true);
+ await sleep(1000);
+ expect(a.child.exitCode).toBeNull();
+ expect(b.child.exitCode).toBeNull();
+
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as { pid: number; mode: string };
+ expect(lock.mode).toBe('daemon');
+ expect(lock.pid).not.toBe(a.child.pid);
+ expect(lock.pid).not.toBe(b.child.pid);
+ }, 25000);
+});
diff --git a/__tests__/object-literal-methods.test.ts b/__tests__/object-literal-methods.test.ts
index 1722ad2d0..39f8de1c6 100644
--- a/__tests__/object-literal-methods.test.ts
+++ b/__tests__/object-literal-methods.test.ts
@@ -55,7 +55,10 @@ describe('object-literal method extraction', () => {
// so an in-store calls edge will resolve once the pipeline runs.
const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!;
const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id);
- expect(fetchUserRefs.map((r) => r.referenceName)).toContain('reset');
+ // `get().reset()` keeps its call receiver (#1683): the ref is the chain
+ // `get().reset`, which the resolver binds to the store's own `reset`.
+ expect(fetchUserRefs.map((r) => r.referenceName)).toContain('get().reset');
+ expect(fetchUserRefs.map((r) => r.referenceName)).not.toContain('reset');
// The action's body wasn't mis-attributed to the file scope (the reason we
// skip the generic body-visit for the store-factory call).
diff --git a/__tests__/resolution-fileexists-containment.test.ts b/__tests__/resolution-fileexists-containment.test.ts
new file mode 100644
index 000000000..311ef17c4
--- /dev/null
+++ b/__tests__/resolution-fileexists-containment.test.ts
@@ -0,0 +1,64 @@
+/**
+ * `fileExists` must not probe outside the project root (#1631).
+ *
+ * `resolveRelativeImport` hands this callback paths built with
+ * `path.relative(projectRoot, basePath)`, which can carry `../` segments, and
+ * `path.join` does not clamp — so a crafted relative import in an indexed file
+ * made the resolver stat arbitrary absolute paths. Nothing outside is read (the
+ * content sinks are guarded separately, #527) and no edge is produced, but the
+ * probe itself is an existence oracle driven by repository content.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { ReferenceResolver } from '../src/resolution';
+import type { QueryBuilder } from '../src/db/queries';
+
+describe('fileExists containment (#1631)', () => {
+ let sandbox: string;
+ let projectRoot: string;
+
+ /** The resolver only needs a project root here — `fileExists` never queries. */
+ const contextFor = (root: string) =>
+ new ReferenceResolver(root, {} as unknown as QueryBuilder).getResolutionContext();
+
+ beforeEach(() => {
+ sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-'));
+ projectRoot = path.join(sandbox, 'proj');
+ fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
+ fs.writeFileSync(path.join(projectRoot, 'src', 'a.js'), 'export const a = 1;');
+ // A real file two levels above the root, as the reproduction in #1631 has.
+ fs.mkdirSync(path.join(sandbox, 'outside'), { recursive: true });
+ fs.writeFileSync(path.join(sandbox, 'outside', 'secret.js'), 'export const secret = 42;');
+ });
+
+ afterEach(() => {
+ fs.rmSync(sandbox, { recursive: true, force: true });
+ });
+
+ it('still reports files inside the root', () => {
+ expect(contextFor(projectRoot).fileExists('src/a.js')).toBe(true);
+ expect(contextFor(projectRoot).fileExists('src/missing.js')).toBe(false);
+ });
+
+ it('refuses to probe a path that escapes the root, even though it exists', () => {
+ const escaping = path.join('..', 'outside', 'secret.js');
+ // Baseline: the target really is there — so `false` can only come from the guard.
+ expect(fs.existsSync(path.join(projectRoot, escaping))).toBe(true);
+
+ expect(contextFor(projectRoot).fileExists(escaping)).toBe(false);
+ });
+
+ it('keeps following an in-root symlink whose target is outside the root (#935)', () => {
+ const link = path.join(projectRoot, 'vendor');
+ try {
+ fs.symlinkSync(path.join(sandbox, 'outside'), link, 'dir');
+ } catch {
+ return; // symlink creation not permitted (e.g. Windows without privilege)
+ }
+ // Lexically inside the root, physically outside — the indexing tier allows this.
+ expect(contextFor(projectRoot).fileExists(path.join('vendor', 'secret.js'))).toBe(true);
+ });
+});
diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts
index decaadee5..da132160e 100644
--- a/__tests__/resolution.test.ts
+++ b/__tests__/resolution.test.ts
@@ -11,7 +11,7 @@ import * as os from 'os';
import { CodeGraph } from '../src';
import { Node, UnresolvedReference } from '../src/types';
import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution';
-import { matchReference, resolveMethodOnType, matchByQualifiedName, preferCallSiteFile, matchMethodCall } from '../src/resolution/name-matcher';
+import { matchReference, resolveMethodOnType, matchByQualifiedName, matchByExactName, preferCallSiteFile, matchMethodCall } from '../src/resolution/name-matcher';
import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache, isPhpIncludePathRef } from '../src/resolution/import-resolver';
import type { UnresolvedRef } from '../src/resolution/types';
import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks';
@@ -1504,6 +1504,60 @@ def external_caller():
expect(externalCalls).toHaveLength(0);
});
+ it('resolves a module-qualified call to a function whose name collides with a builtin collection method, and does not fabricate one from an unrelated chained receiver (#1681)', async () => {
+ // `ledger.append(row)` (module imported, method name `append`) previously
+ // never reached resolution: isBuiltInOrExternal's Python built-in-method
+ // filter treated ANY `x.append(...)` as `list.append` unless `X` matched a
+ // known CLASS, so a real MODULE export named `append` was dropped before
+ // resolveViaImport ever ran. Separately, `d.setdefault(k, []).append(x)` —
+ // a non-identifier (call-chain) receiver — used to degrade at extraction
+ // to a BARE `append` ref and exact-match ledger.append (#1683/#1748 fixed
+ // that half; assert both directions here).
+ fs.writeFileSync(
+ path.join(tempDir, 'ledger.py'),
+ 'def append(row):\n return True\n\n\ndef path():\n return "ledger.jsonl"\n'
+ );
+ fs.writeFileSync(
+ path.join(tempDir, 'record.py'),
+ `from . import ledger
+
+
+def add_outcome(row):
+ if not ledger.append(row):
+ return None
+ return ledger.path()
+`
+ );
+ fs.writeFileSync(
+ path.join(tempDir, 'unrelated.py'),
+ `def build_map():
+ rows_by_file = {}
+ rows_by_file.setdefault("f", []).append({"x": 1})
+ return rows_by_file
+`
+ );
+
+ cg = await CodeGraph.init(tempDir, { index: true });
+
+ const ledgerAppend = cg
+ .getNodesByKind('function')
+ .find((n) => n.name === 'append' && n.filePath.replace(/\\/g, '/') === 'ledger.py');
+ expect(ledgerAppend).toBeDefined();
+
+ // The real, import-qualified call must resolve.
+ const addOutcome = cg.getNodesByKind('function').find((n) => n.name === 'add_outcome');
+ expect(addOutcome).toBeDefined();
+ const addOutcomeCalls = cg.getOutgoingEdges(addOutcome!.id).filter((e) => e.kind === 'calls');
+ expect(addOutcomeCalls.map((e) => e.target)).toContain(ledgerAppend!.id);
+
+ // The unrelated dict/list `.append()` on a chained receiver must NOT
+ // fabricate an edge to ledger.py's append.
+ const buildMap = cg.getNodesByKind('function').find((n) => n.name === 'build_map');
+ expect(buildMap).toBeDefined();
+ const buildMapCalls = cg.getOutgoingEdges(buildMap!.id).filter((e) => e.kind === 'calls');
+ expect(buildMapCalls.map((e) => e.target)).not.toContain(ledgerAppend!.id);
+ });
+
it('attaches Go methods to their receiver type across files (#583, cross-file half)', async () => {
// In Go a type's methods are commonly declared in a different file from the
// `type` declaration (`type Box` in box.go, `func (b *Box) Get()` in
@@ -5386,4 +5440,269 @@ in
expect(importedFilePaths('main.nix')).toEqual([]);
});
});
+
+ describe('Bindings in a module that exports nothing (#1719)', () => {
+ it('does not treat documentation headings as package imports', () => {
+ // Inject the planned Markdown node shape without depending on its extractor.
+ const heading: Node = {
+ id: 'heading:vite', name: 'vite', qualifiedName: 'guide.md#vite',
+ kind: 'module', language: 'markdown' as Node['language'], filePath: 'guide.md',
+ startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0,
+ };
+ const context = {
+ getNodesByName: () => [heading], getNodesInFile: () => [],
+ getNodesByQualifiedName: () => [], getNodesByKind: () => [],
+ fileExists: () => false, readFile: () => null,
+ getProjectRoot: () => tempDir, getAllFiles: () => [],
+ } as ResolutionContext;
+ const ref: UnresolvedRef = {
+ fromNodeId: 'file:consumer.ts', referenceName: 'vite', referenceKind: 'imports',
+ filePath: 'consumer.ts', language: 'typescript', line: 1, column: 0,
+ };
+ expect(matchByExactName(ref, context)).toBeNull();
+ expect(matchByExactName({ ...ref, language: 'markdown' as Node['language'] }, context)?.targetNodeId).toBe(heading.id);
+ context.getNodesByName = () => [{ ...heading, id: 'fn:vite', kind: 'function', language: 'typescript', filePath: 'vite.ts' }];
+ expect(matchByExactName(ref, context)?.targetNodeId).toBe('fn:vite');
+ });
+
+ it('ignores export examples in strings and comments when checking module visibility', async () => {
+ fs.mkdirSync(path.join(tempDir, 'src'));
+ fs.writeFileSync(path.join(tempDir, 'src/private.js'), [
+ "import fs from 'node:fs'",
+ 'const example = `',
+ 'export const example = 1',
+ '`',
+ '/*',
+ 'export { hidden }',
+ '*/',
+ 'function hidden() { return fs }',
+ 'hidden()',
+ ].join('\n'));
+ fs.writeFileSync(path.join(tempDir, 'src/consumer.js'), 'hidden()');
+ fs.mkdirSync(path.join(tempDir, 'legacy'));
+ fs.writeFileSync(path.join(tempDir, 'legacy/global.js'), 'function hidden() { return 1 }');
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+ const hidden = cg.getNodesByKind('function').find((n) => n.name === 'hidden' && n.filePath === 'src/private.js');
+ expect(hidden).toBeDefined();
+ const callers = cg.getIncomingEdges(hidden!.id).filter((e) => e.kind === 'calls');
+ expect(callers.some((e) => cg.getNode(e.source)?.filePath === 'src/consumer.js')).toBe(false);
+ expect(callers.some((e) => cg.getNode(e.source)?.filePath === 'src/private.js')).toBe(true);
+ const consumer = cg.getNodesByKind('file').find((n) => n.filePath === 'src/consumer.js');
+ expect(cg.getOutgoingEdges(consumer!.id).filter((e) => e.kind === 'calls')).toEqual([]);
+ });
+
+ it('does not name-match a method call to another file\'s JSON value', async () => {
+ fs.writeFileSync(path.join(tempDir, 'data.json'), '{"content": "hello"}');
+ fs.writeFileSync(path.join(tempDir, 'data.js'), "const content = require('./data.json')\nmodule.exports = { content }\n");
+ fs.writeFileSync(path.join(tempDir, 'consumer.js'), 'export async function read(page) { return page.frame("main").content() }');
+ fs.writeFileSync(path.join(tempDir, 'use-data.js'), "import { content } from './data'\nconsole.log(content)\n");
+ fs.writeFileSync(path.join(tempDir, 'callback.js'), "const callback = require('./handler.js')\nmodule.exports = { callback }\n");
+ fs.writeFileSync(path.join(tempDir, 'call.js'), 'callback()');
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+ const content = cg.getNodesByKind('constant').find((n) => n.name === 'content');
+ expect(content).toBeDefined();
+ expect(cg.getIncomingEdges(content!.id).filter((e) => e.kind === 'calls')).toEqual([]);
+ expect(cg.getIncomingEdges(content!.id).some((e) => e.kind === 'imports')).toBe(true);
+ const callback = cg.getNodesByKind('constant').find((n) => n.name === 'callback');
+ expect(callback).toBeDefined();
+ expect(cg.getIncomingEdges(callback!.id).some((e) => e.kind === 'calls')).toBe(true);
+ });
+
+ it('keeps a local file dependency import when a closer private name collides', async () => {
+ fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ dependencies: { 'local-dep': 'file:./dep' } }));
+ fs.mkdirSync(path.join(tempDir, 'dep'));
+ fs.mkdirSync(path.join(tempDir, 'src'));
+ fs.writeFileSync(path.join(tempDir, 'dep/package.json'), JSON.stringify({ name: 'local-dep', main: 'index.js' }));
+ fs.writeFileSync(path.join(tempDir, 'dep/index.js'), "export const msg = 'local'\n");
+ fs.writeFileSync(path.join(tempDir, 'src/private.js'), "import fs from 'node:fs'\nconst msg = 'private'\n");
+ fs.writeFileSync(path.join(tempDir, 'src/consumer.js'), "import { msg } from 'local-dep'\nconsole.log(msg)\n");
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+ const msg = cg.getNodesByKind('constant').find((n) => n.name === 'msg' && n.filePath === 'dep/index.js');
+ expect(msg).toBeDefined();
+ expect(cg.getIncomingEdges(msg!.id).some((e) => e.kind === 'imports')).toBe(true);
+ });
+
+ it('preserves executable CommonJS exports inside nested template interpolations', async () => {
+ fs.writeFileSync(path.join(tempDir, 'cjs.js'), [
+ "import fs from 'node:fs'",
+ 'function helper() { return fs }',
+ 'const text = `outer ${`inner ${module.exports = { helper }}`}`',
+ ].join('\n'));
+ fs.writeFileSync(path.join(tempDir, 'consumer.js'), 'helper()');
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+ const helper = cg.getNodesByKind('function').find((n) => n.name === 'helper');
+ expect(helper).toBeDefined();
+ expect(cg.getIncomingEdges(helper!.id).some((e) =>
+ e.kind === 'calls' && cg.getNode(e.source)?.filePath === 'consumer.js')).toBe(true);
+ });
+
+ it.each(['export function visible() { return fs }', 'function visible() { return fs }\nexport { visible }'])('preserves real exports after a regex containing a backtick: %s', async (declaration) => {
+ fs.writeFileSync(path.join(tempDir, 'exported.js'), "import fs from 'node:fs'\nconst re = /`/\nif (fs) /`/.test('text')\nelse /`/.test('other')\nconst make = () => /`/\n" + declaration + '\n');
+ fs.writeFileSync(path.join(tempDir, 'consumer.js'), 'visible()');
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+ const visible = cg.getNodesByKind('function').find((n) => n.name === 'visible');
+ expect(visible).toBeDefined();
+ expect(cg.getIncomingEdges(visible!.id).some((e) => e.kind === 'calls')).toBe(true);
+ });
+
+ // On vitejs/vite, every `import { defineConfig } from 'vite'` across the
+ // playground resolved onto `playground/ssr-html/test-stacktrace.js::vite`
+ // — `const vite = await createServer(…)` at module scope in a file with
+ // zero exports — because exact-match commits whenever one candidate
+ // survives, and nothing asked whether an import could reach it. Only
+ // `sealed.js` may be filtered; every other file here is a class that must
+ // NOT be — a classic script (a top-level binding really is a reachable
+ // global), a CommonJS module, one exporting through `exports["x"]`, an ESM
+ // file whose export is a later `export { … }` statement (which leaves
+ // `isExported` false on the declaration's node), and one contributing a
+ // name through `declare global` while exporting nothing of its own.
+ let tmpDir: string;
+ let cg: CodeGraph;
+
+ afterEach(() => {
+ cg?.close();
+ if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it('drops them as cross-file candidates, and keeps scripts, CJS and later exports', async () => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1719-'));
+ fs.writeFileSync(
+ path.join(tmpDir, 'sealed.js'),
+ `import fsp from 'node:fs/promises'
+
+function widget() {
+ return fsp
+}
+
+widget()
+`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'script.js'),
+ `function gadget() {
+ return 1
+}
+`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'cjs.js'),
+ `import osp from 'node:os'
+
+function helper() {
+ return osp
+}
+
+module.exports = { helper }
+`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'later.js'),
+ `import pathp from 'node:path'
+
+function parser() {
+ return pathp
+}
+
+export { parser }
+`
+ );
+ // `exports["x"]` is a CommonJS export too, and a file declaring globals
+ // offers them to every other file whether or not it exports anything of
+ // its own. Both would read as sealed on a test that looked only for
+ // `export …`, `module.exports` and `exports.x`.
+ fs.writeFileSync(
+ path.join(tmpDir, 'bracket.js'),
+ `import urlp from 'node:url'
+
+function bracketed() {
+ return urlp
+}
+
+exports["bracketed"] = bracketed
+`
+ );
+ // A module with imports and no export of its own still contributes every
+ // name in `declare global` to every other file. `plain.ts` is the control
+ // that makes the assertion mean something: it is the same "import, no
+ // export" shape holding the same kind of declaration, so the pair differs
+ // only by the `declare global`, and an assertion on StrayFace alone would
+ // pass whatever the guard did.
+ fs.writeFileSync(
+ path.join(tmpDir, 'ambient.ts'),
+ `import './later'
+
+declare global {
+ interface StrayFace {
+ a: number
+ }
+}
+`
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'plain.ts'),
+ `import './later'
+
+interface HiddenFace {
+ a: number
+}
+
+const unused: HiddenFace = { a: 1 }
+`
+ );
+ // A type annotation is the reference here, so this consumer must be .ts.
+ fs.writeFileSync(
+ path.join(tmpDir, 'consumer.ts'),
+ `const face: StrayFace = { a: 1 }
+const hidden: HiddenFace = { a: 2 }
+
+export function use(): number {
+ return face.a + hidden.a
+}
+`
+ );
+ // Nothing here is bound by an import, so every name is a free reference
+ // that falls through to exact name matching — the path this rule sits on.
+ // A bare import would reach that path too, but a bare specifier names a
+ // package outside the graph, so no project node is the right target for
+ // it and such a fixture would assert a resolution nothing should make.
+ fs.writeFileSync(
+ path.join(tmpDir, 'consumer.js'),
+ `widget()
+gadget()
+helper()
+parser()
+bracketed()
+`
+ );
+
+ cg = await CodeGraph.init(tmpDir, { index: true });
+ cg.resolveReferences();
+
+ // Incoming edges rather than callers, so the interfaces are asked the
+ // same question as the functions: a type annotation is a reference, not
+ // a call.
+ const reachedFrom = (consumer: string, name: string): boolean => {
+ const target = cg
+ .searchNodes(name, { limit: 10 })
+ .find((r) => r.node.name === name && r.node.filePath !== consumer);
+ expect(target, `no node named ${name}`).toBeDefined();
+ return cg
+ .getIncomingEdges(target!.node.id)
+ .some((e) => cg.getNode(e.source)?.filePath === consumer);
+ };
+
+ expect(reachedFrom('consumer.js', 'widget')).toBe(false);
+ expect(reachedFrom('consumer.js', 'gadget')).toBe(true);
+ expect(reachedFrom('consumer.js', 'helper')).toBe(true);
+ expect(reachedFrom('consumer.js', 'parser')).toBe(true);
+ expect(reachedFrom('consumer.js', 'bracketed')).toBe(true);
+ expect(reachedFrom('consumer.ts', 'StrayFace')).toBe(true);
+ expect(reachedFrom('consumer.ts', 'HiddenFace')).toBe(false);
+ }, 30000);
+ });
});
diff --git a/__tests__/wal-deferral.test.ts b/__tests__/wal-deferral.test.ts
index f88a84ee6..22744f898 100644
--- a/__tests__/wal-deferral.test.ts
+++ b/__tests__/wal-deferral.test.ts
@@ -9,12 +9,12 @@
* the valve's trigger/dedupe/backpressure logic, and the end-to-end indexAll
* behavior (identical graph with and without deferral; interval restored).
*/
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DatabaseConnection } from '../src/db';
-import { WalCheckpointValve, resolveWalValveMb } from '../src/db/wal-valve';
+import { WalCheckpointValve, WalValveAbortError, resolveWalValveMb } from '../src/db/wal-valve';
import CodeGraph from '../src/index';
let tmpDir: string;
@@ -193,6 +193,20 @@ function writeFixtureProject(): void {
}
}
+
+async function seedPendingRefs(cg: CodeGraph): Promise {
+ const raw = (cg as unknown as { db: DatabaseConnection }).db.getDb();
+ const node = raw.prepare("SELECT id, file_path FROM nodes WHERE kind = 'function' LIMIT 1").get() as
+ | { id: string; file_path: string }
+ | undefined;
+ expect(node).toBeDefined();
+ const ins = raw.prepare(
+ "INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, status) VALUES (?, ?, 'calls', 1, 0, ?, 'typescript', 'pending')"
+ );
+ ins.run(node!.id, 'helper0', node!.file_path);
+ ins.run(node!.id, 'helper1', node!.file_path);
+}
+
describe('indexAll WAL deferral end-to-end', () => {
it('produces the same graph with and without deferral, and restores the interval', async () => {
@@ -298,6 +312,35 @@ describe('sync WAL deferral end-to-end (#1248)', () => {
delete process.env.CODEGRAPH_NO_WAL_DEFER;
}
});
+
+ it('applies WAL backpressure during changed-file storage and orphan resolution (#1539)', async () => {
+ writeFixtureProject();
+ const cg = CodeGraph.initSync(tmpDir);
+ await cg.indexAll();
+ const backpressure = vi
+ .spyOn(WalCheckpointValve.prototype, 'backpressure')
+ .mockReturnValue(null);
+
+ try {
+ fs.writeFileSync(
+ path.join(tmpDir, 'src', 'mod0.ts'),
+ `export function fn0(x: number): number { return helper0(x) + 100; }\n` +
+ `function helper0(x: number): number { return x * 100; }\n`
+ );
+ const changed = await cg.sync();
+ expect(changed.filesModified).toBe(1);
+ expect(backpressure).toHaveBeenCalled();
+
+ backpressure.mockClear();
+ await seedPendingRefs(cg);
+ const recovered = await cg.sync();
+ expect(recovered.filesAdded + recovered.filesModified + recovered.filesRemoved).toBe(0);
+ expect(backpressure).toHaveBeenCalled();
+ } finally {
+ backpressure.mockRestore();
+ await cg.close();
+ }
+ });
});
describe('resolution-phase WAL backpressure plumbing (§7a.1)', () => {
@@ -308,19 +351,6 @@ describe('resolution-phase WAL backpressure plumbing (§7a.1)', () => {
// 22GB WAL on a 4.6GB DB. These pin that the batch loop (a) calls the hook
// at the pool-idle boundary and (b) actually parks on a returned promise.
- async function seedPendingRefs(cg: CodeGraph): Promise {
- const raw = (cg as unknown as { db: DatabaseConnection }).db.getDb();
- const node = raw.prepare("SELECT id, file_path FROM nodes WHERE kind = 'function' LIMIT 1").get() as
- | { id: string; file_path: string }
- | undefined;
- expect(node).toBeDefined();
- const ins = raw.prepare(
- "INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, status) VALUES (?, ?, 'calls', 1, 0, ?, 'typescript', 'pending')"
- );
- ins.run(node!.id, 'helper0', node!.file_path);
- ins.run(node!.id, 'helper1', node!.file_path);
- }
-
it('calls the backpressure hook once per settled batch', async () => {
writeFixtureProject();
const cg = CodeGraph.initSync(tmpDir);
@@ -417,6 +447,65 @@ describe('valve file-size trigger (§7a.1: backfilled WAL still grows the file)'
});
});
+
+describe('WAL valve fail-closed (#1539)', () => {
+ it('aborts with WalValveAbortError when parked backfills cannot progress past the file cap', async () => {
+ const db = openDb();
+ db.setWalAutocheckpoint(0);
+ writeRows(db, 800); // well past a 0.5MB soft / 2MB file cap
+ expect(db.getWalSizeBytes()).toBeGreaterThan(2 * 1024 * 1024);
+
+ const valve = new WalCheckpointValve(db, 0.5);
+ // Simulate a reader pinning every PASSIVE/TRUNCATE attempt.
+ db.checkpointWalPassive = async () => ({ busy: 1, log: 100, checkpointed: 0 });
+ db.checkpointWalTruncate = async () => ({ busy: 1, log: 100, checkpointed: 0 });
+
+ const bp = valve.backpressure();
+ expect(bp).not.toBeNull();
+ await expect(bp!).rejects.toBeInstanceOf(WalValveAbortError);
+ try {
+ await bp!;
+ } catch (err) {
+ expect(err).toMatchObject({
+ name: 'WalValveAbortError',
+ code: 'WAL_VALVE_ABORT',
+ });
+ expect((err as WalValveAbortError).message).toMatch(/Aborting to avoid unbounded disk growth/);
+ expect((err as WalValveAbortError).walBytes).toBeGreaterThan((err as WalValveAbortError).fileCapBytes);
+ }
+ // Caps remain enforceable: a subsequent backpressure call still parks (no
+ // futility latch that returns null and lets the writer race past the cap).
+ const again = valve.backpressure();
+ expect(again).not.toBeNull();
+ await expect(again!).rejects.toBeInstanceOf(WalValveAbortError);
+ db.close();
+ });
+
+ it('aborts when checkpoint machinery is unavailable while over the file cap', async () => {
+ const db = openDb();
+ db.setWalAutocheckpoint(0);
+ writeRows(db, 800);
+ const valve = new WalCheckpointValve(db, 0.5);
+ db.checkpointWalPassive = async () => null;
+ const bp = valve.backpressure();
+ expect(bp).not.toBeNull();
+ await expect(bp!).rejects.toBeInstanceOf(WalValveAbortError);
+ db.close();
+ });
+
+ it('does not abort a soft foldNow give-up that stays under both caps', async () => {
+ const db = openDb();
+ db.setWalAutocheckpoint(0);
+ writeRows(db, 50); // small WAL
+ const valve = new WalCheckpointValve(db, 1024); // 1GB soft — hard 2GB, fileCap 4GB
+ db.checkpointWalPassive = async () => ({ busy: 1, log: 10, checkpointed: 0 });
+ // foldNow calls backfillFully even with modest growth; under caps this is soft.
+ await expect(valve.foldNow()).resolves.toBeUndefined();
+ expect(valve.backpressure()).toBeNull();
+ db.close();
+ });
+});
+
describe('resolveWalValveMb DB-size scaling (§7a.2 fold-tax reduction)', () => {
it('scales soft cap ~dbSize/4 within [256, 2048]MB; env always wins', () => {
const GB = 1024 * 1024 * 1024;
diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts
index 942fd5bcd..6b87a55ca 100644
--- a/__tests__/watcher.test.ts
+++ b/__tests__/watcher.test.ts
@@ -608,6 +608,42 @@ describe('FileWatcher', () => {
watcher.stop();
});
+ it('info/exclude patterns drop worktree paths from pending (#1728)', async () => {
+ const { execFileSync } = await import('child_process');
+ // Re-init the testDir as a real git repo so buildScopeIgnore can read
+ // .git/info/exclude (createTempDir fixtures are usually plain dirs).
+ execFileSync('git', ['init', '-q'], { cwd: testDir, stdio: 'pipe' });
+ fs.mkdirSync(path.join(testDir, '.claude', 'worktrees', 'w1', 'src'), { recursive: true });
+ fs.writeFileSync(
+ path.join(testDir, '.claude', 'worktrees', 'w1', 'src', 'x.ts'),
+ 'export const x = 1;\n',
+ );
+ fs.writeFileSync(
+ path.join(testDir, '.git', 'info', 'exclude'),
+ '**/.claude/worktrees/\n',
+ );
+
+ const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+ const watcher = newWatcher(syncFn, { debounceMs: 100 });
+ watcher.start();
+ await watcher.waitUntilReady();
+
+ __emitWatchEventForTests(testDir, '.claude/worktrees/w1/src/x.ts');
+ expect(watcher.getPendingFiles().map((p) => p.path)).not.toContain(
+ '.claude/worktrees/w1/src/x.ts',
+ );
+ await new Promise((r) => setTimeout(r, 300));
+ expect(syncFn).not.toHaveBeenCalled();
+
+ // In-scope edits still schedule a scoped sync.
+ fs.writeFileSync(path.join(testDir, 'src', 'ok.ts'), 'export const ok = 1;\n');
+ __emitWatchEventForTests(testDir, 'src/ok.ts');
+ await waitFor(() => syncFn.mock.calls.length > 0);
+ expect(syncFn.mock.calls[0]![0]).toEqual(['src/ok.ts']);
+
+ watcher.stop();
+ });
+
it('a nested .gitignore inside the scope forces a full sync', async () => {
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 100 });
diff --git a/__tests__/writer-lock.test.ts b/__tests__/writer-lock.test.ts
new file mode 100644
index 000000000..c3ba44723
--- /dev/null
+++ b/__tests__/writer-lock.test.ts
@@ -0,0 +1,87 @@
+/**
+ * Project writer lock (#1740) — unit coverage for acquire / re-entrant /
+ * stale-dead-pid / live-holder refusal.
+ */
+
+import { afterEach, describe, expect, it } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import {
+ decodeWriterLockInfo,
+ getWriterPidPath,
+ releaseWriterLock,
+ tryAcquireWriterLock,
+ writerLockHeldMessage,
+} from '../src/mcp/writer-lock';
+
+describe('writer lock (#1740)', () => {
+ let dir: string;
+
+ afterEach(() => {
+ if (dir) {
+ releaseWriterLock(dir);
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
+ }
+ });
+
+ function makeProject(): string {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg1740-lock-'));
+ fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
+ return dir;
+ }
+
+ it('acquires and releases writer.pid', () => {
+ const root = makeProject();
+ const r = tryAcquireWriterLock(root, 'direct');
+ expect(r.kind).toBe('acquired');
+ expect(fs.existsSync(getWriterPidPath(root))).toBe(true);
+ const info = decodeWriterLockInfo(fs.readFileSync(getWriterPidPath(root), 'utf8'));
+ expect(info?.pid).toBe(process.pid);
+ expect(info?.mode).toBe('direct');
+ releaseWriterLock(root);
+ expect(fs.existsSync(getWriterPidPath(root))).toBe(false);
+ });
+
+ it('is re-entrant for the same pid', () => {
+ const root = makeProject();
+ expect(tryAcquireWriterLock(root, 'daemon').kind).toBe('acquired');
+ const again = tryAcquireWriterLock(root, 'fallback');
+ expect(again.kind).toBe('acquired');
+ releaseWriterLock(root);
+ });
+
+ it('reports taken when a live foreign pid holds the lock', () => {
+ const root = makeProject();
+ // Use our own pid first, then overwrite with a fake live-looking pid by
+ // writing a pid that is alive: process.pid of this test — simulate foreign
+ // by writing a different alive pid. On Linux, PID 1 is almost always alive.
+ fs.writeFileSync(
+ getWriterPidPath(root),
+ JSON.stringify({ pid: 1, mode: 'direct', startedAt: Date.now() }) + '\n',
+ { flag: 'wx' },
+ );
+ const r = tryAcquireWriterLock(root, 'direct');
+ expect(r.kind).toBe('taken');
+ if (r.kind === 'taken') {
+ expect(r.existing?.pid).toBe(1);
+ const msg = writerLockHeldMessage(r.existing, r.pidPath);
+ expect(msg).toMatch(/writer lock held/i);
+ expect(msg).toMatch(/CODEGRAPH_NO_DAEMON/);
+ expect(msg).toMatch(/daemon stop/);
+ }
+ });
+
+ it('clears a stale dead-pid lock and acquires', () => {
+ const root = makeProject();
+ // Pick a pid that is extremely unlikely to be alive.
+ const deadPid = 2147483646;
+ fs.writeFileSync(
+ getWriterPidPath(root),
+ JSON.stringify({ pid: deadPid, mode: 'direct', startedAt: Date.now() }) + '\n',
+ );
+ const r = tryAcquireWriterLock(root, 'direct');
+ expect(r.kind).toBe('acquired');
+ releaseWriterLock(root);
+ });
+});
diff --git a/codegraph-kernel/src/ccpp/mod.rs b/codegraph-kernel/src/ccpp/mod.rs
index 78877d7da..e82715d76 100644
--- a/codegraph-kernel/src/ccpp/mod.rs
+++ b/codegraph-kernel/src/ccpp/mod.rs
@@ -52,6 +52,10 @@
//! per-caller targets (insertion-ordered, branch reassignments accumulate);
//! a later bare `k(args)` emits one `calls` ref PER target and suppresses
//! the local name. Template args stripped like base-class refs (#1043).
+//! - pure-virtual methods (#1727): cpp in-class `virtual T f(...) = 0;` is a
+//! `field_declaration` (not `function_definition`); mint a method node so
+//! abstract-base calls and cpp-override synthesis have a target. Mirrors
+//! TS `methodTypes` + `classifyMethodNode` / `isAbstract`.
//! - stack construction (#1035): cpp `declaration` with class-like named
//! `type` and an init_declarator whose value is argument_list /
//! initializer_list → `instantiates` (most-vexing-parse excluded).
@@ -66,7 +70,7 @@
use crate::buffers::{
build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow,
- RefRow, StrRef, Tables, FLAG_IS_EXPORTED, FUNCTION_REF_CODE, NONE, NONE_STR,
+ RefRow, StrRef, Tables, FLAG_IS_ABSTRACT, FLAG_IS_EXPORTED, FUNCTION_REF_CODE, NONE, NONE_STR,
};
use crate::docstring::preceding_docstring;
use crate::ids;
@@ -289,6 +293,7 @@ struct Extra {
signature: Option,
visibility: Option,
is_exported: Option,
+ is_abstract: Option,
return_type: Option,
qualified_name: Option,
}
@@ -508,6 +513,9 @@ impl<'t> Walker<'t> {
if let Some(v) = extra.is_exported {
flags.set(FLAG_IS_EXPORTED, v);
}
+ if let Some(v) = extra.is_abstract {
+ flags.set(FLAG_IS_ABSTRACT, v);
+ }
let name_ref = self.arena.put(name);
let qn_ref = self.arena.put(&qualified);
let id_ref = self.arena.put(&id);
@@ -740,6 +748,36 @@ impl<'t> Walker<'t> {
.any(|c| c.kind() == "type_qualifier" && self.text(c) == "const")
}
+ /// `#1727`: C++ pure-virtual method declaration (`virtual int read(int key) = 0;`).
+ /// tree-sitter-cpp shapes these as `field_declaration` whose declarator unwraps
+ /// to a `function_declarator`, with the pure-virtual `= 0` as a DIRECT
+ /// `number_literal` "0" child (default-arg `= 0` lives inside
+ /// `parameter_declaration` and must not match).
+ fn is_cpp_pure_virtual_method_decl(&self, node: Node<'_>) -> bool {
+ if node.kind() != "field_declaration" {
+ return false;
+ }
+ let Some(mut declarator) = node.child_by_field_name("declarator") else {
+ return false;
+ };
+ while matches!(declarator.kind(), "pointer_declarator" | "reference_declarator") {
+ let inner = declarator
+ .child_by_field_name("declarator")
+ .or_else(|| declarator.named_child(0));
+ let Some(inner) = inner else {
+ return false;
+ };
+ declarator = inner;
+ }
+ if declarator.kind() != "function_declarator" {
+ return false;
+ }
+ (0..node.named_child_count())
+ .filter_map(|i| node.named_child(i))
+ .any(|c| c.kind() == "number_literal" && self.text(c) == "0")
+ }
+
+
/// cppExtractor.isMisparsedFunction (languages/c-cpp.ts:811). cpp only.
fn is_misparsed_function(&self, name: &str, node: Node) -> bool {
if self.variant != Variant::Cpp {
@@ -830,6 +868,17 @@ impl<'t> Walker<'t> {
self.extract_variable(node);
self.scan_fn_ref_subtree(node, 0);
skip_children = true;
+ } else if self.variant == Variant::Cpp
+ && kind == "field_declaration"
+ && self.inside_class_like()
+ && self.is_cpp_pure_virtual_method_decl(node)
+ {
+ // Pure-virtual methods have no `function_definition` body — mint the
+ // method node so calls through the abstract base and cpp-override
+ // synthesis have a target (#1727). Non-pure field_declarations fall
+ // through to the children walk (data members / prototypes).
+ self.extract_method(node);
+ skip_children = true;
} else if kind == "preproc_include" {
self.extract_import(node);
} else if kind == "call_expression" {
@@ -914,6 +963,11 @@ impl<'t> Walker<'t> {
let extra = Extra {
docstring: preceding_docstring(node, self.src),
visibility: if self.variant == Variant::Cpp { self.visibility_of(node) } else { None },
+ is_abstract: if self.variant == Variant::Cpp && self.is_cpp_pure_virtual_method_decl(node) {
+ Some(true)
+ } else {
+ None
+ },
return_type: self.return_type_of(node),
qualified_name: receiver_type
.as_ref()
diff --git a/codegraph-kernel/src/python.rs b/codegraph-kernel/src/python.rs
index db932d10f..79a825c1c 100644
--- a/codegraph-kernel/src/python.rs
+++ b/codegraph-kernel/src/python.rs
@@ -631,6 +631,13 @@ impl<'t> Walker<'t> {
} else {
callee_name = method_name.to_string();
}
+ } else if let Some(r) = receiver.filter(|r| r.kind() == "call") {
+ // Call receiver — `d.setdefault(k, []).append(v)` (#1683):
+ // `().`, or nothing when the inner callee
+ // is not a plain name / attribute chain. Mirrors
+ // TreeSitterExtractor.extractCall.
+ let Some(inner) = self.plain_inner_callee(r) else { return };
+ callee_name = format!("{inner}().{method_name}");
} else {
callee_name = method_name.to_string();
}
@@ -649,6 +656,22 @@ impl<'t> Walker<'t> {
}
}
+ /// The callee of a call receiver when it is a plain identifier or attribute
+ /// chain (`make`, `d.setdefault`), whitespace stripped (#1683).
+ fn plain_inner_callee(&self, call: Node<'t>) -> Option {
+ let inner = call.child_by_field_name("function")?;
+ let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect();
+ if text.is_empty() {
+ return None;
+ }
+ let ok = text.split('.').all(|seg| {
+ let mut chars = seg.chars();
+ matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
+ && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
+ });
+ if ok { Some(text) } else { None }
+ }
+
/// extractDecoratorsFor — python decorators are PRECEDING SIBLINGS inside
/// decorated_definition. Only bare-identifier decorators yield a target
/// (python's `call` kind isn't `call_expression`, and `attribute` isn't in
diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs
index d5812643c..14fc563ee 100644
--- a/codegraph-kernel/src/tsjs/extractors.rs
+++ b/codegraph-kernel/src/tsjs/extractors.rs
@@ -23,7 +23,7 @@ impl<'t> Walker<'t> {
// variable_declarator (`export const useAuth = () => {}`).
if name_override.is_none()
&& name == ""
- && matches!(node.kind(), "arrow_function" | "function_expression")
+ && matches!(node.kind(), "arrow_function" | "function_expression" | "generator_function")
{
if let Some(parent) = node.parent() {
if parent.kind() == "variable_declarator" {
@@ -342,9 +342,9 @@ impl<'t> Walker<'t> {
}
let name = self.text(name_node).to_string();
- // Arrow/function values extract as functions, named by the declarator.
+ // Arrow/function/generator values extract as functions, named by the declarator.
if let Some(v) = value {
- if matches!(v.kind(), "arrow_function" | "function_expression") {
+ if matches!(v.kind(), "arrow_function" | "function_expression" | "generator_function") {
self.extract_function(v, None);
continue;
}
@@ -1111,9 +1111,14 @@ impl<'t> Walker<'t> {
} else {
callee_name = method_name.to_string();
}
+ } else if let Some(r) = receiver.filter(|r| r.kind() == "call_expression") {
+ // Call receiver — `make().run()` (#1683): keep the inner
+ // callee as `().`, or emit nothing when it
+ // is not a plain name / member chain. Mirrors
+ // TreeSitterExtractor.extractCall.
+ let Some(inner) = self.plain_inner_callee(r) else { return };
+ callee_name = format!("{inner}().{method_name}");
} else {
- // (the call-receiver re-encode branches are other
- // languages'; TS/JS keeps the bare method name)
callee_name = method_name.to_string();
}
}
@@ -1136,6 +1141,22 @@ impl<'t> Walker<'t> {
// --- extractInstantiation -----------------------------------------------------------
+ /// The callee of a call-expression receiver when it is a plain identifier
+ /// or member chain (`make`, `d.setdefault`), whitespace stripped (#1683).
+ fn plain_inner_callee(&self, call: Node<'t>) -> Option {
+ let inner = call.child_by_field_name("function")?;
+ let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect();
+ if text.is_empty() {
+ return None;
+ }
+ let ok = text.split('.').all(|seg| {
+ let mut chars = seg.chars();
+ matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
+ && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
+ });
+ if ok { Some(text) } else { None }
+ }
+
pub(super) fn extract_instantiation(&mut self, node: Node<'t>) {
if self.stack.is_empty() {
return;
diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs
index afe6361d5..7ce4dcd99 100644
--- a/codegraph-kernel/src/tsjs/mod.rs
+++ b/codegraph-kernel/src/tsjs/mod.rs
@@ -60,7 +60,7 @@ fn is_method_type(v: Variant, kind: &str) -> bool {
}
fn is_function_type(kind: &str) -> bool {
- matches!(kind, "function_declaration" | "arrow_function" | "function_expression")
+ matches!(kind, "function_declaration" | "generator_function_declaration" | "arrow_function" | "function_expression" | "generator_function")
}
fn is_class_type(v: Variant, kind: &str) -> bool {
@@ -792,7 +792,7 @@ impl<'t> Walker<'t> {
if let Some(name_node) = node.child_by_field_name("name") {
return self.text(name_node).to_string();
}
- if matches!(node.kind(), "arrow_function" | "function_expression") {
+ if matches!(node.kind(), "arrow_function" | "function_expression" | "generator_function") {
return "".to_string();
}
for i in 0..node.named_child_count() {
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
new file mode 100644
index 000000000..4ce220c2d
--- /dev/null
+++ b/docs/AGENTS.md
@@ -0,0 +1,36 @@
+# AGENTS.md (docs/)
+
+Nested Codex guidance under `docs/`. Loaded with the repo-root `AGENTS.md` when cwd is under `docs/` (Codex walks root to cwd; shared `project_doc_max_bytes` budget).
+
+Root `AGENTS.md` already carries the non-negotiable retrieval principles (adapt-the-tool, explore budgets, end-to-end synthesis). This file holds the longer validation methodology and Excalidraw worked example that were moved out of root to fit the budget.
+
+### 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 ""`): 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`).
+ - **Every run also reports three feedback metrics** — residual context occupancy, explore sufficiency (what the agent did NEXT after each explore), and allocation efficiency (share of returned bytes the answer cited) — under each run, plus a side-by-side arm table (`compare-arms.mjs`). Entry point: `docs/benchmarks/agent-eval-feedback-metrics.md`. Reading them: `Read a file we returned` is an allocation miss, `Read a file we did NOT return`/`Grep` is recall; allocation efficiency is **relative** (attribution is by citation) so it is only valid between builds on the same question; occupancy *shares* are Claude Code / 200k and don't transfer to another host — the arm ratio does.
+ - **The `codegraph` CLI is blocked in every arm** (`no-cli-shim.sh`: sanitized PATH + a PreToolUse hook, shared by both harnesses). Without it 14 of 15 without-arm runs in one 7-repo pass reached codegraph through Bash. Check the contamination row before believing any number: `CLI calls that RETURNED output` > 0 invalidates the run (in a new-vs-baseline A/B it silently drops calls from all three metrics, since a CLI explore is not a tool call).
+ - **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 "" [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 | 115–139s | 9–10 | 10–11 | 0 |
+| Broken (explore-budget regression) | 131–139s | 5–10 | 3–5 | 6–14 |
+| Fixed (budget + msgs + synthesis) | 64–112s | 0–2 | 2–4 | 3–**10** |
+| + trace-first steering | **51–74s** | **0–2** | 0–4 | **3–4** |
+
+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 3–10 to 3–4, 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).
+
+
+Also see: `docs/design/dynamic-dispatch-coverage-playbook.md`, `docs/design/callback-edge-synthesis.md`, `docs/benchmarks/call-sequence-analysis.md`, `docs/benchmarks/agent-eval-feedback-metrics.md`.
diff --git a/src/db/wal-valve.ts b/src/db/wal-valve.ts
index 8a95f5f5c..948612797 100644
--- a/src/db/wal-valve.ts
+++ b/src/db/wal-valve.ts
@@ -42,10 +42,39 @@
* the next pass covers everything, the WAL wraps on the following commit,
* and the pause is the disk's honest catch-up cost — the correct terminal
* mode when hardware genuinely can't keep up with the append rate.
+ *
+ * Fail-closed (#1539): if parked backfills cannot progress (a reader pinning
+ * frames) while the WAL is past the hard/file caps, the valve throws
+ * {@link WalValveAbortError} instead of releasing the writer. The previous
+ * "futility latch" disabled parking for 60s after consecutive give-ups so a
+ * pinned reader would not churn checkpoint workers — but that also let the
+ * WAL grow without a bound (observed 64 GiB on a kernel-scale daemon catch-up
+ * with the query pool holding read marks). Aborting with a clear error is the
+ * safe terminal mode; the caller closes readers / retries once the pin clears.
*/
import type { DatabaseConnection } from './index';
+/**
+ * Thrown when the valve cannot checkpoint past its documented caps while a
+ * reader pins WAL frames (#1539). Callers (index/sync) should surface this and
+ * stop writing rather than risk unbounded disk growth.
+ */
+export class WalValveAbortError extends Error {
+ readonly code = 'WAL_VALVE_ABORT' as const;
+ readonly walBytes: number;
+ readonly fileCapBytes: number;
+ readonly hardBytes: number;
+
+ constructor(message: string, sizes: { walBytes: number; fileCapBytes: number; hardBytes: number }) {
+ super(message);
+ this.name = 'WalValveAbortError';
+ this.walBytes = sizes.walBytes;
+ this.fileCapBytes = sizes.fileCapBytes;
+ this.hardBytes = sizes.hardBytes;
+ }
+}
+
/** Soft WAL-growth threshold (MB) that triggers an off-thread passive checkpoint. */
const DEFAULT_WAL_VALVE_MB = 256;
/** Hard cap = this × soft threshold; past it the writer pauses for a full backfill. */
@@ -93,17 +122,10 @@ export class WalCheckpointValve {
private readonly fileCapBytes: number;
/**
- * Futility latch: consecutive backfill give-ups (a reader pinning the WAL)
- * disable further writer pauses for a cooldown, so a pinned phase degrades
- * to the pre-valve behavior (unbounded WAL, folded when the pinner exits)
- * instead of burning a 20-pass checkpoint attempt — each pass a worker
- * thread + fresh connection — at EVERY over-cap boundary. That churn is
- * what turned a pinned kernel-scale resolution from slow into OOM-killed
- * (§7a.1 run 1: 22GB WAL, exit 137 at an envelope the pre-fix build
- * survived).
+ * Consecutive parked-backfill give-ups. Used only for diagnostics in the
+ * abort message — parking is never disabled (#1539 fail-closed).
*/
private consecutiveGiveUps = 0;
- private futileUntil = 0;
constructor(
private readonly db: DatabaseConnection,
@@ -173,7 +195,6 @@ export class WalCheckpointValve {
*/
backpressure(): Promise | null {
if (this.pause) return this.pause;
- if (Date.now() < this.futileUntil) return null; // pinned reader — parking is churn, not progress
// Two independent triggers:
// - growth: un-backfilled BACKLOG past the hard cap (the original valve).
// - file size: a WAL can stay fully backfilled and still grow without
@@ -219,15 +240,31 @@ export class WalCheckpointValve {
/**
* With the writer parked on the returned promise, loop passive passes until
* one reports the entire WAL backfilled (typically the second: the first
- * drains the pass that was already running against a stale snapshot). Gives
- * up after a bounded number of passes — e.g. a reader pinning the WAL —
- * because unbounded WAL growth degrades; a wedged writer never recovers.
+ * drains the pass that was already running against a stale snapshot). After
+ * a bounded number of passes without a full backfill — e.g. a reader
+ * pinning the WAL — throws {@link WalValveAbortError} when still past the
+ * hard/file caps (#1539 fail-closed). Soft give-up under those caps is
+ * reserved for foldNow on a modest backlog that could not complete.
*/
private async backfillFully(): Promise {
for (let i = 0; i < MAX_PAUSED_BACKFILL_PASSES; i++) {
if (this.inflight) await this.inflight; // fold in the stale in-flight pass first
const res = await this.db.checkpointWalPassive();
- if (!res) return; // checkpoint machinery unavailable — don't spin
+ if (!res) {
+ // Machinery unavailable: fail closed past the documented caps (#1539),
+ // otherwise soft-return so a non-WAL / closing connection does not abort.
+ const walBytes = this.db.getWalSizeBytes();
+ const growth = this.growthBytes();
+ if (walBytes > this.fileCapBytes || growth > this.hardBytes) {
+ throw new WalValveAbortError(
+ `WAL checkpoint machinery unavailable while over the documented cap ` +
+ `(wal=${this.mb(walBytes)}, fileCap=${this.mb(this.fileCapBytes)}). ` +
+ `Aborting to avoid unbounded disk growth.`,
+ { walBytes, fileCapBytes: this.fileCapBytes, hardBytes: this.hardBytes }
+ );
+ }
+ return;
+ }
this.log(`backfill pass ${i + 1}: busy=${res.busy} log=${res.log} checkpointed=${res.checkpointed} wal=${this.mb(this.db.getWalSizeBytes())}`);
if (res.busy === 0 && res.log === res.checkpointed) {
// Backfill complete AND we are at a parked barrier (backfillFully only
@@ -240,21 +277,37 @@ export class WalCheckpointValve {
if (trunc) this.log(`truncate: busy=${trunc.busy} wal=${this.mb(this.db.getWalSizeBytes())}`);
this.sizeAtLastFullBackfill = this.db.getWalSizeBytes();
this.consecutiveGiveUps = 0;
- this.futileUntil = 0;
return;
}
}
this.consecutiveGiveUps++;
- if (this.consecutiveGiveUps >= 2) {
- this.futileUntil = Date.now() + 60_000;
- }
- const msg = `backfill gave up after ${MAX_PAUSED_BACKFILL_PASSES} passes (streak ${this.consecutiveGiveUps}${this.futileUntil ? ', parking disabled 60s' : ''}) — a reader is pinning the WAL`;
+ const walBytes = this.db.getWalSizeBytes();
+ const growth = this.growthBytes();
+ const msg =
+ `backfill gave up after ${MAX_PAUSED_BACKFILL_PASSES} passes ` +
+ `(streak ${this.consecutiveGiveUps}) — a reader is pinning the WAL ` +
+ `(wal=${this.mb(walBytes)} growth=${this.mb(growth)} ` +
+ `hard=${this.mb(this.hardBytes)} fileCap=${this.mb(this.fileCapBytes)})`;
this.log(msg);
// Give-ups are rare and load-bearing for §7a.1-class diagnosis — surface
// them on any timing-instrumented run, not just valve-debug ones.
if (process.env.CODEGRAPH_SYNTH_TIMINGS && !process.env.CODEGRAPH_WAL_VALVE_DEBUG) {
console.error(`[wal-valve] ${msg}`);
}
+ // Fail closed (#1539): never release the writer past the documented caps
+ // when checkpoints cannot progress. The old futility latch disabled
+ // parking for 60s and allowed unbounded growth (64 GiB observed).
+ if (walBytes > this.fileCapBytes || growth > this.hardBytes) {
+ throw new WalValveAbortError(
+ `WAL checkpoint cannot progress while a reader pins frames ` +
+ `(wal=${this.mb(walBytes)}, growth=${this.mb(growth)}, ` +
+ `fileCap=${this.mb(this.fileCapBytes)}, hard=${this.mb(this.hardBytes)}, ` +
+ `give-ups=${this.consecutiveGiveUps}). Aborting to avoid unbounded disk growth. ` +
+ `Close concurrent readers (for example the MCP query pool) and retry, ` +
+ `or raise CODEGRAPH_WAL_VALVE_MB if the threshold is too tight for this project.`,
+ { walBytes, fileCapBytes: this.fileCapBytes, hardBytes: this.hardBytes }
+ );
+ }
}
private fire(): void {
diff --git a/src/extraction/index.ts b/src/extraction/index.ts
index 93be48352..c2bb2f3bd 100644
--- a/src/extraction/index.ts
+++ b/src/extraction/index.ts
@@ -301,17 +301,117 @@ function readGitignorePatterns(giPath: string): string {
return kept.join('\n');
}
+/**
+ * Resolve the repository GIT_DIR for `repoRoot` (a `.git` directory, or the
+ * target of a `.git` file pointer). Null when this isn't a git checkout.
+ */
+function resolveGitDir(repoRoot: string): string | null {
+ const gitPath = path.join(repoRoot, '.git');
+ let st: fs.Stats;
+ try {
+ st = fs.statSync(gitPath);
+ } catch {
+ return null;
+ }
+ if (st.isDirectory()) return gitPath;
+ if (!st.isFile()) return null;
+ try {
+ const raw = fs.readFileSync(gitPath, 'utf8').match(/^gitdir:\s*(.+)$/m)?.[1]?.trim();
+ if (!raw) return null;
+ return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(repoRoot, raw);
+ } catch {
+ return null;
+ }
+}
+
+/** Expand a leading `~/` the way git does for `core.excludesFile`. */
+function expandUserPath(p: string): string {
+ if (p === '~') return os.homedir();
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
+ return p;
+}
+
+/**
+ * Root-relative exclude patterns from git sources that are NOT the root
+ * `.gitignore`: `.git/info/exclude` and `core.excludesFile`. Same semantics as
+ * the root `.gitignore`, so they merge into {@link buildDefaultIgnore}. Without
+ * these, the watcher / FS-walk scope silently diverged from
+ * `git ls-files --exclude-standard` (#1728).
+ */
+function readGitExcludeExtraPatterns(rootDir: string): string {
+ const chunks: string[] = [];
+ const gitDir = resolveGitDir(rootDir);
+ if (gitDir) {
+ const excludePath = path.join(gitDir, 'info', 'exclude');
+ if (fs.existsSync(excludePath)) {
+ const patterns = readGitignorePatterns(excludePath);
+ if (patterns) chunks.push(patterns);
+ }
+ }
+ try {
+ const configured = execFileSync(
+ 'git',
+ ['-C', rootDir, 'config', '--get', 'core.excludesFile'],
+ { encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] },
+ ).trim();
+ if (configured) {
+ const abs = expandUserPath(configured);
+ if (fs.existsSync(abs)) {
+ const patterns = readGitignorePatterns(abs);
+ if (patterns) chunks.push(patterns);
+ }
+ }
+ } catch {
+ // No git, unset, or timeout — leave extras empty.
+ }
+ return chunks.join('\n');
+}
+
+/**
+ * Directories `git ls-files -o -i --exclude-standard --directory` reports as
+ * ignored-untracked. Seeded into {@link ScopeIgnore} so nested `.gitignore`
+ * effects (and any exclude-standard rule the flat matcher might miss) prune the
+ * watcher the same way the indexer skips them (#1728).
+ */
+function listGitIgnoredDirectories(rootDir: string): string[] {
+ try {
+ const out = execFileSync(
+ 'git',
+ ['-C', rootDir, 'ls-files', '-z', '-o', '-i', '--exclude-standard', '--directory'],
+ {
+ encoding: 'utf8',
+ timeout: 60_000,
+ maxBuffer: 50 * 1024 * 1024,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ },
+ );
+ const dirs: string[] = [];
+ for (const entry of out.split('\0')) {
+ if (!entry) continue;
+ dirs.push(entry.endsWith('/') ? entry : `${entry}/`);
+ }
+ return dirs;
+ } catch {
+ return [];
+ }
+}
+
/**
* An `ignore` matcher seeded with the built-in defaults, merged with the project's
- * root .gitignore so a negation there (e.g. `!vendor/`) overrides a default. Shared
- * by both enumeration paths so behavior is identical with or without git — and so
- * the defaults apply to tracked files too (committing a dependency dir doesn't make
- * it project code; the explicit `.gitignore` negation is the only opt-in).
+ * root .gitignore so a negation there (e.g. `!vendor/`) overrides a default, plus
+ * git's other root-relative exclude files (`.git/info/exclude`, `core.excludesFile`)
+ * so watcher / FS-walk scope matches `git ls-files --exclude-standard` (#1728).
+ * Shared by both enumeration paths so behavior is identical with or without git —
+ * and so the defaults apply to tracked files too (committing a dependency dir
+ * doesn't make it project code; the explicit `.gitignore` negation is the only
+ * opt-in).
*/
export function buildDefaultIgnore(rootDir: string): Ignore {
const ig = ignore().add(DEFAULT_IGNORE_PATTERNS);
const rootGitignore = path.join(rootDir, '.gitignore');
if (fs.existsSync(rootGitignore)) ig.add(readGitignorePatterns(rootGitignore));
+ const extra = readGitExcludeExtraPatterns(rootDir);
+ if (extra) ig.add(extra);
return ig;
}
@@ -630,14 +730,17 @@ function findNestedGitRepos(absDir: string, relPrefix: string): string[] {
/**
* Workspace-scope ignore matcher. Ordinary paths get the root's matcher
- * (built-in defaults + root `.gitignore`); paths inside an EMBEDDED repo get
- * that repo's own matcher (defaults + its root `.gitignore`) — the parent's
- * `.gitignore` hides a child repo from git, not from the index (#514). A
- * directory path (trailing slash) that is an ANCESTOR of an embedded root is
- * never ignored, so directory-pruning callers (the Linux per-directory
- * watcher) still descend to reach the embedded repos.
+ * (built-in defaults + root `.gitignore` + `.git/info/exclude` +
+ * `core.excludesFile`, plus directories `git ls-files --exclude-standard`
+ * reports as ignored); paths inside an EMBEDDED repo get that repo's own
+ * matcher — the parent's `.gitignore` hides a child repo from git, not from
+ * the index (#514). A directory path (trailing slash) that is an ANCESTOR of
+ * an embedded root is never ignored, so directory-pruning callers (the Linux
+ * per-directory watcher) still descend to reach the embedded repos.
*
- * Single source of truth for indexer and watcher scope — they must not diverge.
+ * Shared by the indexer (scoped sync / skip checks) and the watcher so their
+ * scope cannot diverge from each other or from `git ls-files --exclude-standard`
+ * (#1728).
*/
export class ScopeIgnore {
private embedded: Array<{ root: string; matcher: Ignore }>;
@@ -710,8 +813,15 @@ export class ScopeIgnore {
export function buildScopeIgnore(rootDir: string, embeddedRoots?: Iterable): ScopeIgnore {
const roots = embeddedRoots ? [...embeddedRoots] : discoverEmbeddedRepoRoots(rootDir);
const include = loadIncludeMatcher(rootDir);
+ // Root matcher already has defaults + root `.gitignore` + info/exclude +
+ // core.excludesFile. Seed ignored-untracked directories from git so nested
+ // `.gitignore` effects prune the watcher identically to the indexer (#1728).
+ const rootMatcher = buildDefaultIgnore(rootDir);
+ for (const dir of listGitIgnoredDirectories(rootDir)) {
+ rootMatcher.add(dir);
+ }
return new ScopeIgnore(
- buildDefaultIgnore(rootDir),
+ rootMatcher,
roots.map((root) => ({ root, matcher: buildDefaultIgnore(path.join(rootDir, root)) })),
loadExcludeMatcher(rootDir),
include,
@@ -1050,6 +1160,10 @@ function getGitVisibleFiles(rootDir: string): Set | null {
{ cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
);
// Directory is gitignored by parent repo — fall back to filesystem walk
+ logDebug('project root is gitignored by a parent repo — falling back to filesystem walk', {
+ rootDir,
+ gitRoot,
+ });
return null;
} catch {
// Not ignored — safe to use git ls-files
@@ -1074,7 +1188,20 @@ function getGitVisibleFiles(rootDir: string): Set | null {
// Git, but still wanted in the graph.)
for (const f of collectIncludedFilesForRoot(rootDir)) visible.add(f);
return visible;
- } catch {
+ } catch (error) {
+ // Any failure here (git missing, a `git rev-parse`/`ls-files` timeout or
+ // buffer overrun under load, an unreadable repo, unsupported flag combo on
+ // older git, etc.) silently sent every caller to `scanDirectoryWalk` with
+ // zero signal that the fast git-delegated path was skipped — making reports
+ // like #1567 (nested-`.gitignore`-excluded `node_modules` walked into)
+ // hard to triage, since both ignore implementations look correct in
+ // isolation but there was no way to tell which one ran. Log it under the
+ // existing CODEGRAPH_DEBUG gate so a future report can confirm or rule out
+ // the fallback in one step.
+ logDebug('git-based file listing unavailable — falling back to filesystem walk', {
+ rootDir,
+ error: error instanceof Error ? error.message : String(error),
+ });
return null;
}
}
@@ -1475,10 +1602,19 @@ export class ExtractionOrchestrator {
* same lifecycle the watcher's own matcher already has.
*/
private scopedSyncMatcher(): ScopeIgnore {
- const key = [PROJECT_CONFIG_FILENAME, '.gitignore']
+ // Bust when any root-level exclude source the matcher reads may have
+ // changed. Nested `.gitignore` edits force a full watcher sync, which
+ // clears this cache (see the full-reconcile branch in sync()).
+ const gitDir = resolveGitDir(this.rootDir);
+ const key = [
+ PROJECT_CONFIG_FILENAME,
+ '.gitignore',
+ gitDir ? path.join(gitDir, 'info', 'exclude') : '',
+ ]
.map((name) => {
+ if (!name) return '-';
try {
- return String(fs.statSync(path.join(this.rootDir, name)).mtimeMs);
+ return String(fs.statSync(path.isAbsolute(name) ? name : path.join(this.rootDir, name)).mtimeMs);
} catch {
return '-';
}
@@ -2695,7 +2831,13 @@ export class ExtractionOrchestrator {
* set is not exactly known (directory removals, event overflow): the full
* scan-diff remains the ground truth those cases need (#1285).
*/
- scopedPaths?: string[]
+ scopedPaths?: string[],
+ /**
+ * Writer-side WAL pressure valve (#1539). Called after every changed file
+ * is stored, when no extraction transaction is open, so a checkpoint can
+ * safely catch up before the next file grows the WAL further.
+ */
+ backpressure?: () => Promise | null
): Promise {
await initGrammars(); // Initialize WASM runtime (grammars loaded lazily below)
const startTime = Date.now();
@@ -2761,6 +2903,10 @@ export class ExtractionOrchestrator {
filesChecked = unique.length;
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scoped: ${Date.now() - tSyncScan}ms (${unique.length} paths, ${trackedFiles.length} tracked)`);
} else {
+ // Full reconcile: drop the memoized scope matcher so a nested
+ // `.gitignore` / exclude-standard change that forced this full sync is
+ // visible to the next scoped sync (#1728).
+ this.scopedMatcher = null;
currentFiles = await scanDirectoryAsync(this.rootDir);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scan: ${Date.now() - tSyncScan}ms (${currentFiles.length} files)`);
filesChecked = currentFiles.length;
@@ -2895,6 +3041,9 @@ export class ExtractionOrchestrator {
const result = await this.indexFile(filePath);
nodesUpdated += result.nodes.length;
+
+ const pause = backpressure?.();
+ if (pause) await pause;
}
// Names whose definition set this sync changed: a `file\0name` pair present
diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts
index cdd573b5a..7dff1639f 100644
--- a/src/extraction/languages/c-cpp.ts
+++ b/src/extraction/languages/c-cpp.ts
@@ -166,6 +166,35 @@ export function stripCppTemplateArgs(name: string): string {
return out.trim();
}
+
+/**
+ * Is this C++ `field_declaration` a pure-virtual method (`virtual int read(int key) = 0;`)?
+ * tree-sitter-cpp shapes those as a field_declaration whose declarator unwraps to a
+ * `function_declarator`, with the pure-virtual `= 0` as a DIRECT `number_literal` "0"
+ * child of the field_declaration (default-arg `= 0` lives inside parameter_declaration
+ * and must not match). Bodiless method prototypes (`int foo();`) and data members
+ * (`int x = 0;`) are excluded — prototypes usually have an out-of-line definition that
+ * already mints the method node; pure virtuals never do (#1727).
+ */
+export function isCppPureVirtualMethodDecl(node: SyntaxNode): boolean {
+ if (node.type !== 'field_declaration') return false;
+ let declarator: SyntaxNode | null = getChildByField(node, 'declarator');
+ if (!declarator) return false;
+ while (
+ declarator.type === 'pointer_declarator' ||
+ declarator.type === 'reference_declarator'
+ ) {
+ const inner: SyntaxNode | null =
+ getChildByField(declarator, 'declarator') || declarator.namedChild(0);
+ if (!inner) return false;
+ declarator = inner;
+ }
+ if (declarator.type !== 'function_declarator') return false;
+ return node.namedChildren.some(
+ (c: SyntaxNode) => c.type === 'number_literal' && c.text === '0'
+ );
+}
+
/**
* A function/method's return type lives in the `function_definition`'s `type`
* field (`Metrics& Metrics::instance()` → `Metrics`). Constructors, destructors,
@@ -1515,8 +1544,56 @@ export function blankCNamedVariadicDefineDots(source: string): string {
* C-detected headers in CUDA projects (llm.c keeps `__device__` helpers and
* kernel prototypes in plain `.h`) — the same content-gated CUDA blank as
* C++. Offset-preserving. */
+/**
+ * Blank the argument list of a statement-level `MACRO( … );` call whose
+ * arguments are designated initializers — betaflight's
+ *
+ * RESET_CONFIG(pidProfile_t, pidProfile,
+ * .pid = { [PID_ROLL] = PID_ROLL_DEFAULT, … },
+ * .pidSumLimit = PIDSUM_LIMIT,
+ * …
+ * );
+ *
+ * tree-sitter-c has no rule for `.field = value` as a call argument. Even a
+ * small statement-level `M(a, b, .x = 1, .y = { 1, 2 },);` with a trailing
+ * comma recovers by extending the enclosing `function_definition` to EOF —
+ * the next function vanishes and later ones nest under the first (#1729 —
+ * 310 functions in 73 files on a betaflight tree, which name matching then
+ * treated as unreachable closures). Emptying the argument list to spaces,
+ * newlines kept, leaves `RESET_CONFIG(\n\n…\n);` — a call the grammar parses
+ * cleanly — at the cost of the references inside the initializer, which the
+ * broken parse was not yielding either. Statement-level only (`);` follows),
+ * macro-cased name only, offsets preserved. Runs before the kernel route
+ * point, so both the wasm and kernel C arms see the same bytes.
+ */
+export function blankCDesignatedMacroArgs(source: string): string {
+ if (source.indexOf('=') === -1) return source;
+ const out = source.split('');
+ const re = /^[ \t]*([A-Z_][A-Z0-9_]*)\s*\(/gm;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(source))) {
+ const open = m.index + m[0].length - 1;
+ let depth = 1;
+ let i = open + 1;
+ for (; i < source.length && depth > 0; i++) {
+ const c = source[i];
+ if (c === '(') depth++;
+ else if (c === ')') depth--;
+ }
+ if (depth !== 0) continue;
+ const close = i - 1;
+ const args = source.slice(open + 1, close);
+ // A designator at argument depth: `.name =` or `[index] =`.
+ if (!/(^|[,{(\s])(\.[A-Za-z_]\w*|\[[^\]]+\])\s*=[^=]/.test(args)) continue;
+ if (!/^\s*;/.test(source.slice(close + 1))) continue;
+ for (let k = open + 1; k < close; k++) if (out[k] !== '\n') out[k] = ' ';
+ re.lastIndex = close;
+ }
+ return out.join('');
+}
+
function preParseCSource(source: string): string {
- const inner = blankCKernelAnnotations(blankCCplusplusGuardBodies(source));
+ const inner = blankCDesignatedMacroArgs(blankCKernelAnnotations(blankCCplusplusGuardBodies(source)));
let blanked = blankCLeadingAttrMacros(
blankLoneMacroLines(
blankCStatementMacroCalls(
@@ -1559,7 +1636,17 @@ export const cppExtractor: LanguageExtractor = {
// get picked as the blast-radius representative over — the single real
// definition, exactly as bodiless struct/enum specifiers are already skipped. (#1093)
skipBodilessClass: true,
- methodTypes: ['function_definition'],
+ // `function_definition` covers inline / out-of-line bodies; `field_declaration`
+ // covers pure-virtual methods (`virtual int read(int key) = 0;`), which have no
+ // body and would otherwise mint no node — so calls through the abstract base and
+ // cpp-override synthesis had nothing to attach to (#1727). classifyMethodNode
+ // keeps ordinary data members / prototypes on the children-walk path.
+ methodTypes: ['function_definition', 'field_declaration'],
+ classifyMethodNode: (node) => {
+ if (node.type !== 'field_declaration') return 'method';
+ return isCppPureVirtualMethodDecl(node) ? 'method' : 'skip';
+ },
+ isAbstract: (node) => (isCppPureVirtualMethodDecl(node) ? true : undefined),
interfaceTypes: [],
structTypes: ['struct_specifier'],
// C++ unions additionally carry member functions, which extract through the
diff --git a/src/extraction/languages/javascript.ts b/src/extraction/languages/javascript.ts
index 3b36348b3..899e1530a 100644
--- a/src/extraction/languages/javascript.ts
+++ b/src/extraction/languages/javascript.ts
@@ -3,7 +3,7 @@ import type { LanguageExtractor } from '../tree-sitter-types';
import { classifyTsClassMember } from './typescript';
export const javascriptExtractor: LanguageExtractor = {
- functionTypes: ['function_declaration', 'arrow_function', 'function_expression'],
+ functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'],
classTypes: ['class_declaration'],
methodTypes: ['method_definition', 'field_definition'],
// JS `field_definition` ≙ TS `public_field_definition`: plain fields are
diff --git a/src/extraction/languages/typescript.ts b/src/extraction/languages/typescript.ts
index 72059114f..133aefd05 100644
--- a/src/extraction/languages/typescript.ts
+++ b/src/extraction/languages/typescript.ts
@@ -39,7 +39,7 @@ export function classifyTsClassMember(node: SyntaxNode): 'method' | 'property' {
}
export const typescriptExtractor: LanguageExtractor = {
- functionTypes: ['function_declaration', 'arrow_function', 'function_expression'],
+ functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'],
classTypes: ['class_declaration', 'abstract_class_declaration'],
methodTypes: ['method_definition', 'public_field_definition'],
classifyMethodNode: classifyTsClassMember,
diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts
index 6808895e1..5a4f8d3b6 100644
--- a/src/extraction/tree-sitter-types.ts
+++ b/src/extraction/tree-sitter-types.ts
@@ -160,6 +160,8 @@ export interface LanguageExtractor {
isAsync?: (node: SyntaxNode) => boolean;
/** Check if node is static */
isStatic?: (node: SyntaxNode) => boolean;
+ /** Check if a method/class is abstract (C++ pure virtual, Java abstract, …). Return true to set; undefined/false leaves the flag unset. */
+ isAbstract?: (node: SyntaxNode) => boolean | undefined;
/** Check if variable declaration is a constant (const vs let/var) */
isConst?: (node: SyntaxNode) => boolean;
/**
@@ -221,9 +223,13 @@ export interface LanguageExtractor {
* both callable and data members (#808): TS/JS class FIELDS
* (`public_field_definition` / `field_definition`) are methods only when
* their value is callable (`onClick = () => {}`); a plain field
- * (`public fonts: Fonts;`, `count = 0`) is a property. Default: 'method'.
+ * (`public fonts: Fonts;`, `count = 0`) is a property. C++ also lists
+ * `field_declaration` in methodTypes so pure-virtual methods (`= 0`) can
+ * mint nodes (#1727); non-callable field_declarations return `'skip'` so
+ * the walker still descends (data-member initializers keep their call
+ * edges). Default: 'method'.
*/
- classifyMethodNode?: (node: SyntaxNode) => 'method' | 'property';
+ classifyMethodNode?: (node: SyntaxNode) => 'method' | 'property' | 'skip';
/**
* Resolve the body node for a function/method/class when it's not a child field.
diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts
index e47fa4f9d..80abbb7c6 100644
--- a/src/extraction/tree-sitter.ts
+++ b/src/extraction/tree-sitter.ts
@@ -171,7 +171,7 @@ function extractNameRaw(node: SyntaxNode, source: string, extractor: LanguageExt
// not from identifiers in their body. Without this, single-expression arrow
// functions like `const fn = () => someIdentifier` get named "someIdentifier"
// instead of "fn", because the fallback below finds the body identifier.
- if (node.type === 'arrow_function' || node.type === 'function_expression') {
+ if (node.type === 'arrow_function' || node.type === 'function_expression' || node.type === 'generator_function') {
return '';
}
@@ -1041,8 +1041,14 @@ export class TreeSitterExtractor {
else if (this.extractor.methodTypes.includes(nodeType)) {
// TS/JS class fields parse as a methodTypes node; only function-valued
// fields are methods — a plain field (`public fonts: Fonts;`) is a
- // property (#808). classifyMethodNode is absent for other languages.
- if (this.extractor.classifyMethodNode?.(node) === 'property') {
+ // property (#808). C++ lists `field_declaration` so pure-virtual methods
+ // mint nodes (#1727); non-callable ones return 'skip' and fall through to
+ // the children walk. classifyMethodNode is absent for other languages.
+ const methodClass = this.extractor.classifyMethodNode?.(node) ?? 'method';
+ if (methodClass === 'skip') {
+ // Not a method — leave skipChildren false so data-member initializers
+ // still contribute call/instantiation edges under the enclosing class.
+ } else if (methodClass === 'property') {
const propNode = this.extractProperty(node);
// Walk the initializer so its calls/instantiations attribute to the
// property (`history = createHistory()` → history calls
@@ -1556,7 +1562,7 @@ export class TreeSitterExtractor {
if (
!nameOverride &&
name === '' &&
- (node.type === 'arrow_function' || node.type === 'function_expression')
+ (node.type === 'arrow_function' || node.type === 'function_expression' || node.type === 'generator_function')
) {
const parent = node.parent;
if (parent?.type === 'variable_declarator') {
@@ -1798,6 +1804,9 @@ export class TreeSitterExtractor {
const visibility = this.extractor.getVisibility?.(node);
const isAsync = this.extractor.isAsync?.(node);
const isStatic = this.extractor.isStatic?.(node);
+ // Only persist abstract when true — a false return must not mint `isAbstract: false`
+ // on every ordinary method (breaks kernel↔wasm parity JSON equality).
+ const isAbstract = this.extractor.isAbstract?.(node) ? true : undefined;
const returnType = this.extractor.getReturnType?.(node, this.source);
const extraProps: Partial = {
docstring,
@@ -1805,6 +1814,7 @@ export class TreeSitterExtractor {
visibility,
isAsync,
isStatic,
+ isAbstract,
returnType,
};
if (receiverType) {
@@ -2638,7 +2648,7 @@ export class TreeSitterExtractor {
}
const name = getNodeText(nameNode, this.source);
// Arrow functions / function expressions: extract as function instead of variable
- if (valueNode && (valueNode.type === 'arrow_function' || valueNode.type === 'function_expression')) {
+ if (valueNode && (valueNode.type === 'arrow_function' || valueNode.type === 'function_expression' || valueNode.type === 'generator_function')) {
this.extractFunction(valueNode);
continue;
}
@@ -4625,6 +4635,32 @@ export class TreeSitterExtractor {
// scope keywords: such calls previously emitted a bare method
// name, which either failed to resolve or resolved ambiguously.
calleeName = `${getNodeText(receiver, this.source)}.${methodName}`;
+ } else if (
+ (this.language === 'typescript' ||
+ this.language === 'javascript' ||
+ this.language === 'tsx' ||
+ this.language === 'jsx' ||
+ this.language === 'python') &&
+ receiver &&
+ (receiver.type === 'call_expression' || receiver.type === 'call')
+ ) {
+ // Receiver that is itself a call — `d.setdefault(k, []).append(v)`,
+ // `make().run()`, `res.json().data` (#1683). The bare method name
+ // this used to emit exact-matched any top-level project symbol of
+ // that name and fabricated a call edge from an unrelated function
+ // (`append`, `get`, `run`…). Keep the inner callee, encoded as
+ // `().` like the Java/Kotlin/C++ chains: the
+ // marker never appears in an ordinary ref, so nothing name-matches
+ // it, and a chain resolver can later infer the receiver's type
+ // from what the inner call returns. An inner callee that is not a
+ // plain name or member chain (`(await x)()`, `arr[0]()`) has no
+ // static receiver at all — emit nothing: a silent miss, never a
+ // wrong edge. The inner call is visited on its own either way.
+ // Mirrored in the kernel (tsjs/extractors.rs, python.rs).
+ const innerFn = getChildByField(receiver, 'function');
+ const innerCallee = innerFn ? getNodeText(innerFn, this.source).replace(/\s+/g, '') : '';
+ if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(innerCallee)) return;
+ calleeName = `${innerCallee}().${methodName}`;
} else if (
this.language === 'go' &&
receiver &&
diff --git a/src/index.ts b/src/index.ts
index 750d3d62c..9941b9c16 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -816,7 +816,13 @@ export class CodeGraph {
try { return this.queries.isNameSegmentVocabEmpty(); } catch { return false; }
})();
- const result = await this.orchestrator.sync(options.onProgress, options.paths);
+ // Writer-side backstop for deferred WAL checkpointing (#1539): sync
+ // previously armed the valve but never called backpressure(), so the
+ // hard/file caps were never enforced during daemon catch-up — only
+ // timer-driven PASSIVE checkpoints ran, and a query-pool reader could
+ // pin frames while the WAL grew without a bound.
+ const backpressure = walValve ? () => walValve!.backpressure() : undefined;
+ const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure);
// Fold the store phase's WAL BEFORE the post-store reads below
// (resolution reads on the main thread) — same rationale as
@@ -914,7 +920,8 @@ export class CodeGraph {
current: done,
total: totalPasses,
});
- }
+ },
+ backpressure
);
}
}
@@ -980,7 +987,8 @@ export class CodeGraph {
current: done,
total: totalPasses,
});
- }
+ },
+ backpressure
);
}
diff --git a/src/mcp/daemon.ts b/src/mcp/daemon.ts
index 500c48a8c..a5735d168 100644
--- a/src/mcp/daemon.ts
+++ b/src/mcp/daemon.ts
@@ -55,6 +55,7 @@ import {
getDaemonSocketPath,
} from './daemon-paths';
import { CodeGraphPackageVersion } from './version';
+import { releaseWriterLock, tryAcquireWriterLock, writerLockHeldMessage } from './writer-lock';
import { registerDaemon, deregisterDaemon } from './daemon-registry';
/** Default idle linger after the last client disconnects. */
@@ -204,6 +205,16 @@ export class Daemon {
* listening — the daemon then sticks around until idle/shutdown.
*/
async start(): Promise {
+ // #1740: claim the project writer lock before opening/watching so a
+ // concurrent direct-mode serve --mcp cannot start a second watcher.
+ const writer = tryAcquireWriterLock(this.projectRoot, 'daemon');
+ if (writer.kind === 'taken') {
+ const msg = writerLockHeldMessage(writer.existing, writer.pidPath);
+ process.stderr.write(`[CodeGraph daemon] ${msg}\n`);
+ this.cleanupLockfile();
+ throw new Error(msg);
+ }
+
// Engine init is deliberately backgrounded — see #172. The first session
// to land waits on `ensureInitialized` either way, and unloaded sessions
// (cross-project tool calls only) shouldn't pay any open cost.
@@ -498,6 +509,7 @@ export class Daemon {
}
private cleanupLockfile(): void {
+ releaseWriterLock(this.projectRoot);
try {
if (fs.existsSync(this.pidPath)) {
// Only remove if it still belongs to us — another daemon may have
diff --git a/src/mcp/dynamic-boundaries.ts b/src/mcp/dynamic-boundaries.ts
index 2378ddaeb..7307bd3fe 100644
--- a/src/mcp/dynamic-boundaries.ts
+++ b/src/mcp/dynamic-boundaries.ts
@@ -21,7 +21,8 @@
* inside a string is a false positive, so {@link blankStringContents} blanks
* them too, quotes preserved.)
*/
-import { stripCommentsForRegex, type CommentLang } from '../resolution/strip-comments';
+import { blankStringContents, stripCommentsForRegex, type CommentLang } from '../resolution/strip-comments';
+export { blankStringContents } from '../resolution/strip-comments';
export interface BoundaryMatch {
/** Stable form id, e.g. 'computed-call' — used for per-form dedupe. */
@@ -222,42 +223,6 @@ function commentLang(language: string): CommentLang | null {
const MAX_MATCHES_PER_BODY = 3;
const MAX_BODY_CHARS = 60_000; // a god-function tail is still scannable; beyond this, truncate
-/**
- * Blank the CONTENTS of string literals (quotes preserved, offsets preserved)
- * so dispatch-shaped prose — docs, error messages, template text — can't fire
- * a matcher. Run AFTER comment stripping (comments are already spaces).
- * Backslash escapes are honored; `'`/`"` strings end at a newline (treated as
- * unterminated, matching the comment stripper); backticks span lines, and
- * `${...}` interpolations inside them are blanked too — missing a dispatch
- * inside a template literal is acceptable, false-firing on prose is not.
- */
-export function blankStringContents(text: string): string {
- const out = text.split('');
- let i = 0;
- const n = text.length;
- while (i < n) {
- const c = text[i]!;
- if (c === '"' || c === "'" || c === '`') {
- const quote = c;
- i++;
- while (i < n && text[i] !== quote) {
- if (text[i] === '\\' && i + 1 < n) {
- out[i] = ' ';
- out[i + 1] = ' ';
- i += 2;
- continue;
- }
- if (quote !== '`' && text[i] === '\n') break; // unterminated — stop blanking
- if (text[i] !== '\n') out[i] = ' '; // keep newlines for line math
- i++;
- }
- if (i < n && text[i] === quote) i++;
- continue;
- }
- i++;
- }
- return out.join('');
-}
/**
* Scan one symbol's body for dynamic-dispatch sites.
diff --git a/src/mcp/engine.ts b/src/mcp/engine.ts
index 8f2e5b6ef..734fafa4f 100644
--- a/src/mcp/engine.ts
+++ b/src/mcp/engine.ts
@@ -16,6 +16,7 @@ import type CodeGraph from '../index';
import { resolveServerRoot } from '../directory';
import { watchDisabledReason } from '../sync';
import { ToolHandler } from './tools';
+import { releaseWriterLock, tryAcquireWriterLock, writerLockHeldMessage } from './writer-lock';
import { QueryPool, resolvePoolSize } from './query-pool';
// Lazy-load the heavy CodeGraph chain (sqlite + query/graph/context layers) OFF
@@ -67,6 +68,8 @@ export class MCPEngine {
// bounded but shouldn't run on every tool call in the no-default state.
private lastRetrySubScanAt = 0;
private watcherStarted = false;
+ /** Set when this engine holds writer.pid (#1740). */
+ private writerLockRoot: string | null = null;
private opts: Required;
private closed = false;
// Off-loop read-tool pool (daemon mode only). Created lazily once the default
@@ -203,6 +206,10 @@ export class MCPEngine {
stop(): void {
if (this.closed) return;
this.closed = true;
+ if (this.writerLockRoot) {
+ releaseWriterLock(this.writerLockRoot);
+ this.writerLockRoot = null;
+ }
// Detach + terminate the worker pool first so no tool call routes to a
// worker mid-teardown; outstanding pool calls resolve with graceful guidance.
this.toolHandler.setQueryPool(null);
@@ -278,6 +285,24 @@ export class MCPEngine {
private startWatching(): void {
if (!this.cg || this.watcherStarted || !this.opts.watch) return;
+ // #1740: only one live watcher/writer per project. Daemon and startDirect
+ // usually already hold writer.pid (re-entrant for this pid). Proxy
+ // in-process fallback acquires here; if another writer holds it, skip the
+ // watcher so we never contend on codegraph.lock until auto-sync degrades.
+ const lockRoot = this.projectPath;
+ if (lockRoot) {
+ const writer = tryAcquireWriterLock(lockRoot, 'fallback');
+ if (writer.kind === 'taken') {
+ const msg = writerLockHeldMessage(writer.existing, writer.pidPath);
+ process.stderr.write(
+ `[CodeGraph MCP] File watcher not started — ${msg}\n`
+ );
+ this.watcherStarted = true;
+ return;
+ }
+ this.writerLockRoot = lockRoot;
+ }
+
const disabledReason = watchDisabledReason(this.projectPath ?? process.cwd());
if (disabledReason) {
process.stderr.write(
diff --git a/src/mcp/index.ts b/src/mcp/index.ts
index 3f57024d2..e4a14a4fb 100644
--- a/src/mcp/index.ts
+++ b/src/mcp/index.ts
@@ -48,6 +48,7 @@ import {
tryAcquireDaemonLock,
} from './daemon';
import { connectWithHello, runLocalHandshakeProxy } from './proxy';
+import { releaseWriterLock, tryAcquireWriterLock, writerLockHeldMessage } from './writer-lock';
import { getDaemonSocketCandidates, probeDaemonIdentity } from './daemon-paths';
import { getTelemetry } from '../telemetry';
import { checkForUpdateInBackground } from '../upgrade/update-check';
@@ -252,6 +253,8 @@ export class MCPServer {
// Idempotency guard for stop().
private stopped = false;
private mode: 'unstarted' | 'direct' | 'proxy' | 'daemon' = 'unstarted';
+ /** Project root whose writer.pid we hold in direct mode (#1740); released on stop. */
+ private writerLockRoot: string | null = null;
constructor(projectPath?: string) {
this.projectPath = projectPath || null;
@@ -329,6 +332,10 @@ export class MCPServer {
stop(): void {
if (this.stopped) return;
this.stopped = true;
+ if (this.writerLockRoot) {
+ releaseWriterLock(this.writerLockRoot);
+ this.writerLockRoot = null;
+ }
if (this.ppidWatchdog) {
clearInterval(this.ppidWatchdog);
this.ppidWatchdog = null;
@@ -358,6 +365,20 @@ export class MCPServer {
if (reason && process.env.CODEGRAPH_MCP_DEBUG) {
process.stderr.write(`[CodeGraph MCP] Direct mode: ${reason}.\n`);
}
+
+ // #1740: refuse a second direct writer on an initialized project. Daemon
+ // mode multiplexes clients; direct mode is single-writer-per-project.
+ const writerRoot = resolveDaemonRoot(this.projectPath);
+ if (writerRoot) {
+ const writer = tryAcquireWriterLock(writerRoot, 'direct');
+ if (writer.kind === 'taken') {
+ const msg = writerLockHeldMessage(writer.existing, writer.pidPath);
+ process.stderr.write(`[CodeGraph MCP] ${msg}\n`);
+ process.exit(1);
+ }
+ this.writerLockRoot = writerRoot;
+ }
+
this.engine = new MCPEngine();
const transport = new StdioTransport();
this.session = new MCPSession(transport, this.engine, {
diff --git a/src/mcp/writer-lock.ts b/src/mcp/writer-lock.ts
new file mode 100644
index 000000000..919e630c5
--- /dev/null
+++ b/src/mcp/writer-lock.ts
@@ -0,0 +1,188 @@
+/**
+ * Project writer lock (#1740).
+ *
+ * At most one long-lived MCP *writer* (shared daemon OR direct-mode /
+ * in-process engine that owns the FileWatcher) may serve a given project.
+ * The shared daemon already multiplexes N stdio proxies onto one writer; this
+ * lock closes the same-OS gap where two direct-mode `serve --mcp` processes
+ * (via `CODEGRAPH_NO_DAEMON=1` or proxy→in-process fallback) each start a
+ * watcher, contend on `codegraph.lock`, and degrade auto-sync.
+ *
+ * Deliberately separate from `daemon.pid`: proxies probe the daemon socket
+ * and may clear a live pid that has no socket. A direct-mode holder must not
+ * look like a daemon. `writer.pid` is only about "who owns live auto-sync".
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { getCodeGraphDir } from '../directory';
+/** Signal-0 liveness (EPERM ⇒ alive). Local copy to avoid a daemon↔writer cycle. */
+function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (err: unknown) {
+ const e = err as NodeJS.ErrnoException;
+ if (e.code === 'EPERM') return true;
+ return false;
+ }
+}
+
+
+/** Absolute path to the writer pid lockfile for `projectRoot`. */
+export function getWriterPidPath(projectRoot: string): string {
+ let root = projectRoot;
+ try { root = fs.realpathSync(projectRoot); } catch { /* keep lexical */ }
+ return path.join(getCodeGraphDir(root), 'writer.pid');
+}
+
+/** Structured contents of the writer pidfile. */
+export interface WriterLockInfo {
+ pid: number;
+ /** `direct` | `daemon` | `fallback` — for actionable error text only. */
+ mode: string;
+ startedAt: number;
+}
+
+export type WriterAcquireResult =
+ | { kind: 'acquired'; pidPath: string; info: WriterLockInfo }
+ | { kind: 'taken'; existing: WriterLockInfo | null; pidPath: string };
+
+function encode(info: WriterLockInfo): string {
+ return JSON.stringify(info) + '\n';
+}
+
+export function decodeWriterLockInfo(raw: string): WriterLockInfo | null {
+ try {
+ const parsed = JSON.parse(raw.trim()) as Partial;
+ if (typeof parsed.pid !== 'number' || typeof parsed.mode !== 'string') return null;
+ return {
+ pid: parsed.pid,
+ mode: parsed.mode,
+ startedAt: typeof parsed.startedAt === 'number' ? parsed.startedAt : 0,
+ };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Atomically create `writer.pid` (link-into-place, O_EXCL fallback). If held
+ * by a dead PID, clear and retry once. Does not steal from a live holder.
+ */
+export function tryAcquireWriterLock(
+ projectRoot: string,
+ mode: string,
+): WriterAcquireResult {
+ const pidPath = getWriterPidPath(projectRoot);
+ fs.mkdirSync(path.dirname(pidPath), { recursive: true });
+
+ const info: WriterLockInfo = {
+ pid: process.pid,
+ mode,
+ startedAt: Date.now(),
+ };
+
+ const attempt = (): WriterAcquireResult => {
+ const tmp = `${pidPath}.${process.pid}.tmp`;
+ let acquired = false;
+ try {
+ fs.writeFileSync(tmp, encode(info), { mode: 0o600 });
+ try {
+ fs.linkSync(tmp, pidPath);
+ acquired = true;
+ } catch (err: unknown) {
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
+ // taken
+ } else {
+ // No hard links — O_EXCL create.
+ try {
+ const fd = fs.openSync(pidPath, 'wx', 0o600);
+ try {
+ fs.writeSync(fd, encode(info));
+ acquired = true;
+ } finally {
+ fs.closeSync(fd);
+ }
+ } catch (e2: unknown) {
+ if ((e2 as NodeJS.ErrnoException).code !== 'EEXIST') throw e2;
+ }
+ }
+ }
+ } finally {
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
+ }
+
+ if (acquired) return { kind: 'acquired', pidPath, info };
+
+ let existing: WriterLockInfo | null = null;
+ try {
+ existing = decodeWriterLockInfo(fs.readFileSync(pidPath, 'utf8'));
+ } catch { /* unreadable */ }
+ return { kind: 'taken', existing, pidPath };
+ };
+
+ let result = attempt();
+ if (result.kind === 'taken' && result.existing && result.existing.pid === process.pid) {
+ // Same process already holds it (daemon acquired before engine watch).
+ return { kind: 'acquired', pidPath: result.pidPath, info: result.existing };
+ }
+ if (result.kind === 'taken') {
+ const existing = result.existing;
+ if (!existing || existing.pid <= 0 || !isProcessAlive(existing.pid)) {
+ // Stale — clear (pid-verified) and retry once.
+ try {
+ const raw = fs.readFileSync(pidPath, 'utf8');
+ const cur = decodeWriterLockInfo(raw);
+ if (!cur || cur.pid === existing?.pid) {
+ if (!cur || cur.pid <= 0 || !isProcessAlive(cur.pid)) {
+ fs.unlinkSync(pidPath);
+ }
+ }
+ } catch { /* ENOENT ok */ }
+ result = attempt();
+ }
+ }
+ return result;
+}
+
+/** Release if we still own the lock (pid match). */
+export function releaseWriterLock(projectRoot: string): void {
+ const pidPath = getWriterPidPath(projectRoot);
+ try {
+ if (!fs.existsSync(pidPath)) return;
+ const info = decodeWriterLockInfo(fs.readFileSync(pidPath, 'utf8'));
+ if (info && info.pid === process.pid) {
+ fs.unlinkSync(pidPath);
+ }
+ } catch { /* best-effort */ }
+}
+
+/** Read current lock without acquiring. */
+export function readWriterLock(projectRoot: string): WriterLockInfo | null {
+ const pidPath = getWriterPidPath(projectRoot);
+ try {
+ return decodeWriterLockInfo(fs.readFileSync(pidPath, 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Actionable message when another live process owns the writer lock (#1740).
+ */
+export function writerLockHeldMessage(
+ existing: WriterLockInfo | null,
+ pidPath: string,
+): string {
+ const who = existing && existing.pid > 0
+ ? `PID ${existing.pid} (${existing.mode || 'unknown'} mode)`
+ : 'another process';
+ return (
+ 'CodeGraph writer lock held by ' + who + '. ' +
+ 'Only one live MCP writer may serve a project (auto-sync / index). ' +
+ 'Stop the other server (codegraph daemon stop if a shared daemon, or end the other MCP session), ' +
+ 'or unset CODEGRAPH_NO_DAEMON so additional clients proxy to the shared daemon. ' +
+ 'If this is stale, delete ' + pidPath
+ );
+}
diff --git a/src/resolution/index.ts b/src/resolution/index.ts
index 7988c1bcf..eb962a505 100644
--- a/src/resolution/index.ts
+++ b/src/resolution/index.ts
@@ -16,8 +16,8 @@ import {
FrameworkResolver,
ImportMapping,
} from './types';
-import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
-import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver';
+import { isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
+import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos, resolveImportPath } from './import-resolver';
import { ResolverPool, minRefsForPool } from './resolver-pool';
import { detectFrameworks } from './frameworks';
import { synthesizeCallbackEdges } from './callback-synthesizer';
@@ -26,6 +26,7 @@ import { loadProjectAliases, type AliasMap } from './path-aliases';
import { loadGoModule, type GoModule } from './go-module';
import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
import { logDebug } from '../errors';
+import { lexicalPathWithinRoot } from '../utils';
import type { ReExport } from './types';
import { LRUCache } from './lru-cache';
@@ -538,8 +539,18 @@ export class ReferenceResolver {
return true;
}
}
- // Fall back to filesystem for files not yet indexed
- const fullPath = path.join(this.projectRoot, filePath);
+ // Fall back to filesystem for files not yet indexed. `path.join` does
+ // not clamp, and relative-import resolution hands us paths carrying
+ // `../` segments, so the probe has to be contained (#1631): a path
+ // outside the root can never be an indexed project file, and the
+ // `knownFiles` check above already answered for everything that is.
+ // Lexical containment only: this is a per-candidate hot path, and the
+ // symlink half of `validatePathWithinRoot` costs two `realpathSync`
+ // calls per probe (~70x slower here). It would also be wrong to apply
+ // — indexing deliberately follows in-root symlinks whose targets live
+ // outside the root (#935), so only the `../` escape is refused.
+ const fullPath = lexicalPathWithinRoot(this.projectRoot, filePath);
+ if (fullPath === null) return false;
try {
return fs.existsSync(fullPath);
} catch (error) {
@@ -974,6 +985,19 @@ export class ReferenceResolver {
if (fwEarly) return fwEarly;
// Strategy 2: Try import-based resolution
+ // A TS/JS/Python call-receiver chain (`useStore.getState().reset`, #1683)
+ // names the ROOT's import, not the method's: letting resolveViaImport see
+ // it binds the call to the imported store constant and the method is
+ // never looked up. The name-matcher owns the chain shape for these
+ // languages — the Java/Kotlin/C++ chains keep their existing path.
+ if (
+ ref.referenceKind === 'calls' &&
+ CHAIN_SHAPE.test(ref.referenceName) &&
+ (ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'python')
+ ) {
+ return this.gateLanguage(matchReference(ref, this.context), ref);
+ }
+
const tImp = this.profileStages ? process.hrtime.bigint() : 0n;
const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref);
if (this.profileStages) this.stageAdd('viaImport', ref, !!importResult, tImp);
@@ -1012,7 +1036,13 @@ export class ReferenceResolver {
// binding it happened to pick. Same-file matches only.
if (nameResult) {
const target = this.queries.getNodeById(nameResult.targetNodeId);
- if (ref.language === 'nix') {
+ // A definition its language makes file-local — a C `static`, a Kotlin
+ // `private fun`, a Go unexported name in another package, a Rust
+ // non-`pub` item outside its module subtree — cannot be what a name in
+ // another file means, whichever strategy chose it (#1730).
+ if (target && !isVisibleAcrossFiles(target, ref, this.context)) {
+ nameResult = null;
+ } else if (ref.language === 'nix') {
if (!target || target.filePath !== ref.filePath) {
nameResult = null;
}
@@ -1983,6 +2013,42 @@ export class ReferenceResolver {
return this.frameworks.map((f) => f.name);
}
+ /**
+ * True when `receiver` is a local name bound by an import that resolves to a
+ * file IN THIS PROJECT — the only case where letting a python
+ * built-in-method name through the filter is safe (#1681).
+ *
+ * Asking only whether SOME import bound the local name is not enough: every
+ * import produces a mapping, stdlib and PyPI included, so that would also be
+ * true for `os`, `requests`, `np`. Opening the filter for them lets
+ * resolveViaImport find no project file, fall through to bare-name matching,
+ * and bind `os.remove(p)` to whatever project method happens to be named
+ * `remove` — reintroducing, through its own escape hatch, the fabricated-edge
+ * class this filter exists to prevent.
+ *
+ * Resolving the specifier is the same question resolveViaImport will ask
+ * next, so a receiver that passes here is one the qualified path can actually
+ * serve; anything else stays a silent miss rather than a wrong edge.
+ */
+ private isPythonProjectModule(ref: UnresolvedRef, receiver: string): boolean {
+ for (const imp of this.context.getImportMappings(ref.filePath, ref.language)) {
+ if (imp.localName !== receiver) continue;
+ // `import pkg.mod` / `import pkg.mod as m` binds the module `source`
+ // names. `from pkg import mod` binds `pkg.mod`, and `from . import mod`
+ // binds `.mod` — join without doubling the dot that makes `.` mean the
+ // current package.
+ const specifier = imp.isNamespace
+ ? imp.source
+ : imp.source.endsWith('.')
+ ? `${imp.source}${imp.exportedName}`
+ : `${imp.source}.${imp.exportedName}`;
+ if (resolveImportPath(specifier, ref.filePath, ref.language!, this.context)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Check if reference is to a built-in or external symbol
*/
@@ -2031,10 +2097,18 @@ export class ReferenceResolver {
}
// Filter built-in methods on non-class receivers
// (e.g., items.append where items is a local list variable)
- // But allow if the capitalized receiver matches a known codebase class
+ // But allow if the capitalized receiver matches a known codebase class,
+ // OR the receiver is itself an imported project module — a module can
+ // export a top-level function sharing a common collection-method name
+ // (`ledger.append`, `from . import ledger`), and that call is a real
+ // project dependency, not `list.append` (#1681). Without this, the
+ // qualified ref never reaches resolveViaImport / resolvePythonModuleMember.
if (PYTHON_BUILT_IN_METHODS.has(method)) {
const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
- if (!this.knownNames?.has(capitalized)) {
+ const isKnownClass = this.knownNames?.has(capitalized) ?? false;
+ const isProjectModule =
+ !isKnownClass && this.isPythonProjectModule(ref, receiver);
+ if (!isKnownClass && !isProjectModule) {
return true;
}
}
@@ -2452,6 +2526,8 @@ export class ReferenceResolver {
if (!result) return result;
if (ref.referenceKind !== 'references' && ref.referenceKind !== 'imports') return result;
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
+ // Package imports cannot target prose found by a framework's name lookup.
+ if (ref.referenceKind === 'imports' && (tgt as string) === 'markdown' && (ref.language as string) !== 'markdown') return null;
if (tgt && ref.language && crossesKnownFamily(tgt, ref.language)) return null;
return result;
}
diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts
index c74d8f272..2ce958e97 100644
--- a/src/resolution/name-matcher.ts
+++ b/src/resolution/name-matcher.ts
@@ -4,8 +4,10 @@
* Handles symbol name matching for reference resolution.
*/
+import * as path from 'path';
import { Language, Node } from '../types';
import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types';
+import { blankStringContents, stripCommentsForRegex } from './strip-comments';
/**
* Ceiling on how many same-named definitions a FUZZY name-match strategy will
@@ -388,6 +390,338 @@ function isLexicallyReachable(
);
}
+/** Languages whose module boundary is `import`/`export` (or CommonJS). */
+const ESM_FAMILY = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']);
+
+/**
+ * A line-initial `import` statement — the marker that a JS/TS file is a MODULE
+ * rather than a classic script. Line-anchored and followed by a name, brace,
+ * star or quote, so a dynamic `import(` and the word inside a comment or string
+ * do not match.
+ */
+const HAS_IMPORT_STATEMENT = /^[ \t]*import[\s{*'"]/m;
+
+/**
+ * Anything the file could offer another file, in every form the extractor's own
+ * `isExported` flag misses. `^export` covers the declaration and later forms
+ * (`export const`, `export { x }`, `export default x`, `export *`); the
+ * CommonJS shapes cover files that never use ESM syntax at all, in both the dot
+ * and the bracket form; and `declare global` contributes names to every file
+ * whether or not the module exports anything of its own. Kept as a source test
+ * rather than a node scan precisely because `isExported` is set only from an
+ * `export_statement` ancestor, so `const x = …; export { x }` and
+ * `module.exports = { x }` both read as unexported on the node.
+ */
+const HAS_ESM_EXPORT = /^[ \t]*export[\s{*]|^[ \t]*declare\s+global\b/m;
+const HAS_CJS_EXPORT = /\bmodule\.exports\b|\bexports\s*[.[]/;
+
+/**
+ * Per-context memo of "this file is a module that exports nothing", asked once
+ * per candidate FILE rather than once per reference. Derived from file source,
+ * so it drops with the context's file caches — clearNameMatcherMemos deletes it
+ * alongside INFER_SCAN_STATES.
+ */
+const SEALED_MODULES = new WeakMap>();
+
+/**
+ * Whether `filePath` is a JS/TS module that exports NOTHING — an import
+ * statement present, no export of any form. No reference from another file can
+ * reach any binding in such a file, so every one of its symbols is a false
+ * candidate for a cross-file name match.
+ *
+ * This is the general case behind a package name capturing a same-named local:
+ * on `vitejs/vite`, 157 cross-file `imports` refs — every `import { defineConfig
+ * } from 'vite'` in the playground and the create-vite templates — resolved onto
+ * `playground/ssr-html/test-stacktrace.js::vite`, which is `const vite = await
+ * createServer(…)` at module scope in a file with zero exports. The existing
+ * guards cannot see it: `isLexicallyReachable` returns early for any candidate
+ * that is not a `function`, and the bare-import guard correctly declines because
+ * `vite` IS a workspace member, so the specifier really is project-local. What
+ * is wrong is only which node the name lands on.
+ *
+ * Deliberately narrow on three axes, because each is a class this would
+ * otherwise resolve wrongly in the opposite direction:
+ *
+ * - **A classic script is exempt.** Requiring an `import` statement means a
+ * non-module `.js` file — concatenated globals, a browser `