diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..6e42d21
--- /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. The MCP entry is OpenCode 2's native `mcp.servers.codegraph` with `disabled: false` and `codemode: false` (so `codegraph_explore` stays on the native tool list); a pre-#1698 `mcp.codegraph` + `enabled` entry is migrated on re-install and removed by uninstall.
+- `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 c39258f..58e6e3d 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,42 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
+- Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461)
+- `codegraph callers`, `codegraph callees` and `codegraph impact` now resolve qualified names, group results and JSON edges by definition, and accept `--file` to narrow ambiguous names; thanks @ferrine. (#1512, #1656)
+- `codegraph callers`, `codegraph callees` and `codegraph impact` (CLI and MCP) now report missing names with did-you-mean suggestions instead of another symbol's results, and exact matches with no callers stay empty; thanks @uvmplus. (#1473, #1481)
+
+#### MCP / indexing
+
+- The prompt hook no longer injects unrelated projects when run from your home directory or a broader directory containing a stray workspace manifest. (#1454)
+
+- Indexing now succeeds when Node.js's SQLite lacks FTS5, with search falling back to name and fuzzy matching; thanks @aniruddhaadak80. (#1532)
+
+- `codegraph_explore` now makes clear that suggested call counts are advisory, so agents keep exploring when an answer is incomplete; thanks @rongbc. (#1504, #1570)
+
+- C++ functions following anonymous namespaces containing raw-string templates are now indexed correctly, even when template text resembles an unfinished macro call. (#1505)
+
+- Indexing now warns when parser errors leave a file with no symbols, including C++ raw strings with 16-character delimiters, so missing code is no longer silent. (#1522)
+
+- `codegraph index ` now refuses uninitialized paths and names the nearest initialized parent instead of silently rebuilding it; thanks @danusha2345. (#1524, #1689)
+
+- Sync now recovers the same connections as a clean index after interrupted reference resolution, including inherited calls and callbacks that previously stayed missing. (#1577)
+
+- `codegraph_explore` now re-serves source to fresh subagents and after context compaction, with cross-call dedup available only through an explicit `CODEGRAPH_EXPLORE_DEDUP=1` opt-in; thanks @danusha2345. (#1620, #1624)
+
+- **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)
+
+- `codegraph install` now honors `CLAUDE_CONFIG_DIR` and `CODEX_HOME` for global Claude Code and Codex setup so CodeGraph loads in your chosen profile (thanks @seanchann; #1627).
+
+- Files opted in with `includeIgnored` now stay indexed on Git older than 2.36, and embedded repositories remain visible to the watcher (thanks @maxmilian and @newshowardz777; #1549).
+
+- `codegraph init` and `codegraph index` now list unsupported file extensions and explain that CodeGraph is inactive when no supported source files are found (#1502).
+
#### 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.
@@ -201,6 +239,42 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
#### Symbols, tests and the viewer
+- Kotlin functions and methods now carry their signature — `(params): ReturnType` — in `codegraph_explore`, `node` and the viewer, instead of no signature at all. Re-index Kotlin projects after upgrading. (#1495)
+- TypeScript/JavaScript value aliases — `export const alias = fn`, `export { fn as alias }`, object-literal `api = { run: fn }`, and same-file `const local = fn` — now forward calls edges to the aliased function, so callers and impact on the implementation include consumers that call through the alias instead of stopping at the binding. Genuine wrappers (`() => fn()`) are unchanged. Re-index after upgrading. Thanks @valkyriweb. (#1482, #1485)
+- `codegraph affected` now finds Go, Python and JVM test files that previously went unreported, while preserving custom `--filter` behavior (thanks @danusha2345; #1507, #1688).
+
+- Calls inside declaration initializers in Kotlin, Java, TypeScript, JavaScript, Scala, Rust and Python now appear under the declaration that owns them, making callers and impact results more accurate after re-indexing with `codegraph index -f` (thanks @danusha2345; #1510, #1511).
+- Java fields initialized with anonymous classes now expose their methods and calls in the graph.
+- Kotlin property accessors, initialization blocks and destructuring declarations now retain their calls with the correct owner.
+- The viewer continues to count module-level initializer calls as top-level file activity in entry points and file screens.
+- `codegraph_explore` again lists a dynamic-dispatch link when the same two symbols are also joined by an ordinary call.
+- Rust unit structs (`struct Unit;`) and their trait implementation relationships now appear in the graph after re-indexing. (#1513, #1514)
+- Imports from Node built-ins or npm packages no longer connect to unrelated type members with matching names; re-index after upgrading to clear existing false dependencies. Thanks @ctype-lab. (#1537)
+
+- Inheritance relationships no longer attach external Rust or npm supertypes to unrelated local symbols with the same name, including in Svelte, Vue and Astro components; re-index after upgrading to clear existing false relationships. Thanks @ctype-lab. (#1536)
+
+- PHP static calls through imported class aliases now reach the correct class when services and repositories share method names, so callers and impact analysis show the right dependencies after re-indexing. (#1545)
+- TypeScript/JavaScript: a call through a field of the enclosing class — `this.mailer.send()` — now resolves on the field's declared type, so a delegating wrapper that shares the method's name no longer records itself as its own callee and `callers`, `impact` and trace stop lying on that shape. A field whose type is external or a builtin stays unresolved rather than guessed. Re-index after upgrading. (#1496)
+- TypeScript and JavaScript collection calls through local variables and their nested properties no longer link to unrelated project methods; re-index after upgrading. (#1566)
+
+- Objective-C headers now index in a project that has no `.m` file. A `.h` file is read as C from its name alone, and only later — once its contents are read — recognized as Objective-C; the grammar for that was never loaded up front, so the file failed with a parser error and nothing in it reached the index. Adding any `.m` file used to make the same header work, which is what made this look arbitrary. Thanks @Juddd. (#1628)
+
+- TypeScript interface methods and properties are now indexed, so `node`, `callers` and impact can find platform `.d.ts` APIs while declaration-only files keep their lower ranking on flow queries; re-index TypeScript projects after upgrading. (#1638)
+- Lua and Luau function expressions assigned to locals, table members, or keyed table fields are now indexed as callable nodes. Calls from `local f = function() ... end`, `M.f = function() ... end`, and callback tables such as `M.handlers = { onClick = function() ... end }` are attributed to the named function or method instead of collapsing onto the file node, so callers and impact no longer omit these handlers. Re-index after upgrading. (#1616, #1650)
+- **Functions bound with `const` inside another function are symbols now.** `const handleClear = () => {…}` inside a React component — every handler that skips `useCallback` — was invisible to `callers`, `callees` and impact, answering "Symbol not found" exactly the way a function with no callers would. It is indexed like its module-level twin, contained by the enclosing function, with its own calls. Re-index after upgrading. (#1669)
+- `codegraph callers`, `callees`, and `query` now clearly report when their result limit hides additional matches, including exact totals in callers/callees JSON output; the `codegraph_callers` and `codegraph_callees` MCP answers carry the same "showing N of M" note. (#1639, #1674)
+- CommonJS controllers written as `exports.getItems = async (req, res) => {…}` or `module.exports.x = function () {…}` are now indexed as exported functions, so `node`, `callers` and impact find every Express handler in that style and the calls inside them belong to the handler instead of the file. Re-index JavaScript projects after upgrading. (#1675)
+- Python parameters annotated with a quoted forward reference — `def f(o: "Alpha")`, or anything under `from __future__ import annotations` — now resolve the methods called on them, the same as the unquoted annotation. Re-index Python projects after upgrading. (#1684)
+- **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)
+- Python method calls on module-scope builtin collections no longer create false calls or file dependencies to unrelated project methods with the same name; re-index after upgrading. (#1652)
+- **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)
+- **Fuzzy matching no longer lands on a closure it cannot reach.** A function nested inside another function is only callable from inside its container, and exact-name matching already declined such candidates; the fuzzy fallback did not, so a builtin method call (`res.text()`, `items.push()`) whose only same-named project symbol was some file's closure resolved onto that closure. The fallback now checks that the one candidate it would commit to is reachable, and declines otherwise — it does not filter the candidate list first, which would turn a crowd of same-named definitions into a single "unique" survivor and hand it every call of that name. On vite that removes the 12 edges onto nested functions and adds none. Re-index after upgrading. Thanks @bompus. (#1708, #1709)
+- **An import that names the emitted extension resolves to its source.** Under `moduleResolution: node16 | nodenext | bundler` TypeScript requires `import { x } from './util.js'` for `util.ts`, and no file of that name exists, so the import resolver returned nothing and every name imported that way fell through to bare-name matching: a method wrapping the same-named helper it imports (`renderDockStyles() { return renderDockStyles(); }`) resolved to itself, and cross-module edges in such projects were name guesses. `.js` / `.jsx` / `.mjs` / `.cjs` specifiers now retry with the source extensions TypeScript compiles from when the emitted file is absent; a real `.js` beside the `.ts` still wins. On a 582-file repo whose `.ts` files import this way, import-backed `calls`/`imports` edges went from 4,002 to 7,312 and the eight wrapper-method self-edges disappeared. Re-index after upgrading. Thanks @bompus. (#1705, #1706)
+
- **The Map groups a repository the way that repository is shaped.** It always drew top-level directories, so a project whose whole program lives under one `src/` opened as a picture of four boxes — `src`, `ios`, `.github`, `(root files)` — with two thirds of the code inside one of them and nothing to say about it. The Map now picks its own grouping: the shallowest one that is not a single box holding the program, so a mobile app opens on `src/app`, `src/components`, `src/api`, `ios/CaptureView` and the rest, and a project packaged as `frontend/src/…` opens on the screens, components and reducers instead of on the word `frontend`. A repository whose top-level directories really are its modules is left exactly where it was. A new **Grouping** control on the right says which one was chosen and lets you take it a level in or out, and a leaf directory is now named for itself rather than as `…/(root files)`. Each box now also says how much leans on it — how many files elsewhere reference straight into it — with a bar along its bottom edge scaled against the most depended-on box on screen, so the folder you have to be careful with is the one you can see at a glance rather than the one with the longest name. The Map also has a **Key** now, like the Screens and Steps tabs — including what the dashed maroon lines mean, which only appear once you select a module: that module reaching back UP into something that depends on it.
- **The Symbol tab opens the Symbol tab.** With no symbol open and no trail to return to, clicking **Symbol** in the top bar took you to the landing page — which, on any project that has screens, is the Screens tab. So the button said Symbol and gave you somebody else's view. It now has an address of its own (`#/s`) that opens the "nothing selected" screen: the search prompt and the where-to-start list of routes, entry files and the symbols the most code depends on.
@@ -217,8 +291,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **The Map covers a multi-root project.** A React Native app's `ios/` beside its `src/` — or any second root holding a fifth of the code — is now on the picture, one level deeper, instead of the map silently drawing only the larger root.
+- **The Claude Code prompt hook's context now arrives inline.** The hook capped its injection at 16,000 characters, but Claude Code shows hook output inline only up to 10,000 and otherwise persists it to a file with a 2 KB preview, so on any repo where explore filled the cap the model saw a file path and the first 2 KB. The cap is now 9,000 characters, under the limit with room for the wrapper. (#1694)
+
+- **`codegraph_explore` is loaded from the first prompt in Claude Code.** Claude Code defers every MCP tool behind a tool-search step, so a fresh session saw only the tool's name until the model searched for it, and the server's "call `codegraph_explore` instead of Read" had nothing loaded to act on. The tool now carries `anthropic/alwaysLoad` in its `_meta`, which exempts it on existing installs, and `codegraph install` writes `alwaysLoad: true` on the Claude Code server entry (re-run it to add the key). Copilot CLI's tool search holds MCP tools back the same way once ~30 tools are connected, so its entry now carries `deferTools: "never"`. (#1696)
+
- Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.
+- Python calls and file dependencies through `from package import module as alias` now appear in the graph, so renamed imports no longer hide live callers or imported modules. Thanks @JoeyNPP. (#1626)
+
## [1.6.0] - 2026-08-26
### Highlights
diff --git a/CLAUDE.md b/CLAUDE.md
index f91f170..217d62d 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 a73d3b2..10a325a 100644
--- a/README.md
+++ b/README.md
@@ -508,12 +508,15 @@ npm install -g @colbymchenry/codegraph
"codegraph": {
"type": "stdio",
"command": "codegraph",
- "args": ["serve", "--mcp"]
+ "args": ["serve", "--mcp"],
+ "alwaysLoad": true
}
}
}
```
+`alwaysLoad` keeps `codegraph_explore` loaded from the first prompt. Claude Code otherwise defers every MCP tool behind a tool-search step, so a fresh session sees only the tool's name until the model searches for it.
+
**Add to `~/.claude/settings.json` (optional, for auto-allow):**
```json
{
@@ -854,7 +857,7 @@ is written):
- **Claude Code**
- **Cursor**
- **Codex CLI**
-- **opencode**
+- **opencode** — MCP entry is OpenCode 2's `mcp.servers.codegraph` with `codemode: false` (keeps `codegraph_explore` on the native tool list; `codegraph install` migrates the older `mcp.codegraph` shape)
- **Hermes Agent**
- **Gemini CLI**
- **Antigravity IDE**
@@ -944,6 +947,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__/alias-binding-resolution.test.ts b/__tests__/alias-binding-resolution.test.ts
new file mode 100644
index 0000000..b7105ce
--- /dev/null
+++ b/__tests__/alias-binding-resolution.test.ts
@@ -0,0 +1,107 @@
+/**
+ * Calls through an alias binding.
+ *
+ * A name bound to nothing but another symbol — `export const alias = fn`,
+ * `export { fn as alias }`, `export const api = { run: fn }`, or a same-file
+ * `const local = fn` — used to resolve to the BINDING, one hop short of the
+ * function. The edge existed, so nothing looked broken, but `callers fn` omitted
+ * every caller that went through the alias and reported a confident zero while
+ * `callers alias` found them.
+ *
+ * Specifiers here are extensionless so these cases stand independently of
+ * `.js`-specifier resolution.
+ */
+import { describe, it, expect, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+
+describe('calls through an alias binding reach the aliased symbol', () => {
+ let cg: CodeGraph;
+ let dir: string;
+
+ afterEach(() => {
+ if (cg) cg.destroy();
+ if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ const index = async (files: Record): Promise => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-alias-'));
+ for (const [name, content] of Object.entries(files)) {
+ fs.writeFileSync(path.join(dir, name), content);
+ }
+ cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
+ await cg.indexAll();
+ };
+
+ const callersOf = (name: string): string[] => {
+ const target = cg.getNodesByKind('function').find((n) => n.name === name);
+ expect(target, `fixture symbol ${name} was not indexed`).toBeDefined();
+ return cg.getCallers(target!.id).map((c) => c.node.name);
+ };
+
+ it('follows `export const alias = fn`', async () => {
+ await index({
+ 'impl.ts': 'export function realImpl(): number { return 1; }\nexport const aliasName = realImpl;\n',
+ 'consumer.ts': "import { aliasName } from './impl';\nexport function consumerFn(): number { return aliasName(); }\n",
+ });
+ expect(callersOf('realImpl')).toContain('consumerFn');
+ });
+
+ it('follows a local `export { fn as alias }` clause', async () => {
+ // The declaration carries no `export` keyword, so extraction does not flag
+ // it exported — the export index must still bind the renamed export to it.
+ await index({
+ 'impl.ts': 'function realImpl(): number { return 1; }\nexport { realImpl as aliasName };\n',
+ 'consumer.ts': "import { aliasName } from './impl';\nexport function consumerFn(): number { return aliasName(); }\n",
+ });
+ expect(callersOf('realImpl')).toContain('consumerFn');
+ });
+
+ it('follows a function reference held in an object-literal property', async () => {
+ await index({
+ 'impl.ts': 'export function realImpl(): number { return 1; }\nexport const api = { run: realImpl };\n',
+ 'consumer.ts': "import { api } from './impl';\nexport function consumerFn(): number { return api.run(); }\n",
+ });
+ expect(callersOf('realImpl')).toContain('consumerFn');
+ });
+
+ it('follows a same-file alias binding', async () => {
+ await index({
+ 'impl.ts':
+ 'function realImpl(): number { return 1; }\n' +
+ 'const localAlias = realImpl;\n' +
+ 'export function consumerFn(): number { return localAlias(); }\n',
+ });
+ expect(callersOf('realImpl')).toContain('consumerFn');
+ });
+
+ it('leaves a genuine wrapper pointing at the wrapper, not the wrapped function', async () => {
+ // `wrapper` is a real function, not an alias: the call site calls IT.
+ await index({
+ 'impl.ts':
+ 'export function realImpl(): number { return 1; }\n' +
+ 'export const wrapper = (): number => realImpl();\n',
+ 'consumer.ts': "import { wrapper } from './impl';\nexport function consumerFn(): number { return wrapper(); }\n",
+ });
+ expect(callersOf('realImpl')).not.toContain('consumerFn');
+ });
+
+ it('does not hop when the aliased name is ambiguous across files', async () => {
+ // Two same-named callables and no same-file declaration to prefer: a hop
+ // would have to guess, and a wrong edge is worse than a missing one.
+ await index({
+ 'one.ts': 'export function shared(): number { return 1; }\n',
+ 'two.ts': 'export function shared(): number { return 2; }\n',
+ 'alias.ts': "import { shared } from './one';\nexport const aliasName = shared;\n",
+ 'consumer.ts': "import { aliasName } from './alias';\nexport function consumerFn(): number { return aliasName(); }\n",
+ });
+
+ const sharedNodes = cg.getNodesByKind('function').filter((n) => n.name === 'shared');
+ expect(sharedNodes).toHaveLength(2);
+ for (const node of sharedNodes) {
+ expect(cg.getCallers(node.id).map((c) => c.node.name)).not.toContain('consumerFn');
+ }
+ });
+});
diff --git a/__tests__/bare-call-no-method.test.ts b/__tests__/bare-call-no-method.test.ts
new file mode 100644
index 0000000..ba5e1d3
--- /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 0000000..28412de
--- /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__/cli-affected-test-conventions.test.ts b/__tests__/cli-affected-test-conventions.test.ts
new file mode 100644
index 0000000..1887e93
--- /dev/null
+++ b/__tests__/cli-affected-test-conventions.test.ts
@@ -0,0 +1,66 @@
+/**
+ * `codegraph affected` recognises every ecosystem's test-file convention (#1507).
+ *
+ * The command used to carry its own six regexes — `.test.`, `.spec.`,
+ * `/tests/`… — so a Go `foo_test.go`, a Python `test_foo.py` or a JVM
+ * `FooTest.kt` beside the changed file was never reported, and "no tests
+ * affected" read as "no coverage". It now shares `isTestPath` with search and
+ * the MCP tools. Exercised end-to-end against the built binary.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { execFileSync } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+function affected(cwd: string, args: string[]): string[] {
+ const out = execFileSync(process.execPath, [BIN, 'affected', ...args, '--quiet', '-p', cwd], {
+ encoding: 'utf-8',
+ env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
+}
+
+describe('codegraph affected — test-file conventions (#1507)', () => {
+ let dir: string;
+
+ beforeAll(async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-affected-conv-'));
+ const w = (rel: string, body: string) => {
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
+ fs.writeFileSync(path.join(dir, rel), body);
+ };
+ w('go.mod', 'module example.com/demo\n\ngo 1.22\n');
+ w('math.go', 'package demo\n\nfunc Add(a, b int) int { return a + b }\n');
+ w('math_test.go', 'package demo\n\nimport "testing"\n\nfunc TestAdd(t *testing.T) { if Add(1, 2) != 3 { t.Fatal("boom") } }\n');
+ w('pkg/calc.py', 'def add(a, b):\n return a + b\n');
+ w('pkg/test_calc.py', 'from pkg.calc import add\n\ndef test_add():\n assert add(1, 2) == 3\n');
+ w('src/main/kotlin/app/Calc.kt', 'package app\n\nclass Calc {\n fun add(a: Int, b: Int): Int = a + b\n}\n');
+ w('src/test/kotlin/app/CalcTest.kt', 'package app\n\nclass CalcTest {\n fun addsNumbers() { Calc().add(1, 2) }\n}\n');
+ const cg = CodeGraph.initSync(dir);
+ await cg.indexAll();
+ cg.close();
+ });
+
+ afterAll(() => {
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('reports the sibling Go _test.go file', () => {
+ expect(affected(dir, ['math.go'])).toEqual(['math_test.go']);
+ });
+
+ it('reports the Python test_ module and the JVM FooTest class', () => {
+ expect(affected(dir, ['pkg/calc.py'])).toEqual(['pkg/test_calc.py']);
+ expect(affected(dir, ['src/main/kotlin/app/Calc.kt'])).toEqual(['src/test/kotlin/app/CalcTest.kt']);
+ });
+
+ it('still honours an explicit --filter glob', () => {
+ expect(affected(dir, ['math.go', '--filter', '*_test.go'])).toEqual(['math_test.go']);
+ expect(affected(dir, ['math.go', '--filter', '*.spec.ts'])).toEqual([]);
+ });
+});
diff --git a/__tests__/cli-definition-grouping.test.ts b/__tests__/cli-definition-grouping.test.ts
new file mode 100644
index 0000000..12f2f6b
--- /dev/null
+++ b/__tests__/cli-definition-grouping.test.ts
@@ -0,0 +1,262 @@
+/** CLI parity with MCP definition grouping and file narrowing (#1512, #1656). */
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { spawnSync } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { ToolHandler } from '../src/mcp/tools';
+import { lookupSymbolNodes } from '../src/graph/symbol-lookup';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+const COMMANDS = ['callers', 'callees', 'impact'] as const;
+type Command = typeof COMMANDS[number];
+let projectRoot: string;
+let cg: CodeGraph;
+let handler: ToolHandler;
+
+function runCli(command: Command, symbol = 'handle', args: string[] = []) {
+ return spawnSync(process.execPath, [BIN, command, '-p', projectRoot, ...args, '--', symbol], {
+ encoding: 'utf-8',
+ env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' },
+ timeout: 30_000,
+ });
+}
+
+function json(command: Command, symbol = 'handle', args: string[] = []) {
+ const result = runCli(command, symbol, [...args, '--json']);
+ expect(result.status, result.stderr).toBe(0);
+ return JSON.parse(result.stdout);
+}
+
+function resultKey(command: Command) {
+ return command === 'impact' ? 'affected' : command;
+}
+
+function write(file: string, source: string) {
+ const absolute = path.join(projectRoot, file);
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
+ fs.writeFileSync(absolute, source);
+}
+
+beforeAll(async () => {
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cli-1512-'));
+ for (const [dir, helper] of [['a', 'alpha'], ['b', 'beta']]) {
+ write(`${dir}/${helper}.js`, `export function ${helper}() { return 1; }\n`);
+ write(`${dir}/svc.js`, `import { ${helper} } from './${helper}.js';\nexport function handle() { return ${helper}(); }\n`);
+ write(`${dir}/main.js`, `import { handle } from './svc.js';\nexport function ${dir}Main() { return handle(); }\nexport function ${dir}Entry() { return ${dir}Main(); }\n`);
+ write(`${dir}/work.js`, `import { shared } from '../shared.js';\nimport { ${helper} } from './${helper}.js';\nexport function work() { shared(); return ${helper}(); }\n`);
+ write(`${dir}/work-caller.js`, `import { work } from './work.js';\nexport function ${dir}Worker() { work(); }\n`);
+ }
+ write('shared.js', 'export function shared() {}\n');
+ write('both.js', "import { work as aWork } from './a/work.js';\nimport { work as bWork } from './b/work.js';\nexport function both() { aWork(); bWork(); }\n");
+ write('quiet-a.js', 'export function quiet() {}\n');
+ write('quiet-b.js', "import { alpha } from './a/alpha.js';\nexport function quiet() { alpha(); }\nexport function wake() { quiet(); }\n");
+ write('scopes.ts', [
+ 'function leftOnly() {}',
+ 'function rightOnly() {}',
+ 'export class Left { run() { leftOnly(); } }',
+ 'export class Right { run() { rightOnly(); } }',
+ ].join('\n'));
+ // Java overloads have separate bodies/nodes; TS signature-only overloads are
+ // intentionally skipped by extraction, so they cannot exercise grouping.
+ write('Overloads.java', [
+ 'public class Overloads {',
+ ' static String stringIdentity(String value) { return value; }',
+ ' static int intIdentity(int value) { return value; }',
+ ' public static String convert(String value) { return stringIdentity(value); }',
+ ' public static int convert(int value) { return intIdentity(value); }',
+ ' public static void convertCaller() { convert(1); convert("value"); }',
+ '}',
+ ].join('\n'));
+ for (let i = 0; i < 55; i++) {
+ write(`crowd/def-${i}.js`, "import { shared } from '../shared.js';\nexport function crowded() { shared(); }\n");
+ }
+ cg = CodeGraph.initSync(projectRoot);
+ await cg.indexAll();
+ handler = new ToolHandler(cg);
+}, 30_000);
+
+afterAll(() => {
+ handler?.closeAll();
+ cg?.close();
+ if (projectRoot) fs.rmSync(projectRoot, { recursive: true, force: true });
+});
+
+describe.each(COMMANDS)('%s definition grouping (#1512)', (command) => {
+ it('attributes every result and graph edge to its definition in JSON', () => {
+ const out = json(command);
+ expect(out.ambiguous).toBe(true);
+ expect(out.aggregation).toBe('union');
+ expect(out.definitions).toHaveLength(2);
+ const key = resultKey(command);
+ for (const [dir, other] of [['a', 'b'], ['b', 'a']]) {
+ const group = out.definitions.find((d: any) => d.definition.filePath === `${dir}/svc.js`);
+ expect(group.definition).toMatchObject({ name: 'handle', kind: 'function', startLine: 2 });
+ expect(group.roots).toHaveLength(1);
+ expect(group[key].length).toBeGreaterThan(0);
+ expect(group[key].every((n: any) => n.filePath.startsWith(`${dir}/`))).toBe(true);
+ expect(JSON.stringify(group)).not.toContain(`"filePath":"${other}/`);
+ const actual = cg.getNodesByName('handle').find(n => n.filePath === `${dir}/svc.js`)!;
+ const expectedNodes = command === 'impact'
+ ? [...cg.getImpactRadius(actual.id, 2).nodes.values()]
+ : cg[command === 'callers' ? 'getCallers' : 'getCallees'](actual.id).map(c => c.node);
+ expect(new Set(group[key].map((n: any) => n.id))).toEqual(new Set(expectedNodes.map(n => n.id)));
+ const ids = new Set([...group.roots, ...group[key].map((n: any) => n.id)]);
+ expect(group.edges.length).toBeGreaterThan(0);
+ for (const edge of group.edges) {
+ expect(ids.has(edge.source)).toBe(true);
+ expect(ids.has(edge.target)).toBe(true);
+ }
+ }
+ });
+
+ it('prints each definition above only its own results', () => {
+ const result = runCli(command);
+ expect(result.status, result.stderr).toBe(0);
+ expect(result.stdout).toContain('2 distinct definitions');
+ expect(result.stdout).toContain('--file');
+ const sections = result.stdout.split(/(?=function handle \(javascript\) — [ab]\/svc\.js:2)/).slice(1);
+ expect(sections).toHaveLength(2);
+ for (const section of sections) {
+ const dir = section.includes('— a/svc.js:2') ? 'a' : 'b';
+ expect(section).toContain(command === 'callees' ? `${dir}/${dir === 'a' ? 'alpha' : 'beta'}.js` : `${dir}/main.js`);
+ expect(section).not.toContain(dir === 'a' ? 'b/' : 'a/');
+ }
+ });
+
+ it.each(['a/svc.js', './a/svc.js'])('--file %s selects the same definition as MCP', async (file) => {
+ const out = json(command, 'handle', ['--file', file]);
+ expect(out.definitions).toHaveLength(1);
+ expect(out.definitions[0].definition.filePath).toBe('a/svc.js');
+ expect(out.targets.every((n: any) => n.filePath === 'a/svc.js')).toBe(true);
+ expect(out.ambiguous).toBe(false);
+ expect(out.filteredOut).toBe(false);
+ expect(out[resultKey(command)].every((n: any) => n.filePath.startsWith('a/'))).toBe(true);
+ const human = runCli(command, 'handle', ['--file', file]).stdout;
+ const mcp = (await handler.execute(`codegraph_${command}`, { symbol: 'handle', file })).content[0]?.text ?? '';
+ for (const text of [human, mcp]) {
+ expect(text).not.toContain('b/');
+ expect(text).not.toContain('distinct definitions');
+ expect(text).toContain(command === 'callees' ? 'a/alpha.js' : 'a/main.js');
+ }
+ });
+
+ it('a suffix matching both files keeps both definitions', () => {
+ const out = json(command, 'handle', ['-f', 'svc.js']);
+ expect(out.definitions).toHaveLength(2);
+ expect(out.filteredOut).toBe(false);
+ });
+
+ it('a non-matching file discloses the fallback in JSON and text', async () => {
+ const note = 'no definition of "handle" matches file "missing.js" — showing all definitions instead.';
+ const out = json(command, 'handle', ['--file', 'missing.js']);
+ expect(out.filteredOut).toBe(true);
+ expect(out.note).toBe(note);
+ expect(out.definitions).toHaveLength(2);
+ expect(runCli(command, 'handle', ['--file', 'missing.js']).stdout).toContain(note);
+ const mcp = await handler.execute(`codegraph_${command}`, { symbol: 'handle', file: 'missing.js' });
+ expect(mcp.content[0]?.text).toContain(note);
+ });
+
+ it('keeps same-file overloads together as MCP does', () => {
+ const out = json(command, 'convert');
+ expect(cg.getNodesByName('convert').length).toBeGreaterThan(1);
+ expect(out.definitions).toHaveLength(1);
+ expect(out.definitions[0].roots.length).toBeGreaterThan(1);
+ expect(out.ambiguous).toBe(false);
+ expect(lookupSymbolNodes(cg, 'convert').ambiguous).toBe(false);
+ expect(out.definitions[0][resultKey(command)].length).toBeGreaterThan(0);
+ });
+
+ it('does not substitute another definition for an unknown qualified name', () => {
+ const out = runCli(command, 'Missing.run');
+ expect(out.status, out.stderr).toBe(0);
+ expect(out.stdout).toContain('Symbol "Missing.run" not found');
+ expect(out.stdout).not.toContain('leftOnly');
+ expect(out.stdout).not.toContain('rightOnly');
+ });
+});
+
+describe('CLI definition boundaries and limits', () => {
+ it('separates different qualified names within the same file', () => {
+ const out = json('callees', 'run', ['--file', 'scopes.ts']);
+ expect(out.definitions).toHaveLength(2);
+ for (const name of ['Left', 'Right']) {
+ const group = out.definitions.find((d: any) => d.definition.qualifiedName === `${name}::run`);
+ expect(group.callees.map((n: any) => n.name)).toEqual([`${name.toLowerCase()}Only`]);
+ }
+ const qualified = json('callees', 'Left.run');
+ expect(qualified.definitions).toHaveLength(1);
+ expect(qualified.callees.map((n: any) => n.name)).toEqual(['leftOnly']);
+ });
+
+ it.each(['callers', 'callees'] as const)('%s includes definitions with no edges', (command) => {
+ const out = json(command, 'quiet');
+ expect(out.definitions).toHaveLength(2);
+ const empty = out.definitions.find((d: any) => d.definition.filePath === 'quiet-a.js');
+ expect(empty[command]).toEqual([]);
+ expect(empty.edges).toEqual([]);
+ expect(empty).toMatchObject({ total: 0, limit: 20, truncated: false });
+ expect(runCli(command, 'quiet').stdout).toContain(`(no ${command})`);
+ });
+
+ it('keeps shared callers and callees in each definition instead of deduplicating across them', () => {
+ for (const command of ['callers', 'callees'] as const) {
+ const out = json(command, 'work');
+ expect(out.definitions).toHaveLength(2);
+ for (const group of out.definitions) {
+ expect(group[command].map((n: any) => n.name)).toContain(command === 'callers' ? 'both' : 'shared');
+ }
+ expect(out[command].filter((n: any) => n.name === (command === 'callers' ? 'both' : 'shared'))).toHaveLength(1);
+ }
+ });
+
+ it.each(['callers', 'callees'] as const)('%s preserves union metadata and limits each definition independently', (command) => {
+ // Callers include the importing file nodes as well as the calling functions.
+ const total = command === 'callers' ? 6 : 3;
+ const perDefinition = command === 'callers' ? 4 : 2;
+ const out = json(command, 'work', ['--limit', '1']);
+ expect(out).toMatchObject({ total, limit: 1, truncated: true });
+ expect(out[command]).toHaveLength(1);
+ for (const group of out.definitions) {
+ expect(group).toMatchObject({ total: perDefinition, limit: 1, truncated: true });
+ expect(group[command]).toHaveLength(1);
+ expect(group.edges).toHaveLength(1);
+ expect(group.edges[0][command === 'callers' ? 'source' : 'target']).toBe(group[command][0].id);
+ }
+ const human = runCli(command, 'work', ['--limit', '1']).stdout;
+ expect(human.split(`Showing 1 of ${perDefinition}; pass --limit to widen.`)).toHaveLength(3);
+ const complete = json(command, 'work', ['--limit', '100']);
+ expect(complete).toMatchObject({ total, limit: 100, truncated: false });
+ for (const group of complete.definitions) {
+ expect(group).toMatchObject({ total: perDefinition, limit: 100, truncated: false });
+ expect(group[command]).toHaveLength(perDefinition);
+ }
+ });
+
+ it('applies impact depth within each definition and reports its own graph counts', () => {
+ for (const depth of [1, 2]) {
+ const out = json('impact', 'handle', ['--depth', String(depth)]);
+ expect(out.depth).toBe(depth);
+ // Each root also has an importing file node at depth one.
+ expect(out.nodeCount).toBe(2 * (depth + 2));
+ expect(out.edgeCount).toBe(2 * (depth + 1));
+ for (const group of out.definitions) {
+ expect(group.nodeCount).toBe(depth + 2);
+ expect(group.affected).toHaveLength(group.nodeCount);
+ expect(group.edgeCount).toBe(depth + 1);
+ expect(group.edges).toHaveLength(group.edgeCount);
+ }
+ }
+ });
+
+ it('enumerates definitions beyond the FTS cap and can narrow to any of them', () => {
+ const out = json('callees', 'crowded');
+ expect(out.definitions).toHaveLength(55);
+ for (const group of out.definitions) expect(group.callees.map((n: any) => n.name)).toEqual(['shared']);
+ const narrowed = json('callees', 'crowded', ['--file', 'crowd/def-54.js']);
+ expect(narrowed.definitions).toHaveLength(1);
+ expect(narrowed.definitions[0].definition.filePath).toBe('crowd/def-54.js');
+ });
+});
diff --git a/__tests__/cli-index-explicit-path.test.ts b/__tests__/cli-index-explicit-path.test.ts
new file mode 100644
index 0000000..c880716
--- /dev/null
+++ b/__tests__/cli-index-explicit-path.test.ts
@@ -0,0 +1,64 @@
+/**
+ * `codegraph index ` rebuilds , never an ancestor (#1524).
+ *
+ * The command used to resolve an uninitialized upward to the nearest
+ * initialized parent and rebuild THAT under a normal "Done" — so
+ * `codegraph index child` from a monorepo re-indexed the whole container and
+ * never said so. An explicit path that is not initialized is now an error that
+ * names the ancestor it would have picked.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { spawnSync } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+function run(cwd: string, args: string[]) {
+ const r = spawnSync(process.execPath, [BIN, ...args], {
+ cwd,
+ encoding: 'utf-8',
+ env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' },
+ });
+ return { status: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
+}
+
+describe('codegraph index (#1524)', () => {
+ let root: string;
+ let parent: string;
+ let child: string;
+
+ beforeAll(async () => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-path-'));
+ parent = path.join(root, 'parent');
+ child = path.join(parent, 'child');
+ fs.mkdirSync(child, { recursive: true });
+ fs.writeFileSync(path.join(parent, 'p.py'), 'def parent_only():\n return 1\n');
+ fs.writeFileSync(path.join(child, 'c.py'), 'def child_only():\n return 2\n');
+ const cg = CodeGraph.initSync(parent);
+ await cg.indexAll();
+ cg.close();
+ });
+
+ afterAll(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('refuses an explicit path that has no index of its own, naming the ancestor it would have rebuilt', () => {
+ const before = fs.statSync(path.join(parent, '.codegraph', 'codegraph.db')).mtimeMs;
+ const r = run(root, ['index', child, '--quiet']);
+ expect(r.status).toBe(1);
+ expect(r.out).toContain(`not initialized in ${child}`);
+ expect(r.out).toContain(parent);
+ // The parent's index was not touched.
+ expect(fs.statSync(path.join(parent, '.codegraph', 'codegraph.db')).mtimeMs).toBe(before);
+ expect(fs.existsSync(path.join(child, '.codegraph'))).toBe(false);
+ });
+
+ it('rebuilds the explicit path when it is initialized, and a bare `index` still resolves upward from a subdirectory', () => {
+ expect(run(root, ['index', parent, '--quiet']).status).toBe(0);
+ expect(run(child, ['index', '--quiet']).status).toBe(0);
+ });
+});
diff --git a/__tests__/cli-parse-warning.test.ts b/__tests__/cli-parse-warning.test.ts
new file mode 100644
index 0000000..e4aa703
--- /dev/null
+++ b/__tests__/cli-parse-warning.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { spawnSync } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+const COLLAPSE_WARNING = 'parse produced no symbols (tree has errors)';
+const SOURCE = `const char* kTemplate = R"FILE_TEMPLATE_V1(
+struct Ignored { int v; };
+)FILE_TEMPLATE_V1";
+
+int after_the_raw_string(int x) {
+ return x + 1;
+}
+`;
+
+describe('CLI parse warnings (#1522)', () => {
+ let root: string;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-parse-warning-'));
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ function run(args: string[]) {
+ const result = spawnSync(process.execPath, [BIN, ...args], {
+ cwd: root,
+ encoding: 'utf-8',
+ timeout: 20_000,
+ env: {
+ ...process.env,
+ CODEGRAPH_NO_DAEMON: '1',
+ CODEGRAPH_WASM_RELAUNCHED: '1',
+ CODEGRAPH_TELEMETRY: '0',
+ NO_COLOR: '1',
+ },
+ });
+ return { status: result.status, out: (result.stdout ?? '') + (result.stderr ?? '') };
+ }
+
+ it('shows a collapsed parse without failing, then stays quiet after a healthy re-index', () => {
+ const sourcePath = path.join(root, 'min.cpp');
+ fs.writeFileSync(sourcePath, SOURCE);
+
+ const collapsed = run(['init', '--yes']);
+ expect(collapsed.status, collapsed.out).toBe(0);
+ expect(collapsed.out).toContain('Indexed 1 files');
+ expect(collapsed.out).toContain(`min.cpp: ${COLLAPSE_WARNING}`);
+
+ fs.writeFileSync(sourcePath, SOURCE.replaceAll('FILE_TEMPLATE_V1', 'FILE_TEMPLATE_V'));
+ const healthy = run(['index']);
+ expect(healthy.status, healthy.out).toBe(0);
+ expect(healthy.out).not.toContain(COLLAPSE_WARNING);
+
+ const query = run(['query', 'after_the_raw_string']);
+ expect(query.status, query.out).toBe(0);
+ expect(query.out).toMatch(/function\s+after_the_raw_string/);
+ }, 30_000);
+});
diff --git a/__tests__/cli-truncation.test.ts b/__tests__/cli-truncation.test.ts
new file mode 100644
index 0000000..d8fbc36
--- /dev/null
+++ b/__tests__/cli-truncation.test.ts
@@ -0,0 +1,101 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { spawnSync } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+function runCli(cwd: string, args: string[]) {
+ return spawnSync(process.execPath, [BIN, ...args, '-p', cwd], {
+ encoding: 'utf-8',
+ env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' },
+ });
+}
+
+describe('CLI truncation reporting (#1639)', () => {
+ let tempDir: string;
+
+ beforeEach(async () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cli-truncation-'));
+ fs.writeFileSync(
+ path.join(tempDir, 'lib.ts'),
+ [
+ 'export function target() {}',
+ 'export function helperA() {}',
+ 'export function helperB() {}',
+ 'export function helperC() {}',
+ 'export function source() { helperA(); helperB(); helperC(); }',
+ 'export function TargetHitOne() {}',
+ 'export function TargetHitTwo() {}',
+ 'export function TargetHitThree() {}',
+ ].join('\n'),
+ );
+ for (let i = 0; i < 3; i++) {
+ fs.writeFileSync(
+ path.join(tempDir, `caller-${i}.ts`),
+ `import { target } from './lib';\nexport function caller${i}() { target(); }\n`,
+ );
+ }
+ const cg = CodeGraph.initSync(tempDir);
+ await cg.indexAll();
+ cg.close();
+ });
+
+ afterEach(() => {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ it('reports exact callers metadata in JSON and human output', () => {
+ const jsonRun = runCli(tempDir, ['callers', 'target', '--limit', '2', '--json']);
+ expect(jsonRun.status).toBe(0);
+ const parsed = JSON.parse(jsonRun.stdout);
+ expect(parsed.callers).toHaveLength(2);
+ expect(parsed.total).toBeGreaterThan(2);
+ expect(parsed.limit).toBe(2);
+ expect(parsed.truncated).toBe(true);
+
+ const humanRun = runCli(tempDir, ['callers', 'target', '--limit', '2']);
+ expect(humanRun.stdout).toMatch(/Callers of "target" \(2 of \d+\):/);
+ expect(humanRun.stdout).toMatch(/Showing 2 of \d+; pass --limit to widen\./);
+
+ const complete = JSON.parse(runCli(tempDir, ['callers', 'target', '--limit', '100', '--json']).stdout);
+ expect(complete.total).toBe(complete.callers.length);
+ expect(complete.limit).toBe(100);
+ expect(complete.truncated).toBe(false);
+ });
+
+ it('reports exact callees metadata in JSON and human output', () => {
+ const jsonRun = runCli(tempDir, ['callees', 'source', '--limit', '2', '--json']);
+ expect(jsonRun.status).toBe(0);
+ const parsed = JSON.parse(jsonRun.stdout);
+ expect(parsed.callees).toHaveLength(2);
+ expect(parsed.total).toBeGreaterThan(2);
+ expect(parsed.limit).toBe(2);
+ expect(parsed.truncated).toBe(true);
+
+ const humanRun = runCli(tempDir, ['callees', 'source', '--limit', '2']);
+ expect(humanRun.stdout).toMatch(/Callees of "source" \(2 of \d+\):/);
+ expect(humanRun.stdout).toMatch(/Showing 2 of \d+; pass --limit to widen\./);
+
+ const complete = JSON.parse(runCli(tempDir, ['callees', 'source', '--limit', '100', '--json']).stdout);
+ expect(complete.total).toBe(complete.callees.length);
+ expect(complete.limit).toBe(100);
+ expect(complete.truncated).toBe(false);
+ });
+
+ it('keeps query --json as an array and reports truncation on stderr', () => {
+ const jsonRun = runCli(tempDir, ['query', 'TargetHit', '--limit', '1', '--json']);
+ expect(jsonRun.status).toBe(0);
+ expect(JSON.parse(jsonRun.stdout)).toHaveLength(1);
+ expect(jsonRun.stderr).toContain('Results truncated at 1; pass --limit to widen.');
+
+ const humanRun = runCli(tempDir, ['query', 'TargetHit', '--limit', '1']);
+ expect(humanRun.stdout).toContain('Results truncated at 1; pass --limit to widen.');
+
+ const complete = runCli(tempDir, ['query', 'TargetHit', '--limit', '100', '--json']);
+ expect(Array.isArray(JSON.parse(complete.stdout))).toBe(true);
+ expect(complete.stderr).not.toContain('Results truncated');
+ });
+});
diff --git a/__tests__/commonjs-exports.test.ts b/__tests__/commonjs-exports.test.ts
new file mode 100644
index 0000000..eaadf22
--- /dev/null
+++ b/__tests__/commonjs-exports.test.ts
@@ -0,0 +1,71 @@
+/**
+ * CommonJS export assignments name the function they hold (#1675).
+ *
+ * `exports.getItems = async (req, res) => {…}` and `module.exports.x =
+ * function () {…}` are how Express controllers are commonly written. The
+ * arrow is anonymous only syntactically — the export property is the name
+ * every `router.get('/items', getItems)` resolves — so it gets the same
+ * treatment `const getItems = () => {}` already has: a function node, exported,
+ * with its calls attributed to it rather than to the file.
+ */
+import { describe, it, expect, beforeAll } from 'vitest';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+beforeAll(async () => {
+ await initGrammars();
+ await loadAllGrammars();
+});
+
+const refsFrom = (result: ReturnType, id: string) =>
+ result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => r.referenceName);
+
+describe('CommonJS export assignments', () => {
+ it('indexes exports.X / module.exports.X functions as exported function nodes', () => {
+ const code = `
+const { findItems, removeItem } = require('./db');
+
+exports.getItems = async (req, res) => {
+ res.json(await findItems());
+};
+
+module.exports.deleteItem = function (req, res) {
+ removeItem(req.params.id);
+ res.end();
+};
+
+exports.plain = 42;
+module.exports = { legacy: 1 };
+`;
+ const result = extractFromSource('src/controller.js', code);
+ const fns = result.nodes.filter((n) => n.kind === 'function');
+ expect(fns.map((n) => n.name).sort()).toEqual(['deleteItem', 'getItems']);
+
+ const getItems = fns.find((n) => n.name === 'getItems')!;
+ const deleteItem = fns.find((n) => n.name === 'deleteItem')!;
+ expect(getItems.startLine).toBe(4);
+ expect(getItems.isExported).toBe(true);
+ expect(deleteItem.isExported).toBe(true);
+ expect(getItems.isAsync).toBe(true);
+
+ // The handlers' calls are their own, not the file's.
+ expect(refsFrom(result, getItems.id)).toContain('findItems');
+ expect(refsFrom(result, deleteItem.id)).toContain('removeItem');
+ const file = result.nodes.find((n) => n.kind === 'file')!;
+ expect(refsFrom(result, file.id)).not.toContain('findItems');
+ expect(refsFrom(result, file.id)).not.toContain('removeItem');
+
+ // A non-function export is not a function, and nothing is left anonymous.
+ expect(result.nodes.map((n) => n.name)).not.toContain('');
+ });
+
+ it('leaves other member assignments alone', () => {
+ const code = `
+const handlers = {};
+handlers.onSave = () => { persist(); };
+app.locals.format = function () { return 1; };
+`;
+ const result = extractFromSource('src/other.js', code);
+ expect(result.nodes.filter((n) => n.kind === 'function')).toEqual([]);
+ });
+});
diff --git a/__tests__/cpp-raw-string-delimiter-haserror.test.ts b/__tests__/cpp-raw-string-delimiter-haserror.test.ts
new file mode 100644
index 0000000..04166ef
--- /dev/null
+++ b/__tests__/cpp-raw-string-delimiter-haserror.test.ts
@@ -0,0 +1,77 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+import { extractFromSource } from '../src/extraction';
+import { getParser, initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
+
+function rawStringSource(delimiter: string): string {
+ return `const char* kTemplate = R"${delimiter}(
+struct Ignored { int v; };
+)${delimiter}";
+
+int after_the_raw_string(int x) {
+ return x + 1;
+}
+`;
+}
+
+describe('C++ raw-string delimiter parse collapse (#1522)', () => {
+ beforeAll(async () => {
+ await initGrammars();
+ await loadGrammarsForLanguages(['cpp', 'c']);
+ });
+
+ it('warns when a legal 16-character delimiter swallows every symbol', () => {
+ const result = extractFromSource('min.cpp', rawStringSource('FILE_TEMPLATE_V1'));
+
+ // The vendored tree-sitter-cpp scanner currently rejects the standard's
+ // maximum delimiter length, consuming the following function as ERROR.
+ expect(result.nodes.filter((n) => n.kind === 'function')).toEqual([]);
+ expect(result.nodes.map((n) => n.kind)).toEqual(['file']);
+ expect(result.errors).toEqual([
+ {
+ message:
+ 'min.cpp: parse produced no symbols (tree has errors) — ' +
+ 'the file is indexed but contributes nothing to the graph',
+ severity: 'warning',
+ code: 'parse_error',
+ },
+ ]);
+ });
+
+ it('extracts the function after a 15-character delimiter without warning', () => {
+ const result = extractFromSource('min.cpp', rawStringSource('FILE_TEMPLATE_V'));
+
+ expect(result.nodes.filter((n) => n.kind === 'function').map((n) => n.name))
+ .toEqual(['after_the_raw_string']);
+ expect(result.errors).toEqual([]);
+ });
+
+ it.each(['min.cpp', 'min.c', 'min.h'])('does not warn on a healthy include-only %s', (filePath) => {
+ const result = extractFromSource(filePath, '#include \n#include \n');
+
+ expect(result.nodes.filter((n) => n.kind !== 'file' && n.kind !== 'import')).toEqual([]);
+ expect(result.errors).toEqual([]);
+ });
+
+ it('does not warn on a healthy empty file with zero symbols', () => {
+ const result = extractFromSource('empty.cpp', '');
+
+ expect(result.nodes.map((n) => n.kind)).toEqual(['file']);
+ expect(result.errors).toEqual([]);
+ });
+
+ it('does not warn on parse errors when a function survives', () => {
+ const source = 'int before_the_raw_string() { return 0; }\n' + rawStringSource('FILE_TEMPLATE_V1');
+ const tree = getParser('cpp')!.parse(source)!;
+ try {
+ expect(tree.rootNode.hasError).toBe(true);
+ } finally {
+ tree.delete();
+ }
+
+ const result = extractFromSource('min.cpp', source);
+
+ expect(result.nodes.filter((n) => n.kind === 'function').map((n) => n.name))
+ .toEqual(['before_the_raw_string']);
+ expect(result.errors).toEqual([]);
+ });
+});
diff --git a/__tests__/cpp-raw-string-preparse-1505.test.ts b/__tests__/cpp-raw-string-preparse-1505.test.ts
new file mode 100644
index 0000000..982e802
--- /dev/null
+++ b/__tests__/cpp-raw-string-preparse-1505.test.ts
@@ -0,0 +1,177 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+import { extractFromSource } from '../src/extraction';
+import { getParser, initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
+import {
+ blankCppAnnotationMacroCalls,
+ blankCppInlineAnnotationMacros,
+ blankCStatementMacroCalls,
+ blankCTypeKeywordArgs,
+ blankCFileScopePrefixedDeclMacros,
+ blankCParameterizedAnnotationMacros,
+ blankCDesignatedMacroArgs,
+ cExtractor,
+ cppExtractor,
+} from '../src/extraction/languages/c-cpp';
+
+// The original parses cleanly; #1505 is preParse corrupting its short delimiter,
+// distinct from the vendored grammar's 16-character delimiter error (#1522).
+function scaffoldSource(delimiter = 'GEN'): string {
+ return `#include
+
+namespace {
+const char* kTpl = R"${delimiter}(
+DECLARE_THING(
+struct Ignored { int v; };
+int nested_fn() { return 1; }
+)${delimiter}";
+}
+
+int create_scaffold(int x) {
+ return x;
+}
+
+int helper_after(int y) {
+ return y + 1;
+}
+`;
+}
+
+const annotationBlankers = [
+ { name: 'line-leading annotations', blank: blankCppAnnotationMacroCalls },
+ { name: 'inline annotations', blank: blankCppInlineAnnotationMacros },
+];
+
+describe('C/C++ raw strings survive preParse (#1505)', () => {
+ beforeAll(async () => {
+ await initGrammars();
+ await loadGrammarsForLanguages(['cpp']);
+ });
+
+ it('indexes both functions after the anonymous namespace without introducing a parse error', () => {
+ const source = scaffoldSource();
+ const rewritten = cppExtractor.preParse!(source, 'scaffold.cpp');
+ for (const text of [source, rewritten]) {
+ const tree = getParser('cpp')!.parse(text)!;
+ try {
+ expect(tree.rootNode.hasError).toBe(false);
+ } finally {
+ tree.delete();
+ }
+ }
+ const result = extractFromSource('scaffold.cpp', source);
+ expect(result.nodes.filter((node) => node.kind === 'function').map((node) => node.name))
+ .toEqual(['create_scaffold', 'helper_after']);
+ expect(result.nodes.some((node) => node.name === 'Ignored')).toBe(false);
+ expect(result.errors).toEqual([]);
+ expect(rewritten).toBe(source);
+ });
+
+ it('keeps the raw-string terminator at its original offset', () => {
+ const source = scaffoldSource('TAG');
+ const closer = source.indexOf(')TAG"');
+ const blanked = blankCppAnnotationMacroCalls(source);
+ expect(blanked.slice(closer, closer + 5)).toBe(')TAG"');
+ expect(blanked).toBe(source);
+ });
+
+ describe.each(annotationBlankers)('$name', ({ blank }) => {
+ it.each(['R', 'LR', 'u8R', 'uR', 'UR'])('leaves %s raw-string contents untouched', (prefix) => {
+ const source = `const auto* text = ${prefix}"TAG(
+DECLARE_THING(
+"quoted ) text" and 'characters' and a backslash \\
+)OTHER"
+value UPARAM(ref) UE_DEPRECATED(
+)TAG";
+`;
+ expect(blank(source)).toBe(source);
+ });
+
+ it.each(['', 'FIFTEEN_CHARS__', 'SIXTEEN_CHARS___'])('protects delimiter %j with CRLF', (delimiter) => {
+ const source = `const char* text = R"${delimiter}(\r\nUE_DEPRECATED(\r\n)${delimiter}";\r\n`;
+ expect(blank(source)).toBe(source);
+ });
+
+ it('leaves an unterminated raw string untouched', () => {
+ const source = 'const char* text = R"TAG(\nUE_DEPRECATED(1)\nint example;';
+ expect(blank(source)).toBe(source);
+ });
+ });
+
+ it.each([
+ { name: 'line-leading', blank: blankCppAnnotationMacroCalls, head: '', macro: 'ANNOTATE', tail: '\nint helper_after() { return 1; }\n' },
+ { name: 'inline', blank: blankCppInlineAnnotationMacros, head: 'using Alias ', macro: 'UE_DEPRECATED', tail: ' = int;\n' },
+ { name: 'C parameterized', blank: blankCParameterizedAnnotationMacros, head: 'static void ', macro: '__section', tail: ' helper_after(void) {}\n' },
+ { name: 'C iterator', blank: blankCStatementMacroCalls, head: 'void iterate() {\n ', macro: 'for_each_item', tail: ' {\n visit();\n }\n}\n' },
+ ])('balances a genuine $name macro containing a raw-string argument', ({ blank, head, macro, tail }) => {
+ const annotation = `${macro}(R"TAG(" ) unbalanced ( " \\
+UE_DEPRECATED(
+)TAG")`;
+ expect(blank(head + annotation + tail))
+ .toBe(head + annotation.replace(/[^\r\n]/g, ' ') + tail);
+ });
+
+ it('balances a C declaration macro with a raw-string argument', () => {
+ const macro = 'static DECLARE_THING(R"TAG(" ) unbalanced ( ")TAG");';
+ const tail = '\nint helper_after(void) {}\n';
+ expect(blankCFileScopePrefixedDeclMacros(macro + tail))
+ .toBe(' '.repeat(macro.length) + tail);
+ });
+
+ it('preserves a raw argument while blanking a later C type-keyword argument', () => {
+ const literal = 'R"TAG(" ), struct Fake, ( ")TAG"';
+ const source = `take(${literal}, struct RealType);`;
+ expect(blankCTypeKeywordArgs(source)).toBe(`take(${literal}, RealType);`);
+ });
+
+ it('only counts designators outside raw arguments when blanking a C macro call', () => {
+ const literal = 'R"TAG(" ) .fake = 1 ( ")TAG"';
+ const head = 'void reset(void) {\n RESET_THING(';
+ const tail = ');\n}\n';
+ expect(blankCDesignatedMacroArgs(head + literal + tail)).toBe(head + literal + tail);
+ const args = literal + ', .field = 1';
+ expect(blankCDesignatedMacroArgs(head + args + tail))
+ .toBe(head + ' '.repeat(args.length) + tail);
+ });
+
+ it.each([
+ { name: 'C iterator macros', blank: blankCStatementMacroCalls },
+ { name: 'C type arguments', blank: blankCTypeKeywordArgs },
+ { name: 'C declaration macros', blank: blankCFileScopePrefixedDeclMacros },
+ { name: 'C parameterized annotations', blank: blankCParameterizedAnnotationMacros },
+ { name: 'C designated initializer arguments', blank: blankCDesignatedMacroArgs },
+ { name: 'C preParse', blank: (source: string) => cExtractor.preParse!(source) },
+ { name: 'C++ preParse', blank: (source: string) => cppExtractor.preParse!(source, 'template.cpp') },
+ ])('$name leaves macro-like raw-string contents untouched', ({ blank }) => {
+ const source = `const auto* text = u8R"TAG(
+ for_each_item(item, list) {
+ visit(item);
+ }
+static DECLARE_THING(value);
+use(struct Example);
+ RESET_THING(.field = 1);
+class EXAMPLE_API Example {
+FORCEINLINE int example() {}
+};
+FMT_BEGIN_NAMESPACE
+int example;
+__section(
+)TAG";
+`;
+ expect(blank(source)).toBe(source);
+ });
+
+ it('ignores raw-string openers in comments and ordinary literals, then resumes blanking after a real raw string', () => {
+ const before = [
+ '// R"COMMENT(',
+ '/* LR"COMMENT( */',
+ 'const char* quoted = "escaped R\\"STRING(";',
+ "const auto digit = 1'000;",
+ 'const char quote = \'"\';',
+ scaffoldSource(),
+ ].join('\n');
+ const annotation = 'UPROPERTY(EditAnywhere)';
+ const tail = '\nint actual_field;\n';
+ expect(blankCppAnnotationMacroCalls(before + annotation + tail))
+ .toBe(before + ' '.repeat(annotation.length) + tail);
+ });
+});
diff --git a/__tests__/cross-file-visibility.test.ts b/__tests__/cross-file-visibility.test.ts
new file mode 100644
index 0000000..1cb0007
--- /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__/explore-cross-call-dedup.test.ts b/__tests__/explore-cross-call-dedup.test.ts
index 608d85c..ce208dc 100644
--- a/__tests__/explore-cross-call-dedup.test.ts
+++ b/__tests__/explore-cross-call-dedup.test.ts
@@ -28,6 +28,7 @@ import { ExploreSessionState, type ExploreProjectState } from '../src/mcp/explor
import {
EXPLORE_DEDUP,
dedupeRange,
+ exploreDedupEnabled,
fileFingerprint,
formatBackReference,
intersectRange,
@@ -41,6 +42,27 @@ const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go');
const QUERY = 'how does payroll cycle create and calculate payslips?';
const POINTER = 'Already sent earlier in this conversation';
+describe('dedup configuration', () => {
+ it('defaults off and requires an explicit truthy opt-in', () => {
+ const previous = process.env.CODEGRAPH_EXPLORE_DEDUP;
+ try {
+ delete process.env.CODEGRAPH_EXPLORE_DEDUP;
+ expect(exploreDedupEnabled()).toBe(false);
+ for (const enabled of ['1', 'true', 'on', 'yes', ' YES ']) {
+ process.env.CODEGRAPH_EXPLORE_DEDUP = enabled;
+ expect(exploreDedupEnabled()).toBe(true);
+ }
+ for (const disabled of ['0', 'false', 'off', 'no', 'unexpected']) {
+ process.env.CODEGRAPH_EXPLORE_DEDUP = disabled;
+ expect(exploreDedupEnabled()).toBe(false);
+ }
+ } finally {
+ if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP;
+ else process.env.CODEGRAPH_EXPLORE_DEDUP = previous;
+ }
+ });
+});
+
/** A prior-state shaped like the session tracker's, for the algebra tests. */
function prior(files: Array<{ path: string; ranges: Array<[number, number]>; fingerprint?: string }>): ExploreProjectState {
return {
@@ -183,8 +205,11 @@ describe('a second call against a real index', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
+ let previousDedup: string | undefined;
beforeAll(async () => {
+ previousDedup = process.env.CODEGRAPH_EXPLORE_DEDUP;
+ process.env.CODEGRAPH_EXPLORE_DEDUP = '1';
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg18-'));
fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
@@ -194,6 +219,8 @@ describe('a second call against a real index', () => {
}, 120_000);
afterAll(() => {
+ if (previousDedup === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP;
+ else process.env.CODEGRAPH_EXPLORE_DEDUP = previousDedup;
if (cg) cg.destroy();
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
@@ -328,6 +355,23 @@ describe('a second call against a real index', () => {
}
}, 120_000);
+ it('re-serves source by default when a connection may outlive the current context', async () => {
+ const session = new ExploreSessionState();
+ const previous = process.env.CODEGRAPH_EXPLORE_DEDUP;
+ delete process.env.CODEGRAPH_EXPLORE_DEDUP;
+ try {
+ const first = await explore(QUERY, session);
+ const second = await explore(QUERY, session);
+ expect(second).toBe(first);
+ expect(second).not.toContain(POINTER);
+ expect([...fencedLines(second).values()].reduce((sum, lines) => sum + lines.size, 0))
+ .toBeGreaterThan(20);
+ } finally {
+ if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP;
+ else process.env.CODEGRAPH_EXPLORE_DEDUP = previous;
+ }
+ }, 120_000);
+
it('reports the reclaimed bytes through the CG-4 diagnostic', async () => {
const sidecar = path.join(testDir, 'cg18-diagnostic.jsonl');
const session = new ExploreSessionState();
diff --git a/__tests__/explore-declaration-only.test.ts b/__tests__/explore-declaration-only.test.ts
index 004711f..ab97488 100644
--- a/__tests__/explore-declaration-only.test.ts
+++ b/__tests__/explore-declaration-only.test.ts
@@ -96,15 +96,36 @@ describe('CG-28 — a declaration-only file does not outrank implementation on a
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
+ /**
+ * Type-level for the purposes of this gate: a type declaration, or a member
+ * an interface declares.
+ *
+ * The second half is not a loosening. Since #1638 a `method_signature` /
+ * `property_signature` is indexed as a `method` / `property` node, so a file
+ * of nothing but interfaces no longer reads as nothing but `interface` kinds
+ * — but a bodiless signature is on the same side of the line as the interface
+ * that owns it, which is exactly how `getAmbientDeclarationPathsAmong` counts
+ * it. What this still catches, and is here to catch, is a `function` or a
+ * `class` creeping into the fixture: that would silently exempt the file and
+ * make every assertion below vacuous.
+ */
+ const isTypeLevel = (n: { id: string; kind: string }, filePath: string): boolean => {
+ if (n.kind === 'interface' || n.kind === 'type_alias') return true;
+ if (n.kind !== 'method' && n.kind !== 'property') return false;
+ const interfaceIds = new Set(
+ cg.getNodesInFile(filePath).filter((x) => x.kind === 'interface').map((x) => x.id),
+ );
+ return cg.getIncomingEdges(n.id)
+ .some((e) => e.kind === 'contains' && interfaceIds.has(e.source));
+ };
+
describe('fixture shape — if this rots, the gate below means nothing', () => {
it('holds two declaration-only files that differ only in the banner', () => {
for (const p of [HANDWRITTEN_DECL, GENERATED_DECL]) {
const nodes = cg.getNodesInFile(p).filter((n) => n.kind !== 'file' && n.kind !== 'import');
expect(nodes.length, `${p} declares nothing`).toBeGreaterThan(10);
- // Every symbol type-level, nothing with a body — the structural test the
- // penalty keys on. A `function`/`class` creeping in would silently exempt
- // the file and make every assertion below vacuous.
- expect(nodes.every((n) => n.kind === 'interface' || n.kind === 'type_alias'), `${p} has a non-type symbol`).toBe(true);
+ // Nothing with a body — the structural test the penalty keys on.
+ expect(nodes.every((n) => isTypeLevel(n, p)), `${p} has a non-type symbol`).toBe(true);
}
// Only one of them announces itself, so the CG-25 penalty is the ONLY
// difference between the two — that is what makes them comparable.
@@ -119,7 +140,7 @@ describe('CG-28 — a declaration-only file does not outrank implementation on a
// structure of any answer about that code.
const nodes = cg.getNodesInFile(SHARED_TYPES).filter((n) => n.kind !== 'file' && n.kind !== 'import');
expect(nodes.length).toBeGreaterThan(0);
- expect(nodes.every((n) => n.kind === 'interface' || n.kind === 'type_alias')).toBe(true);
+ expect(nodes.every((n) => isTypeLevel(n, SHARED_TYPES))).toBe(true);
expect(cg.getFile(SHARED_TYPES)?.generated).toBeFalsy();
});
@@ -176,6 +197,23 @@ describe('CG-28 — a declaration-only file does not outrank implementation on a
expect(isAmbient(SHARED_TYPES)).toBe(false);
expect(isAmbient(HANDWRITTEN_DECL)).toBe(true);
});
+
+ it('still flags a shim whose interfaces now contribute method/property nodes', () => {
+ // The silent-failure guard for #1638. Interface members are indexed, so a
+ // pure-interface `.d.ts` no longer holds only `interface` kinds — and the
+ // ambient rule is spelled as "EVERY declared symbol is type-level". Read
+ // literally that stops flagging the moment the extractor improves, and
+ // nothing else fails: the file just quietly ranks undamped again.
+ //
+ // Pinned from both ends on purpose. The `toBeGreaterThan(0)` half is what
+ // keeps the other half honest — assert only the flag and this test would
+ // still pass on an index where the members were never extracted at all,
+ // which is precisely the state it exists to detect a regression FROM.
+ const members = cg.getNodesInFile(HANDWRITTEN_DECL)
+ .filter((n) => n.kind === 'method' || n.kind === 'property');
+ expect(members.length, 'interface members are not indexed — see #1638').toBeGreaterThan(0);
+ expect(cg.ambientDeclarationFilePredicate([HANDWRITTEN_DECL])(HANDWRITTEN_DECL)).toBe(true);
+ });
});
describe('the counter-case — a query that NAMES a declared type', () => {
diff --git a/__tests__/explore-elided-symbol-names.test.ts b/__tests__/explore-elided-symbol-names.test.ts
new file mode 100644
index 0000000..e8b8ab9
--- /dev/null
+++ b/__tests__/explore-elided-symbol-names.test.ts
@@ -0,0 +1,153 @@
+/**
+ * Regression for #1711 — when codegraph_explore trims a file, elided symbols
+ * must be named (gap markers + header bias), not left as a bare `... (gap) ...`
+ * while the footer asks for "exact names" the model was never given.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import {
+ ToolHandler,
+ formatGapMarker,
+ symbolsBetweenRanges,
+ biasHeaderSymbols,
+ joinPartsWithNamedGaps,
+} from '../src/mcp/tools';
+
+describe('#1711 helpers — name what a trim dropped', () => {
+ it('formatGapMarker stays bare when the hole has no symbols', () => {
+ expect(formatGapMarker('a.ts', [])).toBe('\n\n... (gap) ...\n\n');
+ });
+
+ it('formatGapMarker lists name (file:line) for elided symbols', () => {
+ const marker = formatGapMarker('src/obs.ts', [
+ { name: 'syncStateNow', kind: 'method', startLine: 1913 },
+ { name: 'performHeavyDraftSync', kind: 'method', startLine: 1867 },
+ ]);
+ expect(marker).toContain('syncStateNow (src/obs.ts:1913)');
+ expect(marker).toContain('performHeavyDraftSync (src/obs.ts:1867)');
+ expect(marker).toMatch(/\.\.\. \(gap: .+\) \.\.\./);
+ });
+
+ it('symbolsBetweenRanges only returns defs that start in the hole', () => {
+ const nodes = [
+ { name: 'keep', kind: 'method', startLine: 10, endLine: 20 },
+ { name: 'elided', kind: 'method', startLine: 30, endLine: 40 },
+ { name: 'also', kind: 'method', startLine: 45, endLine: 50 },
+ { name: 'later', kind: 'method', startLine: 60, endLine: 70 },
+ { name: 'imp', kind: 'import', startLine: 35, endLine: 35 },
+ ];
+ const hit = symbolsBetweenRanges(nodes, 20, 60);
+ expect(hit.map((h) => h.name)).toEqual(['elided', 'also']);
+ });
+
+ it('biasHeaderSymbols prefers elided labels over frequency alone', () => {
+ const { shown } = biasHeaderSymbols(
+ [
+ 'imports0(method)', 'imports0(method)', 'imports0(method)',
+ 'imports1(method)', 'imports1(method)',
+ 'noise(method)',
+ ],
+ [{ name: 'syncStateNow', kind: 'method', startLine: 100 }],
+ 3,
+ );
+ expect(shown[0]).toBe('syncStateNow(method)');
+ expect(shown).toContain('imports0(method)');
+ });
+
+ it('joinPartsWithNamedGaps annotates the hole between parts', () => {
+ const text = joinPartsWithNamedGaps(
+ 'f.ts',
+ [
+ { range: { start: 1, end: 5 }, text: 'ONE' },
+ { range: { start: 40, end: 45 }, text: 'TWO' },
+ ],
+ [{ name: 'mid', kind: 'function', startLine: 20, endLine: 25 }],
+ );
+ expect(text).toContain('ONE');
+ expect(text).toContain('TWO');
+ expect(text).toContain('mid (f.ts:20)');
+ });
+});
+
+describe('#1711 explore — trimmed file names its elisions', () => {
+ let dir: string;
+ let cg: CodeGraph;
+ let response: string;
+
+ beforeAll(async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1711-'));
+ fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"cg1711","version":"1.0.0"}\n');
+ const srcDir = path.join(dir, 'src');
+ fs.mkdirSync(srcDir);
+ // One large observer + noise files so the budget trims rather than shipping whole.
+ const lines: string[] = ['export class EspnDraftObserver {'];
+ for (let i = 0; i < 40; i++) {
+ lines.push(` imports${i}() { return ${i}; }`, '');
+ }
+ const big = (name: string, next: string | null, n: number) => {
+ lines.push(` ${name}() {`);
+ lines.push(` const marker = "${name}_MARKER";`);
+ for (let j = 0; j < n; j++) lines.push(` const x${j} = ${j} + marker.length;`);
+ lines.push(next ? ` return this.${next}();` : ' return marker;');
+ lines.push(' }', '');
+ };
+ big('persistDraftState', null, 60);
+ big('performHeavyDraftSync', 'persistDraftState', 60);
+ big('syncStateNow', 'performHeavyDraftSync', 60);
+ big('scrapeFullDraftState', 'syncStateNow', 60);
+ for (let i = 0; i < 30; i++) {
+ lines.push(` calls${i}() { return ${i}; }`, '');
+ }
+ lines.push('}', '');
+ fs.writeFileSync(path.join(srcDir, 'espn-draft-observer.ts'), lines.join('\n'));
+ for (let i = 1; i <= 20; i++) {
+ fs.writeFileSync(path.join(srcDir, `noise${i}.ts`), `export const n${i} = ${i};\n`);
+ }
+
+ cg = CodeGraph.initSync(dir);
+ await cg.indexAll();
+ const result = await new ToolHandler(cg).execute('codegraph_explore', {
+ query:
+ 'In this repos ESPN draft observer (espn-draft-observer.ts), name in order the chain of methods from scrapeFullDraftState to the method that calls storage.saveDraftState. One line.',
+ });
+ response = result.content?.[0]?.text ?? '';
+ }, 120_000);
+
+ afterAll(() => {
+ cg?.destroy();
+ if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('still renders the observer file (ranker chooses the right file)', () => {
+ expect(response).toContain('espn-draft-observer.ts');
+ });
+
+ it('names elided symbols inside gap markers as name (file:line)', () => {
+ // Match only in-fence gap markers that list at least one path:line ref.
+ const namedGaps = response.match(/\.\.\. \(gap: [^\n]*?\([^\n]+?:\d+\)[^\n]*?\) \.\.\./g) ?? [];
+ expect(namedGaps.length, `response head:\n${response.slice(0, 2000)}`).toBeGreaterThan(0);
+ for (const g of namedGaps) {
+ expect(g).toMatch(/\w+ \([^\s)]+:\d+\)/);
+ }
+ });
+
+ it('footer points at named gaps / header instead of asking for unknown names', () => {
+ if (!response.includes('trimmed for size')) return;
+ expect(response).toMatch(/preferred in the file header|named inside gap markers/);
+ });
+
+ it('biases the file header away from filler-only when symbols were elided', () => {
+ const header = response.split('\n').find((l) => l.includes('**`src/espn-draft-observer.ts`**'));
+ expect(header).toBeDefined();
+ // Either the header names a chain method, or a named gap does — never
+ // neither while the footer asks for exact names.
+ const namesAnswer = /syncStateNow|performHeavyDraftSync|persistDraftState|scrapeFullDraftState/;
+ const namedSomewhere =
+ namesAnswer.test(header!) ||
+ namesAnswer.test(response);
+ expect(namedSomewhere).toBe(true);
+ });
+});
diff --git a/__tests__/explore-output-budget.test.ts b/__tests__/explore-output-budget.test.ts
index 9d9a0b3..efe302f 100644
--- a/__tests__/explore-output-budget.test.ts
+++ b/__tests__/explore-output-budget.test.ts
@@ -6,7 +6,7 @@
* grep+Read. These tests pin the per-tier budget shape so future tuning
* doesn't silently drift the small-project case back into bloat.
*/
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -198,7 +198,26 @@ describe('codegraph_explore output respects the adaptive budget', () => {
const text = result.content?.[0]?.text ?? '';
expect(text).not.toContain('### Additional relevant files');
expect(text).not.toContain('Complete source code is included above');
- expect(text).not.toContain('Explore budget:');
+ expect(text).not.toContain('advisory only, NOT a quota');
+ });
+
+ it('emits advisory-only exploration guidance on medium projects — never quota wording', async () => {
+ // Medium tier (500–4,999 files) turns the guidance note on. The synthetic
+ // project is tiny, so fake the stats to land in that tier — the note's
+ // WORDING is what this test pins. Regression guard: quota phrasing
+ // ("remaining calls" / "Synthesize once") must never come back — agents
+ // read it as a hard cap, stop exploring early, and fall back to grep+Read.
+ const spy = vi.spyOn(cg, 'getStats').mockReturnValue({ fileCount: 1000 } as ReturnType);
+ try {
+ const result = await handler.execute('codegraph_explore', { query: 'Session method helper' });
+ const text = result.content?.[0]?.text ?? '';
+ expect(text).toContain('advisory only, NOT a quota');
+ expect(text).toContain('extra calls are never rejected or rate-limited');
+ expect(text).not.toContain('remaining calls');
+ expect(text).not.toContain('Synthesize once');
+ } finally {
+ spy.mockRestore();
+ }
});
it('still includes the Relationships section — it is the cheapest structural signal', async () => {
diff --git a/__tests__/expo-router.test.ts b/__tests__/expo-router.test.ts
index f4ecda8..3463e11 100644
--- a/__tests__/expo-router.test.ts
+++ b/__tests__/expo-router.test.ts
@@ -560,7 +560,8 @@ describe('expo-router: end-to-end', () => {
const detail = screens.screens.find((s) => s.path === '/object-detail')!;
const tap = screens.links.find((l) => l.from === home.id && l.to === detail.id)!;
expect(tap).toBeDefined();
- expect(tap.via.map((v) => v.name)).toEqual(['ItemCard', 'openObjectDetail']);
+ // `handlePress` is a symbol of its own (#1669), so the tap passes through it.
+ expect(tap.via.map((v) => v.name)).toEqual(['ItemCard', 'handlePress', 'openObjectDetail']);
expect(tap.when).toBe('props.collected');
expect(tap.sites[0]!.href).toBe('/object-detail?detectionItem=${…}');
// Navigation nothing on a screen reaches is an origin, not dropped: the
diff --git a/__tests__/extraction-old-git.test.ts b/__tests__/extraction-old-git.test.ts
new file mode 100644
index 0000000..38f6239
--- /dev/null
+++ b/__tests__/extraction-old-git.test.ts
@@ -0,0 +1,107 @@
+/**
+ * Regression: git older than 2.36 rejects `ls-files -s --recurse-submodules` (#1549).
+ *
+ * Kept in its own file rather than appended to extraction.test.ts: that suite
+ * loads every tree-sitter grammar in `beforeAll`, and running a git-scan case
+ * after it pushed the worker past its memory ceiling.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { execFileSync } from 'child_process';
+import { scanDirectory } from '../src/extraction';
+
+function createTempDir(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-'));
+}
+
+// git < 2.36 rejects `ls-files -s --recurse-submodules` outright: the guard in
+// builtin/ls-files.c listed `show_stage` among the modes that die, and it was
+// only dropped in 2.36. The die is unconditional — it does not check whether the
+// repo has submodules — so on Ubuntu 22.04 (git 2.34.1), Debian 11 (2.30.2) and
+// older, every call threw, `getGitVisibleFiles` swallowed it, and the whole
+// git-visible path went with it: `includeIgnored`, gitlink recursion and the
+// `codegraph.json` `include` allowlist all silently stopped applying (#1549).
+//
+// A PATH shim reproduces that on any git version, which is what makes this
+// testable in CI at all.
+describe('Old git without `ls-files -s --recurse-submodules` support (#1549)', () => {
+ let tempDir: string;
+ let originalPath: string | undefined;
+
+ const runGit = (cwd: string, ...args: string[]) =>
+ execFileSync('git', args, { cwd, stdio: 'pipe' });
+
+ const makeRepo = (dir: string, base: string) => {
+ fs.mkdirSync(dir, { recursive: true });
+ runGit(dir, 'init', '-q');
+ runGit(dir, 'config', 'user.email', 'test@test.com');
+ runGit(dir, 'config', 'user.name', 'Test');
+ fs.writeFileSync(path.join(dir, `${base}.ts`), `export const ${base} = 1;`);
+ runGit(dir, 'add', '-A');
+ runGit(dir, 'commit', '-q', '-m', `${base} init`);
+ };
+
+ /** A `git` that dies exactly like < 2.36 when it sees -s with --recurse-submodules. */
+ const installOldGitShim = () => {
+ const shimDir = path.join(tempDir, '.shim');
+ fs.mkdirSync(shimDir, { recursive: true });
+ const realGit = execFileSync('which', ['git']).toString().trim();
+ const shim = path.join(shimDir, 'git');
+ fs.writeFileSync(
+ shim,
+ [
+ '#!/bin/sh',
+ 'for a in "$@"; do',
+ ' [ "$a" = "--recurse-submodules" ] && rs=1',
+ ' [ "$a" = "-s" ] && st=1',
+ 'done',
+ 'if [ -n "$rs" ] && [ -n "$st" ]; then',
+ ' echo "fatal: ls-files --recurse-submodules unsupported mode" >&2',
+ ' exit 128',
+ 'fi',
+ `exec ${JSON.stringify(realGit)} "$@"`,
+ ].join('\n'),
+ );
+ fs.chmodSync(shim, 0o755);
+ originalPath = process.env.PATH;
+ process.env.PATH = `${shimDir}:${originalPath ?? ''}`;
+ };
+
+ beforeEach(() => {
+ tempDir = createTempDir();
+ });
+
+ afterEach(() => {
+ if (originalPath !== undefined) process.env.PATH = originalPath;
+ originalPath = undefined;
+ });
+
+ it('still honours includeIgnored when `ls-files --recurse-submodules` is unsupported', () => {
+ const root = path.join(tempDir, 'root');
+ makeRepo(root, 'a');
+ // An embedded repo that .gitignore excludes but codegraph.json opts back in.
+ makeRepo(path.join(root, 'dir_b'), 'b');
+ fs.writeFileSync(path.join(root, '.gitignore'), 'dir_b/\n');
+ fs.writeFileSync(
+ path.join(root, 'codegraph.json'),
+ JSON.stringify({ includeIgnored: ['dir_b/'] }),
+ );
+ runGit(root, 'add', '-A');
+ runGit(root, 'commit', '-q', '-m', 'ignore dir_b');
+
+ // Baseline: the real git resolves both files.
+ const withRealGit = scanDirectory(root);
+ expect(withRealGit).toContain('a.ts');
+ expect(withRealGit).toContain(path.join('dir_b', 'b.ts'));
+
+ installOldGitShim();
+
+ // The opted-in file must survive the unsupported-mode failure, not vanish.
+ const withOldGit = scanDirectory(root);
+ expect(withOldGit).toContain('a.ts');
+ expect(withOldGit).toContain(path.join('dir_b', 'b.ts'));
+ });
+});
diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts
index ad0ba23..16e0fc2 100644
--- a/__tests__/extraction.test.ts
+++ b/__tests__/extraction.test.ts
@@ -8,8 +8,9 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
+import { execFileSync } from 'child_process';
import { CodeGraph } from '../src';
-import { extractFromSource, scanDirectory, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore } from '../src/extraction';
+import { extractFromSource, scanDirectory, scanDirectoryAsync, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore, type ScanSkipStats } from '../src/extraction';
import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars';
import { stripCppTemplateArgs, blankCppExportMacros, blankCppInlineMacros, blankMetalAttributes, blankCudaConstructs, blankCppAnnotationMacroCalls, blankCppApiPrefixMacros, blankCppInlineAnnotationMacros, blankCLeadingAttrMacros, recoverMangledCppName } from '../src/extraction/languages/c-cpp';
import { normalizePath } from '../src/utils';
@@ -566,6 +567,55 @@ interface Hprops {
expect(refs.some((r) => r.referenceName === 'IOrderField')).toBe(true);
});
+ it('indexes interface members, not just the interface itself', () => {
+ // tree-sitter-typescript spells interface members `method_signature` /
+ // `property_signature`, distinct from the class-member types the extractor
+ // listed, so they were never captured (#1638). Java/C# are unaffected —
+ // their grammars reuse `method_declaration`, already in their methodTypes.
+ // The cost lands on `.d.ts` platform APIs: with no declaration node, call
+ // sites through the interface have nothing to attach an edge to.
+ const code = `
+export interface PlatformApi {
+ fetchPage(id: string): Promise;
+ version: string;
+}
+`;
+ const result = extractFromSource('api.d.ts', code);
+
+ const iface = result.nodes.find((n) => n.kind === 'interface' && n.name === 'PlatformApi');
+ const method = result.nodes.find((n) => n.kind === 'method' && n.name === 'fetchPage');
+ const prop = result.nodes.find((n) => n.kind === 'property' && n.name === 'version');
+ expect(iface).toBeDefined();
+ expect(method).toBeDefined();
+ expect(prop).toBeDefined();
+
+ // Attached to the interface, not merely present. A member the graph holds
+ // but hangs off the file is not a declaration a call edge can be resolved
+ // through, which is the whole point of extracting it.
+ const contained = result.edges
+ .filter((e) => e.kind === 'contains' && e.source === iface!.id)
+ .map((e) => e.target);
+ expect(contained).toContain(method!.id);
+ expect(contained).toContain(prop!.id);
+ });
+
+ it('does not mint a top-level function from a type literal method signature', () => {
+ // The failure mode the class-like guard on `method_signature` exists for
+ // (#1638). `extractMethod` treats a method node with no class-like parent
+ // as a free function — right for `method_definition`, wrong for a bodiless
+ // signature, whose only home outside an interface is a type literal. Those
+ // members are already extracted onto the alias (#359), so without the guard
+ // the file gains a phantom `function stop` beside the real `Handle::stop`.
+ const result = extractFromSource('t.ts', `
+export type Handle = { stop(): void; label: string };
+`);
+
+ const alias = result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'Handle');
+ expect(alias).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'method' && n.name === 'stop')).toBeDefined();
+ expect(result.nodes.filter((n) => n.kind === 'function' && n.name === 'stop')).toEqual([]);
+ });
+
it('should extract type references from interface method signatures', () => {
const code = `
import type { IPage } from '../PromoterList';
@@ -745,6 +795,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 = `
@@ -842,10 +949,20 @@ export type Names = ['alpha', 'beta'];
`;
const result = extractFromSource('noise.ts', code);
+ // Since #1638 the fixture's own interfaces legitimately declare `id` / `name`
+ // (`User::id`, `User::name`, `Service::name`), so membership in the name list
+ // no longer implies a leak. What #634 guards is the *source*: a node minted
+ // from a string literal in `Pick` or a tuple has no declaring
+ // interface, so exclude anything a `contains` edge ties to one.
+ const ifaceIds = new Set(result.nodes.filter((n) => n.kind === 'interface').map((n) => n.id));
+ const declaredInInterface = new Set(
+ result.edges.filter((e) => e.kind === 'contains' && ifaceIds.has(e.source)).map((e) => e.target)
+ );
const leaked = result.nodes.filter(
(n) =>
(n.kind === 'method' || n.kind === 'property') &&
- ['id', 'name', 'foo', 'bar', 'alpha', 'beta'].includes(n.name)
+ ['id', 'name', 'foo', 'bar', 'alpha', 'beta'].includes(n.name) &&
+ !declaredInInterface.has(n.id)
);
expect(leaked).toEqual([]);
});
@@ -978,6 +1095,42 @@ const token = getTokenMp();
);
expect(call).toBeDefined();
});
+
+ describe('initializer walk is scoped to the declared symbol (#693 for TS/JS)', () => {
+ const code = `
+const eager = load();
+const obj = { handler: () => target(), plain: target() };
+const list = [() => target()];
+export const exported = { handler: () => target() };
+`;
+ const callersOf = (name: string) => {
+ const result = extractFromSource('app.ts', code);
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
+ return result.unresolvedReferences
+ .filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
+ .map((u) => byId.get(u.fromNodeId))
+ .map((n) => (n ? `${n.kind}:${n.name}` : '?'))
+ .sort();
+ };
+
+ it("a plain call initializer names the CONSTANT as caller, not the file", () => {
+ // The walk ran with only the file on the stack, so `load` recorded the
+ // file as its caller — useless for callers/impact.
+ expect(callersOf('load')).toEqual(['constant:eager']);
+ });
+
+ it('a non-exported object literal contributes calls (it was skipped outright)', () => {
+ // `exported`'s members are minted as their own function nodes, so its
+ // arrow's call comes from `handler`; the non-exported ones attribute to
+ // the declared constant.
+ expect(callersOf('target')).toEqual([
+ 'constant:list',
+ 'constant:obj',
+ 'constant:obj',
+ 'function:handler',
+ ]);
+ });
+ });
});
describe('File Node Extraction', () => {
@@ -1066,6 +1219,42 @@ class UserService:
expect(classNode).toBeDefined();
expect(classNode?.name).toBe('UserService');
});
+
+ it('walks a module-level assignment initializer scoped to the name (#693 for Python)', () => {
+ // The assignment minted a node and stopped, so everything a module builds
+ // at import time — `app = FastAPI()`, `ENGINE = create_engine(url)` — was
+ // missing from the graph. A tuple target mints no symbol, so its
+ // right-hand side attributes to the enclosing scope instead of vanishing.
+ const code = `
+def target(): pass
+def compute(): return 1
+
+APP = compute()
+handler = lambda: target()
+MAPPING = {"a": compute()}
+first, second = compute(), target()
+
+class K:
+ ATTR = compute()
+`;
+ const result = extractFromSource('app.py', code);
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
+ const owners = result.unresolvedReferences
+ .filter((u) => u.referenceKind === 'calls')
+ .map((u) => {
+ const n = byId.get(u.fromNodeId);
+ return `${u.referenceName}<-${n ? `${n.kind}:${n.name}` : '?'}`;
+ })
+ .sort();
+ expect(owners).toEqual([
+ 'compute<-class:K', // a class attribute still rides the class (no node of its own)
+ 'compute<-file:app.py', // the tuple target mints nothing
+ 'compute<-variable:APP',
+ 'compute<-variable:MAPPING',
+ 'target<-file:app.py',
+ 'target<-variable:handler',
+ ]);
+ });
});
describe('Go Extraction', () => {
@@ -1136,6 +1325,35 @@ pub struct User {
expect(structNode?.name).toBe('User');
});
+ it('should extract unit and tuple structs, not just brace structs', () => {
+ // A unit struct has no body field, but it IS a complete definition —
+ // Rust has no forward declarations. Skipping it dropped the type and
+ // every `impl Trait for UnitStruct` edge with it.
+ const code = `
+pub struct Unit;
+pub struct Tuple(pub u32);
+pub struct Brace { pub x: u32 }
+`;
+ const result = extractFromSource('shapes.rs', code);
+
+ const structs = result.nodes.filter((n) => n.kind === 'struct').map((n) => n.name).sort();
+ expect(structs).toEqual(['Brace', 'Tuple', 'Unit']);
+ });
+
+ it('should link impl Trait for a unit struct', () => {
+ const code = `
+pub struct Unit;
+pub trait Greet { fn hi(&self) -> String; }
+impl Greet for Unit { fn hi(&self) -> String { "unit".into() } }
+`;
+ const result = extractFromSource('greet.rs', code);
+
+ const unit = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Unit');
+ expect(unit).toBeDefined();
+ const trait = result.nodes.find((n) => n.kind === 'trait' && n.name === 'Greet');
+ expect(trait).toBeDefined();
+ });
+
it('should extract trait declarations', () => {
const code = `
pub trait Repository {
@@ -1362,6 +1580,26 @@ impl Counter {
expect(implRefs).toHaveLength(0);
});
+ it('walks a const/static initializer scoped to the declared symbol (#693 for Rust)', () => {
+ // The declaration minted a node and stopped, so a handler table, a
+ // lazily-built singleton or any computed const linked to nothing.
+ const code = `
+const LEN: usize = compute_len();
+static REGISTRY: Lazy = Lazy::new(|| build_cfg());
+`;
+ const result = extractFromSource('lib.rs', code);
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
+ const owner = (name: string) => {
+ const u = result.unresolvedReferences.find(
+ (r) => r.referenceKind === 'calls' && r.referenceName === name
+ );
+ const n = u ? byId.get(u.fromNodeId) : undefined;
+ return n ? `${n.kind}:${n.name}` : undefined;
+ };
+ expect(owner('compute_len')).toBe('variable:LEN');
+ expect(owner('build_cfg')).toBe('variable:REGISTRY');
+ });
+
it('should extract union declarations and their impl edges', () => {
const code = `
pub union Reg {
@@ -1569,6 +1807,37 @@ public class Splitter {
);
expect(sepStart, 'override inside the lambda-returned anon class should be a method node').toBeDefined();
});
+
+ it('walks a field initializer scoped to the field (#693 for Java)', () => {
+ // The dispatcher only scanned a field_declaration for function-as-value
+ // candidates, so a lambda or anonymous class holding the work — the
+ // Android listener idiom — contributed no call edge and `target` looked
+ // callerless.
+ const code = `
+package p;
+class T {
+ private final Runnable fieldLambda = () -> target();
+ private final Runnable anonClass = new Runnable() {
+ public void run() { target(); }
+ };
+ private final int eager = compute();
+ void directCall() { target(); }
+ private void target() {}
+ private static int compute() { return 1; }
+}
+`;
+ const result = extractFromSource('T.java', code);
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
+ const callersOf = (name: string) =>
+ result.unresolvedReferences
+ .filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
+ .map((u) => byId.get(u.fromNodeId)?.name)
+ .sort();
+
+ // `run` is the anonymous class's override, itself extracted under the field.
+ expect(callersOf('target')).toEqual(['directCall', 'fieldLambda', 'run']);
+ expect(callersOf('compute')).toEqual(['eager']);
+ });
});
describe('C# Extraction', () => {
@@ -2172,6 +2441,120 @@ class Bar {
const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
expect(cls?.qualifiedName).toBe('Bar');
});
+
+ describe('property initializers are walked, attributed to the property (#693 for Kotlin)', () => {
+ // The property hook consumes the whole property_declaration subtree, so
+ // before this the initializer was only scanned for function-as-value
+ // candidates and every call inside it vanished from the graph. Android/MSDK
+ // callbacks are declared exactly this way (`private val l = Listener { … }`),
+ // so anything reached only through one looked like it had no callers at all.
+ const code = `
+package repro
+
+class Repro {
+ private val fieldLambda: () -> Unit = { target() }
+ private val samField = Runnable { target() }
+ private val plain = target()
+ private val delegated by lazy { target() }
+ private val anonObject = object : Runnable { override fun run() { target() } }
+
+ fun directCall() { target() }
+ fun lambdaInMethod() { run { target() } }
+
+ private fun target() {}
+}
+
+object Holder {
+ val topLevelLambda: () -> Unit = { hit() }
+ private fun hit() {}
+}
+`;
+ const callersOf = (target: string) => {
+ const result = extractFromSource('Repro.kt', code);
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
+ return result.unresolvedReferences
+ .filter((u) => u.referenceKind === 'calls' && u.referenceName === target)
+ .map((u) => byId.get(u.fromNodeId)?.name)
+ .sort();
+ };
+
+ it('a lambda / SAM / plain / delegated / object initializer calls FROM the property', () => {
+ // `run` is the anonymous object's override, extracted as its own node
+ // under `anonObject` — the same shape Go's initializer walk produces.
+ expect(callersOf('target')).toEqual([
+ 'delegated',
+ 'directCall',
+ 'fieldLambda',
+ 'lambdaInMethod',
+ 'plain',
+ 'run',
+ 'samField',
+ ]);
+ });
+
+ it('a property in an `object` singleton is a caller too', () => {
+ expect(callersOf('hit')).toEqual(['topLevelLambda']);
+ });
+
+ it('an accessor body belongs to its property, written on either line', () => {
+ // `val x: T get() = …` nests the accessor UNDER the declaration; written
+ // on its own line the grammar makes it a following SIBLING instead. Both
+ // used to lose their calls (the nested one) or hand them to the enclosing
+ // class (the sibling); both now attribute to the property.
+ const src = `
+package p
+
+class C {
+ val sameLine: Int get() = compute()
+ val nextLine: Int
+ get() = compute()
+ var written: Int = 0
+ set(v) { store(v) }
+ private fun compute(): Int = 1
+ private fun store(v: Int) {}
+}
+`;
+ const result = extractFromSource('C.kt', src);
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
+ const ownersOf = (name: string) =>
+ result.unresolvedReferences
+ .filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
+ .map((u) => {
+ const n = byId.get(u.fromNodeId);
+ return n ? `${n.kind}:${n.name}` : '?';
+ })
+ .sort();
+ expect(ownersOf('compute')).toEqual(['field:nextLine', 'field:sameLine']);
+ expect(ownersOf('store')).toEqual(['field:written']);
+ });
+
+ it('an `init` block and a destructuring RHS no longer vanish', () => {
+ // Both mint no symbol of their own, so the hook consumed them and their
+ // code disappeared entirely; they now attribute to the enclosing scope.
+ const src = `
+package p
+
+class C {
+ init { val q = initCall() }
+ val (a, b) = makePair()
+}
+
+val (t1, t2) = topMakePair()
+`;
+ const result = extractFromSource('C.kt', src);
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
+ const owner = (name: string) => {
+ const u = result.unresolvedReferences.find(
+ (r) => r.referenceKind === 'calls' && r.referenceName === name
+ );
+ const n = u ? byId.get(u.fromNodeId) : undefined;
+ return n ? `${n.kind}:${n.name}` : undefined;
+ };
+ expect(owner('initCall')).toBe('class:C');
+ expect(owner('makePair')).toBe('class:C');
+ expect(owner('topMakePair')).toBe('namespace:p');
+ });
+ });
});
describe('Dart Extraction', () => {
@@ -5811,6 +6194,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;
@@ -7293,6 +7744,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;
@@ -7637,6 +8187,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);
+ });
});
// =============================================================================
@@ -7906,6 +8532,35 @@ def processData(): Unit = {
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
expect(calls.length).toBeGreaterThan(0);
});
+
+ it('walks a val/var initializer scoped to the declared symbol (#693 for Scala)', () => {
+ // The val/var hook minted the node and returned true, so the dispatcher
+ // only scanned the subtree for function-as-value candidates — every call
+ // in an initializer was dropped, which on a `val`-heavy codebase
+ // (SpinalHDL, Akka wiring) is most of the wiring.
+ const code = `
+class C {
+ val fieldLambda: () => Unit = () => target()
+ val direct = target()
+ lazy val lazily = target()
+ private def target(): Unit = {}
+}
+
+object O {
+ val topLambda = () => hit()
+ def hit(): Unit = {}
+}
+`;
+ const result = extractFromSource('C.scala', code);
+ const byId = new Map(result.nodes.map((n) => [n.id, n]));
+ const callersOf = (name: string) =>
+ result.unresolvedReferences
+ .filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
+ .map((u) => byId.get(u.fromNodeId)?.name)
+ .sort();
+ expect(callersOf('target')).toEqual(['direct', 'fieldLambda', 'lazily']);
+ expect(callersOf('hit')).toEqual(['topLambda']);
+ });
});
});
@@ -8486,6 +9141,58 @@ function M:send(data) return self end
const send = methods.find((m) => m.name === 'send');
expect(send?.qualifiedName).toBe('M::send');
});
+
+ it('should name function expressions from local, member, and table-field bindings', () => {
+ const code = `
+local function helper() return 1 end
+local localFn = function() return helper() end
+local M = {
+ callbacks = {
+ onStart = function() return helper() end,
+ ["onStop"] = function() return helper() end,
+ [DYNAMIC] = function() return helper() end,
+ },
+}
+M.assignedFn = function() return helper() end
+M["bracketFn"] = function() return helper() end
+localFn()
+`;
+ const result = extractFromSource('handlers.lua', code);
+ const localFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'localFn');
+ const assignedFn = result.nodes.find(
+ (n) => n.kind === 'method' && n.qualifiedName === 'M::assignedFn'
+ );
+ const onStart = result.nodes.find(
+ (n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::onStart'
+ );
+ const onStop = result.nodes.find(
+ (n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::onStop'
+ );
+ const bracketFn = result.nodes.find(
+ (n) => n.kind === 'method' && n.qualifiedName === 'M::bracketFn'
+ );
+
+ expect(localFn).toBeDefined();
+ expect(assignedFn).toBeDefined();
+ expect(onStart).toBeDefined();
+ expect(onStop).toBeDefined();
+ expect(bracketFn).toBeDefined();
+ expect(result.nodes.some((n) => n.name === 'DYNAMIC')).toBe(false);
+ expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'localFn')).toBe(false);
+
+ for (const callable of [localFn, assignedFn, onStart, onStop, bracketFn]) {
+ expect(
+ result.unresolvedReferences.some(
+ (r) => r.fromNodeId === callable!.id && r.referenceKind === 'calls' && r.referenceName === 'helper'
+ )
+ ).toBe(true);
+ }
+ expect(
+ result.unresolvedReferences.some(
+ (r) => r.referenceKind === 'calls' && r.referenceName === 'localFn'
+ )
+ ).toBe(true);
+ });
});
describe('Variable extraction', () => {
@@ -11671,6 +12378,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 = [
@@ -11919,3 +12722,63 @@ describe('C/C++ kernel-port preParse blanks (R7a)', () => {
expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'size')).toBe(true);
});
});
+
+// `init` on a project CodeGraph has no grammar for used to look identical to a
+// successful index of an empty repo: 0 files, `index_state: complete`, exit 0.
+// Nothing said "there are 24k files here and I understood none of them", so an
+// agent told to trust the graph concluded the code did not exist (#1502).
+//
+// The scan already visits every file, so the count comes from the walk it
+// already does — no second pass.
+describe('Unsupported-language projects report what they skipped (#1502)', () => {
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = createTempDir();
+ });
+
+ it('counts files it could not index, by extension, on the git path', async () => {
+ const runGit = (...args: string[]) =>
+ execFileSync('git', args, { cwd: tempDir, stdio: 'pipe' });
+ fs.mkdirSync(tempDir, { recursive: true });
+ runGit('init', '-q');
+ runGit('config', 'user.email', 'test@test.com');
+ runGit('config', 'user.name', 'Test');
+ fs.writeFileSync(path.join(tempDir, 'a.move'), 'module a {}');
+ fs.writeFileSync(path.join(tempDir, 'b.move'), 'module b {}');
+ fs.writeFileSync(path.join(tempDir, 'c.pl'), 'print 1;');
+ runGit('add', '-A');
+ runGit('commit', '-q', '-m', 'unsupported only');
+
+ const stats: ScanSkipStats = { unsupportedByExtension: new Map() };
+ const files = await scanDirectoryAsync(tempDir, undefined, stats);
+
+ expect(files).toEqual([]);
+ expect(stats.unsupportedByExtension.get('.move')).toBe(2);
+ expect(stats.unsupportedByExtension.get('.pl')).toBe(1);
+ });
+
+ it('counts them on the filesystem-walk path too (non-git project)', async () => {
+ fs.mkdirSync(tempDir, { recursive: true });
+ fs.writeFileSync(path.join(tempDir, 'a.move'), 'module a {}');
+ fs.writeFileSync(path.join(tempDir, 'b.pl'), 'print 1;');
+
+ const stats: ScanSkipStats = { unsupportedByExtension: new Map() };
+ const files = await scanDirectoryAsync(tempDir, undefined, stats);
+
+ expect(files).toEqual([]);
+ expect(stats.unsupportedByExtension.get('.move')).toBe(1);
+ expect(stats.unsupportedByExtension.get('.pl')).toBe(1);
+ });
+
+ it('stays silent when every file was indexable', async () => {
+ fs.mkdirSync(tempDir, { recursive: true });
+ fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export const a = 1;');
+
+ const stats: ScanSkipStats = { unsupportedByExtension: new Map() };
+ const files = await scanDirectoryAsync(tempDir, undefined, stats);
+
+ expect(files).toEqual(['a.ts']);
+ expect(stats.unsupportedByExtension.size).toBe(0);
+ });
+});
diff --git a/__tests__/fixtures/kernel-parity/Torture.java b/__tests__/fixtures/kernel-parity/Torture.java
index 703dc3b..9ecb8d8 100644
--- a/__tests__/fixtures/kernel-parity/Torture.java
+++ b/__tests__/fixtures/kernel-parity/Torture.java
@@ -22,6 +22,15 @@ public class TortureService extends BaseService implements Runnable, AutoCloseab
protected int count = 0;
private final List names;
int packagePrivate, secondDeclarator;
+ /** Field initializers — walked scoped to the field (#693). */
+ private final Runnable fieldLambda = () -> helper(RETRY_LIMITS);
+ private final Runnable fieldAnonClass = new Runnable() {
+ @Override
+ public void run() {
+ helper(RETRY_LIMITS);
+ }
+ };
+ private final Runnable fieldMethodRef = TortureService::compute;
/** Ctor javadoc. */
public TortureService(List names) {
diff --git a/__tests__/fixtures/kernel-parity/torture.cpp b/__tests__/fixtures/kernel-parity/torture.cpp
index aa6e382..b6b160f 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 50ddd02..190f56e 100644
--- a/__tests__/fixtures/kernel-parity/torture.js
+++ b/__tests__/fixtures/kernel-parity/torture.js
@@ -73,3 +73,22 @@ export default {
},
},
};
+
+// Initializer walks attributed to the declared symbol (#693). A plain call
+// leaked to the FILE node; a non-exported object literal was skipped outright.
+const eagerConfig = loadConfig();
+const handlerMap = { onSave: () => persist(eagerConfig), onLoad: loadConfig() };
+const lazyList = [() => persist(eagerConfig)];
+// --- CommonJS export assignments (#1675) -----------------------------------
+exports.getItems = async (req, res) => { res.json(await findItems()); };
+module.exports.deleteItem = function (req, res) { removeItem(req.params.id); res.end(); };
+exports.plain = 42;
+handlers.onSave = () => { persist(); };
+// --- 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.kt b/__tests__/fixtures/kernel-parity/torture.kt
index 130611c..c5531c5 100644
--- a/__tests__/fixtures/kernel-parity/torture.kt
+++ b/__tests__/fixtures/kernel-parity/torture.kt
@@ -50,6 +50,13 @@ val topDelegated by lazy { WidgetK(1) }
val (destA, destB) = makePair()
val withGetter: Int
get() = 42
+val initLambda: () -> Unit = { caller() }
+val initSam = Runnable { caller() }
+val initObject = object : Runnable {
+ override fun run() {
+ caller()
+ }
+}
class WidgetK(val size: Int, private var name: String = defaultName()) {
val area: Int = size * size
@@ -265,3 +272,20 @@ fun labeledLambda() {
}
fun whereClause(): Int where Int : Comparable = 1
+
+class AccessorK {
+ val sameLineGetter: Int get() = compute()
+ var sameLinePair: Int get() = compute()
+ set(v) { draw(v) }
+}
+
+class SiblingAccessorK {
+ var nextLine: Int = 0
+ get() = compute()
+ set(v) { draw(v) }
+ val (localA, localB) = makePair()
+ init {
+ val fromInit = compute()
+ register(fromInit)
+ }
+}
diff --git a/__tests__/fixtures/kernel-parity/torture.lua b/__tests__/fixtures/kernel-parity/torture.lua
index 52da48c..1b3c21c 100644
--- a/__tests__/fixtures/kernel-parity/torture.lua
+++ b/__tests__/fixtures/kernel-parity/torture.lua
@@ -24,7 +24,7 @@ local function localFn(...)
return select("#", ...)
end
--- doc for anonAssigned (variable, initializer invisible)
+-- doc for anonAssigned (function named from its local binding)
local anonAssigned = function(v)
return hidden(v)
end
@@ -68,6 +68,15 @@ M.assigned = function(z)
return topFn(z)
end
+M.callbacks = {
+ on_start = function()
+ return topFn(17)
+ end,
+ ["on_stop"] = function()
+ return topFn(18)
+ end,
+}
+
M.handlers = { on_start = topFn, on_stop = localFn, skipped = missing }
local tbl = { cb = topFn, [1] = localFn, nested = { deep_cb = topFn } }
diff --git a/__tests__/fixtures/kernel-parity/torture.py b/__tests__/fixtures/kernel-parity/torture.py
index 900fc74..001d64f 100644
--- a/__tests__/fixtures/kernel-parity/torture.py
+++ b/__tests__/fixtures/kernel-parity/torture.py
@@ -47,3 +47,17 @@ def shadowed():
handlers = {"recv": target_cb}
callbacks = [target_cb, view]
+
+# Initializer walks attributed to the assigned name (#693).
+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__/fixtures/kernel-parity/torture.rs b/__tests__/fixtures/kernel-parity/torture.rs
index 8fb8382..63fe390 100644
--- a/__tests__/fixtures/kernel-parity/torture.rs
+++ b/__tests__/fixtures/kernel-parity/torture.rs
@@ -285,6 +285,11 @@ fn mount() {
routes![top_level_h];
+// Initializer walks attributed to the declared symbol (#693).
+const INIT_CONST: usize = compute_len();
+static INIT_LAZY: Lazy = Lazy::new(|| build_cfg());
+static INIT_ALIAS: fn() = free_fn;
+
pub union Reg {
pub raw: u32,
pub halves: [u16; 2],
diff --git a/__tests__/fixtures/kernel-parity/torture.scala b/__tests__/fixtures/kernel-parity/torture.scala
index de536e6..c1e4703 100644
--- a/__tests__/fixtures/kernel-parity/torture.scala
+++ b/__tests__/fixtures/kernel-parity/torture.scala
@@ -175,3 +175,10 @@ package object utilpkg {
def pkgHelper(): Int = 1
val pkgShared = 2
}
+
+class InitWalk {
+ val initLambda: () => Unit = () => helperCall()
+ val initDirect = helperCall()
+ lazy val initLazy = process(1)
+ val initAnon = new Runnable { def run(): Unit = helperCall() }
+}
diff --git a/__tests__/fixtures/kernel-parity/torture.tsx b/__tests__/fixtures/kernel-parity/torture.tsx
index de27c0a..b7f4be7 100644
--- a/__tests__/fixtures/kernel-parity/torture.tsx
+++ b/__tests__/fixtures/kernel-parity/torture.tsx
@@ -212,3 +212,21 @@ import('./dynamic-module');
new NS.Widget(makeArg());
new Map();
super_weird?.();
+
+// --- call through a field of the enclosing class (#1496) ---------------------
+export class FieldDelegator {
+ constructor(private readonly mailer: { send(m: string): string }, private items: string[]) {}
+ send(msg: string): string { return this.mailer.send(msg); }
+ push(msg: string): void { this.items.push(msg); this.mailer.send(msg).trim(); }
+ direct(): void { this.send('x'); super.toString(); }
+}
+
+// --- const-bound functions inside a body (#1669) -----------------------------
+export function NestedHandlers({ items, onPick }: { items: string[]; onPick: (a: unknown, b: unknown) => void }) {
+ const handleClear = () => { onPick(null, null); };
+ const describe = function (item: string) { return formatLabel(item); };
+ let later = (x: string) => parseLabel(x);
+ const count = items.length;
+ const [a, b] = [() => 1, () => 2];
+ return items.map((i) => );
+}
diff --git a/__tests__/fixtures/php-import-alias-static/app/Http/Controllers/Backend/SettleController.php b/__tests__/fixtures/php-import-alias-static/app/Http/Controllers/Backend/SettleController.php
new file mode 100644
index 0000000..3b1e60a
--- /dev/null
+++ b/__tests__/fixtures/php-import-alias-static/app/Http/Controllers/Backend/SettleController.php
@@ -0,0 +1,9 @@
+_SELECTED, [$startDay, $endDay], $id);
+ }
+}
diff --git a/__tests__/fixtures/php-import-alias-static/app/Repositories/SettleRepository.php b/__tests__/fixtures/php-import-alias-static/app/Repositories/SettleRepository.php
new file mode 100644
index 0000000..7fc8e5f
--- /dev/null
+++ b/__tests__/fixtures/php-import-alias-static/app/Repositories/SettleRepository.php
@@ -0,0 +1,8 @@
+ {
@@ -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__/frameworks.test.ts b/__tests__/frameworks.test.ts
index a1d7fb3..e467ab6 100644
--- a/__tests__/frameworks.test.ts
+++ b/__tests__/frameworks.test.ts
@@ -837,6 +837,118 @@ describe('railsResolver.extract', () => {
import { springResolver } from '../src/resolution/frameworks/java';
describe('springResolver.extract', () => {
+ it.each([
+ ['UserController.java', '{"/a", "/b"}', '@GetMapping({"/x", "/y"})', 'public String handle() { return "ok"; }'],
+ ['UserController.java', 'path = {"/a", "/b"}', '@RequestMapping(value = {"/x", "/y"}, method = RequestMethod.GET)', 'public String handle() { return "ok"; }'],
+ ['UserController.kt', 'value = ["/a", "/b"]', '@GetMapping(path = ["/x", "/y"])', 'fun handle(): String = "ok"'],
+ ])('indexes every class/method path pair in %s with %s and %s (#1461)', (filePath, base, mapping, handler) => {
+ const src = `@RestController
+@RequestMapping(${base})
+public class UserController {
+ ${mapping}
+ ${handler}
+}`;
+ const { nodes, references } = springResolver.extract!(filePath, src);
+ expect(nodes.map(n => n.name)).toEqual(['GET /a/x', 'GET /a/y', 'GET /b/x', 'GET /b/y']);
+ expect(new Set(nodes.map(n => n.id)).size).toBe(4);
+ expect(references.map(r => [r.fromNodeId, r.referenceName])).toEqual(nodes.map(n => [n.id, 'handle']));
+ });
+
+ it.each(['ErrorHandler.PATH', 'PATH', 'value = ErrorHandler.PATH', 'path = PATH'])(
+ 'resolves a same-file constant prefix in @RequestMapping(%s) (#1461)', (args) => {
+ const src = `@Controller
+@RequestMapping(${args})
+public class ErrorHandler {
+ public static final String PATH = "/error";
+ @RequestMapping(method = {RequestMethod.GET})
+ public String handle() { return "err"; }
+}`;
+ const { nodes, references } = springResolver.extract!('ErrorHandler.java', src);
+ expect(nodes.map(n => n.name)).toEqual(['GET /error']);
+ expect(references.map(r => [r.fromNodeId, r.referenceName])).toEqual([[nodes[0].id, 'handle']]);
+ },
+ );
+
+ it('keeps literals and resolved constants in path arrays, including URI variables (#1461)', () => {
+ const src = `@RequestMapping({"/api", "/{tenant}/api"})
+public class ItemController {
+ public static final String ITEMS = "/items";
+ @GetMapping(path = {ITEMS, "/items/{id}", External.MISSING}, produces = "application/json")
+ public String get() { return "ok"; }
+}`;
+ const { nodes, references } = springResolver.extract!('ItemController.java', src);
+ expect(nodes.map(n => n.name)).toEqual([
+ 'GET /api/items', 'GET /api/items/{id}', 'GET /{tenant}/api/items', 'GET /{tenant}/api/items/{id}',
+ ]);
+ expect(references.map(r => r.referenceName)).toEqual(['get', 'get', 'get', 'get']);
+ });
+
+ it.each([
+ ['value = "/ok", produces = "application/json"', '/base/ok'],
+ ['consumes = {"application/json", "text/plain"}, path = "/ok", produces = "application/json"', '/base/ok'],
+ ['produces = "application/json", consumes = "text/plain"', '/base'],
+ ])('only treats path arguments as paths: %s (#1461)', (args, expected) => {
+ const src = `@RequestMapping("/base")
+public class UserController {
+ @GetMapping(${args})
+ public String handle() { return "ok"; }
+}`;
+ const { nodes } = springResolver.extract!('UserController.java', src);
+ expect(nodes.map(n => n.name)).toEqual([`GET ${expected}`]);
+ });
+
+ it.each([
+ ['External.MISSING', '@GetMapping'],
+ ['value = MISSING, produces = "application/json"', '@GetMapping("/ok")'],
+ ['"/base"', '@GetMapping(External.MISSING)'],
+ ['"/base"', '@GetMapping(path = MISSING, produces = "application/json")'],
+ ['"/base"', '@RequestMapping(value = MISSING, method = RequestMethod.GET)'],
+ ])('omits unresolved paths: class %s, method %s (#1461)', (base, mapping) => {
+ const src = `@RequestMapping(${base})
+public class UserController {
+ // public static final String MISSING = "/comment";
+ ${mapping}
+ public String handle() { return "ok"; }
+}`;
+ expect(springResolver.extract!('UserController.java', src)).toEqual({ nodes: [], references: [] });
+ });
+
+ it.each([
+ ['@GetMapping', 'GET'],
+ ['@GetMapping()', 'GET'],
+ ['@RequestMapping(method = RequestMethod.GET)', 'GET'],
+ ['@RequestMapping(method = {RequestMethod.GET})', 'GET'],
+ ['@RequestMapping', 'ANY'],
+ ])('inherits the class prefix for %s without emitting a class route (#1461)', (mapping, verb) => {
+ const src = `@RequestMapping("/base")
+public class UserController {
+ ${mapping}
+ public String handle() { return "ok"; }
+}`;
+ const { nodes, references } = springResolver.extract!('UserController.java', src);
+ expect(nodes.map(n => n.name)).toEqual([`${verb} /base`]);
+ expect(references.map(r => r.referenceName)).toEqual(['handle']);
+ });
+
+ it('preserves annotation and reference line numbers after multiline Javadocs (#1461)', () => {
+ const src = `/**
+ * Controller documentation.
+ */
+@RequestMapping("/base")
+public class UserController {
+ /**
+ * Handler documentation with @GetMapping("/fake").
+ */
+ @GetMapping({"/x", "/y"})
+ public String handle() { return "ok"; }
+}`;
+ const { nodes, references } = springResolver.extract!('UserController.java', src);
+ expect(nodes.map(n => [n.name, n.startLine, n.endLine])).toEqual([
+ ['GET /base/x', 9, 9], ['GET /base/y', 9, 9],
+ ]);
+ expect(references.map(r => [r.referenceName, r.line])).toEqual([['handle', 9], ['handle', 9]]);
+ });
+
it('extracts route with @GetMapping and next method', () => {
const src = `
@GetMapping("/users")
diff --git a/__tests__/frontload-hook.test.ts b/__tests__/frontload-hook.test.ts
index c8698cf..95cd83a 100644
--- a/__tests__/frontload-hook.test.ts
+++ b/__tests__/frontload-hook.test.ts
@@ -8,11 +8,15 @@
* logic), since the end-to-end hook is validated by a live agent run, not a
* unit test.
*/
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens } from '../src/directory';
+import { planFrontload, findIndexedSubprojectRoots, unsafeIndexRootReason, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory';
+
+// Make the built-in exports configurable so HOME can point at a real temp
+// fixture without changing the process environment or the user's home files.
+vi.mock('os', async (importOriginal) => ({ ...await importOriginal() }));
/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
function mkIndexed(dir: string): string {
@@ -30,7 +34,10 @@ function mkWorkspaceRoot(dir: string): string {
describe('planFrontload — front-load hook project resolution (#964)', () => {
let tmp: string;
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); });
- afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
+ afterEach(() => {
+ vi.restoreAllMocks();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ });
it('cwd is itself indexed → front-load cwd (the common single-project case)', () => {
mkIndexed(tmp);
@@ -92,6 +99,29 @@ describe('planFrontload — front-load hook project resolution (#964)', () => {
expect(plan.nudgeProjects).toEqual([]);
});
+ it.each([
+ { root: 'home', manifest: 'package.json', children: 1 },
+ { root: 'home', manifest: 'package.json', children: 2 },
+ { root: 'home', manifest: 'WORKSPACE', children: 1 },
+ { root: 'parent of home', manifest: 'package.json', children: 1 },
+ ])('$root with stray $manifest and $children indexed children → no-op (#1454)', ({ root, manifest, children }) => {
+ const homeDir = root === 'home' ? tmp : path.join(tmp, 'user');
+ fs.mkdirSync(homeDir, { recursive: true });
+ vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
+ if (manifest === 'package.json') mkWorkspaceRoot(tmp);
+ else fs.mkdirSync(path.join(tmp, manifest)); // Even a WORKSPACE directory opens the manifest gate.
+ mkIndexed(path.join(tmp, 'packages', 'api'));
+ if (children === 2) mkIndexed(path.join(tmp, 'packages', 'web'));
+ expect(unsafeIndexRootReason(tmp)).toBe(root === 'home' ? 'your home directory' : 'a parent of your home directory');
+
+ expect(planFrontload(tmp, 'how does authentication work end to end?')).toEqual({
+ exploreRoot: null,
+ nudgeProjects: [],
+ viaSubScan: false,
+ });
+ expect(findIndexedSubprojectRoots(tmp)).toEqual([]);
+ });
+
it('nothing indexed anywhere → no-op', () => {
mkWorkspaceRoot(tmp);
fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true });
@@ -321,3 +351,28 @@ describe('isStructuralPrompt — cheap candidate gate (keyword OR code-token)',
expect(isStructuralPrompt('')).toBe(false);
});
});
+
+describe('prompt-hook injection cap (#1694)', () => {
+ it('PROMPT_HOOK_INJECTION_MAX stays under Claude Code\'s 10k inline hook-output limit', () => {
+ expect(PROMPT_HOOK_INJECTION_MAX).toBe(9000);
+ expect(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT).toBe(10_000);
+ expect(PROMPT_HOOK_INJECTION_MAX).toBeLessThan(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT);
+ // Leave headroom for the wrapper + projectPath nudge lines.
+ expect(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT - PROMPT_HOOK_INJECTION_MAX).toBeGreaterThanOrEqual(500);
+ });
+
+ it('capPromptHookInjection leaves short payloads intact', () => {
+ expect(capPromptHookInjection('hello')).toBe('hello');
+ expect(capPromptHookInjection('x'.repeat(PROMPT_HOOK_INJECTION_MAX))).toBe('x'.repeat(PROMPT_HOOK_INJECTION_MAX));
+ });
+
+ it('capPromptHookInjection truncates oversize payloads with the explore notice', () => {
+ const over = 'a'.repeat(PROMPT_HOOK_INJECTION_MAX + 500);
+ const out = capPromptHookInjection(over);
+ expect(out.length).toBeLessThan(over.length);
+ expect(out.startsWith('a'.repeat(PROMPT_HOOK_INJECTION_MAX))).toBe(true);
+ expect(out).toContain('…(truncated; call codegraph_explore for the rest)');
+ // Capped body alone must still fit under the host inline limit.
+ expect(out.length).toBeLessThan(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT);
+ });
+});
diff --git a/__tests__/fts5-fallback.test.ts b/__tests__/fts5-fallback.test.ts
new file mode 100644
index 0000000..19d6022
--- /dev/null
+++ b/__tests__/fts5-fallback.test.ts
@@ -0,0 +1,166 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { DatabaseConnection } from '../src/db';
+import { QueryBuilder } from '../src/db/queries';
+import { Node } from '../src/types';
+
+// Use real SQLite for every operation except the unsupported-module error.
+// This must exercise fallback even when the test runner's Node has FTS5.
+const { DatabaseSync } = require('node:sqlite');
+
+function simulateMissingFts5(): () => number {
+ const exec = DatabaseSync.prototype.exec;
+ let attempts = 0;
+ vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: unknown, sql: string) {
+ if (/CREATE VIRTUAL TABLE\b[^;]*\bUSING fts5\s*\(/i.test(sql)) {
+ attempts++;
+ throw new Error('no such module: fts5');
+ }
+ return exec.call(this, sql);
+ });
+ return () => attempts;
+}
+
+function makeNode(name: string, docstring?: string): Node {
+ return {
+ id: name,
+ kind: 'function',
+ name,
+ qualifiedName: name,
+ filePath: 'src/users.ts',
+ language: 'typescript',
+ startLine: 1,
+ endLine: 1,
+ startColumn: 0,
+ endColumn: 0,
+ docstring,
+ updatedAt: Date.now(),
+ };
+}
+
+describe('FTS5 fallback (#1532)', () => {
+ let dir: string;
+ let connections: DatabaseConnection[];
+
+ beforeEach(() => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fts5-fallback-'));
+ connections = [];
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ for (const connection of connections) connection.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ function initialize(filename = 'test.db'): DatabaseConnection {
+ const connection = DatabaseConnection.initialize(path.join(dir, filename));
+ connections.push(connection);
+ return connection;
+ }
+
+ function reopen(connection: DatabaseConnection): DatabaseConnection {
+ connection.close();
+ const reopened = DatabaseConnection.open(path.join(dir, 'test.db'));
+ connections.push(reopened);
+ return reopened;
+ }
+
+ it.each(['initialization', 'reopening'])('uses LIKE and fuzzy search after %s without FTS5', (state) => {
+ const attempts = simulateMissingFts5();
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ let connection = initialize();
+
+ expect(attempts()).toBe(1);
+ expect(connection.fts5Available).toBe(false);
+ expect(warn).toHaveBeenCalledOnce();
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('no such module: fts5'));
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('LIKE + fuzzy matching'));
+
+ if (state === 'reopening') connection = reopen(connection);
+ expect(connection.fts5Available).toBe(false);
+
+ const db = connection.getDb();
+ expect(db.prepare("SELECT name FROM sqlite_master WHERE name = 'nodes_fts' OR name IN ('nodes_ai', 'nodes_ad', 'nodes_au')").all()).toEqual([]);
+ const exec = vi.spyOn(db, 'exec');
+ connection.beginBulkNodeLoad();
+ connection.endBulkNodeLoad();
+ expect(exec).not.toHaveBeenCalled();
+
+ const queries = new QueryBuilder(db);
+ queries.insertNodes([makeNode('getUser'), makeNode('getUserProfile')]);
+ const prepare = vi.spyOn(db, 'prepare');
+
+ expect(queries.searchNodes('User').map(result => result.node.name)).toEqual(expect.arrayContaining(['getUser', 'getUserProfile']));
+ expect(queries.searchNodes('getUssr').map(result => result.node.name)).toEqual(['getUser']);
+ // A failed MATCH query is already caught by searchNodesFTS; pin that the
+ // unavailable path skips the FTS query entirely, rather than retrying it.
+ expect(prepare.mock.calls.some(([sql]) => /\bnodes_fts\b/.test(sql))).toBe(false);
+
+ queries.setMetadata('project_name', 'fts5-fallback');
+ expect(queries.getMetadata('project_name')).toBe('fts5-fallback');
+ });
+
+ it('keeps every non-FTS table and index when FTS5 creation fails', () => {
+ const control = initialize('control.db');
+ const nonFtsSchema = (connection: DatabaseConnection) => connection.getDb().prepare(`
+ SELECT type, name, sql FROM sqlite_master
+ WHERE name NOT LIKE 'nodes_fts%'
+ AND name NOT IN ('nodes_ai', 'nodes_ad', 'nodes_au')
+ ORDER BY type, name
+ `).all();
+ const expected = nonFtsSchema(control);
+
+ simulateMissingFts5();
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const fallback = initialize();
+
+ expect(fallback.fts5Available).toBe(false);
+ expect(nonFtsSchema(fallback)).toEqual(expected);
+ });
+
+ it.each(['initialization', 'reopening'])('uses real FTS5 after %s', (state) => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ let connection = initialize();
+ expect(connection.fts5Available).toBe(true);
+ new QueryBuilder(connection.getDb()).insertNode(makeNode('loadRecord', 'quasar nebula'));
+
+ if (state === 'reopening') connection = reopen(connection);
+ expect(connection.fts5Available).toBe(true);
+ const queries = new QueryBuilder(connection.getDb());
+ // Only the docstring contains this token: LIKE/fuzzy name search cannot
+ // make this assertion pass if the FTS path is accidentally disabled.
+ expect(queries.searchNodes('nebula').map(result => result.node.name)).toEqual(['loadRecord']);
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it('rebuilds real FTS5 after a bulk node load', () => {
+ const connection = initialize();
+ const queries = new QueryBuilder(connection.getDb());
+
+ connection.beginBulkNodeLoad();
+ queries.insertNode(makeNode('loadRecord', 'quasar nebula'));
+ expect(queries.searchNodes('nebula')).toEqual([]);
+ connection.endBulkNodeLoad();
+
+ expect(queries.searchNodes('nebula').map(result => result.node.name)).toEqual(['loadRecord']);
+ queries.insertNode(makeNode('saveRecord', 'pulsar supernova'));
+ expect(queries.searchNodes('supernova').map(result => result.node.name)).toEqual(['saveRecord']);
+ });
+
+ it('repairs an interrupted real FTS5 bulk load on open', () => {
+ let connection = initialize();
+ connection.beginBulkNodeLoad();
+ new QueryBuilder(connection.getDb()).insertNode(makeNode('loadRecord', 'quasar nebula'));
+
+ connection = reopen(connection);
+
+ expect(connection.fts5Available).toBe(true);
+ const queries = new QueryBuilder(connection.getDb());
+ expect(queries.searchNodes('nebula').map(result => result.node.name)).toEqual(['loadRecord']);
+ queries.insertNode(makeNode('saveRecord', 'pulsar supernova'));
+ expect(queries.searchNodes('supernova').map(result => result.node.name)).toEqual(['saveRecord']);
+ });
+});
diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts
index fe5016c..4e156c3 100644
--- a/__tests__/function-ref.test.ts
+++ b/__tests__/function-ref.test.ts
@@ -795,8 +795,10 @@ describe('Function-as-value capture (#756)', () => {
// The DRF wiring: get_serializer_class → the imported serializer class,
// via `return` — the issue's headline gap. The module-level registry
- // dict rides the file node.
+ // dict rides BOTH the assigned name (the initializer walk, #693) and the
+ // file node (the dispatcher's own scan, which runs either way).
expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerFull'))).toEqual([
+ 'SERIALIZER_REGISTRY',
'get_serializer_class',
'views.py',
]);
diff --git a/__tests__/fuzzy-lexical-reach.test.ts b/__tests__/fuzzy-lexical-reach.test.ts
new file mode 100644
index 0000000..b0ff1c4
--- /dev/null
+++ b/__tests__/fuzzy-lexical-reach.test.ts
@@ -0,0 +1,153 @@
+/**
+ * A function nested inside another function is only callable from inside its
+ * container. matchByExactName already filters candidates that way; matchFuzzy
+ * must too, or a call to a builtin method (`res.text()`) whose only same-named
+ * project symbol is some file's closure resolves onto that closure.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CodeGraph } from '../src';
+import { matchFuzzy } from '../src/resolution/name-matcher';
+import type { Node } from '../src/types';
+import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
+
+describe('fuzzy matching respects lexical reachability of nested functions', () => {
+ let tempDir: string;
+ let cg: CodeGraph | null = null;
+
+ beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-fuzzy-reach-'));
+ });
+
+ afterEach(() => {
+ cg?.destroy();
+ cg = null;
+ try {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ } catch {
+ // Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway.
+ }
+ });
+
+ it('does not resolve a builtin method call onto another file\'s closure of the same name', async () => {
+ fs.writeFileSync(
+ path.join(tempDir, 'seed.ts'),
+ [
+ 'export function readSeedState(raw: string): string {',
+ ' function text(): string {',
+ ' return raw.trim();',
+ ' }',
+ ' return text();',
+ '}',
+ '',
+ ].join('\n')
+ );
+ fs.writeFileSync(
+ path.join(tempDir, 'fetch.ts'),
+ [
+ 'export async function readOkText(settled: { value: Response }): Promise {',
+ ' // A chained receiver reaches the resolver as the bare method name.',
+ ' return settled.value.text();',
+ '}',
+ '',
+ ].join('\n')
+ );
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+
+ const closure = cg
+ .getNodesByKind('function')
+ .find((n) => n.name === 'text' && n.filePath === 'seed.ts');
+ const caller = cg.getNodesByKind('function').find((n) => n.name === 'readOkText');
+ expect(closure).toBeDefined();
+ expect(caller).toBeDefined();
+
+ const fromCaller = cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls');
+ expect(fromCaller.map((e) => e.target)).not.toContain(closure!.id);
+
+ // The in-container call still resolves.
+ const container = cg.getNodesByKind('function').find((n) => n.name === 'readSeedState');
+ const inside = cg.getOutgoingEdges(container!.id).filter((e) => e.kind === 'calls');
+ expect(inside.map((e) => e.target)).toContain(closure!.id);
+ });
+});
+
+/**
+ * The reachability check must sit on the one candidate matchFuzzy would
+ * commit to, never on the candidate set. Filtering a crowd of same-named
+ * definitions down to the reachable ones leaves a single survivor, and the
+ * strategy then hands it every call of that name: vite has a dozen `resolve`
+ * definitions, most nested, and one reachable `resolve` method inherited 59
+ * `import { resolve } from 'node:path'` calls that way (#1709). Driven
+ * directly, so the shape is pinned regardless of what the earlier strategies
+ * make of a given fixture.
+ */
+describe('fuzzy reachability rejects a unique guess but never manufactures one', () => {
+ const node = (partial: Partial & Pick): Node => ({
+ qualifiedName: partial.name,
+ language: 'typescript',
+ startLine: 1,
+ endLine: 1,
+ startColumn: 0,
+ endColumn: 0,
+ updatedAt: 0,
+ ...partial,
+ });
+ // build.ts: function build() { const resolve = …; function resolve() {} }
+ const container = node({ id: 'f:build', kind: 'function', name: 'build', filePath: 'build.ts', startLine: 1, endLine: 40 });
+ const closure = node({ id: 'f:build.resolve', kind: 'function', name: 'resolve', qualifiedName: 'build::resolve', filePath: 'build.ts', startLine: 10, endLine: 12 });
+ // pluginContainer.ts: class PluginContainer { resolve() {} }
+ const method = node({ id: 'm:resolve', kind: 'method', name: 'resolve', qualifiedName: 'PluginContainer::resolve', filePath: 'pluginContainer.ts', startLine: 5, endLine: 9 });
+ const contextWith = (nodes: Node[]): ResolutionContext =>
+ ({
+ getNodesInFile: () => [],
+ getNodesByName: (name: string) => nodes.filter((n) => n.name === name),
+ getNodesByLowerName: (name: string) => nodes.filter((n) => n.name.toLowerCase() === name),
+ getNodesByQualifiedName: (qn: string) => [container].filter((n) => n.qualifiedName === qn),
+ getNodesByKind: () => [],
+ fileExists: () => false,
+ readFile: () => null,
+ getFileLines: () => [],
+ getProjectRoot: () => '',
+ getAllFiles: () => [],
+ getImportMappings: () => [],
+ }) as unknown as ResolutionContext;
+ const callFrom = (filePath: string, line: number): UnresolvedRef => ({
+ fromNodeId: 'f:caller',
+ referenceName: 'resolve',
+ referenceKind: 'calls',
+ line,
+ column: 2,
+ filePath,
+ language: 'typescript',
+ });
+
+ it('declines the sole candidate when it is a closure the call cannot reach', () => {
+ expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([closure]))).toBeNull();
+ });
+
+ it('still resolves the sole candidate from inside its container', () => {
+ expect(matchFuzzy(callFrom('build.ts', 20), contextWith([closure]))?.targetNodeId).toBe('f:build.resolve');
+ });
+
+ it('does not let the unreachable closure drop out and leave the method as a "unique" match', () => {
+ // Two same-named callables: ambiguous, exactly as before the check existed.
+ expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([closure, method]))).toBeNull();
+ });
+
+ it('trusts no nesting in C, where a nested function is an extraction artifact', () => {
+ // betaflight: tree-sitter-c's recovery from `RESET_CONFIG(…, .pid = {…})`
+ // runs resetPidProfile to the end of pid.c, so every function after it is
+ // "nested" in the graph. C has no nested named functions; the call reaches it.
+ const cClosure = node({ ...closure, id: 'f:c', language: 'c' as Node['language'], filePath: 'pid.c' });
+ const cRef = { ...callFrom('core.c', 3), language: 'c' as UnresolvedRef['language'] };
+ expect(matchFuzzy(cRef, contextWith([cClosure]))?.targetNodeId).toBe('f:c');
+ });
+
+ it('resolves a lone reachable method as before', () => {
+ expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([method]))?.targetNodeId).toBe('m:resolve');
+ });
+});
diff --git a/__tests__/import-emitted-specifier.test.ts b/__tests__/import-emitted-specifier.test.ts
new file mode 100644
index 0000000..1df1b77
--- /dev/null
+++ b/__tests__/import-emitted-specifier.test.ts
@@ -0,0 +1,126 @@
+/**
+ * TypeScript's node16/nodenext/bundler resolution writes the EMITTED extension
+ * in a relative specifier (`./util.js` for `util.ts`). The import resolver must
+ * map that back to the source file that is actually in the repo; otherwise the
+ * imported names fall through to bare-name matching and a method that wraps a
+ * same-named import resolves to itself.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CodeGraph } from '../src';
+import { resolveImportPath } from '../src/resolution/import-resolver';
+import type { ResolutionContext } from '../src/resolution';
+
+function contextWithFiles(files: string[]): ResolutionContext {
+ const set = new Set(files);
+ return {
+ getNodesInFile: () => [],
+ getNodesByName: () => [],
+ getNodesByQualifiedName: () => [],
+ getNodesByKind: () => [],
+ fileExists: (p: string) => set.has(p),
+ readFile: () => null,
+ getProjectRoot: () => '/test',
+ getAllFiles: () => files,
+ getNodesByLowerName: () => [],
+ getImportMappings: () => [],
+ } as unknown as ResolutionContext;
+}
+
+describe('emitted-extension import specifiers (`./x.js` naming `x.ts`)', () => {
+ it('maps a relative .js specifier onto the .ts source', () => {
+ const ctx = contextWithFiles(['shared/engine.ts', 'shared/util.ts']);
+ expect(resolveImportPath('./util.js', 'shared/engine.ts', 'typescript', ctx)).toBe('shared/util.ts');
+ });
+
+ it('prefers a real .js file over the remap when both exist', () => {
+ const ctx = contextWithFiles(['shared/engine.ts', 'shared/util.js', 'shared/util.ts']);
+ expect(resolveImportPath('./util.js', 'shared/engine.ts', 'typescript', ctx)).toBe('shared/util.js');
+ });
+
+ it('maps .jsx, .mjs and .cjs onto their TypeScript sources', () => {
+ const ctx = contextWithFiles(['app/a.tsx', 'app/View.tsx', 'app/esm.mts', 'app/cjs.cts']);
+ expect(resolveImportPath('./View.jsx', 'app/a.tsx', 'tsx', ctx)).toBe('app/View.tsx');
+ expect(resolveImportPath('./esm.mjs', 'app/a.tsx', 'tsx', ctx)).toBe('app/esm.mts');
+ expect(resolveImportPath('./cjs.cjs', 'app/a.tsx', 'tsx', ctx)).toBe('app/cjs.cts');
+ });
+
+ it('maps an aliased .js specifier through tsconfig paths', () => {
+ const files = ['src/main.ts', 'src/lib/util.ts'];
+ const ctx = {
+ ...contextWithFiles(files),
+ getProjectAliases: () => ({
+ baseUrl: '/test',
+ patterns: [{ prefix: '@/', suffix: '', hasWildcard: true, replacements: ['src/*'] }],
+ }),
+ } as unknown as ResolutionContext;
+ expect(resolveImportPath('@/lib/util.js', 'src/main.ts', 'typescript', ctx)).toBe('src/lib/util.ts');
+ });
+
+ it('leaves a specifier that names no source unresolved', () => {
+ const ctx = contextWithFiles(['shared/engine.ts']);
+ expect(resolveImportPath('./missing.js', 'shared/engine.ts', 'typescript', ctx)).toBeNull();
+ });
+
+ it('does not remap for a language without TypeScript emit (python)', () => {
+ const ctx = contextWithFiles(['pkg/a.py', 'pkg/b.ts']);
+ expect(resolveImportPath('./b.js', 'pkg/a.py', 'python', ctx)).toBeNull();
+ });
+});
+
+describe('end to end: a wrapper method calling the same-named import it wraps', () => {
+ let tempDir: string;
+ let cg: CodeGraph | null = null;
+
+ beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-emitted-spec-'));
+ });
+
+ afterEach(() => {
+ cg?.destroy();
+ cg = null;
+ try {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ } catch {
+ // Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway.
+ }
+ });
+
+ it('links the call to the imported function, not to the method itself', async () => {
+ fs.writeFileSync(
+ path.join(tempDir, 'template.ts'),
+ 'export function renderDockStyles(): string {\n return ".dock {}";\n}\n'
+ );
+ fs.writeFileSync(
+ path.join(tempDir, 'sidebar.ts'),
+ [
+ 'import { renderDockStyles } from "./template.js";',
+ '',
+ 'export class Sidebar {',
+ ' renderDockStyles(): string {',
+ ' return renderDockStyles();',
+ ' }',
+ '}',
+ '',
+ ].join('\n')
+ );
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+
+ const method = cg.getNodesByKind('method').find((n) => n.name === 'renderDockStyles');
+ const fn = cg
+ .getNodesByKind('function')
+ .find((n) => n.name === 'renderDockStyles' && n.filePath === 'template.ts');
+ expect(method).toBeDefined();
+ expect(fn).toBeDefined();
+ const targets = cg
+ .getOutgoingEdges(method!.id)
+ .filter((e) => e.kind === 'calls')
+ .map((e) => e.target);
+ expect(targets).toContain(fn!.id);
+ expect(targets).not.toContain(method!.id);
+ });
+});
diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts
index 4ec3e59..bdf57bd 100644
--- a/__tests__/installer-targets.test.ts
+++ b/__tests__/installer-targets.test.ts
@@ -40,6 +40,8 @@ function setHome(dir: string): { restore: () => void } {
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
HERMES_HOME: process.env.HERMES_HOME,
COPILOT_HOME: process.env.COPILOT_HOME,
+ CODEX_HOME: process.env.CODEX_HOME,
+ CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
};
process.env.HOME = dir;
process.env.USERPROFILE = dir;
@@ -47,6 +49,8 @@ function setHome(dir: string): { restore: () => void } {
process.env.XDG_CONFIG_HOME = path.join(dir, '.config');
delete process.env.HERMES_HOME;
delete process.env.COPILOT_HOME;
+ delete process.env.CODEX_HOME;
+ delete process.env.CLAUDE_CONFIG_DIR;
return {
restore() {
if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME;
@@ -55,6 +59,8 @@ function setHome(dir: string): { restore: () => void } {
if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME;
if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME;
if (prev.COPILOT_HOME === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = prev.COPILOT_HOME;
+ if (prev.CODEX_HOME === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev.CODEX_HOME;
+ if (prev.CLAUDE_CONFIG_DIR === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev.CLAUDE_CONFIG_DIR;
},
};
}
@@ -138,6 +144,8 @@ describe('Installer targets — contract', () => {
// opencode uses `mcp` not `mcpServers`. Match its shape too.
if (target.id === 'opencode') {
delete seed.mcpServers;
+ // Keep a v1-shaped sibling — real configs mix shapes during the
+ // OpenCode 1→2 transition; install must not disturb it (#1698).
seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } };
}
// VS Code's mcp.json uses `servers`; the JetBrains Copilot
@@ -153,7 +161,10 @@ describe('Installer targets — contract', () => {
const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
if (target.id === 'opencode') {
expect(after.mcp.other).toBeDefined();
- expect(after.mcp.codegraph).toBeDefined();
+ expect(after.mcp.servers.codegraph).toBeDefined();
+ expect(after.mcp.servers.codegraph.codemode).toBe(false);
+ expect(after.mcp.servers.codegraph.disabled).toBe(false);
+ expect(after.mcp.codegraph).toBeUndefined();
} else if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') {
expect(after.servers.other).toBeDefined();
expect(after.servers.codegraph).toBeDefined();
@@ -875,7 +886,7 @@ describe('Installer targets — partial-state idempotency', () => {
expect(body).toContain(' telegram:\n - hermes-telegram');
});
- it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => {
+ it('opencode: uninstall removes only mcp.servers.codegraph, preserves comments and siblings', () => {
const opencode = getTarget('opencode')!;
const dir = path.join(tmpHome, '.config', 'opencode');
fs.mkdirSync(dir, { recursive: true });
@@ -892,13 +903,15 @@ describe('Installer targets — partial-state idempotency', () => {
].join('\n'));
opencode.install('global', { autoAllow: true });
- const afterInstall = fs.readFileSync(file, 'utf-8');
- expect(afterInstall).toContain('"codegraph"');
- expect(afterInstall).toContain('"other"');
+ const afterInstall = parseJsonc(fs.readFileSync(file, 'utf-8'));
+ expect(afterInstall.mcp.servers.codegraph).toBeDefined();
+ expect(afterInstall.mcp.servers.codegraph.codemode).toBe(false);
+ expect(afterInstall.mcp.other).toBeDefined();
opencode.uninstall('global');
const afterUninstall = fs.readFileSync(file, 'utf-8');
expect(afterUninstall).not.toContain('codegraph');
+ expect(afterUninstall).not.toContain('"servers"');
expect(afterUninstall).toContain('// important comment');
expect(afterUninstall).toContain('"other"');
});
@@ -976,6 +989,22 @@ describe('Installer targets — partial-state idempotency', () => {
expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false);
const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
+ // Exempt from Claude Code's tool-search deferral (#1696).
+ expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true);
+ });
+
+ it('claude: re-running install on an entry that predates alwaysLoad adds the key (#1696)', () => {
+ const claude = getTarget('claude')!;
+ fs.writeFileSync(
+ path.join(tmpCwd, '.mcp.json'),
+ JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2),
+ );
+ const result = claude.install('local', { autoAllow: false });
+ const mcp = result.files.find((f) => f.path.replace(/\\/g, '/').endsWith('/.mcp.json'));
+ expect(mcp?.action).toBe('updated');
+ const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
+ expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true);
+ expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']);
});
it('claude: install creates the CLAUDE.md codegraph block (#704)', () => {
@@ -1010,6 +1039,7 @@ describe('Installer targets — partial-state idempotency', () => {
claude.install('global', { autoAllow: false });
const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
+ expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true);
});
it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => {
@@ -1813,6 +1843,149 @@ function listAllFiles(dir: string): string[] {
return out;
}
+// ---------------------------------------------------------------------------
+// opencode OpenCode 2 native MCP shape (#1698)
+//
+// OpenCode 2 reads `mcp.servers.` with `disabled` / `codemode`. The
+// v1 `mcp.` + `enabled` shape still connects but drops `codemode`
+// during normalization — so the installer must write the native shape and
+// migrate/uninstall either.
+// ---------------------------------------------------------------------------
+describe('Installer targets — opencode native MCP shape (#1698)', () => {
+ let tmpHome: string;
+ let tmpCwd: string;
+ let origCwd: string;
+ let homeRestore: { restore: () => void };
+
+ beforeEach(() => {
+ tmpHome = mkTmpDir('home');
+ tmpCwd = mkTmpDir('cwd');
+ origCwd = process.cwd();
+ process.chdir(tmpCwd);
+ homeRestore = setHome(tmpHome);
+ });
+
+ afterEach(() => {
+ homeRestore.restore();
+ process.chdir(origCwd);
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ fs.rmSync(tmpCwd, { recursive: true, force: true });
+ });
+
+ const configFile = () => path.join(tmpHome, '.config', 'opencode', 'opencode.jsonc');
+
+ it('install writes mcp.servers.codegraph with disabled:false and codemode:false', () => {
+ const opencode = getTarget('opencode')!;
+ opencode.install('global', { autoAllow: true });
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf-8'));
+ expect(cfg.mcp.codegraph).toBeUndefined();
+ expect(cfg.mcp.servers.codegraph).toEqual({
+ type: 'local',
+ command: ['codegraph', 'serve', '--mcp'],
+ disabled: false,
+ codemode: false,
+ });
+ });
+
+ it('printConfig shows the native OpenCode 2 shape', () => {
+ const out = getTarget('opencode')!.printConfig('global');
+ expect(out).toContain('"servers"');
+ expect(out).toContain('"codemode": false');
+ expect(out).toContain('"disabled": false');
+ expect(out).not.toContain('"enabled"');
+ // No v1 top-level mcp.codegraph key in the snippet.
+ expect(out).not.toMatch(/"mcp"\s*:\s*\{\s*"codegraph"/);
+ });
+
+ it('re-install migrates a v1 mcp.codegraph entry to mcp.servers.codegraph', () => {
+ const dir = path.dirname(configFile());
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(configFile(), [
+ '{',
+ ' // keep me',
+ ' "$schema": "https://opencode.ai/config.json",',
+ ' "mcp": {',
+ ' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },',
+ ' "other": { "type": "local", "command": ["x"], "enabled": true }',
+ ' }',
+ '}',
+ '',
+ ].join('\n'));
+
+ const opencode = getTarget('opencode')!;
+ expect(opencode.detect('global').alreadyConfigured).toBe(true);
+
+ const result = opencode.install('global', { autoAllow: true });
+ expect(result.files.find((f) => f.path === configFile())!.action).toBe('updated');
+
+ const text = fs.readFileSync(configFile(), 'utf-8');
+ expect(text).toContain('// keep me');
+ const cfg = parseJsonc(text);
+ expect(cfg.mcp.codegraph).toBeUndefined();
+ expect(cfg.mcp.other).toBeDefined();
+ expect(cfg.mcp.servers.codegraph).toEqual({
+ type: 'local',
+ command: ['codegraph', 'serve', '--mcp'],
+ disabled: false,
+ codemode: false,
+ });
+
+ // Idempotent after migration.
+ const second = opencode.install('global', { autoAllow: true });
+ expect(second.files.find((f) => f.path === configFile())!.action).toBe('unchanged');
+ });
+
+ it('uninstall removes a leftover v1 mcp.codegraph entry', () => {
+ const dir = path.dirname(configFile());
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(configFile(), [
+ '{',
+ ' // keep me',
+ ' "$schema": "https://opencode.ai/config.json",',
+ ' "mcp": {',
+ ' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },',
+ ' "other": { "type": "local", "command": ["x"], "enabled": true }',
+ ' }',
+ '}',
+ '',
+ ].join('\n'));
+
+ const opencode = getTarget('opencode')!;
+ opencode.uninstall('global');
+ const text = fs.readFileSync(configFile(), 'utf-8');
+ expect(text).toContain('// keep me');
+ expect(text).toContain('"other"');
+ expect(text).not.toContain('codegraph');
+ expect(opencode.detect('global').alreadyConfigured).toBe(false);
+ });
+
+ it('uninstall removes a native mcp.servers.codegraph entry and an emptied servers wrapper', () => {
+ const dir = path.dirname(configFile());
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(configFile(), JSON.stringify({
+ $schema: 'https://opencode.ai/config.json',
+ mcp: {
+ servers: {
+ codegraph: {
+ type: 'local',
+ command: ['codegraph', 'serve', '--mcp'],
+ disabled: false,
+ codemode: false,
+ },
+ },
+ },
+ }, null, 2) + '\n');
+
+ const opencode = getTarget('opencode')!;
+ opencode.uninstall('global');
+ const text = fs.readFileSync(configFile(), 'utf-8');
+ expect(text).not.toContain('codegraph');
+ expect(text).not.toContain('"servers"');
+ expect(text).not.toContain('"mcp"');
+ expect(opencode.detect('global').alreadyConfigured).toBe(false);
+ });
+});
+
// ---------------------------------------------------------------------------
// opencode global config path — XDG on every platform (#535)
//
@@ -2180,7 +2353,7 @@ describe('Installer targets — Copilot family', () => {
// ---- copilot-cli ----
- it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"])', () => {
+ it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"], deferTools: "never")', () => {
const t = getTarget('copilot-cli')!;
const result = t.install('global', { autoAllow: true });
@@ -2193,9 +2366,26 @@ describe('Installer targets — Copilot family', () => {
command: 'codegraph',
args: ['serve', '--mcp'],
tools: ['*'],
+ // Exempt from Copilot CLI's tool search, the same way `alwaysLoad` exempts it in Claude Code (#1696).
+ deferTools: 'never',
});
});
+ it('copilot-cli: re-running install on an entry that predates deferTools adds the key (#1696)', () => {
+ const t = getTarget('copilot-cli')!;
+ const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(
+ file,
+ JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'], tools: ['*'] } } }, null, 2),
+ );
+ const result = t.install('global', { autoAllow: true });
+ expect(result.files[0].action).toBe('updated');
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
+ expect(cfg.mcpServers.codegraph.deferTools).toBe('never');
+ expect(cfg.mcpServers.codegraph.tools).toEqual(['*']);
+ });
+
it('copilot-cli: is global-only — local install skips with a clear note, uninstall is a no-op', () => {
const t = getTarget('copilot-cli')!;
expect(t.supportsLocation('local')).toBe(false);
@@ -2481,3 +2671,208 @@ describe('Installer targets — Copilot family', () => {
expect(jetbrains.detect('global').alreadyConfigured).toBe(true);
});
});
+
+describe('Installer targets — Claude CLAUDE_CONFIG_DIR override (#1627)', () => {
+ let tmpHome: string;
+ let tmpCwd: string;
+ let origCwd: string;
+ let homeRestore: { restore: () => void };
+
+ beforeEach(() => {
+ tmpHome = mkTmpDir('home');
+ tmpCwd = mkTmpDir('cwd');
+ origCwd = process.cwd();
+ process.chdir(tmpCwd);
+ homeRestore = setHome(tmpHome);
+ });
+
+ afterEach(() => {
+ homeRestore.restore();
+ process.chdir(origCwd);
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ fs.rmSync(tmpCwd, { recursive: true, force: true });
+ });
+
+ it.each(['absolute', 'relative'])('global install honors %s CLAUDE_CONFIG_DIR paths', (kind) => {
+ const custom = path.join(tmpHome, 'claude profile');
+ process.env.CLAUDE_CONFIG_DIR = kind === 'relative' ? path.relative(tmpCwd, custom) : custom;
+
+ const claude = getTarget('claude')!;
+ const result = claude.install('global', { autoAllow: true });
+ const paths = [
+ path.join(custom, '.claude.json'),
+ path.join(custom, 'settings.json'),
+ path.join(custom, 'CLAUDE.md'),
+ ] as const;
+
+ expect(result.files.map((f) => f.path)).toEqual(paths);
+ const mcp = JSON.parse(fs.readFileSync(paths[0], 'utf-8'));
+ expect(mcp.mcpServers.codegraph.alwaysLoad).toBe(true);
+ const settings = JSON.parse(fs.readFileSync(paths[1], 'utf-8'));
+ expect(settings.permissions.allow).toContain('mcp__codegraph__*');
+ expect(fs.readFileSync(paths[2], 'utf-8')).toContain('codegraph explore');
+ expect(claude.describePaths('global')).toEqual(paths);
+ expect(claude.printConfig('global')).toContain(`# Add to ${paths[0]}`);
+
+ const before = paths.map((p) => fs.readFileSync(p, 'utf-8'));
+ expect(claude.install('global', { autoAllow: true }).files.every((f) => f.action === 'unchanged')).toBe(true);
+ expect(paths.map((p) => fs.readFileSync(p, 'utf-8'))).toEqual(before);
+ expect(fs.existsSync(path.join(tmpHome, '.claude'))).toBe(false);
+ expect(fs.existsSync(path.join(tmpHome, '.claude.json'))).toBe(false);
+ });
+
+ it('detect and uninstall follow CLAUDE_CONFIG_DIR without touching the default profile', () => {
+ const claude = getTarget('claude')!;
+ claude.install('global', { autoAllow: true });
+ const defaults = claude.describePaths('global');
+ const before = defaults.map((p) => fs.readFileSync(p, 'utf-8'));
+
+ const custom = path.join(tmpHome, 'claude-profile');
+ process.env.CLAUDE_CONFIG_DIR = custom;
+ const mcpPath = path.join(custom, '.claude.json');
+ expect(claude.detect('global')).toEqual({
+ installed: false, alreadyConfigured: false, configPath: mcpPath,
+ });
+
+ claude.install('global', { autoAllow: true });
+ expect(claude.detect('global')).toEqual({
+ installed: true, alreadyConfigured: true, configPath: mcpPath,
+ });
+
+ const removed = claude.uninstall('global');
+ expect(removed.files).toEqual([
+ { path: mcpPath, action: 'removed' },
+ { path: path.join(custom, 'settings.json'), action: 'removed' },
+ { path: path.join(custom, 'CLAUDE.md'), action: 'removed' },
+ ]);
+ expect(JSON.parse(fs.readFileSync(mcpPath, 'utf-8')).mcpServers).toBeUndefined();
+ expect(JSON.parse(fs.readFileSync(path.join(custom, 'settings.json'), 'utf-8')).permissions).toBeUndefined();
+ expect(fs.existsSync(path.join(custom, 'CLAUDE.md'))).toBe(false);
+ expect(claude.detect('global').alreadyConfigured).toBe(false);
+ expect(defaults.map((p) => fs.readFileSync(p, 'utf-8'))).toEqual(before);
+ });
+
+ it.each([undefined, '', ' '])('falls back to the default profile when CLAUDE_CONFIG_DIR is %j', (override) => {
+ if (override !== undefined) process.env.CLAUDE_CONFIG_DIR = override;
+ const claude = getTarget('claude')!;
+ const result = claude.install('global', { autoAllow: true });
+
+ expect(result.files.map((f) => f.path)).toEqual([
+ path.join(tmpHome, '.claude.json'),
+ path.join(tmpHome, '.claude', 'settings.json'),
+ path.join(tmpHome, '.claude', 'CLAUDE.md'),
+ ]);
+ expect(JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8')).mcpServers.codegraph).toBeDefined();
+ expect(fs.existsSync(path.join(tmpHome, '.claude', 'settings.json'))).toBe(true);
+ expect(fs.existsSync(path.join(tmpHome, '.claude', 'CLAUDE.md'))).toBe(true);
+ // Claude Code keeps the default MCP JSON beside ~/.claude, not inside it.
+ expect(fs.existsSync(path.join(tmpHome, '.claude', '.claude.json'))).toBe(false);
+ });
+
+ it('leaves local install, detect, and uninstall unaffected by CLAUDE_CONFIG_DIR', () => {
+ const custom = path.join(tmpHome, 'claude-profile');
+ process.env.CLAUDE_CONFIG_DIR = custom;
+ const claude = getTarget('claude')!;
+ const result = claude.install('local', { autoAllow: true });
+ const mcpPath = path.join(tmpCwd, '.mcp.json');
+
+ expect(result.files.map((f) => f.path)).toEqual([
+ mcpPath,
+ path.join(tmpCwd, '.claude', 'settings.json'),
+ path.join(tmpCwd, '.claude', 'CLAUDE.md'),
+ ]);
+ expect(JSON.parse(fs.readFileSync(mcpPath, 'utf-8')).mcpServers.codegraph).toBeDefined();
+ expect(claude.detect('local')).toEqual({
+ installed: true, alreadyConfigured: true, configPath: mcpPath,
+ });
+ claude.uninstall('local');
+ expect(claude.detect('local').alreadyConfigured).toBe(false);
+ expect(fs.existsSync(custom)).toBe(false);
+ expect(fs.existsSync(path.join(tmpHome, '.claude'))).toBe(false);
+ expect(fs.existsSync(path.join(tmpHome, '.claude.json'))).toBe(false);
+ });
+});
+
+describe('Installer targets — Codex CODEX_HOME override (#1627)', () => {
+ let tmpHome: string;
+ let tmpCwd: string;
+ let origCwd: string;
+ let homeRestore: { restore: () => void };
+
+ beforeEach(() => {
+ tmpHome = mkTmpDir('home');
+ tmpCwd = mkTmpDir('cwd');
+ origCwd = process.cwd();
+ process.chdir(tmpCwd);
+ homeRestore = setHome(tmpHome);
+ });
+
+ afterEach(() => {
+ homeRestore.restore();
+ process.chdir(origCwd);
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ fs.rmSync(tmpCwd, { recursive: true, force: true });
+ });
+
+ const defaultDir = () => path.join(tmpHome, '.codex');
+
+ it('global install writes to $CODEX_HOME, not ~/.codex', () => {
+ const custom = path.join(tmpHome, 'codex-profile');
+ process.env.CODEX_HOME = custom;
+
+ const codex = getTarget('codex')!;
+ const result = codex.install('global', { autoAllow: false });
+
+ const toml = result.files.find((f) => f.path.endsWith('config.toml'))!;
+ expect(path.resolve(toml.path)).toBe(path.resolve(path.join(custom, 'config.toml')));
+ expect(fs.readFileSync(path.join(custom, 'config.toml'), 'utf-8')).toContain('[mcp_servers.codegraph]');
+ // The global AGENTS.md follows the config dir.
+ expect(fs.existsSync(path.join(custom, 'AGENTS.md'))).toBe(true);
+ // Nothing of ours may land in the default profile Codex is not reading.
+ expect(fs.existsSync(defaultDir())).toBe(false);
+ });
+
+ it('detect and uninstall follow $CODEX_HOME too', () => {
+ const custom = path.join(tmpHome, 'codex-profile');
+ process.env.CODEX_HOME = custom;
+ const codex = getTarget('codex')!;
+
+ expect(codex.detect('global').alreadyConfigured).toBe(false);
+ codex.install('global', { autoAllow: false });
+
+ const detected = codex.detect('global');
+ expect(detected.alreadyConfigured).toBe(true);
+ expect(path.resolve(detected.configPath!)).toBe(path.resolve(path.join(custom, 'config.toml')));
+
+ const removed = codex.uninstall('global');
+ expect(path.resolve(removed.files.find((f) => f.path.endsWith('config.toml'))!.path))
+ .toBe(path.resolve(path.join(custom, 'config.toml')));
+ // Our table was the only content, so the file goes with it.
+ expect(fs.existsSync(path.join(custom, 'config.toml'))).toBe(false);
+ });
+
+ it('falls back to ~/.codex when CODEX_HOME is unset or blank', () => {
+ const codex = getTarget('codex')!;
+ codex.install('global', { autoAllow: false });
+ expect(fs.existsSync(path.join(defaultDir(), 'config.toml'))).toBe(true);
+
+ fs.rmSync(defaultDir(), { recursive: true, force: true });
+ process.env.CODEX_HOME = ' '; // set-but-empty must not become the config dir
+ codex.install('global', { autoAllow: false });
+ expect(fs.existsSync(path.join(defaultDir(), 'config.toml'))).toBe(true);
+ });
+
+ it('leaves the local install alone — CODEX_HOME is the user layer only (#1531)', () => {
+ const custom = path.join(tmpHome, 'codex-profile');
+ process.env.CODEX_HOME = custom;
+
+ const codex = getTarget('codex')!;
+ const result = codex.install('local', { autoAllow: false });
+
+ const paths = result.files.map((f) => f.path.replace(/\\/g, '/'));
+ expect(paths.some((p) => p.endsWith('/.codex/config.toml'))).toBe(true);
+ expect(fs.existsSync(path.join(process.cwd(), '.codex', 'config.toml'))).toBe(true);
+ // The project layer lives beside the project, never under the user profile.
+ expect(fs.existsSync(path.join(custom, 'config.toml'))).toBe(false);
+ });
+});
diff --git a/__tests__/kernel-kotlin-parity.test.ts b/__tests__/kernel-kotlin-parity.test.ts
index 4e45408..649c444 100644
--- a/__tests__/kernel-kotlin-parity.test.ts
+++ b/__tests__/kernel-kotlin-parity.test.ts
@@ -5,7 +5,9 @@
* compiled from the vendored fwcd 0.3.8 C sources, the arc's first
* vendored-grammar-C language) produces the SAME ExtractionResult as the
* wasm TreeSitterExtractor over the checked-in torture fixture (torture.kt:
- * the property hook's scope classification, extension-function receiver QNs
+ * the property hook's scope classification and its initializer walk (a
+ * lambda / SAM / anonymous-object RHS attributing its calls to the property),
+ * extension-function receiver QNs
* (`WidgetK::extend`, the qualified `com::qext` bug) + the owner-contains
* fallback, expect/actual → node DECORATORS (the KMP synthesizer feed),
* the bodiless-vs-bodied class header asymmetry, comment-glued
diff --git a/__tests__/kernel-lua-parity.test.ts b/__tests__/kernel-lua-parity.test.ts
index 2e1e9b2..e87b007 100644
--- a/__tests__/kernel-lua-parity.test.ts
+++ b/__tests__/kernel-lua-parity.test.ts
@@ -127,6 +127,22 @@ describe.skipIf(!kernelBuilt)('kernel Lua/Luau extraction parity', () => {
// lua functions carry NO isExported (undefined — not false).
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'topFn');
expect(fn?.isExported).toBeUndefined();
+ expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'anonAssigned')).toBe(true);
+ expect(result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M::assigned')).toBe(true);
+ expect(
+ result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::on_start')
+ ).toBe(true);
+ expect(
+ result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::on_stop')
+ ).toBe(true);
+ for (const qualifiedName of ['M::assigned', 'M.callbacks::on_start', 'M.callbacks::on_stop']) {
+ const callable = result.nodes.find((n) => n.qualifiedName === qualifiedName)!;
+ expect(
+ refs.some(
+ (r) => r.fromNodeId === callable.id && r.referenceKind === 'calls' && r.referenceName === 'topFn'
+ )
+ ).toBe(true);
+ }
// variables DO carry isExported === false.
const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'core');
expect(v?.isExported).toBe(false);
diff --git a/__tests__/kernel-rustlang-parity.test.ts b/__tests__/kernel-rustlang-parity.test.ts
index 07c897b..7ba73fe 100644
--- a/__tests__/kernel-rustlang-parity.test.ts
+++ b/__tests__/kernel-rustlang-parity.test.ts
@@ -5,7 +5,8 @@
* SAME ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
* unresolved refs compared as canonicalized multisets — over the checked-in
* torture fixture (torture.rs: impl/trait quirks incl. generic / lifetime /
- * reference / scoped / generic-trait impl receivers (#1588), unit-struct skip, phantom
+ * reference / scoped / generic-trait impl receivers (#1588), unit structs
+ * (a bodiless struct IS a definition — both walkers mint a node), phantom
* const identifiers, use-binding refs incl. nested groups + wildcard-emits-
* nothing, chained-call re-encode, turbofish, Rocket route macros body-only,
* fn-ref shapes, value-ref shadowing, attribute-broken docstrings, dead-code
diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts
index c16d41f..3d4316a 100644
--- a/__tests__/kernel-tsjs-parity.test.ts
+++ b/__tests__/kernel-tsjs-parity.test.ts
@@ -76,7 +76,7 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => {
resetKernelForTests();
});
- function assertParity(filePath: string, source: string, language: Language): void {
+ function assertParity(filePath: string, source: string, language: Language): ExtractionResult {
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
delete process.env.CODEGRAPH_KERNEL;
const viaKernel = tryKernelExtract(filePath, source, language);
@@ -93,8 +93,32 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => {
expect(k.refs, `${filePath}: refs`).toEqual(w.refs);
// Meaningful comparison, not empty-vs-empty.
expect(viaWasm.nodes.length).toBeGreaterThan(3);
+ return viaWasm;
}
+ it.each([
+ ['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
+ ] as const)('leaves nested identifier receivers unresolved and keeps argument calls: %s (#1566)', (ext, language) => {
+ const result = assertParity(`fixture.${ext}`, `
+function readKey() { return 'answer'; }
+function local() {
+ const values = new Map();
+ return values.get(readKey());
+}
+function nested(holder) {
+ holder.values.get(readKey());
+ holder.values?.get(readKey());
+ holder['values'].get(readKey());
+ holder.deep.values.get(readKey());
+}
+`, language);
+ const nested = result.nodes.find((n) => n.name === 'nested' && n.kind === 'function');
+ expect(nested).toBeDefined();
+ expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls' && r.fromNodeId === nested!.id)
+ .map((r) => r.referenceName)).toEqual(['readKey', 'readKey', 'readKey', 'readKey']);
+ expect(result.unresolvedReferences.some((r) => r.referenceName === 'values.get')).toBe(true);
+ });
+
it('torture fixture (tsx): components, stores, RTK, fn-refs, value-refs, decorators', () => {
const file = path.join(FIXTURE_DIR, 'torture.tsx');
assertParity('fixtures/torture.tsx', fs.readFileSync(file, 'utf8'), 'tsx');
diff --git a/__tests__/mcp-callers-truncation.test.ts b/__tests__/mcp-callers-truncation.test.ts
new file mode 100644
index 0000000..ccb6083
--- /dev/null
+++ b/__tests__/mcp-callers-truncation.test.ts
@@ -0,0 +1,97 @@
+/**
+ * The MCP `codegraph_callers` / `codegraph_callees` answers say when their
+ * `limit` cut the list (#1639, #1674). A capped list with no marker reads as
+ * the complete set, and an agent under-counts "who calls this" from it.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CodeGraph } from '../src';
+import { ToolHandler } from '../src/mcp/tools';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+let tmpDir: string;
+let cg: CodeGraph;
+let handler: ToolHandler;
+
+const text = async (tool: string, args: Record): Promise => {
+ const res = await handler.execute(tool, args);
+ return res.content?.[0]?.text ?? '';
+};
+
+const CALLERS = 25;
+
+beforeAll(async () => {
+ await initGrammars();
+ await loadAllGrammars();
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1674-'));
+ fs.mkdirSync(path.join(tmpDir, 'src'));
+ // `warm` lives in a file of another name: one definition, the flat list.
+ fs.writeFileSync(path.join(tmpDir, 'src', 'target.ts'), 'export function warm(n: number): number { return n; }\n');
+ fs.writeFileSync(
+ path.join(tmpDir, 'src', 'callers.ts'),
+ "import { warm } from './target';\n" +
+ Array.from({ length: CALLERS }, (_, i) => `export function caller${i}(): number { return warm(${i}); }`).join('\n') +
+ '\n'
+ );
+ // `hot` shares its name with its file, so the answer groups per definition.
+ fs.writeFileSync(path.join(tmpDir, 'src', 'hot.ts'), 'export function hot(n: number): number { return n; }\n');
+ fs.writeFileSync(
+ path.join(tmpDir, 'src', 'hot-callers.ts'),
+ "import { hot } from './hot';\n" +
+ Array.from({ length: CALLERS }, (_, i) => `export function hotCaller${i}(): number { return hot(${i}); }`).join('\n') +
+ '\n'
+ );
+ fs.writeFileSync(
+ path.join(tmpDir, 'src', 'fan.ts'),
+ Array.from({ length: CALLERS }, (_, i) => `export function helper${i}(): number { return ${i}; }`).join('\n') +
+ `\nexport function fanout(): number { return ${Array.from({ length: CALLERS }, (_, i) => `helper${i}()`).join(' + ')}; }\n`
+ );
+ cg = CodeGraph.initSync(tmpDir);
+ await cg.indexAll();
+ handler = new ToolHandler(cg);
+});
+
+afterAll(() => {
+ cg.destroy();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+describe('codegraph_callers truncation', () => {
+ it('says how many callers the default limit hid', async () => {
+ const out = await text('codegraph_callers', { symbol: 'warm' });
+ // The importing file counts as a caller too, so the total is at least CALLERS.
+ const m = out.match(/Showing 20 of (\d+) callers; pass `limit`/);
+ expect(m).not.toBeNull();
+ expect(Number(m![1])).toBeGreaterThanOrEqual(CALLERS);
+ expect(out.match(/^- caller\d+ /gm)?.length).toBe(20);
+ });
+
+ it('is silent when the list is complete', async () => {
+ const out = await text('codegraph_callers', { symbol: 'warm', limit: 100 });
+ expect(out).not.toContain('Showing');
+ expect(out.match(/^- caller\d+ /gm)?.length).toBe(CALLERS);
+ });
+
+ it('marks the cut inside each per-definition section too', async () => {
+ const out = await text('codegraph_callers', { symbol: 'hot' });
+ expect(out).toContain('distinct definitions');
+ expect(out).toMatch(/- … \+\d+ more \(pass `limit` to widen\)/);
+ expect(await text('codegraph_callers', { symbol: 'hot', limit: 100 })).not.toContain('more (pass');
+ });
+});
+
+describe('codegraph_callees truncation', () => {
+ it('says how many callees the default limit hid', async () => {
+ const out = await text('codegraph_callees', { symbol: 'fanout' });
+ const m = out.match(/Showing 20 of (\d+) callees; pass `limit`/);
+ expect(m).not.toBeNull();
+ expect(Number(m![1])).toBe(CALLERS);
+ });
+
+ it('is silent when the list is complete', async () => {
+ const out = await text('codegraph_callees', { symbol: 'fanout', limit: 100 });
+ expect(out).not.toContain('Showing');
+ });
+});
diff --git a/__tests__/mcp-tool-annotations.test.ts b/__tests__/mcp-tool-annotations.test.ts
index 28dbe25..2b5920b 100644
--- a/__tests__/mcp-tool-annotations.test.ts
+++ b/__tests__/mcp-tool-annotations.test.ts
@@ -12,6 +12,9 @@
* rewrites codegraph_explore's description via spread), and the no-default-
* project surface (`withRequiredProjectPath`, which clones the schema). A drop in
* any of those would silently re-block the tools in Ask mode.
+ *
+ * `codegraph_explore`'s `_meta` (`anthropic/alwaysLoad`, #1696) rides the same
+ * spreads, so each surface is checked for it here too.
*/
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import * as fs from 'fs';
@@ -34,6 +37,13 @@ function expectReadOnly(tool: ToolDefinition): void {
expect(tool.annotations!.openWorldHint).toBe(false);
}
+/** Assert the explore tool in a `tools/list` surface is marked always-load for Claude Code (#1696). */
+function expectExploreAlwaysLoad(surface: ToolDefinition[]): void {
+ const explore = surface.find((t) => t.name === 'codegraph_explore');
+ expect(explore, 'codegraph_explore is missing from the surface').toBeDefined();
+ expect(explore!._meta).toEqual({ 'anthropic/alwaysLoad': true });
+}
+
describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
const original = process.env[ENV];
afterEach(() => {
@@ -44,6 +54,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
it('every tool in the master array is annotated read-only', () => {
expect(tools.length).toBeGreaterThan(0);
for (const tool of tools) expectReadOnly(tool);
+ expectExploreAlwaysLoad(tools);
});
it('the static proxy surface carries annotations on every exposed tool', () => {
@@ -52,6 +63,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
const got = getStaticTools();
expect(got.map((t) => t.name).sort()).toEqual(tools.map((t) => t.name).sort());
for (const tool of got) expectReadOnly(tool);
+ expectExploreAlwaysLoad(got);
});
it('the no-default-project surface keeps annotations through the schema clone', () => {
@@ -65,6 +77,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
// Sanity: this IS the clone path (projectPath got marked required).
expect(tool.inputSchema.required ?? []).toContain('projectPath');
}
+ expectExploreAlwaysLoad(got);
});
});
@@ -95,11 +108,13 @@ describe('Live tool surface keeps annotations with a project open (#1018)', () =
expect(got.length).toBeGreaterThan(0);
for (const tool of got) expectReadOnly(tool);
- // explore's description is regenerated with a per-repo budget suffix via
- // object spread; the annotation must survive that rewrite.
+ // explore's description is regenerated with a per-repo advisory-guidance
+ // suffix via object spread; the annotation must survive that rewrite.
const explore = got.find((t) => t.name === 'codegraph_explore');
expect(explore).toBeDefined();
- expect(explore!.description).toMatch(/Budget: make at most/);
+ expect(explore!.description).toMatch(/advisory only, NOT a quota/);
+ expect(explore!.description).not.toMatch(/make at most/);
expectReadOnly(explore!);
+ expectExploreAlwaysLoad(got);
});
});
diff --git a/__tests__/mcp-writer-lock.test.ts b/__tests__/mcp-writer-lock.test.ts
new file mode 100644
index 0000000..be68f94
--- /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__/nested-declarator-functions.test.ts b/__tests__/nested-declarator-functions.test.ts
new file mode 100644
index 0000000..0e04316
--- /dev/null
+++ b/__tests__/nested-declarator-functions.test.ts
@@ -0,0 +1,76 @@
+/**
+ * A function bound by a `const` inside another function is a symbol (#1669).
+ *
+ * `const handleClear = () => {…}` inside a component is how every React
+ * handler that skips `useCallback` is written. At module scope the same
+ * declaration already names a function; inside a body it was skipped, so the
+ * handler was absent from callers / impact — "Symbol not found", which reads
+ * exactly like "no callers" — and its calls attributed to the component.
+ */
+import { describe, it, expect, beforeAll } from 'vitest';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+beforeAll(async () => {
+ await initGrammars();
+ await loadAllGrammars();
+});
+
+const refsFrom = (result: ReturnType, id: string) =>
+ result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => r.referenceName);
+
+describe('declarator-bound functions inside a body', () => {
+ it('extracts const arrows and function expressions as functions of the enclosing one', () => {
+ const code = `
+import { formatLabel, parseLabel } from './labels'
+export default function Widget({ items, onPick }) {
+ const handleClear = () => {
+ onPick(null, null)
+ }
+ const describe = function (item) {
+ return formatLabel(item)
+ }
+ let later = (x) => parseLabel(x)
+ const count = items.length
+ const [a, b] = [() => 1, () => 2]
+ return items.map((i) => )
+}
+`;
+ const result = extractFromSource('src/widget.jsx', code);
+ const fns = result.nodes.filter((n) => n.kind === 'function');
+ const names = fns.map((n) => n.name);
+ expect(names).toEqual(expect.arrayContaining(['Widget', 'handleClear', 'describe', 'later']));
+ // A value, a destructuring and an inline arrow stay out.
+ expect(names).not.toContain('count');
+ expect(names).not.toContain('a');
+ expect(names.filter((n) => n === '')).toEqual([]);
+
+ const widget = fns.find((n) => n.name === 'Widget')!;
+ const handleClear = fns.find((n) => n.name === 'handleClear')!;
+ const describeFn = fns.find((n) => n.name === 'describe')!;
+ expect(handleClear.qualifiedName).toBe('Widget::handleClear');
+ expect(handleClear.startLine).toBe(4);
+ expect(describeFn.startLine).toBe(7);
+
+ // The handler's calls are its own; the component keeps what it does itself.
+ expect(refsFrom(result, handleClear.id)).toContain('onPick');
+ expect(refsFrom(result, widget.id)).not.toContain('onPick');
+ expect(refsFrom(result, describeFn.id)).toContain('formatLabel');
+ expect(refsFrom(result, widget.id)).toContain('handleClear');
+
+ // Containment: the component contains its handlers.
+ const contains = result.edges.filter((e) => e.kind === 'contains' && e.source === widget.id).map((e) => e.target);
+ expect(contains).toContain(handleClear.id);
+ expect(contains).toContain(describeFn.id);
+ });
+
+ it('does not apply outside the JS family', () => {
+ const code = `
+def outer():
+ inner = lambda x: x + 1
+ return inner(1)
+`;
+ const result = extractFromSource('src/mod.py', code);
+ expect(result.nodes.filter((n) => n.kind === 'function').map((n) => n.name)).toEqual(['outer']);
+ });
+});
diff --git a/__tests__/no-silent-fuzzy-symbol.test.ts b/__tests__/no-silent-fuzzy-symbol.test.ts
new file mode 100644
index 0000000..80cc775
--- /dev/null
+++ b/__tests__/no-silent-fuzzy-symbol.test.ts
@@ -0,0 +1,217 @@
+/**
+ * #1473 — callers/callees/impact must not silently answer for a different
+ * symbol when the requested name has no exact match (or has an exact match
+ * with zero callers). Fuzzy FTS hits may appear only as did-you-mean hints.
+ */
+
+import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
+import { execFileSync } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+beforeAll(async () => {
+ await initGrammars();
+ await loadAllGrammars();
+});
+
+function hasSqliteBindings(): boolean {
+ try {
+ const { DatabaseSync } = require('node:sqlite');
+ const db = new DatabaseSync(':memory:');
+ db.close();
+ return true;
+ } catch {
+ return false;
+ }
+}
+const HAS_SQLITE = hasSqliteBindings();
+
+function tmpRoot(prefix: string): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+}
+
+function rmTree(dir: string): void {
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
+}
+
+function runCli(args: string[], cwd: string): { stdout: string; status: number } {
+ try {
+ const stdout = execFileSync(process.execPath, [BIN, ...args], {
+ cwd,
+ encoding: 'utf-8',
+ env: {
+ ...process.env,
+ CODEGRAPH_NO_DAEMON: '1',
+ CODEGRAPH_TELEMETRY: '0',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ return { stdout, status: 0 };
+ } catch (err: unknown) {
+ const e = err as { stdout?: string; status?: number };
+ return { stdout: e.stdout ?? '', status: typeof e.status === 'number' ? e.status : 1 };
+ }
+}
+
+describe.skipIf(!HAS_SQLITE)('no silent fuzzy substitution (#1473) — MCP', () => {
+ let projectRoot: string;
+ let cg: any;
+ let handler: any;
+
+ beforeEach(async () => {
+ projectRoot = tmpRoot('codegraph-1473-mcp-');
+ const src = path.join(projectRoot, 'src', 'a', 'b', 'c');
+ fs.mkdirSync(src, { recursive: true });
+ fs.writeFileSync(
+ path.join(src, 'D.java'),
+ `package a.b.c;\n\npublic class D {\n public void e() { System.out.println("e"); }\n public void ef() { System.out.println("ef"); }\n}\n`
+ );
+ fs.writeFileSync(
+ path.join(src, 'Caller.java'),
+ `package a.b.c;\n\npublic class Caller {\n public void callsEfOnly() { D d = new D(); d.ef(); }\n public void alsoCallsEf() { D d = new D(); d.ef(); }\n}\n`
+ );
+ // Case-differing pair: exact Fetch has 0 callers; lowercase fetch has callers.
+ fs.writeFileSync(
+ path.join(projectRoot, 'src', 'Fetch.cs'),
+ `public class Torture {\n public void Fetch() {}\n}\n`
+ );
+ fs.writeFileSync(
+ path.join(projectRoot, 'src', 'fetch.py'),
+ `def fetch():\n return 1\n\ndef load():\n return fetch()\n`
+ );
+
+ const CodeGraph = (await import('../src/index')).default;
+ const { ToolHandler } = await import('../src/mcp/tools');
+ cg = CodeGraph.initSync(projectRoot);
+ await cg.indexAll();
+ handler = new ToolHandler(cg);
+ });
+
+ afterEach(() => {
+ handler?.closeAll();
+ cg?.destroy();
+ rmTree(projectRoot);
+ });
+
+ async function text(tool: string, args: Record): Promise {
+ const res = await handler.execute(tool, args);
+ return res.content?.[0]?.text ?? '';
+ }
+
+ it('callers: missing name is not found (with did-you-mean), not a fuzzy hit labelled as the typed name', async () => {
+ const out = await text('codegraph_callers', { symbol: 'Calls' });
+ expect(out).toMatch(/Symbol "Calls" not found/);
+ expect(out).toMatch(/Did you mean:/);
+ expect(out).not.toMatch(/Callees of Calls|Callers of Calls/);
+ expect(out).not.toMatch(/\bef\b/);
+ });
+
+ it('callees: missing name does not return another method\'s callees', async () => {
+ const out = await text('codegraph_callees', { symbol: 'Calls' });
+ expect(out).toMatch(/Symbol "Calls" not found/);
+ expect(out).not.toContain('Callees of Calls');
+ });
+
+ it('impact: missing prefix does not substitute a longer name', async () => {
+ const out = await text('codegraph_impact', { symbol: 'callsEf' });
+ expect(out).toMatch(/Symbol "callsEf" not found/);
+ expect(out).toMatch(/Did you mean:.*callsEfOnly/);
+ // Suggestion only — must not claim impact results for the mistyped name.
+ expect(out).not.toMatch(/Impact:|"callsEf" affects|affected/);
+ });
+
+ it('callers: exact name with zero callers stays empty (no case-sibling substitution)', async () => {
+ const out = await text('codegraph_callers', { symbol: 'Fetch' });
+ expect(out).toMatch(/No callers found for "Fetch"/);
+ expect(out).not.toContain('load');
+ });
+
+ it('callers: real exact name still resolves', async () => {
+ const out = await text('codegraph_callers', { symbol: 'ef' });
+ expect(out).toContain('Callers of ef');
+ expect(out).toContain('callsEfOnly');
+ expect(out).toContain('alsoCallsEf');
+ });
+
+ it('findAllSymbols returns no nodes for a fuzzy-only hit', async () => {
+ const findAllSymbols = (handler as any).findAllSymbols.bind(handler);
+ const all = findAllSymbols(cg, 'Calls');
+ expect(all.nodes).toEqual([]);
+ expect(all.note).toMatch(/Did you mean:/);
+ });
+});
+
+describe.skipIf(!HAS_SQLITE || !fs.existsSync(BIN))('no silent fuzzy substitution (#1473) — CLI', () => {
+ let projectRoot: string;
+
+ beforeEach(async () => {
+ projectRoot = tmpRoot('codegraph-1473-cli-');
+ const src = path.join(projectRoot, 'src', 'a', 'b', 'c');
+ fs.mkdirSync(src, { recursive: true });
+ fs.writeFileSync(
+ path.join(src, 'D.java'),
+ `package a.b.c;\n\npublic class D {\n public void e() { System.out.println("e"); }\n public void ef() { System.out.println("ef"); }\n}\n`
+ );
+ fs.writeFileSync(
+ path.join(src, 'Caller.java'),
+ `package a.b.c;\n\npublic class Caller {\n public void callsEfOnly() { D d = new D(); d.ef(); }\n public void alsoCallsEf() { D d = new D(); d.ef(); }\n}\n`
+ );
+ fs.writeFileSync(
+ path.join(projectRoot, 'src', 'Fetch.cs'),
+ `public class Torture {\n public void Fetch() {}\n}\n`
+ );
+ fs.writeFileSync(
+ path.join(projectRoot, 'src', 'fetch.py'),
+ `def fetch():\n return 1\n\ndef load():\n return fetch()\n`
+ );
+
+ const CodeGraph = (await import('../src/index')).default;
+ const cg = CodeGraph.initSync(projectRoot);
+ await cg.indexAll();
+ cg.close();
+ });
+
+ afterEach(() => {
+ rmTree(projectRoot);
+ });
+
+ it('callers: fuzzy-only name → not found with did-you-mean (JSON stays empty)', () => {
+ const { stdout } = runCli(['callers', 'Calls', '--json'], projectRoot);
+ // JSON path is only taken on a successful resolve; not-found prints info text.
+ expect(stdout).toMatch(/Symbol "Calls" not found/);
+ expect(stdout).toMatch(/did you mean/i);
+ expect(stdout).not.toMatch(/"name":\s*"ef"/);
+ });
+
+ it('callees: fuzzy-only name → not found', () => {
+ const { stdout } = runCli(['callees', 'Calls', '--json'], projectRoot);
+ expect(stdout).toMatch(/Symbol "Calls" not found/);
+ expect(stdout).not.toMatch(/"name":\s*"ef"/);
+ });
+
+ it('impact: prefix of a real name → not found', () => {
+ const { stdout } = runCli(['impact', 'callsEf', '--json'], projectRoot);
+ expect(stdout).toMatch(/Symbol "callsEf" not found/);
+ expect(stdout).toMatch(/did you mean:.*callsEfOnly/i);
+ // Not a successful JSON impact payload for the fuzzy hit.
+ expect(stdout).not.toMatch(/"affected"\s*:/);
+ expect(stdout).not.toMatch(/"symbol":\s*"callsEf"/);
+ });
+
+ it('callers: exact Fetch with zero callers → empty list, not fetch\'s callers', () => {
+ const { stdout } = runCli(['callers', 'Fetch', '--json'], projectRoot);
+ expect(stdout).toContain('"symbol": "Fetch"');
+ expect(stdout).toMatch(/"callers":\s*\[\s*\]/);
+ expect(stdout).not.toContain('load');
+ });
+
+ it('callers: exact ef still lists real callers', () => {
+ const { stdout } = runCli(['callers', 'ef', '--json'], projectRoot);
+ expect(stdout).toContain('callsEfOnly');
+ expect(stdout).toContain('alsoCallsEf');
+ });
+});
diff --git a/__tests__/object-literal-methods.test.ts b/__tests__/object-literal-methods.test.ts
index 1722ad2..ed12f45 100644
--- a/__tests__/object-literal-methods.test.ts
+++ b/__tests__/object-literal-methods.test.ts
@@ -53,9 +53,16 @@ describe('object-literal method extraction', () => {
// Each action's body was walked: fetchUser references its sibling `reset`,
// so an in-store calls edge will resolve once the pipeline runs.
- const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!;
+ // By KIND as well as name: the fixture's `Store` interface declares a
+ // `fetchUser` too, and since #1638 that signature is a node of its own —
+ // one that appears FIRST in the file, so a name-only lookup finds the
+ // declaration and reads its return type where the action's body was meant.
+ const fetchUser = result.nodes.find((n) => n.kind === 'function' && 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__/orphaned-refs-sweep.test.ts b/__tests__/orphaned-refs-sweep.test.ts
index 7864ab9..e1ee21e 100644
--- a/__tests__/orphaned-refs-sweep.test.ts
+++ b/__tests__/orphaned-refs-sweep.test.ts
@@ -18,6 +18,8 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
+import { createDatabase } from '../src/db/sqlite-adapter';
+import type { ReferenceResolver } from '../src/resolution';
describe('Orphaned refs sweep (#1187)', () => {
let testDir: string;
@@ -62,6 +64,99 @@ describe('Orphaned refs sweep (#1187)', () => {
return hit!.node;
}
+ // Compare call sites and resolution evidence, not just edge counts: a
+ // recovery can also silently downgrade confidence without losing a row.
+ function graphSnapshot() {
+ const { db } = createDatabase(path.join(testDir, '.codegraph', 'codegraph.db'), { readOnly: true });
+ try {
+ const sorted = (sql: string) => db.prepare(sql).all().map((row) => JSON.stringify(row)).sort();
+ return {
+ nodes: sorted('SELECT id, kind, name, qualified_name, file_path FROM nodes'),
+ edges: sorted('SELECT source, target, kind, line, col, metadata, provenance FROM edges'),
+ refs: sorted('SELECT from_node_id, reference_name, reference_kind, line, col, file_path, language, status FROM unresolved_refs'),
+ };
+ } finally {
+ db.close();
+ }
+ }
+
+ describe('recovery has clean-index resolution parity (#1577)', () => {
+ it('persists prerequisites before calls even when the orphan order is reversed', async () => {
+ fs.writeFileSync(path.join(testDir, 'aTypes.java'), [
+ 'class Base { void draw() {} }',
+ 'class Child extends Base {}',
+ 'class Decoy { void draw() {} }',
+ ].join('\n'));
+ // Put the caller beyond the first clean-index batch. Recovery below
+ // queues that same caller FIRST and its inheritance prerequisite LAST.
+ fs.writeFileSync(path.join(testDir, 'bPadding.java'),
+ 'class Padding { void noop() {\n' + 'externalCall();\n'.repeat(5100) + '} }\n');
+ fs.writeFileSync(path.join(testDir, 'zCaller.java'),
+ 'class Caller { void run(Child child) { child.draw(); } }\n');
+
+ cg = CodeGraph.initSync(testDir);
+ await cg.indexAll();
+ const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Base::draw')!;
+ expect(callerFiles(target)).toEqual(['zCaller.java']);
+ const clean = graphSnapshot();
+
+ for (const file of ['zCaller.java', 'bPadding.java', 'aTypes.java']) {
+ await interruptAfterExtraction(file);
+ }
+ cg.destroy();
+ cg = CodeGraph.openSync(testDir);
+ expect(cg.getPendingReferenceCount()).toBeGreaterThan(5000);
+
+ const recovered = await cg.sync();
+ expect(recovered.filesAdded + recovered.filesModified + recovered.filesRemoved).toBe(0);
+ expect(cg.getPendingReferenceCount()).toBe(0);
+ expect(callerFiles(target)).toEqual(['zCaller.java']);
+ expect(graphSnapshot()).toEqual(clean);
+
+ await cg.sync();
+ expect(graphSnapshot()).toEqual(clean);
+ }, 15000);
+
+ it('recovers inherited callbacks when the process restarts before the deferred pass', async () => {
+ fs.writeFileSync(path.join(testDir, 'form.ts'), [
+ 'class Base { handleSubmit() {} }',
+ 'class Unrelated { missingHandler() {} }',
+ 'class Form extends Base {',
+ ' wire() { bus.on("submit", this.handleSubmit); }',
+ ' save() { bus.on("save", this.handleSubmit); }',
+ ' confirm() { bus.on("confirm", this.handleSubmit); }',
+ ' missing() { bus.on("missing", this.missingHandler); }',
+ '}',
+ ].join('\n'));
+ cg = CodeGraph.initSync(testDir);
+ await cg.indexAll();
+ const target = findMethod('handleSubmit');
+ expect(cg.getIncomingEdges(target.id).filter((e) => e.kind === 'references')).toHaveLength(3);
+ const clean = graphSnapshot();
+
+ await interruptAfterExtraction('form.ts');
+ // Stop after the final batch has persisted, before the deferred
+ // inherited-member pass runs. There are no later batches to hide the
+ // bug: failed rows plus a lost in-memory queue used to look healthy.
+ // One ref per batch also exercises consecutive all-deferred batches:
+ // their intentionally pending rows must not trip the non-progress guard.
+ const resolver = (cg as unknown as { resolver: ReferenceResolver }).resolver;
+ await expect(resolver.resolveAndPersistBatched((current, total) => {
+ if (current === total) throw new Error('interrupted before deferred resolution');
+ }, 1)).rejects.toThrow('interrupted before deferred resolution');
+ cg.destroy();
+ cg = CodeGraph.openSync(testDir);
+
+ await cg.sync();
+ expect(cg.getIncomingEdges(target.id).filter((e) => e.kind === 'references')).toHaveLength(3);
+ expect(cg.getIncomingEdges(findMethod('missingHandler').id).filter((e) => e.kind === 'references')).toEqual([]);
+ expect(cg.getPendingReferenceCount()).toBe(0);
+ expect(graphSnapshot()).toEqual(clean);
+ await cg.sync();
+ expect(graphSnapshot()).toEqual(clean);
+ });
+ });
+
describe('sync() heals an interrupted resolution run', () => {
beforeEach(async () => {
// The #1187 shape: a concrete @Component class called through Spring
diff --git a/__tests__/php-import-alias-static-resolution.test.ts b/__tests__/php-import-alias-static-resolution.test.ts
new file mode 100644
index 0000000..d664a63
--- /dev/null
+++ b/__tests__/php-import-alias-static-resolution.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+const fixtureDir = path.join(__dirname, 'fixtures', 'php-import-alias-static');
+
+describe('PHP static calls through import aliases (#1545)', () => {
+ let dir: string;
+ let cg: CodeGraph | undefined;
+
+ beforeEach(() => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'php-static-alias-'));
+ fs.cpSync(fixtureDir, dir, { recursive: true });
+ });
+
+ afterEach(() => {
+ cg?.close();
+ cg = undefined;
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('attributes callers, callees and impact to SettleService instead of SettleRepository', async () => {
+ cg = await CodeGraph.init(dir, { silent: true });
+ await cg.indexAll();
+ const method = (qualifiedName: string) => {
+ const node = cg!.searchNodes(qualifiedName.split('::').pop()!)
+ .map((result) => result.node)
+ .find((n) => n.qualifiedName === qualifiedName);
+ expect(node, qualifiedName).toBeDefined();
+ return node!;
+ };
+ const excel = method('App\\Http\\Controllers\\Backend::SettleController::excel');
+ const service = method('App\\Services::SettleService::getSettlesToExcel');
+ const repository = method('App\\Repositories::SettleRepository::getSettlesToExcel');
+
+ expect(cg.getCallees(excel.id).map(({ node }) => node.id)).toContain(service.id);
+ expect(cg.getCallees(excel.id).map(({ node }) => node.id)).not.toContain(repository.id);
+ expect(cg.getCallers(service.id).map(({ node }) => node.id)).toContain(excel.id);
+ expect(cg.getCallers(repository.id).map(({ node }) => node.id)).not.toContain(excel.id);
+ expect([...cg.getImpactRadius(service.id).nodes.keys()]).toContain(excel.id);
+ expect([...cg.getImpactRadius(repository.id).nodes.keys()]).not.toContain(excel.id);
+ });
+
+ const write = (file: string, source: string) => {
+ const target = path.join(dir, file);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, source);
+ };
+
+ const controllerPath = 'app/Http/Controllers/Backend/SettleController.php';
+ const servicePath = 'app/Services/SettleService.php';
+ const controllerSource = fs.readFileSync(path.join(fixtureDir, controllerPath), 'utf8');
+ const serviceSource = fs.readFileSync(path.join(fixtureDir, servicePath), 'utf8');
+ const serviceMethod = 'App\\Services::SettleService::getSettlesToExcel';
+
+ const callees = async () => {
+ cg = await CodeGraph.init(dir, { silent: true });
+ await cg.indexAll();
+ const excel = cg.searchNodes('excel').map(({ node }) => node)
+ .find((n) => n.kind === 'method' && n.filePath === controllerPath)!;
+ expect(excel).toBeDefined();
+ return cg.getCallees(excel.id).map(({ node }) => node.qualifiedName).sort();
+ };
+
+ it('uses the imported namespace even when another namespace declares SettleService', async () => {
+ write('app/Repositories/SettleService.php', serviceSource.replace('App\\Services', 'App\\Repositories'));
+ expect(await callees()).toEqual([serviceMethod]);
+ });
+
+ it('uses the import instead of a class whose actual name is the alias', async () => {
+ write('app/Http/Controllers/Backend/Settle.php', serviceSource
+ .replace('App\\Services', 'App\\Http\\Controllers\\Backend')
+ .replace('class SettleService', 'class Settle'));
+ expect(await callees()).toEqual([serviceMethod]);
+ });
+
+ it('constrains the method to its owner when another class shares the imported file', async () => {
+ write(servicePath, serviceSource.replace(
+ 'class SettleService',
+ 'class SettleServiceDecoy { public static function getSettlesToExcel() {} }\nclass SettleService',
+ ));
+ expect(await callees()).toEqual([serviceMethod]);
+ });
+
+ it('resolves by namespace even when the file is not named after the imported class', async () => {
+ fs.renameSync(path.join(dir, servicePath), path.join(dir, 'app/Services/exports.php'));
+ expect(await callees()).toEqual([serviceMethod]);
+ });
+
+ it('handles an import with a leading namespace separator', async () => {
+ write(controllerPath, controllerSource.replace('use App\\', 'use \\App\\'));
+ expect(await callees()).toEqual([serviceMethod]);
+ });
+
+ it('keeps an unaliased class import on its declared namespace', async () => {
+ write(controllerPath, controllerSource.replace(' as Settle;', ';').replace('Settle::', 'SettleService::'));
+ write('app/Repositories/SettleService.php', serviceSource.replace('App\\Services', 'App\\Repositories'));
+ expect(await callees()).toEqual([serviceMethod]);
+ });
+
+ it('supports an alias for a class in the global namespace', async () => {
+ write(controllerPath, controllerSource.replace('App\\Services\\SettleService', 'SettleService'));
+ write(servicePath, serviceSource.replace('namespace App\\Services;', ''));
+ expect(await callees()).toEqual(['SettleService::getSettlesToExcel']);
+ });
+
+ it.each(['method missing', 'class outside the index'])('leaves the call unresolved when the imported %s', async (scenario) => {
+ if (scenario === 'method missing') {
+ write(servicePath, serviceSource.replace('getSettlesToExcel', 'otherMethod'));
+ } else {
+ fs.rmSync(path.join(dir, servicePath));
+ // Even the right short name in the wrong namespace cannot donate a method.
+ write('app/Repositories/SettleService.php', serviceSource.replace('App\\Services', 'App\\Repositories'));
+ }
+ expect(await callees()).toEqual([]);
+ });
+
+ it('keeps a variable and a static receiver with the same spelling in separate namespaces', async () => {
+ write('app/Services/OtherService.php', String.raw`getSettlesToExcel();\n return Settle::',
+ ));
+ expect((await callees()).filter((name) => name.endsWith('::getSettlesToExcel'))).toEqual([
+ 'App\\Services::OtherService::getSettlesToExcel',
+ serviceMethod,
+ ]);
+ });
+});
diff --git a/__tests__/preload-languages.test.ts b/__tests__/preload-languages.test.ts
new file mode 100644
index 0000000..5f8e5ec
--- /dev/null
+++ b/__tests__/preload-languages.test.ts
@@ -0,0 +1,41 @@
+/**
+ * Grammar preload set for a file list (#1628).
+ *
+ * Path-only detection calls every `.h` file C, but parse-time detection reads
+ * the source and can reclassify it as C++ or Objective-C. Workers only ever
+ * receive the grammars named by this set, so a header that turns out to be
+ * Objective-C in a project with no `.m` file had no parser to go to and the
+ * file failed outright with `Failed to get parser for language: objc`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { preloadLanguagesForFiles } from '../src/extraction';
+
+describe('grammar preload set (#1628)', () => {
+ it('covers both ambiguous readings of a .h file, C++ and Objective-C', () => {
+ const langs = preloadLanguagesForFiles(['repro.h']);
+ // Path-only detection says C…
+ expect(langs).toContain('c');
+ // …and parse-time detection may say either of these instead.
+ expect(langs).toContain('cpp');
+ expect(langs).toContain('objc');
+ });
+
+ it('adds nothing for a project with no C-family headers', () => {
+ const langs = preloadLanguagesForFiles(['a.ts', 'b.py']);
+ expect(langs).not.toContain('c');
+ expect(langs).not.toContain('cpp');
+ expect(langs).not.toContain('objc');
+ });
+
+ it('does not duplicate a language the files already need', () => {
+ const langs = preloadLanguagesForFiles(['repro.h', 'seed.m', 'other.cpp']);
+ expect(langs.filter((l) => l === 'objc')).toHaveLength(1);
+ expect(langs.filter((l) => l === 'cpp')).toHaveLength(1);
+ });
+
+ it('honors extension overrides when detecting the base set', () => {
+ const langs = preloadLanguagesForFiles(['weird.frob'], { '.frob': 'python' });
+ expect(langs).toContain('python');
+ });
+});
diff --git a/__tests__/python-module-scope-collection-methods.test.ts b/__tests__/python-module-scope-collection-methods.test.ts
new file mode 100644
index 0000000..e770798
--- /dev/null
+++ b/__tests__/python-module-scope-collection-methods.test.ts
@@ -0,0 +1,106 @@
+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';
+
+const collections = [
+ { name: 'dict_literal', value: '{"answer": "42"}', method: 'get' },
+ { name: 'empty_dict', value: '{}', method: 'get' },
+ { name: 'dict_constructor', value: 'dict()', method: 'get' },
+ { name: 'list_literal', value: '[1]', method: 'append' },
+ { name: 'empty_list', value: '[]', method: 'append' },
+ { name: 'list_constructor', value: 'list()', method: 'append' },
+ { name: 'set_literal', value: '{1}', method: 'add' },
+ { name: 'set_constructor', value: 'set()', method: 'add' },
+ { name: 'tuple_literal', value: '(1,)', method: 'index' },
+ { name: 'empty_tuple', value: '()', method: 'index' },
+ { name: 'tuple_constructor', value: 'tuple()', method: 'index' },
+ { name: 'frozenset_constructor', value: 'frozenset()', method: 'union' },
+];
+
+let dir: string;
+let cg: CodeGraph;
+
+beforeAll(async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1652-'));
+ fs.writeFileSync(path.join(dir, 'settings.py'), `DEFAULTS = {"answer": "42"}
+
+def read_setting(name):
+ return DEFAULTS.get(name, None)
+`);
+ fs.writeFileSync(path.join(dir, 'cache.py'), `class LRUCache:
+ def __init__(self):
+ self._store = {}
+
+ def get(self, key):
+ return self._store.get(key)
+
+class ProjectCollection:
+ def append(self, item):
+ pass
+ def add(self, item):
+ pass
+ def index(self, item):
+ return 0
+ def union(self, item):
+ return self
+`);
+ for (const { name, value, method } of collections) {
+ // Capitalizing lRUCache matches a real class. Its name must not override
+ // the same-file binding's collection initializer (#1652).
+ fs.writeFileSync(path.join(dir, `${name}.py`), `lRUCache = ${value}
+
+def use_${name}(item):
+ return lRUCache.${method}(item)
+`);
+ }
+ fs.writeFileSync(path.join(dir, 'unknown.py'), `UNKNOWN = load_defaults()
+
+def read_unknown(name):
+ return UNKNOWN.get(name)
+`);
+ fs.writeFileSync(path.join(dir, 'client.py'), `from cache import LRUCache
+
+def read_cache(lRUCache: LRUCache, name):
+ return lRUCache.get(name)
+`);
+ cg = await CodeGraph.init(dir, { index: true });
+});
+
+afterAll(() => {
+ cg?.destroy();
+ fs.rmSync(dir, { recursive: true, force: true });
+});
+
+function expectNoMethodCall(callerName: string, file: string, methodName: string) {
+ const caller = cg.getNodesByName(callerName).find((n) => n.kind === 'function' && n.filePath === file);
+ const method = cg.getNodesByName(methodName).find((n) => n.kind === 'method' && n.filePath === 'cache.py');
+ expect(caller).toBeDefined();
+ expect(method).toBeDefined();
+ expect(cg.getCallers(method!.id).map(({ node }) => node.id)).not.toContain(caller!.id);
+ expect(cg.getCallees(caller!.id).map(({ node }) => node.id)).not.toContain(method!.id);
+}
+
+describe('Python module-scope collection methods (#1652)', () => {
+ it('does not connect DEFAULTS.get to the unrelated LRUCache.get method', () => {
+ expectNoMethodCall('read_setting', 'settings.py', 'get');
+ });
+
+ it.each(collections)('keeps $name ($method) external even when the receiver resembles a class', ({ name, method }) => {
+ expectNoMethodCall(`use_${name}`, `${name}.py`, method);
+ });
+
+ it('does not treat an unrelated variable as evidence of a project class', () => {
+ expectNoMethodCall('read_unknown', 'unknown.py', 'get');
+ });
+
+ it('preserves real instance calls despite same-named collections in other files', () => {
+ const caller = cg.getNodesByName('read_cache').find((n) => n.kind === 'function')!;
+ const method = cg.getNodesByName('get').find((n) => n.kind === 'method' && n.filePath === 'cache.py')!;
+ expect(caller).toBeDefined();
+ expect(method).toBeDefined();
+ expect(cg.getCallees(caller.id).map(({ node }) => node.id)).toContain(method.id);
+ expect(cg.getCallers(method.id).map(({ node }) => node.id)).toContain(caller.id);
+ });
+});
diff --git a/__tests__/python-quoted-annotation.test.ts b/__tests__/python-quoted-annotation.test.ts
new file mode 100644
index 0000000..fb0a6db
--- /dev/null
+++ b/__tests__/python-quoted-annotation.test.ts
@@ -0,0 +1,55 @@
+/**
+ * A quoted (forward-reference) parameter annotation names a receiver type too
+ * (#1684): `def f(o: "Alpha")` resolves `o.render()` exactly like `def f(o:
+ * Alpha)`. Quoted annotations are ordinary Python — forward references, and
+ * everything under `from __future__ import annotations`.
+ */
+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 { 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-1684-'));
+ fs.mkdirSync(path.join(dir, 'pkg'));
+ fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
+ fs.writeFileSync(
+ path.join(dir, 'pkg', 'a.py'),
+ 'def render(x):\n return x\n\nclass Alpha:\n def render(self):\n return "a"\n\nclass Beta:\n def render(self):\n return "b"\n'
+ );
+ fs.writeFileSync(
+ path.join(dir, 'pkg', 'b.py'),
+ 'from __future__ import annotations\nfrom pkg.a import Alpha, Beta\n\n' +
+ 'def quoted(o: "Alpha"):\n return o.render()\n\n' +
+ "def single_quoted(o: 'Beta'):\n return o.render()\n\n" +
+ 'def unquoted(o: Alpha):\n return o.render()\n'
+ );
+ cg = CodeGraph.initSync(dir);
+ await cg.indexAll();
+});
+
+afterAll(() => {
+ cg.destroy();
+ fs.rmSync(dir, { recursive: true, force: true });
+});
+
+const calleeOf = (fn: string): string[] =>
+ cg
+ .getCallees(cg.getNodesByName(fn).find((n) => n.kind === 'function')!.id)
+ .map(({ node }) => node.qualifiedName)
+ .sort();
+
+describe('quoted forward-reference annotations (#1684)', () => {
+ it('resolves the method on the quoted type, the same as the unquoted annotation', () => {
+ expect(calleeOf('unquoted')).toEqual(['Alpha::render']);
+ expect(calleeOf('quoted')).toEqual(['Alpha::render']);
+ expect(calleeOf('single_quoted')).toEqual(['Beta::render']);
+ });
+});
diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts
index 21d9505..fea8d22 100644
--- a/__tests__/react-router.test.ts
+++ b/__tests__/react-router.test.ts
@@ -229,6 +229,14 @@ describe('react-router: a routed app end to end', () => {
if (!n) throw new Error(`no symbol ${name}`);
return n;
};
+ // A handler written as `const submitHandler = () => {…}` inside a screen is a
+ // symbol of its own (#1669), so a navigation it makes is ITS edge — the same
+ // shape a `useCallback` handler has — and the screen reaches it by calling it.
+ const symIn = (name: string, file: string): Node => {
+ const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import' && n.filePath.endsWith(file));
+ if (!n) throw new Error(`no symbol ${name} in ${file}`);
+ return n;
+ };
const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
const hrefs = (from: Node) =>
navs(from)
@@ -250,17 +258,24 @@ describe('react-router: a routed app end to end', () => {
it('the payment screen pushes to both pages it leads to — the bounce out and the one on submit', () => {
const payment = sym('PaymentScreen');
- expect(hrefs(payment)).toEqual(['/placeorder', '/shipping']);
- const byHref = new Map(navs(payment).map((e) => [(e.metadata as Record).href, e]));
+ const submit = symIn('submitHandler', 'PaymentScreen.js');
+ // The bounce-out is the component's own; the push on submit belongs to its handler.
+ expect(hrefs(payment)).toEqual(['/shipping']);
+ expect(hrefs(submit)).toEqual(['/placeorder']);
+ // `onSubmit={submitHandler}` is the screen's reference to it; the Screens
+ // walk below rides that hop.
+ expect(cg.getOutgoingEdges(payment.id).some((e) => e.target === submit.id && e.kind === 'references')).toBe(true);
+ const byHref = new Map([...navs(payment), ...navs(submit)].map((e) => [(e.metadata as Record).href, e]));
expect(byHref.get('/shipping')!.target).toBe(route('/shipping').id);
expect(byHref.get('/placeorder')!.target).toBe(route('/placeorder').id);
expect(byHref.get('/placeorder')!.metadata).toMatchObject({ navMethod: 'push' });
});
it('history.replace navigates, and v6’s navigate() with a template hole reaches the :id route', () => {
- expect(navs(sym('ShippingScreen'))[0]!.target).toBe(route('/payment').id);
- expect(navs(sym('ShippingScreen'))[0]!.metadata).toMatchObject({ href: '/payment', navMethod: 'replace' });
- const product = navs(sym('ProductScreen'));
+ const shippingSubmit = symIn('submitHandler', 'ShippingScreen.js');
+ expect(navs(shippingSubmit)[0]!.target).toBe(route('/payment').id);
+ expect(navs(shippingSubmit)[0]!.metadata).toMatchObject({ href: '/payment', navMethod: 'replace' });
+ const product = navs(sym('addToCart'));
expect(product).toHaveLength(1);
expect(product[0]!.target).toBe(route('/cart/:id?').id);
expect(product[0]!.metadata).toMatchObject({ href: '/cart/${…}', navMethod: 'navigate' });
@@ -288,7 +303,8 @@ describe('react-router: a routed app end to end', () => {
const link = screens.links.find((l) => l.from === at('/payment').id && l.to === at('/placeorder').id)!;
expect(link).toBeDefined();
expect(link.sites[0]).toMatchObject({ href: '/placeorder', method: 'push' });
- expect(link.via).toEqual([]);
+ // The submit handler is the hop between the screen and the push.
+ expect(link.via.map((v) => v.name)).toEqual(['submitHandler']);
expect(screens.links.find((l) => l.from === at('/shipping').id && l.to === at('/payment').id)).toBeDefined();
expect(screens.links.find((l) => l.from === at('/product/:id').id && l.to === at('/cart/:id?').id)).toBeDefined();
});
diff --git a/__tests__/reference-target-kind.test.ts b/__tests__/reference-target-kind.test.ts
new file mode 100644
index 0000000..43746af
--- /dev/null
+++ b/__tests__/reference-target-kind.test.ts
@@ -0,0 +1,215 @@
+/**
+ * Reference target-kind gate — `extends`/`implements` and `imports`.
+ *
+ * The name-matcher treats node kind as a scoring BONUS, never a filter, and
+ * awards no bonus at all for inheritance refs. When exactly one same-named
+ * node exists, the single-candidate shortcut adopts it unconditionally at
+ * confidence 0.9. So a supertype that lives OUTSIDE the repo — imported by a
+ * bare name — bound to whatever local symbol happened to share that name,
+ * asserting an inheritance relationship absent from the source:
+ *
+ * use std::error::Error; // the supertype is out-of-repo
+ * impl Error for MapperError {} // ...but `MapperError::Error` is a variant
+ * → implements: enum MapperError -> enum_member Error
+ *
+ * The gate drops any inheritance resolution whose target cannot be a
+ * supertype. It only ever removes edges, so the tests below pin BOTH
+ * directions: the false edge is gone, and every legitimate supertype kind
+ * (in-repo trait, interface, class, and TS object-type alias) still resolves.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+describe('reference target-kind gate', () => {
+ let dir: string;
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'inh-kind-')); });
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+ const write = (rel: string, body: string) => {
+ const p = path.join(dir, rel);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, body);
+ };
+
+ type InhEdge = { src: string; srcKind: string; tgt: string; tgtKind: string; kind: string };
+
+ const load = async (): Promise<{ edges: InhEdge[]; failed: { name: string; kind: string }[] }> => {
+ const cg = await CodeGraph.init(dir, { silent: true });
+ await cg.indexAll();
+ const db = (cg as any).db.db;
+ const edges: InhEdge[] = db
+ .prepare(
+ `SELECT s.name src, s.kind srcKind, t.name tgt, t.kind tgtKind, e.kind kind
+ FROM edges e
+ JOIN nodes s ON s.id = e.source
+ JOIN nodes t ON t.id = e.target
+ WHERE e.kind IN ('extends', 'implements')`
+ )
+ .all();
+ const failed: { name: string; kind: string }[] = db
+ .prepare(
+ `SELECT reference_name name, reference_kind kind
+ FROM unresolved_refs
+ WHERE reference_kind IN ('extends', 'implements')`
+ )
+ .all();
+ cg.close?.();
+ return { edges, failed };
+ };
+
+ const has = (edges: InhEdge[], src: string, tgt: string, tgtKind: string) =>
+ edges.some((e) => e.src === src && e.tgt === tgt && e.tgtKind === tgtKind);
+
+ it('drops an out-of-repo Rust supertype that name-matched a local enum member', async () => {
+ write(
+ 'src/lib.rs',
+ `use std::error::Error;\n\n` +
+ `pub enum MapperError {\n Error,\n Missing,\n}\n\n` +
+ `impl Error for MapperError {}\n`
+ );
+ const { edges, failed } = await load();
+ expect(has(edges, 'MapperError', 'Error', 'enum_member')).toBe(false);
+ // The reference is not silently forgotten — it stays on record as failed,
+ // which is the honest outcome for a supertype the repo does not contain.
+ expect(failed.some((r) => r.name === 'Error')).toBe(true);
+ });
+
+ it('does not relocate the false edge onto a same-named local type alias', async () => {
+ // The kind filter alone would have moved this edge from the enum member to
+ // `type Error`, which IS a legal supertype kind — still false data, and
+ // harder for a consumer to reject. Locality is what removes it.
+ write('src/alias.rs', `pub type Error = String;\n`);
+ write(
+ 'src/lib.rs',
+ `mod alias;\n\nuse std::error::Error;\n\n` +
+ `pub enum MapperError {\n Missing,\n}\n\n` +
+ `impl Error for MapperError {}\n`
+ );
+ const { edges, failed } = await load();
+ expect(edges.filter((e) => e.tgt === 'Error')).toEqual([]);
+ expect(failed.some((r) => r.name === 'Error')).toBe(true);
+ });
+
+ it('keeps a supertype imported by an in-repo `use` path', async () => {
+ write('src/ports.rs', `pub trait Sha256Port {\n fn hash(&self) -> String;\n}\n`);
+ write(
+ 'src/lib.rs',
+ `mod ports;\n\nuse crate::ports::Sha256Port;\n\n` +
+ `pub struct Hasher {\n salt: String,\n}\n\n` +
+ `impl Sha256Port for Hasher {\n fn hash(&self) -> String { String::new() }\n}\n`
+ );
+ const { edges } = await load();
+ expect(has(edges, 'Hasher', 'Sha256Port', 'trait')).toBe(true);
+ });
+
+ it('keeps a trait reached through a re-exported sibling-crate module', async () => {
+ // `crate::ports` here is a re-export of ANOTHER crate's module, so no
+ // `src/ports.rs` exists to walk to. Treating "module path does not resolve
+ // to a file" as proof of out-of-repo deleted 13 real trait implementations
+ // on the reference fixture — hence the rule keys on stdlib roots only.
+ write('Cargo.toml', `[workspace]\nmembers = ["core", "app"]\n`);
+ write('core/Cargo.toml', `[package]\nname = "pupil_core"\nversion = "0.1.0"\n`);
+ write('core/src/lib.rs', `pub mod ports;\n`);
+ write('core/src/ports.rs', `pub trait CacheStore {\n fn get(&self);\n}\n`);
+ write('app/Cargo.toml', `[package]\nname = "app"\nversion = "0.1.0"\n`);
+ write('app/src/lib.rs', `pub use pupil_core::ports;\n\npub mod platform;\n`);
+ write(
+ 'app/src/platform.rs',
+ `use crate::ports::CacheStore;\n\npub struct SafStorage {\n root: String,\n}\n\n` +
+ `impl CacheStore for SafStorage {\n fn get(&self) {}\n}\n`
+ );
+ const { edges } = await load();
+ expect(has(edges, 'SafStorage', 'CacheStore', 'trait')).toBe(true);
+ });
+
+ it('still resolves an in-repo Rust trait (the gate is not a blanket block)', async () => {
+ write(
+ 'src/lib.rs',
+ `pub trait Mapper {\n fn map(&self) -> u32;\n}\n\n` +
+ `pub enum MapperError {\n Mapper,\n}\n\n` +
+ `pub struct Real {\n n: u32,\n}\n\n` +
+ `impl Mapper for Real {\n fn map(&self) -> u32 { 1 }\n}\n`
+ );
+ const { edges } = await load();
+ expect(has(edges, 'Real', 'Mapper', 'trait')).toBe(true);
+ expect(has(edges, 'Real', 'Mapper', 'enum_member')).toBe(false);
+ });
+
+ it('keeps a TypeScript class implementing an object-type alias', async () => {
+ write(
+ 'src/api.ts',
+ `export type SearchApi = { query(q: string): string };\n\n` +
+ `export class LocalSearch implements SearchApi {\n` +
+ ` query(q: string): string { return q; }\n}\n`
+ );
+ const { edges } = await load();
+ expect(has(edges, 'LocalSearch', 'SearchApi', 'type_alias')).toBe(true);
+ });
+
+ it.each([
+ ['svelte', 'src/Box.svelte', '\n
hi
\n'],
+ ['vue', 'src/Box.vue', '\n\n'],
+ ['astro', 'src/Box.astro', '---\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n---\n\n'],
+ ])('drops an npm supertype in a %s single-file component', async (_lang, file, body) => {
+ // An SFC imports inside its \n
hi
\n`
+ );
+ const { edges } = await load();
+ expect(has(edges, 'SfcBox', 'Serializable', 'class')).toBe(true);
+ });
+
+ it('does not resolve an import to a type member that shares its name', async () => {
+ // `import * as path from 'node:path'` is unresolvable — the module is
+ // external — so the name-matcher looked for any node called `path` and
+ // found a class property. No language lets you import a type's member.
+ write('src/types.ts', `export class Request {\n path = '';\n url = '';\n}\n`);
+ write(
+ 'src/run.ts',
+ `import * as path from 'node:path';\n\nexport function run() {\n return path.join('a', 'b');\n}\n`
+ );
+ const cg = await CodeGraph.init(dir, { silent: true });
+ await cg.indexAll();
+ const db = (cg as any).db.db;
+ const rows: { tgt: string; tgtKind: string }[] = db
+ .prepare(
+ `SELECT t.name tgt, t.kind tgtKind
+ FROM edges e JOIN nodes t ON t.id = e.target
+ WHERE e.kind = 'imports'`
+ )
+ .all();
+ cg.close?.();
+ expect(rows.filter((r) => r.tgtKind === 'property' || r.tgtKind === 'field')).toEqual([]);
+ });
+
+ it('keeps class extends class and class implements interface', async () => {
+ write(
+ 'src/base.ts',
+ `export interface Runner { run(): void }\n` +
+ `export class Base { run(): void {} }\n` +
+ `export class Child extends Base implements Runner { run(): void {} }\n`
+ );
+ const { edges } = await load();
+ expect(has(edges, 'Child', 'Base', 'class')).toBe(true);
+ expect(has(edges, 'Child', 'Runner', 'interface')).toBe(true);
+ });
+});
diff --git a/__tests__/resolution-fileexists-containment.test.ts b/__tests__/resolution-fileexists-containment.test.ts
new file mode 100644
index 0000000..311ef17
--- /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 decaade..b8d25f6 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,119 @@ 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('resolves Python module-attribute calls and file imports through an alias (#1626)', async () => {
+ // #715 taught resolvePythonModuleMember to fall back to a dotted-module
+ // file lookup, which fixed `from pkg import module` (#578). The aliased
+ // form still missed: the module path was rebuilt from the LOCAL name, so
+ // `from pkg import module as alias` looked for `pkg.alias` — a file that
+ // does not exist — and the call landed in unresolved_refs. The plain
+ // `import top as alias` form is a namespace import and binds at `source`,
+ // so it was already correct; it is pinned here so the fix can't regress it.
+ fs.mkdirSync(path.join(tempDir, 'pkg'));
+ fs.writeFileSync(path.join(tempDir, 'pkg', '__init__.py'), '');
+ fs.writeFileSync(
+ path.join(tempDir, 'pkg', 'module.py'),
+ 'def func():\n return 1\n'
+ );
+ fs.writeFileSync(
+ path.join(tempDir, 'top_level.py'),
+ 'def top_func():\n return 2\n'
+ );
+ fs.writeFileSync(
+ path.join(tempDir, 'main.py'),
+ `from pkg import module as mod_alias
+import top_level as tl
+
+
+def from_import_caller():
+ return mod_alias.func()
+
+
+def plain_import_caller():
+ return tl.top_func()
+`
+ );
+
+ cg = await CodeGraph.init(tempDir, { index: true });
+
+ const fromImportCaller = cg.getNodesByKind('function').filter((n) => n.name === 'from_import_caller')[0];
+ expect(fromImportCaller).toBeDefined();
+ const aliasCalls = cg.getOutgoingEdges(fromImportCaller!.id).filter((e) => e.kind === 'calls');
+ expect(aliasCalls).toHaveLength(1);
+ const aliasTarget = cg.getNode(aliasCalls[0]!.target);
+ expect(aliasTarget?.name).toBe('func');
+ expect(aliasTarget?.filePath.replace(/\\/g, '/')).toBe('pkg/module.py');
+
+ const plainCaller = cg.getNodesByKind('function').filter((n) => n.name === 'plain_import_caller')[0];
+ expect(plainCaller).toBeDefined();
+ const plainCalls = cg.getOutgoingEdges(plainCaller!.id).filter((e) => e.kind === 'calls');
+ expect(plainCalls).toHaveLength(1);
+ expect(cg.getNode(plainCalls[0]!.target)?.name).toBe('top_func');
+
+ // The file dependency must resolve too: fixing only the member lookup
+ // restores calls but leaves the aliased module's imports edge missing.
+ const mainFile = cg.getNodesByKind('file').find((n) => n.filePath === 'main.py');
+ const moduleFile = cg.getNodesByKind('file').find((n) => n.filePath.replace(/\\/g, '/') === 'pkg/module.py');
+ expect(mainFile).toBeDefined();
+ expect(moduleFile).toBeDefined();
+ const fileImports = cg.getOutgoingEdges(mainFile!.id).filter((e) => e.kind === 'imports');
+ expect(fileImports.map((e) => e.target)).toContain(moduleFile!.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
@@ -2001,6 +2114,32 @@ func main() {
});
});
+ describe('Lua function-expression resolution (#1616)', () => {
+ it('attributes helper calls to each assigned callable instead of the file node', async () => {
+ fs.writeFileSync(
+ path.join(tempDir, 'util.lua'),
+ `util = {}\nfunction util.helper() return 1 end\nreturn util\n`
+ );
+ fs.writeFileSync(
+ path.join(tempDir, 'handlers.lua'),
+ `local M = {}\nfunction M.namedFn() return util.helper() end\nM.assignedFn = function() return util.helper() end\nM.callbacks = { onStart = function() return util.helper() end }\nreturn M\n`
+ );
+
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+
+ const helper = cg
+ .getNodesByKind('method')
+ .find((n) => n.qualifiedName === 'util::helper');
+ expect(helper).toBeDefined();
+ const callers = cg.getCallers(helper!.id).map((c) => c.node);
+ expect(callers.some((n) => n.qualifiedName === 'M::namedFn')).toBe(true);
+ expect(callers.some((n) => n.qualifiedName === 'M::assignedFn')).toBe(true);
+ expect(callers.some((n) => n.qualifiedName === 'M.callbacks::onStart')).toBe(true);
+ expect(callers.some((n) => n.kind === 'file' && n.filePath === 'handlers.lua')).toBe(false);
+ });
+ });
+
describe('Watchdog-safe resolution on collision-heavy repos (#1122)', () => {
// On a large Java-style repo, per-ref resolution cost is unbounded in the
// worst case (a colliding method name whose candidate set misses the LRU
@@ -2177,6 +2316,87 @@ func main() {
});
describe('Local-variable receiver-type inference (#1108)', () => {
+ it.each(['ts', 'tsx', 'js', 'jsx'])('keeps built-in Map calls off project methods — %s (#1566)', async (ext) => {
+ const typed = ext === 'ts' || ext === 'tsx';
+ fs.writeFileSync(path.join(tempDir, `cache.${ext}`), `
+export class LRUCache {
+ get(key) { return key; }
+ set(key, value) { return value; }
+ has(key) { return true; }
+}
+export function useLocalMap() {
+ const values = new Map${typed ? '' : ''}();
+ values.set('answer', '42');
+ values.get('answer');
+ return values.has('answer');
+}
+export function useNestedMap(holder${typed ? ': { values: Map }' : ''}) {
+ return holder.values.get('answer');
+}
+export function useProjectCache() {
+ const cache = new LRUCache();
+ cache.set('answer', '42');
+ cache.get('answer');
+ return cache.has('answer');
+}
+`);
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+
+ for (const name of ['useLocalMap', 'useNestedMap', 'useProjectCache']) {
+ const caller = cg.getNodesByName(name).find((n) => n.kind === 'function');
+ expect(caller, name).toBeDefined();
+ const calls = cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls');
+ if (name === 'useProjectCache') {
+ const methods = cg.getNodesByKind('method').filter((n) => n.qualifiedName.startsWith('LRUCache::'));
+ expect(methods).toHaveLength(3);
+ expect(calls.map((e) => e.target).sort()).toEqual(methods.map((n) => n.id).sort());
+ expect(calls.every((e) => e.metadata?.confidence === 0.9)).toBe(true);
+ } else {
+ expect.soft(calls, `${ext}: ${name} must not call a project method`).toEqual([]);
+ }
+ }
+ });
+
+ it('keeps a validated project class that shadows Map (#1566)', async () => {
+ fs.writeFileSync(path.join(tempDir, 'shadow.ts'), `
+export class Map { get() { return 1; } }
+export class Other { get() { return 2; } }
+export function useShadow() {
+ const values = new Map();
+ return values.get();
+}
+`);
+ cg = await CodeGraph.init(tempDir, { index: true });
+ const caller = cg.getNodesByName('useShadow').find((n) => n.kind === 'function');
+ expect(caller).toBeDefined();
+ expect(cg.getCallees(caller!.id).filter(({ edge }) => edge.kind === 'calls').map(({ node }) => node.qualifiedName))
+ .toEqual(['Map::get']);
+ });
+
+ it.each([
+ ['Set', 'has'], ['WeakMap', 'get'], ['WeakSet', 'has'], ['Array', 'map'], ['Promise', 'then'],
+ ])('declines same-name guesses for an inferred %s receiver (#1566)', async (type, method) => {
+ fs.writeFileSync(path.join(tempDir, 'builtin.ts'), `
+export class Collision { ${method}() { return 1; } }
+export function constructed() {
+ const values = new ${type}();
+ return values.${method}();
+}
+export function annotated(values: ${type}) {
+ return values.${method}();
+}
+`);
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+ expect(cg.getNodesByKind('method').some((n) => n.name === method)).toBe(true);
+ for (const name of ['constructed', 'annotated']) {
+ const caller = cg.getNodesByName(name).find((n) => n.kind === 'function');
+ expect(caller, name).toBeDefined();
+ expect.soft(cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls'), name).toEqual([]);
+ }
+ });
+
// `lg.log()` where `lg` is a local whose type is inferred from its
// declaration/initializer. Before this, only C++ resolved these; every
// other language produced no method edge. Each case is one file with a
@@ -5386,4 +5606,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__/symbol-lookup.test.ts b/__tests__/symbol-lookup.test.ts
index c81aaab..9074336 100644
--- a/__tests__/symbol-lookup.test.ts
+++ b/__tests__/symbol-lookup.test.ts
@@ -17,6 +17,8 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { matchesSymbol, lookupSymbolNodes, isQualifiedSymbol } from '../src/graph/symbol-lookup';
+import type { Node } from '../src/types';
beforeAll(async () => {
await initGrammars();
@@ -155,6 +157,26 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — module-qualified lookups (#173)'
expect(matches.length).toBe(0);
});
+ it('findAllSymbols rejects a fuzzy-only bare prefix with a suggestion (#1473)', () => {
+ expect(cg.getNodesByName('run_due')).toEqual([]);
+ expect(cg.searchNodes('run_due').length).toBeGreaterThan(0);
+ const all = findAllSymbols(cg, 'run_due');
+ expect(all.nodes).toEqual([]);
+ expect(all.note).toMatch(/Did you mean:.*run_due_tasks/);
+ });
+
+ it('findAllSymbols rejects an unknown qualifier even when the bare tail exists (#173)', () => {
+ expect(cg.getNodesByName('run').length).toBeGreaterThan(0);
+ expect(findAllSymbols(cg, 'missing::run').nodes).toEqual([]);
+ });
+
+ it('preserves codegraph_node file-basename lookup (#1473)', () => {
+ expect(cg.getNodesByName('stage_apply')).toEqual([]);
+ const matches = findSymbolMatches(cg, 'stage_apply');
+ expect(matches.length).toBeGreaterThan(0);
+ expect(matches[0]!.filePath).toMatch(/configurator\/stage_apply\.rs$/);
+ });
+
it('codegraph_node with a `file` hint pins an overloaded name to that file', async () => {
// `run` is defined in BOTH stage_apply.rs and stage_detect.rs. A bare lookup
// returns both; the `file` hint narrows to the one the caller saw in a trail.
@@ -220,3 +242,184 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — dotted lookups (regression for #
expect((text.match(/\*\*Location:\*\*/g) || []).length).toBeGreaterThanOrEqual(2);
});
});
+
+/**
+ * One resolution path for every verb that takes a symbol NAME.
+ *
+ * `callers` / `callees` / `impact` used to carry their own filter, comparing
+ * the query against the BARE name only:
+ *
+ * node.name === symbol || node.name.endsWith('.' + symbol)
+ *
+ * which fails in two opposite directions at once. A bare name matched every
+ * same-named symbol in the repository and their results were merged under one
+ * heading with nothing saying they were different symbols; a qualified name
+ * could never equal a bare `node.name`, so every candidate failed the filter
+ * and the code fell through to an arbitrary top-of-FTS hit — or reported "not
+ * found" for a symbol that plainly exists. Both now go through
+ * `lookupSymbolNodes`.
+ */
+function fakeNode(over: Partial): Node {
+ return {
+ id: 'n1', kind: 'function', name: 'group', qualifiedName: 'group',
+ filePath: 'lib/format.ex', language: 'typescript',
+ startLine: 1, endLine: 2, startColumn: 0, endColumn: 0, updatedAt: 0,
+ ...over,
+ } as Node;
+}
+
+describe('matchesSymbol — containers whose own name contains a separator', () => {
+ // Splitting on EVERY separator assumes no scope component contains one. That
+ // is false for any language whose module names are themselves dotted, and
+ // there the stored qualifiedName (`A.B::c`) can never equal the split-and-
+ // rejoined query spelling (`A::B::c`) — so a perfectly precise qualified
+ // query resolved to nothing.
+ const node = fakeNode({ name: 'group', qualifiedName: 'AppWeb.Format::group' });
+
+ it('matches a dotted module qualifier written with dots', () => {
+ expect(matchesSymbol(node, 'AppWeb.Format.group')).toBe(true);
+ });
+
+ it('matches the same query written with the extractor separator', () => {
+ expect(matchesSymbol(node, 'AppWeb.Format::group')).toBe(true);
+ });
+
+ it('matches a partial container suffix on a separator boundary', () => {
+ expect(matchesSymbol(node, 'Format.group')).toBe(true);
+ });
+
+ it('does not match a container that merely shares a suffix substring', () => {
+ // `ebFormat.group` is not a boundary-aligned suffix of `AppWeb.Format.group`.
+ expect(matchesSymbol(node, 'ebFormat.group')).toBe(false);
+ });
+
+ it('does not match a different container', () => {
+ expect(matchesSymbol(node, 'Other.Format.group')).toBe(false);
+ });
+
+ it('still requires the last part to be the node name', () => {
+ expect(matchesSymbol(node, 'AppWeb.Format.other')).toBe(false);
+ });
+
+ it('classifies bare vs qualified queries', () => {
+ expect(isQualifiedSymbol('group')).toBe(false);
+ expect(isQualifiedSymbol('A.B.group')).toBe(true);
+ expect(isQualifiedSymbol('A::group')).toBe(true);
+ expect(isQualifiedSymbol('a/b')).toBe(true);
+ });
+});
+
+describe.skipIf(!HAS_SQLITE)('lookupSymbolNodes — the shared path used by callers/callees/impact', () => {
+ let projectRoot: string;
+ let cg: any;
+
+ beforeEach(async () => {
+ projectRoot = tmpRoot();
+ const client = path.join(projectRoot, 'client');
+ const pkg = path.join(projectRoot, 'pkg', 'fmtutil');
+ fs.mkdirSync(client, { recursive: true });
+ fs.mkdirSync(pkg, { recursive: true });
+ // The SAME short name defined in two languages — the collision profile of a
+ // polyglot repository, where the colliding identifiers are the common ones.
+ fs.writeFileSync(
+ path.join(client, 'chart.ts'),
+ `export function group(rows: number[][]): number[][] { return rows; }\n`
+ );
+ fs.writeFileSync(
+ path.join(client, 'Editor.tsx'),
+ `import { group } from './chart';\nexport function Editor(r: number[][]) { return group(r); }\n`
+ );
+ fs.writeFileSync(
+ path.join(pkg, 'format.py'),
+ `def group(items, size):\n return items\n`
+ );
+ fs.writeFileSync(
+ path.join(projectRoot, 'pkg', 'planner.py'),
+ `from pkg.fmtutil.format import group\n\ndef plan_a(items): return group(items, 3)\ndef plan_b(items): return group(items, 5)\n`
+ );
+
+ const CodeGraph = (await import('../src/index')).default;
+ cg = CodeGraph.initSync(projectRoot, {
+ config: { include: ['**/*.ts', '**/*.tsx', '**/*.py'], exclude: [] },
+ });
+ await cg.indexAll();
+ });
+
+ afterEach(() => {
+ cg?.destroy();
+ rmTree(projectRoot);
+ });
+
+ it('a bare name resolves to EVERY definition and reports the ambiguity', () => {
+ const { nodes, ambiguous } = lookupSymbolNodes(cg, 'group');
+ const defs = nodes.filter((n) => n.kind === 'function');
+ expect(defs.length).toBe(2);
+ expect(new Set(defs.map((n) => n.language))).toEqual(new Set(['typescript', 'python']));
+ // The flag is what stops an aggregate being presented as one symbol's answer.
+ expect(ambiguous).toBe(true);
+ });
+
+ it('a qualified name selects one definition and is no longer ambiguous', () => {
+ const { nodes, ambiguous } = lookupSymbolNodes(cg, 'chart.group');
+ expect(nodes.length).toBe(1);
+ expect(nodes[0]!.language).toBe('typescript');
+ expect(nodes[0]!.filePath).toMatch(/chart\.ts$/);
+ expect(ambiguous).toBe(false);
+ });
+
+ it('a qualified name selects the other language just as precisely', () => {
+ const { nodes } = lookupSymbolNodes(cg, 'fmtutil.format.group');
+ expect(nodes.length).toBe(1);
+ expect(nodes[0]!.language).toBe('python');
+ expect(nodes[0]!.filePath).toMatch(/fmtutil\/format\.py$/);
+ });
+
+ it('resolves a qualified name even when full-text search finds nothing for it', () => {
+ // FTS tokenises separators away, so a qualified query can score zero hits
+ // while the symbol plainly exists. Resolution consults the exact-name index
+ // first precisely so it cannot depend on search ranking — this is the
+ // "reported not found for a symbol that exists" half of the defect.
+ const fts = cg.searchNodes('fmtutil.format.group', { limit: 50 });
+ const { nodes } = lookupSymbolNodes(cg, 'fmtutil.format.group');
+ expect(nodes.length).toBe(1);
+ expect(nodes[0]!.filePath).toMatch(/format\.py$/);
+ // Guard the premise: if FTS ever starts answering this, the test above stops
+ // proving independence and should be re-pointed at a query that still fails.
+ expect(Array.isArray(fts)).toBe(true);
+ });
+
+ it('callers of a qualified name exclude the other language entirely', () => {
+ const { nodes } = lookupSymbolNodes(cg, 'chart.group');
+ const callerFiles = nodes.flatMap((n: any) =>
+ cg.getCallers(n.id).map((c: any) => c.node.filePath)
+ );
+ expect(callerFiles.length).toBeGreaterThan(0);
+ for (const f of callerFiles) expect(f).not.toMatch(/\.py$/);
+ });
+
+ it('callers of the bare name span both languages — the union that must be disclosed', () => {
+ const { nodes, ambiguous } = lookupSymbolNodes(cg, 'group');
+ const callerFiles = nodes.flatMap((n: any) =>
+ cg.getCallers(n.id).map((c: any) => c.node.filePath)
+ );
+ expect(ambiguous).toBe(true);
+ expect(callerFiles.some((f: string) => f.endsWith('.py'))).toBe(true);
+ expect(callerFiles.some((f: string) => f.endsWith('.tsx'))).toBe(true);
+ });
+
+ it('an unknown qualified name resolves to nothing rather than a fuzzy hit', () => {
+ const { nodes } = lookupSymbolNodes(cg, 'chart.nonexistent_fn');
+ expect(nodes.length).toBe(0);
+ });
+
+ it.each(['grou', 'Group'])('rejects fuzzy-only bare name "%s" (#1473)', (symbol) => {
+ expect(cg.getNodesByName(symbol)).toEqual([]);
+ expect(cg.searchNodes(symbol).length).toBeGreaterThan(0);
+ expect(lookupSymbolNodes(cg, symbol)).toEqual({ nodes: [], ambiguous: false });
+ });
+
+ it('rejects an unknown qualifier even when the bare tail exists (#173)', () => {
+ expect(cg.getNodesByName('group').length).toBeGreaterThan(0);
+ expect(lookupSymbolNodes(cg, 'missing.group')).toEqual({ nodes: [], ambiguous: false });
+ });
+});
diff --git a/__tests__/ts-chained-receiver.test.ts b/__tests__/ts-chained-receiver.test.ts
new file mode 100644
index 0000000..267aadb
--- /dev/null
+++ b/__tests__/ts-chained-receiver.test.ts
@@ -0,0 +1,101 @@
+/**
+ * A TS/JS member call reached through a host namespace — `chrome.storage.local
+ * .get(k)`, `document.body.querySelector(s)` — ends in a platform API. Emitting
+ * the bare method name for it let every such call exact-match whatever project
+ * symbol shared the name, so a storage wrapper's `get` called itself (#1707).
+ * Those are dropped, as are untyped identifier chains (#1566). The existing
+ * `window.MyNs.run()` and `this..m()` paths remain outside that guard.
+ */
+
+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';
+
+let dir: string;
+let cg: CodeGraph;
+
+beforeAll(async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1707-'));
+ const w = (rel: string, body: string) => fs.writeFileSync(path.join(dir, rel), body);
+ w(
+ 'storage.ts',
+ 'declare const chrome: any;\n' +
+ 'export const DraftHubStorage = {\n' +
+ ' async get(key: string): Promise {\n' +
+ ' const result = await chrome.storage.local.get([key]);\n' +
+ ' return result[key];\n' +
+ ' },\n' +
+ '};\n'
+ );
+ w(
+ 'dom.ts',
+ 'export function querySelector(sel: string): string { return sel; }\n' +
+ 'export function findRow(): unknown {\n' +
+ ' return document.body.querySelector("tr");\n' +
+ '}\n'
+ );
+ w(
+ 'service.ts',
+ 'declare const window: any;\n' +
+ 'export function ping(): string { return "pong"; }\n' +
+ 'export function viaGlobal(): string {\n' +
+ ' return window.MyNs.ping();\n' +
+ '}\n' +
+ 'export class PingService { ping(): string { return "service"; } }\n' +
+ 'export class Runner {\n' +
+ ' constructor(private svc: PingService) {}\n' +
+ ' run(): string { return this.svc.ping(); }\n' +
+ '}\n' +
+ 'export class AnonymousRunner {\n' +
+ ' constructor(private svc: { ping(): string }) {}\n' +
+ ' run(): string { return this.svc.ping(); }\n' +
+ '}\n'
+ );
+ cg = await CodeGraph.init(dir, { index: true });
+ cg.resolveReferences();
+});
+
+afterAll(() => {
+ cg.destroy();
+ try {
+ fs.rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway.
+ }
+});
+
+const fn = (name: string, file: string) =>
+ cg.getNodesByKind('function').find((n) => n.name === name && n.filePath === file)!;
+const method = (qn: string) => cg.getNodesByKind('method').find((n) => n.qualifiedName === qn)!;
+const callTargets = (id: string) =>
+ cg
+ .getOutgoingEdges(id)
+ .filter((e) => e.kind === 'calls')
+ .map((e) => e.target);
+
+describe('TS/JS call through a host-global chain (#1707)', () => {
+ it('does not make a storage wrapper call itself through chrome.storage.local.get', () => {
+ const get = fn('get', 'storage.ts');
+ expect(get).toBeDefined();
+ expect(callTargets(get.id)).not.toContain(get.id);
+ });
+
+ it('does not bind document.body.querySelector to a same-named project function', () => {
+ expect(callTargets(fn('findRow', 'dom.ts').id)).not.toContain(
+ fn('querySelector', 'dom.ts').id
+ );
+ });
+
+ it('keeps a chain rooted at a project value — window.MyNs.m() and this..m()', () => {
+ const ping = fn('ping', 'service.ts').id;
+ expect(callTargets(fn('viaGlobal', 'service.ts').id)).toContain(ping);
+ expect(callTargets(method('Runner::run').id)).toEqual([method('PingService::ping').id]);
+ });
+
+ it('does not guess a same-named project target for an anonymous field type (#1496)', () => {
+ // Neither the top-level ping nor PingService::ping establishes what svc is.
+ expect(callTargets(method('AnonymousRunner::run').id)).toEqual([]);
+ });
+});
diff --git a/__tests__/ts-this-field-call.test.ts b/__tests__/ts-this-field-call.test.ts
new file mode 100644
index 0000000..ce2f675
--- /dev/null
+++ b/__tests__/ts-this-field-call.test.ts
@@ -0,0 +1,106 @@
+/**
+ * A TS/JS call through a field of the enclosing class resolves on the field's
+ * declared type, never by bare name (#1496).
+ *
+ * `this.mailer.send(msg)` inside `Notifier.send()` used to be emitted as the
+ * bare `send`, which exact-matched the nearest same-named method — the
+ * calling method itself. The stored self-edge `Notifier::send → Notifier::send`
+ * made callers, callees, impact and trace silently wrong on exactly the
+ * shape a delegating wrapper takes. The identical call resolved correctly
+ * whenever the wrapper had any other name.
+ */
+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 { 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-1496-'));
+ fs.mkdirSync(path.join(dir, 'src'));
+ const w = (rel: string, body: string) => fs.writeFileSync(path.join(dir, 'src', rel), body);
+ w('mailer.ts', 'export class Mailer {\n send(msg: string): string { return msg; }\n}\n');
+ w(
+ 'notifier.ts',
+ "import { Mailer } from './mailer';\n" +
+ 'export class Notifier {\n' +
+ ' constructor(private readonly mailer: Mailer, private items: string[]) {}\n' +
+ ' send(msg: string): string { return this.mailer.send(msg); }\n' +
+ ' other(msg: string): string { return this.mailer.send(msg); }\n' +
+ ' push(msg: string): void { this.items.push(msg); }\n' +
+ '}\n'
+ );
+ // Plain JS: the field's type is only known from its `new` initializer.
+ // (resolveMethodOnType matches within one language, so the JS wrapper gets a JS Mailer.)
+ w('legacy-mailer.js', 'class LegacyMailer {\n send(msg) { return msg; }\n}\nmodule.exports = { LegacyMailer };\n');
+ w(
+ 'legacy.js',
+ "const { LegacyMailer } = require('./legacy-mailer');\n" +
+ 'class LegacyNotifier {\n' +
+ ' constructor() { this.mailer = new LegacyMailer(); }\n' +
+ ' send(msg) { return this.mailer.send(msg); }\n' +
+ '}\n' +
+ 'module.exports = { LegacyNotifier };\n'
+ );
+ // A field typed as the type OF a value: an object literal used as a namespace.
+ w(
+ 'storage.ts',
+ 'export const DraftHubStorage = {\n' +
+ ' async get(key: string): Promise { return key; },\n' +
+ ' async getSettings(): Promise