ソースを参照

Merge remote-tracking branch 'origin/main'

# Conflicts:
#	CHANGELOG.md
Colby McHenry 7 時間 前
コミット
7a23648db9
100 ファイル変更9313 行追加760 行削除
  1. 283 0
      AGENTS.md
  2. 80 0
      CHANGELOG.md
  3. 4 82
      CLAUDE.md
  4. 7 2
      README.md
  5. 107 0
      __tests__/alias-binding-resolution.test.ts
  6. 148 0
      __tests__/bare-call-no-method.test.ts
  7. 81 0
      __tests__/call-receiver-no-fabrication.test.ts
  8. 66 0
      __tests__/cli-affected-test-conventions.test.ts
  9. 262 0
      __tests__/cli-definition-grouping.test.ts
  10. 64 0
      __tests__/cli-index-explicit-path.test.ts
  11. 63 0
      __tests__/cli-parse-warning.test.ts
  12. 101 0
      __tests__/cli-truncation.test.ts
  13. 71 0
      __tests__/commonjs-exports.test.ts
  14. 77 0
      __tests__/cpp-raw-string-delimiter-haserror.test.ts
  15. 177 0
      __tests__/cpp-raw-string-preparse-1505.test.ts
  16. 150 0
      __tests__/cross-file-visibility.test.ts
  17. 44 0
      __tests__/explore-cross-call-dedup.test.ts
  18. 43 5
      __tests__/explore-declaration-only.test.ts
  19. 153 0
      __tests__/explore-elided-symbol-names.test.ts
  20. 21 2
      __tests__/explore-output-budget.test.ts
  21. 2 1
      __tests__/expo-router.test.ts
  22. 107 0
      __tests__/extraction-old-git.test.ts
  23. 865 2
      __tests__/extraction.test.ts
  24. 9 0
      __tests__/fixtures/kernel-parity/Torture.java
  25. 2 0
      __tests__/fixtures/kernel-parity/torture.cpp
  26. 19 0
      __tests__/fixtures/kernel-parity/torture.js
  27. 24 0
      __tests__/fixtures/kernel-parity/torture.kt
  28. 10 1
      __tests__/fixtures/kernel-parity/torture.lua
  29. 14 0
      __tests__/fixtures/kernel-parity/torture.py
  30. 5 0
      __tests__/fixtures/kernel-parity/torture.rs
  31. 7 0
      __tests__/fixtures/kernel-parity/torture.scala
  32. 18 0
      __tests__/fixtures/kernel-parity/torture.tsx
  33. 9 0
      __tests__/fixtures/php-import-alias-static/app/Http/Controllers/Backend/SettleController.php
  34. 8 0
      __tests__/fixtures/php-import-alias-static/app/Repositories/SettleRepository.php
  35. 8 0
      __tests__/fixtures/php-import-alias-static/app/Services/SettleService.php
  36. 107 0
      __tests__/frameworks-integration.test.ts
  37. 112 0
      __tests__/frameworks.test.ts
  38. 58 3
      __tests__/frontload-hook.test.ts
  39. 166 0
      __tests__/fts5-fallback.test.ts
  40. 3 1
      __tests__/function-ref.test.ts
  41. 153 0
      __tests__/fuzzy-lexical-reach.test.ts
  42. 126 0
      __tests__/import-emitted-specifier.test.ts
  43. 401 6
      __tests__/installer-targets.test.ts
  44. 3 1
      __tests__/kernel-kotlin-parity.test.ts
  45. 16 0
      __tests__/kernel-lua-parity.test.ts
  46. 2 1
      __tests__/kernel-rustlang-parity.test.ts
  47. 25 1
      __tests__/kernel-tsjs-parity.test.ts
  48. 97 0
      __tests__/mcp-callers-truncation.test.ts
  49. 18 3
      __tests__/mcp-tool-annotations.test.ts
  50. 128 0
      __tests__/mcp-writer-lock.test.ts
  51. 76 0
      __tests__/nested-declarator-functions.test.ts
  52. 217 0
      __tests__/no-silent-fuzzy-symbol.test.ts
  53. 9 2
      __tests__/object-literal-methods.test.ts
  54. 95 0
      __tests__/orphaned-refs-sweep.test.ts
  55. 136 0
      __tests__/php-import-alias-static-resolution.test.ts
  56. 41 0
      __tests__/preload-languages.test.ts
  57. 106 0
      __tests__/python-module-scope-collection-methods.test.ts
  58. 55 0
      __tests__/python-quoted-annotation.test.ts
  59. 22 6
      __tests__/react-router.test.ts
  60. 215 0
      __tests__/reference-target-kind.test.ts
  61. 64 0
      __tests__/resolution-fileexists-containment.test.ts
  62. 486 1
      __tests__/resolution.test.ts
  63. 203 0
      __tests__/symbol-lookup.test.ts
  64. 101 0
      __tests__/ts-chained-receiver.test.ts
  65. 106 0
      __tests__/ts-this-field-call.test.ts
  66. 104 15
      __tests__/wal-deferral.test.ts
  67. 36 0
      __tests__/watcher.test.ts
  68. 87 0
      __tests__/writer-lock.test.ts
  69. 55 1
      codegraph-kernel/src/ccpp/mod.rs
  70. 10 0
      codegraph-kernel/src/java.rs
  71. 182 26
      codegraph-kernel/src/kotlin.rs
  72. 132 12
      codegraph-kernel/src/lua.rs
  73. 53 7
      codegraph-kernel/src/python.rs
  74. 32 5
      codegraph-kernel/src/rustlang.rs
  75. 12 0
      codegraph-kernel/src/scala.rs
  76. 149 20
      codegraph-kernel/src/tsjs/extractors.rs
  77. 72 10
      codegraph-kernel/src/tsjs/mod.rs
  78. 36 0
      docs/AGENTS.md
  79. 23 13
      docs/design/kotlin-kernel-port-checklist.md
  80. 2 2
      docs/design/rust-lang-kernel-port-checklist.md
  81. 1 1
      scripts/agent-eval/probe-suite-envelope.mjs
  82. 272 235
      src/bin/codegraph.ts
  83. 54 5
      src/db/index.ts
  84. 107 21
      src/db/queries.ts
  85. 72 19
      src/db/wal-valve.ts
  86. 29 0
      src/directory.ts
  87. 15 1
      src/extraction/cfml-extractor.ts
  88. 286 43
      src/extraction/index.ts
  89. 163 12
      src/extraction/languages/c-cpp.ts
  90. 1 1
      src/extraction/languages/javascript.ts
  91. 161 29
      src/extraction/languages/kotlin.ts
  92. 4 1
      src/extraction/languages/lua.ts
  93. 3 0
      src/extraction/languages/rust.ts
  94. 10 0
      src/extraction/languages/scala.ts
  95. 9 2
      src/extraction/languages/typescript.ts
  96. 21 2
      src/extraction/tree-sitter-types.ts
  97. 396 45
      src/extraction/tree-sitter.ts
  98. 53 109
      src/graph/named-symbol-flow.ts
  99. 204 0
      src/graph/symbol-lookup.ts
  100. 11 3
      src/index.ts

+ 283 - 0
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 `<Link href>` — 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 `<!-- CODEGRAPH_START -->`/`<!-- CODEGRAPH_END -->` markers. The installer **stopped writing** a `## CodeGraph` block into each agent's instructions file (`CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.config/opencode/AGENTS.md` / `~/.gemini/GEMINI.md` / `.cursor/rules/codegraph.mdc` / Kiro steering doc) because it duplicated the MCP `initialize` instructions verbatim (issue #529). Each target's `install` (self-heal on upgrade) and `uninstall` use the markers to **strip** a block a previous install left behind. `server-instructions.ts` is the single source of truth for agent-facing guidance.
+- All installer changes need matching coverage in `__tests__/installer-targets.test.ts` — there are ~47 parameterized contract tests covering install idempotency, sibling preservation, uninstall reverses install, byte-equal re-runs returning `unchanged`, and partial-state recovery for Codex.
+
+### Cursor MCP working-directory quirk
+
+Cursor launches MCP subprocesses with the wrong cwd and doesn't pass `rootUri` in `initialize`. The installer injects `--path` into Cursor's MCP args — absolute path for local installs, `${workspaceFolder}` for global installs. If you touch Cursor wiring, preserve this.
+
+### MCP server instructions
+
+`src/mcp/server-instructions.ts` is sent back to the agent in the MCP `initialize` response. This is the *first* thing every agent sees about how to use the tools, and as of issue #529 it is the **single source of truth** for agent-facing tool guidance — the installer no longer writes a duplicate `## CodeGraph` instructions block into `CLAUDE.md` / `AGENTS.md` / `.cursor/rules/codegraph.mdc`. Edit tool guidance here and nowhere else.
+
+## Retrieval performance & dynamic-dispatch coverage (do not regress)
+
+CodeGraph's core value is letting an agent answer **structural/flow** questions ("how does X reach Y", trace, impact, callers) with a few **fast** codegraph calls and **zero Read/Grep**. The optimization target is **wall-clock latency + tool-call count** — *don't optimize for token cost*. (Cost is **lower**, not "flat" as earlier framing claimed: a current-build with-vs-without A/B across the 7 README repos, median of 4, saved on average **35% cost · 57% tokens · 46% time · 71% tool calls** — reproducing the published README. The mechanism is **far fewer turns over a much smaller accumulated context** — NOT cache-ability: the without-arm's huge token volume is *mostly* cheap cache-reads, which is why token-count savings (57%) look bigger than cost savings (35%). Measure tokens by **summing per-turn assistant usage**, not `result.usage` (last-turn only in current Claude Code). See `docs/benchmarks/call-sequence-analysis.md`.) The mechanism that drives everything here: **an agent falls back to Read/Grep the instant a codegraph answer is insufficient.** So every change is judged by one question — is codegraph's answer sufficient enough to *stop* the agent from reading?
+
+**Target behavior:** a flow question resolves in **1 codegraph call on small repos, scaling to 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/<pid>/fdinfo/*` (sum `^inotify ` lines on the fd whose `readlink` is `anon_inode:inotify`).
+
+### Windows (Parallels VM + SSH)
+
+For any Windows-specific PR, bug, or implementation, validate it on the real Windows VM rather than guessing. Connection details live in the gitignored **`.parallels`** file at the repo root (VM name, guest IP, SSH user/key). `prlctl exec` needs Parallels Pro and is unavailable, so SSH is the bridge.
+
+- Connect / run from the Mac host: `ssh <user>@<guest_ip> "..."`. For multi-line work, pipe PowerShell over stdin and **refresh PATH from the registry** first (sshd's session has a stale PATH after winget installs):
+  ```
+  ssh colby@10.211.55.3 "powershell -NoProfile -ExecutionPolicy Bypass -Command -" <<'PS'
+  $env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User")
+  Set-Location C:\dev\codegraph
+  PS
+  ```
+- Clone fresh into a **Windows-local** path (`C:\dev\codegraph`) and `npm ci` there — never run npm against the shared Mac repo, since `esbuild`/`rollup` ship platform-specific binaries.
+- Guest toolchain (winget): Node LTS, Git, and the **VC++ ARM64 redistributable** (required by `@rollup/rollup-win32-arm64-msvc`, which vitest pulls in).
+- Fetch a contributor PR head straight from their fork to dodge `pull/<n>/head` lag: `git fetch <fork-url> <branch>` then `git checkout -f FETCH_HEAD`.
+- Known pre-existing Windows failures (they reproduce on `main`, unrelated to your change — confirm against `origin/main` before blaming your PR, and don't let them mask new regressions): `security.test.ts > Session marker symlink resistance > does not follow a pre-planted symlink` (symlink creation needs privileges on Windows); and the `mcp-initialize.test.ts` / `mcp-roots.test.ts` suites, which fail in `afterEach` with `EPERM` removing the temp dir because a spawned `serve --mcp` (its `--liftoff-only` re-exec grandchild) still holds the cwd / SQLite file open — a Windows file-locking quirk, not a logic bug.
+
+## Releases
+
+Released to npm and mirrored as [GitHub Releases](https://github.com/colbymchenry/codegraph/releases). `CHANGELOG.md` is the source of truth; GitHub Release notes are extracted from it.
+
+### Writing changelog entries
+
+**Default: write entries under `## [Unreleased]`** — that's the section reserved for work landing between releases. **Don't pre-create a `## [X.Y.Z]` block** for the next release: the Release workflow's first step is `scripts/prepare-release.mjs`, which automatically promotes everything under `[Unreleased]` into a new `## [X.Y.Z] - <YYYY-MM-DD>` block at release time (or merges into a pre-existing `[X.Y.Z]` block if one exists — but you don't need one). Pre-staging is what caused the v0.9.5 sparse-release-notes incident: a sparse `[0.9.5]` block hand-added before the rest of the work landed got picked by the extractor over the much-larger `[Unreleased]` section above it. Don't do that.
+
+Formatting rules for any entry (anywhere — `[Unreleased]` or otherwise):
+
+1. **Write friendly, user-facing notes — not engineer-facing ones.** Group under `### New Features` and `### Fixes` (sentence-case). Surface `### Breaking Changes` and `### Security` as their own sections **only when the release has them**; fold improvement-flavored changes into New Features. Omit empty sections. (This replaces the old Keep-a-Changelog `Added/Changed/Fixed/Removed/Deprecated` grouping: the GitHub Release page extracts each version block **verbatim** via `scripts/extract-release-notes.mjs`, and the old dense, implementation-focused entries rendered as an unreadable wall of text — so the whole CHANGELOG was rewritten to this format and every published release re-noted to match.)
+2. **One plain-language sentence per bullet:** what changed and why it matters to a user. Lead with the capability, or with the symptom that's now fixed.
+3. **Strip the internals.** No internal file paths (`src/...`), no internal symbol / function / class names, no benchmark numbers / percentages / node-or-edge counts. **Keep:** language & framework names (Go, Spring, NestJS, …), things a user types or sets (`codegraph install`, `codegraph_explore`, the `CODEGRAPH_*` env vars), agent / IDE names (Claude Code, Cursor, opencode, Kiro, …), and a brief `Thanks @user` when a contributor is credited.
+4. Issue / PR references in entries are by number (`(#403)` etc.); the GitHub renderer auto-links them in the published release notes.
+5. **Don't add a `[X.Y.Z]: https://...` link reference yourself** — `prepare-release.mjs` appends it automatically when it promotes the version (idempotent: a re-run is a no-op if it already exists).
+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 `[<version>]` (and auto-commit + push that
+CHANGELOG change back to `main` so on-disk truth matches the published
+notes), then bundles a Node runtime per platform (`scripts/build-bundle.sh`)
+and publishes both the GitHub Release and the npm thin-installer
+(`scripts/pack-npm.sh`: a shim package + per-platform packages).
+Publishing manually is **wrong** now — a plain `npm publish` ships the root
+package (non-bundled), which breaks anyone on Node < 22.5.
+
+**Claude does NOT bump the version unless explicitly asked.** The maintainer
+typically does it themselves — often by editing `package.json` directly via
+the GitHub web UI. Don't proactively commit a version bump as part of
+unrelated work, and don't propose one when summarizing a PR.
+
+When the maintainer DOES bump the version, the only edit strictly required is
+to `package.json` — the workflow's "Sync package-lock.json" step detects a
+mismatch between `package.json` and `package-lock.json`, runs
+`npm install --package-lock-only --ignore-scripts` to rewrite the lock file's
+version fields (top-level + `packages.""`), and auto-commits + pushes the
+result back to `main` with `[skip ci]`. So a GitHub-web-UI single-file edit to
+`package.json` is enough to kick off a clean release. (If they edit both files
+locally, that's fine too — the sync step no-ops.)
+
+Once `package.json` is at the target version on `main`, trigger
+**Actions → Release → Run workflow** (on `main`). The workflow:
+
+1. Syncs `package-lock.json` to `package.json`'s version if they've drifted; commits + pushes that change.
+2. Runs `prepare-release.mjs <X.Y.Z>` → promotes `[Unreleased]` → `[X.Y.Z] - <today>` in `CHANGELOG.md`, appends the link reference, commits + pushes the move with `[skip ci]`.
+3. Builds every platform bundle on one runner, generates `SHA256SUMS`.
+4. Creates the GitHub Release with notes from the freshly-promoted `[X.Y.Z]` block.
+5. Publishes the npm shim + per-platform packages. Requires the `NPM_TOKEN` repo secret.
+
+**Do not run `npm publish`, `git push`, or `git tag` yourself** — these are
+publish actions on shared state. Write the files, hand the user the commands.
+
+## House rules
+
+- The `0.7.x` line is in active multi-agent rollout. Any change to `src/installer/` (especially `targets/`) needs corresponding test coverage and a CHANGELOG entry — installer regressions break every new install silently.
+- When changing what the MCP tools do or how agents should use them, edit `src/mcp/server-instructions.ts` — it is the **single source of truth** for agent-facing tool guidance (issue #529). The installer no longer writes a duplicate instructions block into `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` / `.cursor/rules/codegraph.mdc` / Kiro steering, so there's nothing to keep in sync anymore. (The repo's own checked-in `.cursor/rules/codegraph.mdc` is dogfooding config — update it too if you use Cursor on this repo, but it ships nowhere.)
+- **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.

+ 80 - 0
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 <path>` 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

ファイルの差分が大きいため隠しています
+ 4 - 82
CLAUDE.md


+ 7 - 2
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`).

+ 107 - 0
__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<string, string>): Promise<void> => {
+    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');
+    }
+  });
+});

+ 148 - 0
__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<string[]> {
+  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<typeof n> => !!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<void> {',
+        '  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');
+  });
+});

+ 81 - 0
__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
+ * `<inner>().<method>`, 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 `<inner>().<method>` 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']);
+  });
+});

+ 66 - 0
__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([]);
+  });
+});

+ 262 - 0
__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');
+  });
+});

+ 64 - 0
__tests__/cli-index-explicit-path.test.ts

@@ -0,0 +1,64 @@
+/**
+ * `codegraph index <path>` rebuilds <path>, never an ancestor (#1524).
+ *
+ * The command used to resolve an uninitialized <path> 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 <path> (#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);
+  });
+});

+ 63 - 0
__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);
+});

+ 101 - 0
__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');
+  });
+});

+ 71 - 0
__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<typeof extractFromSource>, 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('<anonymous>');
+  });
+
+  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([]);
+  });
+});

+ 77 - 0
__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 <stdio.h>\n#include <stdlib.h>\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([]);
+  });
+});

+ 177 - 0
__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 <string>
+
+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);
+  });
+});

+ 150 - 0
__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<string, string>): 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<string[]> {
+  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<typeof n> => !!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');
+  });
+});

+ 44 - 0
__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();

+ 43 - 5
__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', () => {

+ 153 - 0
__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);
+  });
+});

+ 21 - 2
__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<CodeGraph['getStats']>);
+    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 () => {

+ 2 - 1
__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

+ 107 - 0
__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'));
+  });
+});

+ 865 - 2
__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<string>;
+  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<User, 'id'>` 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<Cfg> = 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);
+  });
+});

+ 9 - 0
__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<String> 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<String> names) {

+ 2 - 0
__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

+ 19 - 0
__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();
+}

+ 24 - 0
__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<Int> = 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)
+    }
+}

+ 10 - 1
__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 } }
 

+ 14 - 0
__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()

+ 5 - 0
__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<Cfg> = Lazy::new(|| build_cfg());
+static INIT_ALIAS: fn() = free_fn;
+
 pub union Reg {
     pub raw: u32,
     pub halves: [u16; 2],

+ 7 - 0
__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() }
+}

+ 18 - 0
__tests__/fixtures/kernel-parity/torture.tsx

@@ -212,3 +212,21 @@ import('./dynamic-module');
 new NS.Widget(makeArg());
 new Map<string, number>();
 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) => <button onClick={handleClear} onDoubleClick={() => describe(i)}>{later(i)}{count}{a()}{b()}</button>);
+}

+ 9 - 0
__tests__/fixtures/php-import-alias-static/app/Http/Controllers/Backend/SettleController.php

@@ -0,0 +1,9 @@
+<?php
+namespace App\Http\Controllers\Backend;
+use App\Services\SettleService as Settle;
+
+class SettleController extends Controller {
+    public function excel($stores, $startDay = null, $endDay = null, $id = null) {
+        return Settle::getSettlesToExcel($stores, request()->_SELECTED, [$startDay, $endDay], $id);
+    }
+}

+ 8 - 0
__tests__/fixtures/php-import-alias-static/app/Repositories/SettleRepository.php

@@ -0,0 +1,8 @@
+<?php
+namespace App\Repositories;
+
+class SettleRepository {
+    public static function getSettlesToExcel($storeIds, $dayRange) {
+        return [];
+    }
+}

+ 8 - 0
__tests__/fixtures/php-import-alias-static/app/Services/SettleService.php

@@ -0,0 +1,8 @@
+<?php
+namespace App\Services;
+
+class SettleService {
+    public static function getSettlesToExcel($stores, $selected, $dayRange, $id) {
+        return [];
+    }
+}

+ 107 - 0
__tests__/frameworks-integration.test.ts

@@ -3,6 +3,10 @@ import * as fs from 'fs';
 import * as path from 'path';
 import * as os from 'os';
 import { CodeGraph } from '../src';
+import { DatabaseConnection, getDatabasePath } from '../src/db';
+import { QueryBuilder } from '../src/db/queries';
+import { createResolver } from '../src/resolution';
+import type { Node } from '../src/types';
 import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
 
 beforeAll(async () => {
@@ -10,6 +14,54 @@ beforeAll(async () => {
   await loadAllGrammars();
 });
 
+describe('Express middleware imports', () => {
+  it('does not resolve package imports into license headings', async () => {
+    const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-express-doc-import-'));
+    let cg: CodeGraph | undefined;
+    try {
+      fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { express: '*', cors: '*' } }));
+      fs.writeFileSync(path.join(tmpDir, 'LICENSE.md'), '# cors\n\n# host-validation-middleware\n');
+      fs.writeFileSync(path.join(tmpDir, 'local.js'), 'export function localMiddleware() {}\n');
+      fs.writeFileSync(path.join(tmpDir, 'server.js'), [
+        "import corsMiddleware from 'cors'",
+        "import { hostValidationMiddleware as originalHostValidationMiddleware } from 'host-validation-middleware'",
+        "import { localMiddleware } from './local.js'",
+        'localMiddleware()',
+      ].join('\n'));
+      cg = await CodeGraph.init(tmpDir, { index: true });
+      const local = cg.getNodesByKind('function').find((n) => n.name === 'localMiddleware');
+      expect(local).toBeDefined();
+      expect(cg.getIncomingEdges(local!.id).some((e) => e.kind === 'imports')).toBe(true);
+      expect(cg.getIncomingEdges(local!.id).some((e) => e.kind === 'calls')).toBe(true);
+      cg.close();
+      cg = undefined;
+      const db = DatabaseConnection.open(getDatabasePath(tmpDir));
+      try {
+        const queries = new QueryBuilder(db.getDb());
+        for (const name of ['cors', 'host-validation-middleware']) {
+          queries.insertNode({
+            id: `heading:${name}`, name, qualifiedName: `LICENSE.md#${name}`,
+            kind: 'module', language: 'markdown' as Node['language'], filePath: 'LICENSE.md',
+            startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0,
+          });
+        }
+        const resolver = createResolver(tmpDir, queries);
+        for (const referenceName of ['cors', 'corsMiddleware', 'host-validation-middleware']) {
+          expect(resolver.resolveOne({
+            fromNodeId: 'file:server.js', referenceName, referenceKind: 'imports',
+            filePath: 'server.js', language: 'javascript', line: 1, column: 0,
+          })).toBeNull();
+        }
+      } finally {
+        db.close();
+      }
+    } finally {
+      cg?.close();
+      fs.rmSync(tmpDir, { recursive: true, force: true });
+    }
+  });
+});
+
 describe('Django end-to-end framework extraction', () => {
   let tmpDir: string | undefined;
   afterEach(() => {
@@ -301,6 +353,61 @@ describe('C++ end-to-end — virtual override synthesis', () => {
 
     cg.close();
   });
+
+  it('indexes pure-virtual base methods and bridges overrides (#1727)', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-pure-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'store.cc'),
+      'class Store {\n' +
+        'public:\n' +
+        '    virtual ~Store() {}\n' +
+        '    virtual int read(int key) = 0;\n' +
+        '};\n' +
+        'class DiskStore : public Store {\n' +
+        'public:\n' +
+        '    int read(int key) override { return key + 1; }\n' +
+        '};\n' +
+        'class MemStore : public Store {\n' +
+        'public:\n' +
+        '    int read(int key) override { return key + 2; }\n' +
+        '};\n' +
+        'int fetch(Store* s, int k) {\n' +
+        '    return s->read(k);\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const storeRead = cg
+      .getNodesByKind('method')
+      .find((n) => n.qualifiedName === 'Store::read');
+    expect(storeRead, 'Store::read pure virtual must be a method node').toBeDefined();
+    expect(storeRead!.isAbstract).toBe(true);
+
+    const diskRead = cg
+      .getNodesByKind('method')
+      .find((n) => n.qualifiedName === 'DiskStore::read');
+    const memRead = cg
+      .getNodesByKind('method')
+      .find((n) => n.qualifiedName === 'MemStore::read');
+    expect(diskRead).toBeDefined();
+    expect(memRead).toBeDefined();
+
+    // cpp-override synthesis: base pure virtual → each override
+    const out = cg.getOutgoingEdges(storeRead!.id).filter((e) => e.kind === 'calls');
+    const targets = out.map((e) => e.target);
+    expect(targets).toContain(diskRead!.id);
+    expect(targets).toContain(memRead!.id);
+
+    // Call through abstract base resolves onto Store::read
+    const fetch = cg.getNodesByKind('function').find((n) => n.name === 'fetch');
+    expect(fetch).toBeDefined();
+    const callees = cg.getCallees(fetch!.id).map((c) => c.node.qualifiedName);
+    expect(callees).toContain('Store::read');
+
+    cg.close();
+  });
 });
 
 describe('Java end-to-end — field-injected bean trace (issue #389)', () => {

+ 112 - 0
__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")

+ 58 - 3
__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<typeof import('os')>() }));
 
 /** 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 <codegraph_context> 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);
+  });
+});

+ 166 - 0
__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']);
+  });
+});

+ 3 - 1
__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',
       ]);

+ 153 - 0
__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<string> {',
+        '  // 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<Node> & Pick<Node, 'id' | 'kind' | 'name' | 'filePath'>): 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');
+  });
+});

+ 126 - 0
__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);
+  });
+});

+ 401 - 6
__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.<name>` with `disabled` / `codemode`. The
+// v1 `mcp.<name>` + `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);
+  });
+});

+ 3 - 1
__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

+ 16 - 0
__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);

+ 2 - 1
__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

+ 25 - 1
__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');

+ 97 - 0
__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<string, unknown>): Promise<string> => {
+  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');
+  });
+});

+ 18 - 3
__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);
   });
 });

+ 128 - 0
__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<void> {
+  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<number | null>((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);
+});

+ 76 - 0
__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<typeof extractFromSource>, 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) => <button onClick={handleClear} onDoubleClick={() => describe(i)}>{later(i)}</button>)
+}
+`;
+    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 === '<anonymous>')).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']);
+  });
+});

+ 217 - 0
__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<string, unknown>): Promise<string> {
+    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');
+  });
+});

+ 9 - 2
__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).

+ 95 - 0
__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

+ 136 - 0
__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`<?php
+namespace App\Services;
+class OtherService {
+    public function getSettlesToExcel() {}
+}
+`);
+    write(controllerPath, controllerSource.replace(
+      'return Settle::',
+      '$Settle = new OtherService();\n        $Settle->getSettlesToExcel();\n        return Settle::',
+    ));
+    expect((await callees()).filter((name) => name.endsWith('::getSettlesToExcel'))).toEqual([
+      'App\\Services::OtherService::getSettlesToExcel',
+      serviceMethod,
+    ]);
+  });
+});

+ 41 - 0
__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');
+  });
+});

+ 106 - 0
__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);
+  });
+});

+ 55 - 0
__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']);
+  });
+});

+ 22 - 6
__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<string, unknown>).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<string, unknown>).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();
   });

+ 215 - 0
__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', '<script lang="ts">\n$IMPORT$\nexport class SfcBox implements Serializable {\n  n = 1;\n}\n</script>\n<div>hi</div>\n'],
+    ['vue', 'src/Box.vue', '<script lang="ts">\n$IMPORT$\nexport class SfcBox implements Serializable {\n  n = 1;\n}\n</script>\n<template><div/></template>\n'],
+    ['astro', 'src/Box.astro', '---\n$IMPORT$\nexport class SfcBox implements Serializable {\n  n = 1;\n}\n---\n<div/>\n'],
+  ])('drops an npm supertype in a %s single-file component', async (_lang, file, body) => {
+    // An SFC imports inside its <script> block (Astro: the `---` frontmatter)
+    // with ordinary ES module syntax, so a bare specifier there is external for
+    // exactly the same reason it is in a .ts file. Missing that, the npm
+    // supertype name-matched the local class below.
+    write('package.json', `{"name":"sfc","version":"1.0.0"}\n`);
+    write('src/models.ts', `export class Serializable {\n  a = 1;\n}\n`);
+    write(file, body.replace('$IMPORT$', `import { Serializable } from 'some-npm-pkg';\n`));
+    const { edges, failed } = await load();
+    expect(edges.filter((e) => e.tgt === 'Serializable')).toEqual([]);
+    expect(failed.some((r) => r.name === 'Serializable')).toBe(true);
+  });
+
+  it('keeps an SFC supertype imported from a relative path', async () => {
+    write('package.json', `{"name":"sfc","version":"1.0.0"}\n`);
+    write('src/models.ts', `export class Serializable {\n  a = 1;\n}\n`);
+    write(
+      'src/Box.svelte',
+      `<script lang="ts">\nimport { Serializable } from './models';\n\n` +
+        `export class SfcBox implements Serializable {\n  n = 1;\n}\n</script>\n<div>hi</div>\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);
+  });
+});

+ 64 - 0
__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);
+  });
+});

+ 486 - 1
__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 ? '<string, string>' : ''}();
+  values.set('answer', '42');
+  values.get('answer');
+  return values.has('answer');
+}
+export function useNestedMap(holder${typed ? ': { values: Map<string, string> }' : ''}) {
+  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}<string>) {
+  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);
+  });
 });

+ 203 - 0
__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>): 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 });
+  });
+});

+ 101 - 0
__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.<field>.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<unknown> {\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.<field>.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([]);
+  });
+});

+ 106 - 0
__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<string> { return key; },\n' +
+      '  async getSettings(): Promise<object> { return {}; },\n' +
+      '};\n'
+  );
+  w(
+    'keeper.ts',
+    "import { DraftHubStorage } from './storage';\n" +
+      'export class Keeper {\n' +
+      '  constructor(private readonly storage: typeof DraftHubStorage) {}\n' +
+      '  async get(key: string): Promise<string> { return this.storage.get(key); }\n' +
+      '  async settings(): Promise<object> { return this.storage.getSettings(); }\n' +
+      '}\n'
+  );
+  cg = CodeGraph.initSync(dir);
+  await cg.indexAll();
+});
+
+afterAll(() => {
+  cg.destroy();
+  fs.rmSync(dir, { recursive: true, force: true });
+});
+
+const method = (qn: string) => cg.getNodesByKind('method').find((n) => n.qualifiedName === qn)!;
+const calleesOf = (qn: string) => cg.getCallees(method(qn).id).map(({ node }) => node.qualifiedName).sort();
+
+describe('this.<field>.<method>() (#1496)', () => {
+  it('resolves on the field\'s declared type even when the wrapper shares the method name', () => {
+    expect(calleesOf('Notifier::send')).toEqual(['Mailer::send']);
+    expect(calleesOf('Notifier::other')).toEqual(['Mailer::send']);
+    // No self-edge anywhere.
+    const self = cg.getCallers(method('Notifier::send').id).some(({ node }) => node.id === method('Notifier::send').id);
+    expect(self).toBe(false);
+  });
+
+  it('reads a JS field initialized in the constructor', () => {
+    expect(calleesOf('LegacyNotifier::send')).toEqual(['LegacyMailer::send']);
+  });
+
+  it('leaves a builtin-typed field unresolved rather than guessing a same-named method', () => {
+    // `this.items.push()` — `string[]` names no project type; the wrapper `push`
+    // must not become its own callee.
+    expect(calleesOf('Notifier::push')).toEqual([]);
+  });
+
+  it('resolves a field typed `typeof <objectLiteral>` onto the literal\'s member', () => {
+    // The members are bare-named functions inside the constant's extent (#1573).
+    expect(calleesOf('Keeper::settings')).toEqual(['getSettings']);
+    expect(calleesOf('Keeper::get')).toEqual(['get']);
+    const self = cg.getCallers(method('Keeper::get').id).some(({ node }) => node.id === method('Keeper::get').id);
+    expect(self).toBe(false);
+  });
+});

+ 104 - 15
__tests__/wal-deferral.test.ts

@@ -9,12 +9,12 @@
  * the valve's trigger/dedupe/backpressure logic, and the end-to-end indexAll
  * behavior (identical graph with and without deferral; interval restored).
  */
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import * as fs from 'fs';
 import * as os from 'os';
 import * as path from 'path';
 import { DatabaseConnection } from '../src/db';
-import { WalCheckpointValve, resolveWalValveMb } from '../src/db/wal-valve';
+import { WalCheckpointValve, WalValveAbortError, resolveWalValveMb } from '../src/db/wal-valve';
 import CodeGraph from '../src/index';
 
 let tmpDir: string;
@@ -193,6 +193,20 @@ function writeFixtureProject(): void {
   }
 }
 
+
+async function seedPendingRefs(cg: CodeGraph): Promise<void> {
+  const raw = (cg as unknown as { db: DatabaseConnection }).db.getDb();
+  const node = raw.prepare("SELECT id, file_path FROM nodes WHERE kind = 'function' LIMIT 1").get() as
+    | { id: string; file_path: string }
+    | undefined;
+  expect(node).toBeDefined();
+  const ins = raw.prepare(
+    "INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, status) VALUES (?, ?, 'calls', 1, 0, ?, 'typescript', 'pending')"
+  );
+  ins.run(node!.id, 'helper0', node!.file_path);
+  ins.run(node!.id, 'helper1', node!.file_path);
+}
+
 describe('indexAll WAL deferral end-to-end', () => {
 
   it('produces the same graph with and without deferral, and restores the interval', async () => {
@@ -298,6 +312,35 @@ describe('sync WAL deferral end-to-end (#1248)', () => {
       delete process.env.CODEGRAPH_NO_WAL_DEFER;
     }
   });
+
+  it('applies WAL backpressure during changed-file storage and orphan resolution (#1539)', async () => {
+    writeFixtureProject();
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+    const backpressure = vi
+      .spyOn(WalCheckpointValve.prototype, 'backpressure')
+      .mockReturnValue(null);
+
+    try {
+      fs.writeFileSync(
+        path.join(tmpDir, 'src', 'mod0.ts'),
+        `export function fn0(x: number): number { return helper0(x) + 100; }\n` +
+        `function helper0(x: number): number { return x * 100; }\n`
+      );
+      const changed = await cg.sync();
+      expect(changed.filesModified).toBe(1);
+      expect(backpressure).toHaveBeenCalled();
+
+      backpressure.mockClear();
+      await seedPendingRefs(cg);
+      const recovered = await cg.sync();
+      expect(recovered.filesAdded + recovered.filesModified + recovered.filesRemoved).toBe(0);
+      expect(backpressure).toHaveBeenCalled();
+    } finally {
+      backpressure.mockRestore();
+      await cg.close();
+    }
+  });
 });
 
 describe('resolution-phase WAL backpressure plumbing (§7a.1)', () => {
@@ -308,19 +351,6 @@ describe('resolution-phase WAL backpressure plumbing (§7a.1)', () => {
   // 22GB WAL on a 4.6GB DB. These pin that the batch loop (a) calls the hook
   // at the pool-idle boundary and (b) actually parks on a returned promise.
 
-  async function seedPendingRefs(cg: CodeGraph): Promise<void> {
-    const raw = (cg as unknown as { db: DatabaseConnection }).db.getDb();
-    const node = raw.prepare("SELECT id, file_path FROM nodes WHERE kind = 'function' LIMIT 1").get() as
-      | { id: string; file_path: string }
-      | undefined;
-    expect(node).toBeDefined();
-    const ins = raw.prepare(
-      "INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, status) VALUES (?, ?, 'calls', 1, 0, ?, 'typescript', 'pending')"
-    );
-    ins.run(node!.id, 'helper0', node!.file_path);
-    ins.run(node!.id, 'helper1', node!.file_path);
-  }
-
   it('calls the backpressure hook once per settled batch', async () => {
     writeFixtureProject();
     const cg = CodeGraph.initSync(tmpDir);
@@ -417,6 +447,65 @@ describe('valve file-size trigger (§7a.1: backfilled WAL still grows the file)'
   });
 });
 
+
+describe('WAL valve fail-closed (#1539)', () => {
+  it('aborts with WalValveAbortError when parked backfills cannot progress past the file cap', async () => {
+    const db = openDb();
+    db.setWalAutocheckpoint(0);
+    writeRows(db, 800); // well past a 0.5MB soft / 2MB file cap
+    expect(db.getWalSizeBytes()).toBeGreaterThan(2 * 1024 * 1024);
+
+    const valve = new WalCheckpointValve(db, 0.5);
+    // Simulate a reader pinning every PASSIVE/TRUNCATE attempt.
+    db.checkpointWalPassive = async () => ({ busy: 1, log: 100, checkpointed: 0 });
+    db.checkpointWalTruncate = async () => ({ busy: 1, log: 100, checkpointed: 0 });
+
+    const bp = valve.backpressure();
+    expect(bp).not.toBeNull();
+    await expect(bp!).rejects.toBeInstanceOf(WalValveAbortError);
+    try {
+      await bp!;
+    } catch (err) {
+      expect(err).toMatchObject({
+        name: 'WalValveAbortError',
+        code: 'WAL_VALVE_ABORT',
+      });
+      expect((err as WalValveAbortError).message).toMatch(/Aborting to avoid unbounded disk growth/);
+      expect((err as WalValveAbortError).walBytes).toBeGreaterThan((err as WalValveAbortError).fileCapBytes);
+    }
+    // Caps remain enforceable: a subsequent backpressure call still parks (no
+    // futility latch that returns null and lets the writer race past the cap).
+    const again = valve.backpressure();
+    expect(again).not.toBeNull();
+    await expect(again!).rejects.toBeInstanceOf(WalValveAbortError);
+    db.close();
+  });
+
+  it('aborts when checkpoint machinery is unavailable while over the file cap', async () => {
+    const db = openDb();
+    db.setWalAutocheckpoint(0);
+    writeRows(db, 800);
+    const valve = new WalCheckpointValve(db, 0.5);
+    db.checkpointWalPassive = async () => null;
+    const bp = valve.backpressure();
+    expect(bp).not.toBeNull();
+    await expect(bp!).rejects.toBeInstanceOf(WalValveAbortError);
+    db.close();
+  });
+
+  it('does not abort a soft foldNow give-up that stays under both caps', async () => {
+    const db = openDb();
+    db.setWalAutocheckpoint(0);
+    writeRows(db, 50); // small WAL
+    const valve = new WalCheckpointValve(db, 1024); // 1GB soft — hard 2GB, fileCap 4GB
+    db.checkpointWalPassive = async () => ({ busy: 1, log: 10, checkpointed: 0 });
+    // foldNow calls backfillFully even with modest growth; under caps this is soft.
+    await expect(valve.foldNow()).resolves.toBeUndefined();
+    expect(valve.backpressure()).toBeNull();
+    db.close();
+  });
+});
+
 describe('resolveWalValveMb DB-size scaling (§7a.2 fold-tax reduction)', () => {
   it('scales soft cap ~dbSize/4 within [256, 2048]MB; env always wins', () => {
     const GB = 1024 * 1024 * 1024;

+ 36 - 0
__tests__/watcher.test.ts

@@ -608,6 +608,42 @@ describe('FileWatcher', () => {
       watcher.stop();
     });
 
+    it('info/exclude patterns drop worktree paths from pending (#1728)', async () => {
+      const { execFileSync } = await import('child_process');
+      // Re-init the testDir as a real git repo so buildScopeIgnore can read
+      // .git/info/exclude (createTempDir fixtures are usually plain dirs).
+      execFileSync('git', ['init', '-q'], { cwd: testDir, stdio: 'pipe' });
+      fs.mkdirSync(path.join(testDir, '.claude', 'worktrees', 'w1', 'src'), { recursive: true });
+      fs.writeFileSync(
+        path.join(testDir, '.claude', 'worktrees', 'w1', 'src', 'x.ts'),
+        'export const x = 1;\n',
+      );
+      fs.writeFileSync(
+        path.join(testDir, '.git', 'info', 'exclude'),
+        '**/.claude/worktrees/\n',
+      );
+
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = newWatcher(syncFn, { debounceMs: 100 });
+      watcher.start();
+      await watcher.waitUntilReady();
+
+      __emitWatchEventForTests(testDir, '.claude/worktrees/w1/src/x.ts');
+      expect(watcher.getPendingFiles().map((p) => p.path)).not.toContain(
+        '.claude/worktrees/w1/src/x.ts',
+      );
+      await new Promise((r) => setTimeout(r, 300));
+      expect(syncFn).not.toHaveBeenCalled();
+
+      // In-scope edits still schedule a scoped sync.
+      fs.writeFileSync(path.join(testDir, 'src', 'ok.ts'), 'export const ok = 1;\n');
+      __emitWatchEventForTests(testDir, 'src/ok.ts');
+      await waitFor(() => syncFn.mock.calls.length > 0);
+      expect(syncFn.mock.calls[0]![0]).toEqual(['src/ok.ts']);
+
+      watcher.stop();
+    });
+
     it('a nested .gitignore inside the scope forces a full sync', async () => {
       const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
       const watcher = newWatcher(syncFn, { debounceMs: 100 });

+ 87 - 0
__tests__/writer-lock.test.ts

@@ -0,0 +1,87 @@
+/**
+ * Project writer lock (#1740) — unit coverage for acquire / re-entrant /
+ * stale-dead-pid / live-holder refusal.
+ */
+
+import { afterEach, describe, expect, it } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import {
+  decodeWriterLockInfo,
+  getWriterPidPath,
+  releaseWriterLock,
+  tryAcquireWriterLock,
+  writerLockHeldMessage,
+} from '../src/mcp/writer-lock';
+
+describe('writer lock (#1740)', () => {
+  let dir: string;
+
+  afterEach(() => {
+    if (dir) {
+      releaseWriterLock(dir);
+      try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
+    }
+  });
+
+  function makeProject(): string {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg1740-lock-'));
+    fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
+    return dir;
+  }
+
+  it('acquires and releases writer.pid', () => {
+    const root = makeProject();
+    const r = tryAcquireWriterLock(root, 'direct');
+    expect(r.kind).toBe('acquired');
+    expect(fs.existsSync(getWriterPidPath(root))).toBe(true);
+    const info = decodeWriterLockInfo(fs.readFileSync(getWriterPidPath(root), 'utf8'));
+    expect(info?.pid).toBe(process.pid);
+    expect(info?.mode).toBe('direct');
+    releaseWriterLock(root);
+    expect(fs.existsSync(getWriterPidPath(root))).toBe(false);
+  });
+
+  it('is re-entrant for the same pid', () => {
+    const root = makeProject();
+    expect(tryAcquireWriterLock(root, 'daemon').kind).toBe('acquired');
+    const again = tryAcquireWriterLock(root, 'fallback');
+    expect(again.kind).toBe('acquired');
+    releaseWriterLock(root);
+  });
+
+  it('reports taken when a live foreign pid holds the lock', () => {
+    const root = makeProject();
+    // Use our own pid first, then overwrite with a fake live-looking pid by
+    // writing a pid that is alive: process.pid of this test — simulate foreign
+    // by writing a different alive pid. On Linux, PID 1 is almost always alive.
+    fs.writeFileSync(
+      getWriterPidPath(root),
+      JSON.stringify({ pid: 1, mode: 'direct', startedAt: Date.now() }) + '\n',
+      { flag: 'wx' },
+    );
+    const r = tryAcquireWriterLock(root, 'direct');
+    expect(r.kind).toBe('taken');
+    if (r.kind === 'taken') {
+      expect(r.existing?.pid).toBe(1);
+      const msg = writerLockHeldMessage(r.existing, r.pidPath);
+      expect(msg).toMatch(/writer lock held/i);
+      expect(msg).toMatch(/CODEGRAPH_NO_DAEMON/);
+      expect(msg).toMatch(/daemon stop/);
+    }
+  });
+
+  it('clears a stale dead-pid lock and acquires', () => {
+    const root = makeProject();
+    // Pick a pid that is extremely unlikely to be alive.
+    const deadPid = 2147483646;
+    fs.writeFileSync(
+      getWriterPidPath(root),
+      JSON.stringify({ pid: deadPid, mode: 'direct', startedAt: Date.now() }) + '\n',
+    );
+    const r = tryAcquireWriterLock(root, 'direct');
+    expect(r.kind).toBe('acquired');
+    releaseWriterLock(root);
+  });
+});

+ 55 - 1
codegraph-kernel/src/ccpp/mod.rs

@@ -52,6 +52,10 @@
 //!    per-caller targets (insertion-ordered, branch reassignments accumulate);
 //!    a later bare `k(args)` emits one `calls` ref PER target and suppresses
 //!    the local name. Template args stripped like base-class refs (#1043).
+//!  - pure-virtual methods (#1727): cpp in-class `virtual T f(...) = 0;` is a
+//!    `field_declaration` (not `function_definition`); mint a method node so
+//!    abstract-base calls and cpp-override synthesis have a target. Mirrors
+//!    TS `methodTypes` + `classifyMethodNode` / `isAbstract`.
 //!  - stack construction (#1035): cpp `declaration` with class-like named
 //!    `type` and an init_declarator whose value is argument_list /
 //!    initializer_list → `instantiates` (most-vexing-parse excluded).
@@ -66,7 +70,7 @@
 
 use crate::buffers::{
     build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow,
-    RefRow, StrRef, Tables, FLAG_IS_EXPORTED, FUNCTION_REF_CODE, NONE, NONE_STR,
+    RefRow, StrRef, Tables, FLAG_IS_ABSTRACT, FLAG_IS_EXPORTED, FUNCTION_REF_CODE, NONE, NONE_STR,
 };
 use crate::docstring::preceding_docstring;
 use crate::ids;
@@ -289,6 +293,7 @@ struct Extra {
     signature: Option<String>,
     visibility: Option<u8>,
     is_exported: Option<bool>,
+    is_abstract: Option<bool>,
     return_type: Option<String>,
     qualified_name: Option<String>,
 }
@@ -508,6 +513,9 @@ impl<'t> Walker<'t> {
         if let Some(v) = extra.is_exported {
             flags.set(FLAG_IS_EXPORTED, v);
         }
+        if let Some(v) = extra.is_abstract {
+            flags.set(FLAG_IS_ABSTRACT, v);
+        }
         let name_ref = self.arena.put(name);
         let qn_ref = self.arena.put(&qualified);
         let id_ref = self.arena.put(&id);
@@ -740,6 +748,36 @@ impl<'t> Walker<'t> {
             .any(|c| c.kind() == "type_qualifier" && self.text(c) == "const")
     }
 
+    /// `#1727`: C++ pure-virtual method declaration (`virtual int read(int key) = 0;`).
+    /// tree-sitter-cpp shapes these as `field_declaration` whose declarator unwraps
+    /// to a `function_declarator`, with the pure-virtual `= 0` as a DIRECT
+    /// `number_literal` "0" child (default-arg `= 0` lives inside
+    /// `parameter_declaration` and must not match).
+    fn is_cpp_pure_virtual_method_decl(&self, node: Node<'_>) -> bool {
+        if node.kind() != "field_declaration" {
+            return false;
+        }
+        let Some(mut declarator) = node.child_by_field_name("declarator") else {
+            return false;
+        };
+        while matches!(declarator.kind(), "pointer_declarator" | "reference_declarator") {
+            let inner = declarator
+                .child_by_field_name("declarator")
+                .or_else(|| declarator.named_child(0));
+            let Some(inner) = inner else {
+                return false;
+            };
+            declarator = inner;
+        }
+        if declarator.kind() != "function_declarator" {
+            return false;
+        }
+        (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .any(|c| c.kind() == "number_literal" && self.text(c) == "0")
+    }
+
+
     /// cppExtractor.isMisparsedFunction (languages/c-cpp.ts:811). cpp only.
     fn is_misparsed_function(&self, name: &str, node: Node) -> bool {
         if self.variant != Variant::Cpp {
@@ -830,6 +868,17 @@ impl<'t> Walker<'t> {
             self.extract_variable(node);
             self.scan_fn_ref_subtree(node, 0);
             skip_children = true;
+        } else if self.variant == Variant::Cpp
+            && kind == "field_declaration"
+            && self.inside_class_like()
+            && self.is_cpp_pure_virtual_method_decl(node)
+        {
+            // Pure-virtual methods have no `function_definition` body — mint the
+            // method node so calls through the abstract base and cpp-override
+            // synthesis have a target (#1727). Non-pure field_declarations fall
+            // through to the children walk (data members / prototypes).
+            self.extract_method(node);
+            skip_children = true;
         } else if kind == "preproc_include" {
             self.extract_import(node);
         } else if kind == "call_expression" {
@@ -914,6 +963,11 @@ impl<'t> Walker<'t> {
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
             visibility: if self.variant == Variant::Cpp { self.visibility_of(node) } else { None },
+            is_abstract: if self.variant == Variant::Cpp && self.is_cpp_pure_virtual_method_decl(node) {
+                Some(true)
+            } else {
+                None
+            },
             return_type: self.return_type_of(node),
             qualified_name: receiver_type
                 .as_ref()

+ 10 - 0
codegraph-kernel/src/java.rs

@@ -759,6 +759,16 @@ impl<'t> Walker<'t> {
                 if let Some(row) = row {
                     self.extract_decorators_for(node, row);
                     self.extract_type_annotations(node, row);
+                    // Walk the initializer ATTRIBUTED to the declared field
+                    // (#693, the Go fix): the dispatcher only fn-ref-scans this
+                    // subtree, so a lambda / method reference / anonymous class
+                    // in `private final Runnable r = () -> target();` emitted no
+                    // call edge at all.
+                    if let Some(value) = decl.child_by_field_name("value") {
+                        self.stack.push(Scope { row, kind: field_kind, name: name.clone() });
+                        self.visit_function_body(value);
+                        self.stack.pop();
+                    }
                 }
             }
         } else {

+ 182 - 26
codegraph-kernel/src/kotlin.rs

@@ -9,10 +9,10 @@
 //! is source-order dependent) and extractModifiers (expect/actual platform
 //! modifiers → the node DECORATORS wire field, on every created node — the
 //! KMP synthesizer's input). Preserved on purpose: the FIELD_COUNT-0 dead
-//! cluster (no signatures, ZERO type-annotation refs), hook-consumed property
-//! initializers emitting nothing, the bodiless-class header re-walk asymmetry,
-//! enum-entry bodies being invisible, KDoc (`multiline_comment`) never being
-//! a docstring AND chain-breaking, comment-gluing into import/package extents,
+//! cluster (no signatures, ZERO type-annotation refs), the bodiless-class
+//! header re-walk asymmetry, enum-entry bodies being invisible, KDoc
+//! (`multiline_comment`) never being a docstring AND chain-breaking,
+//! comment-gluing into import/package extents,
 //! `@Anno(args)` emitting nothing while `@Anno` emits decorates, zero
 //! instantiates refs (constructors are capitalized `calls`), the qualified-
 //! receiver `com::qext` bug, the paren-then-lambda `trailing()` garbage
@@ -87,6 +87,66 @@ fn strip_js_ws(s: &str) -> String {
     s.chars().filter(|c| !is_js_space(*c)).collect()
 }
 
+/// A property's CODE children: the named child right after the `=` token, a
+/// `property_delegate` (`by lazy { … }`), and an accessor the grammar nested
+/// under the declaration (`val x: Int get() = compute()` — written on ONE line;
+/// an accessor on its own line parses as a SIBLING of the property and is not
+/// reachable from here). What stays unwalked is the declaration itself —
+/// modifiers, the `val`/`var` keyword, the name+type, and an extension
+/// receiver's type and type parameters. (Go's #693 fix walks the `value` field
+/// for the same reason; this grammar exposes no fields at all, hence the `=`
+/// anchor.)
+fn property_initializers<'t>(node: Node<'t>) -> Vec<Node<'t>> {
+    let mut out: Vec<Node<'t>> = Vec::new();
+    let mut after_eq = false;
+    for i in 0..node.child_count() {
+        let Some(c) = node.child(i) else { continue };
+        if !c.is_named() {
+            if c.kind() == "=" {
+                after_eq = true;
+            }
+            continue;
+        }
+        if after_eq {
+            out.push(c);
+            after_eq = false;
+        } else if matches!(c.kind(), "property_delegate" | "getter" | "setter") {
+            out.push(c);
+        }
+    }
+    out
+}
+
+/// Accessors written on their OWN line parse as SIBLINGS of the property, not
+/// as children of it (same-line ones nest — see property_initializers). Walking
+/// back over any accessors between us and the declaration finds the property an
+/// accessor belongs to; None when this accessor stands alone.
+fn accessor_owner<'t>(node: Node<'t>) -> Option<Node<'t>> {
+    let mut p = node.prev_named_sibling();
+    while let Some(n) = p {
+        if matches!(n.kind(), "getter" | "setter") {
+            p = n.prev_named_sibling();
+            continue;
+        }
+        return if n.kind() == "property_declaration" { Some(n) } else { None };
+    }
+    None
+}
+
+/// The sibling accessors that follow a property declaration, in source order.
+fn following_accessors<'t>(node: Node<'t>) -> Vec<Node<'t>> {
+    let mut out = Vec::new();
+    let mut n = node.next_named_sibling();
+    while let Some(c) = n {
+        if !matches!(c.kind(), "getter" | "setter") {
+            break;
+        }
+        out.push(c);
+        n = c.next_named_sibling();
+    }
+    out
+}
+
 struct Scope {
     row: u32,
     kind: &'static str,
@@ -489,6 +549,39 @@ impl<'t> Walker<'t> {
             .any(|c| c.kind() == "modifiers" && self.text(c).contains("suspend"))
     }
 
+    /// `(params): ReturnType` — the positional read TreeSitterExtractor's
+    /// kotlin getSignature does (#1495): the `function_value_parameters` child,
+    /// then the type node that follows it before the body. Verbatim source text,
+    /// so it round-trips through parity byte-for-byte.
+    fn signature_of(&self, node: Node) -> Option<String> {
+        let mut params: Option<Node> = None;
+        let mut return_type: Option<Node> = None;
+        for i in 0..node.named_child_count() {
+            let Some(child) = node.named_child(i) else { continue };
+            if child.kind() == "function_value_parameters" {
+                params = Some(child);
+                continue;
+            }
+            if params.is_none() {
+                continue;
+            }
+            if matches!(child.kind(), "function_body" | "type_constraints") {
+                break;
+            }
+            if matches!(child.kind(), "user_type" | "nullable_type" | "function_type") {
+                return_type = Some(child);
+                break;
+            }
+        }
+        let params = params?;
+        let mut sig = self.text(params).to_string();
+        if let Some(rt) = return_type {
+            sig.push_str(": ");
+            sig.push_str(self.text(rt));
+        }
+        Some(sig)
+    }
+
     /// extractKotlinReturnType — positional: the first user_type/nullable_type
     /// AFTER function_value_parameters; function_body/type_constraints first →
     /// None; Unit/Nothing → None; `: T` generic params leak (preserve).
@@ -583,25 +676,23 @@ impl<'t> Walker<'t> {
     // --- the visitNode hook (property branch ONLY — fun-interface recovery is
     // defer-shielded and not ported) ------------------------------------------------
 
-    fn try_visit_hook(&mut self, node: Node<'t>) -> bool {
-        if node.kind() != "property_declaration" {
-            return false;
-        }
+    /// A property's node kind, or None when the declaration mints no node at
+    /// all: destructuring, an unreadable name, or a local (inside a function
+    /// body / `init` block / lambda / accessor). Kind by enclosing scope — a
+    /// singleton `object` / `companion object` (and a top-level property) holds
+    /// SHARED values (`val`→constant, `var`→variable, the Scala-object rule; a
+    /// `const val` is just a val); a class/interface/enum instance `val`/`var`
+    /// is per-instance state → `field`.
+    fn property_kind(&self, node: Node<'t>) -> Option<&'static str> {
         let var_decl = (0..node.named_child_count())
             .filter_map(|i| node.named_child(i))
-            .find(|c| c.kind() == "variable_declaration");
-        let name_node = var_decl.and_then(|vd| {
-            (0..vd.named_child_count())
-                .filter_map(|i| vd.named_child(i))
-                .find(|c| c.kind() == "simple_identifier")
-        });
-        let Some(name_node) = name_node else { return false }; // destructuring → decline
-        let name = self.text(name_node).to_string();
-        if name.is_empty() {
-            return false;
+            .find(|c| c.kind() == "variable_declaration")?;
+        let name_node = (0..var_decl.named_child_count())
+            .filter_map(|i| var_decl.named_child(i))
+            .find(|c| c.kind() == "simple_identifier")?;
+        if self.text(name_node).is_empty() {
+            return None;
         }
-
-        // Scope walk up the parent chain — first match wins.
         let mut scope: &str = "const";
         let mut p = node.parent();
         while let Some(pn) = p {
@@ -624,24 +715,89 @@ impl<'t> Walker<'t> {
             p = pn.parent();
         }
         if scope == "local" {
-            return true; // a local — extract nothing, subtree still scanned
+            return None;
         }
-
         let binding = (0..node.named_child_count())
             .filter_map(|i| node.named_child(i))
             .find(|c| c.kind() == "binding_pattern_kind");
         let is_val = binding.map(|b| self.text(b) == "val").unwrap_or(false);
-        let kind: &'static str = if scope == "instance" {
+        Some(if scope == "instance" {
             "field"
         } else if is_val {
             "constant"
         } else {
             "variable"
+        })
+    }
+
+    fn try_visit_hook(&mut self, node: Node<'t>) -> bool {
+        // An own-line accessor already walked by its owning property below. The
+        // ownership test re-derives the property's kind rather than remembering
+        // it: a destructured or local declaration mints no node, so its
+        // accessors were NOT consumed and must keep falling through.
+        if matches!(node.kind(), "getter" | "setter") {
+            return accessor_owner(node)
+                .and_then(|owner| self.property_kind(owner))
+                .is_some();
+        }
+        if node.kind() != "property_declaration" {
+            return false;
+        }
+        let var_decl = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "variable_declaration");
+        let name_node = var_decl.and_then(|vd| {
+            (0..vd.named_child_count())
+                .filter_map(|i| vd.named_child(i))
+                .find(|c| c.kind() == "simple_identifier")
+        });
+        // Destructuring (`val (a, b) = makePair()`): NEITHER arm mints a symbol
+        // for the destructured names — declining just routes the node to
+        // extractField/extractVariable, which both find nothing for kotlin and
+        // end in the same fn-ref scan. But the RHS is CODE, and it was vanishing
+        // whole. Consume the node here and walk it at the ENCLOSING scope (no
+        // symbol of its own to attribute to).
+        let Some(name_node) = name_node else {
+            for init in property_initializers(node) {
+                self.visit_function_body(init);
+            }
+            return true;
+        };
+        let name = self.text(name_node).to_string();
+        if name.is_empty() {
+            return false;
+        }
+        let Some(kind) = self.property_kind(node) else {
+            // A local — no node is minted, but the initializer is still code.
+            // Walk it at the ENCLOSING scope: an `init { }` block's
+            // `val q = load()` is the CLASS calling load, and it used to
+            // disappear entirely (only the block's bare statements survived).
+            for init in property_initializers(node) {
+                self.visit_function_body(init);
+            }
+            return true;
         };
         // The `type`-field signature read is dead (zero fields) → signature
         // undefined; NO docstring/visibility/isStatic — the modifiers merge in
         // create_node still decorates expect/actual properties.
-        self.create_node(kind, &name, node, Extra::default());
+        let row = self.create_node(kind, &name, node, Extra::default());
+        // Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go
+        // fix, ported): without this the subtree is only fn-ref-scanned, so a
+        // lambda / SAM / object initializer (`val cb = Runnable { target() }` —
+        // the idiomatic Android callback field) contributed NO call edge at all.
+        // The property also OWNS any accessor written on its own line, which the
+        // grammar makes a following SIBLING rather than a child; those bodies
+        // used to attribute to the enclosing class.
+        if let Some(row) = row {
+            self.stack.push(Scope { row, kind, name: name.clone() });
+            for init in property_initializers(node) {
+                self.visit_function_body(init);
+            }
+            for acc in following_accessors(node) {
+                self.visit_function_body(acc);
+            }
+            self.stack.pop();
+        }
         true
     }
 
@@ -795,7 +951,7 @@ impl<'t> Walker<'t> {
         }
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
-            signature: None, // dead hook (zero fields)
+            signature: self.signature_of(node),
             visibility: Some(self.visibility_of(node)),
             is_async: Some(self.is_async(node)),
             is_static: Some(false), // kotlin isStatic is always false
@@ -820,7 +976,7 @@ impl<'t> Walker<'t> {
         let qualified_override = receiver.as_ref().map(|r| format!("{r}::{name}"));
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
-            signature: None,
+            signature: self.signature_of(node),
             visibility: Some(self.visibility_of(node)),
             is_async: Some(self.is_async(node)),
             is_static: Some(false),

+ 132 - 12
codegraph-kernel/src/lua.rs

@@ -468,7 +468,7 @@ impl<'t> Walker<'t> {
             }
             // plain path returns false → children re-visited (the
             // typeof(require(...)) alias+import pair rides this).
-        } else if kind == "variable_declaration" {
+        } else if matches!(kind, "variable_declaration" | "assignment_statement") {
             self.extract_variable(node);
             // Initializer subtrees are never walked — candidates only.
             self.scan_fn_ref_subtree(node, 0);
@@ -578,21 +578,38 @@ impl<'t> Walker<'t> {
             }
             None => Vec::new(),
         };
-        let names: Vec<Node<'t>> = match var_list {
+        let targets: Vec<Node<'t>> = match var_list {
             Some(vl) => {
                 let mut c = vl.walk();
-                vl.named_children(&mut c).filter(|n| n.kind() == "identifier").collect()
+                vl.named_children(&mut c).collect()
             }
             None => Vec::new(),
         };
-        for (i, name_node) in names.iter().enumerate() {
-            let name = self.text(*name_node);
-            if name.is_empty() {
+        for (i, name_node) in targets.iter().enumerate() {
+            let Some((name, receiver, full_name)) = self.lua_assignment_target(*name_node) else {
+                continue;
+            };
+            let value = values.get(i).copied();
+            if let Some(value) = value {
+                if value.kind() == "function_definition" {
+                    self.extract_lua_function_value(
+                        value,
+                        name,
+                        receiver,
+                        docstring.clone(),
+                    );
+                    continue;
+                }
+                if value.kind() == "table_constructor" {
+                    self.extract_lua_table_functions(value, full_name);
+                }
+            }
+            // Dotted assignments update table members, not standalone vars.
+            if receiver.is_some() || node.kind() == "assignment_statement" {
                 continue;
             }
             // Positional value pairing; a missing value → NO signature key.
-            let signature = values.get(i).map(|v| util::init_signature(self.text(*v)));
-            let name = name.to_string();
+            let signature = value.map(|v| util::init_signature(self.text(v)));
             self.create_node(
                 "variable",
                 &name,
@@ -607,6 +624,110 @@ impl<'t> Walker<'t> {
         }
     }
 
+    fn lua_assignment_target(&self, node: Node<'t>) -> Option<(String, Option<String>, String)> {
+        if node.kind() == "identifier" {
+            let name = self.text(node).trim().to_string();
+            if name.is_empty() {
+                return None;
+            }
+            return Some((name.clone(), None, name));
+        }
+        if !matches!(
+            node.kind(),
+            "dot_index_expression" | "method_index_expression" | "bracket_index_expression"
+        ) {
+            return None;
+        }
+        let table = node.child_by_field_name("table")?;
+        let field = node
+            .child_by_field_name("field")
+            .or_else(|| node.child_by_field_name("method"))?;
+        let receiver = self.text(table).trim().to_string();
+        let name = self.lua_static_field_name(field, node.kind() == "bracket_index_expression");
+        if receiver.is_empty() || name.is_empty() {
+            return None;
+        }
+        let full_name = format!("{receiver}.{name}");
+        Some((name, Some(receiver), full_name))
+    }
+
+    fn lua_static_field_name(&self, node: Node<'t>, bracketed: bool) -> String {
+        if node.kind() == "identifier" {
+            return if bracketed {
+                String::new()
+            } else {
+                self.text(node).trim().to_string()
+            };
+        }
+        if node.kind() == "string" {
+            let mut cursor = node.walk();
+            for child in node.named_children(&mut cursor) {
+                if child.kind() == "string_content" {
+                    return self.text(child).trim().to_string();
+                }
+            }
+        }
+        String::new()
+    }
+
+    fn extract_lua_function_value(
+        &mut self,
+        node: Node<'t>,
+        name: String,
+        receiver: Option<String>,
+        docstring: Option<String>,
+    ) {
+        let signature = self.signature_of(node);
+        let (kind, qualified_name_override, is_exported) = match receiver {
+            Some(receiver) => (
+                "method",
+                Some(format!("{receiver}::{name}")),
+                None,
+            ),
+            None => ("function", None, self.is_exported_of(node)),
+        };
+        let row = self.create_node(
+            kind,
+            &name,
+            node,
+            Extra {
+                docstring,
+                signature,
+                qualified_name_override,
+                is_exported,
+                ..Default::default()
+            },
+        );
+        let Some(row) = row else { return };
+        self.stack.push(Scope { row, kind, name });
+        if let Some(body) = node.child_by_field_name("body") {
+            self.visit_body(body);
+        }
+        self.stack.pop();
+    }
+
+    fn extract_lua_table_functions(&mut self, table: Node<'t>, receiver: String) {
+        let mut cursor = table.walk();
+        let fields: Vec<Node<'t>> = table.named_children(&mut cursor).collect();
+        for field in fields {
+            if field.kind() != "field" {
+                continue;
+            }
+            let Some(name_node) = field.child_by_field_name("name") else { continue };
+            let Some(value) = field.child_by_field_name("value") else { continue };
+            let bracketed = self.text(field).trim_start().starts_with('[');
+            let name = self.lua_static_field_name(name_node, bracketed);
+            if name.is_empty() {
+                continue;
+            }
+            if value.kind() == "function_definition" {
+                self.extract_lua_function_value(value, name, Some(receiver.clone()), None);
+            } else if value.kind() == "table_constructor" {
+                self.extract_lua_table_functions(value, format!("{receiver}.{name}"));
+            }
+        }
+    }
+
     // --- extractTypeAlias (2890; plain path 2967-2991) — luau only --------
 
     /// Returns skipChildren (always false on the plain path).
@@ -790,13 +911,12 @@ impl<'t> Walker<'t> {
             return;
         }
         // Halt at nested function definitions (their bodies are walked — and
-        // attributed — by extractFunction). function_definition (anonymous)
-        // is deliberately NOT in the halt list — the scan descends into
-        // anonymous initializer bodies, attributing candidates to the file.
+        // attributed — by extractFunction). Lua function_definition values are
+        // now extracted from their assignment target and must stop this scan too.
         if depth > 0
             && matches!(
                 node.kind(),
-                "function_declaration" | "arrow_function" | "function_expression"
+                "function_declaration" | "function_definition" | "arrow_function" | "function_expression"
                     | "lambda_literal" | "lambda_expression"
             )
         {

+ 53 - 7
codegraph-kernel/src/python.rs

@@ -475,14 +475,37 @@ impl<'t> Walker<'t> {
         let docstring = preceding_docstring(node, self.src);
         let left = node.child_by_field_name("left").or_else(|| node.named_child(0));
         let right = node.child_by_field_name("right").or_else(|| node.named_child(1));
-        let Some(left) = left else { return };
-        if !matches!(left.kind(), "identifier" | "constant") {
-            return;
+        let mut assigned: Option<(u32, String)> = None;
+        if let Some(left) = left {
+            if matches!(left.kind(), "identifier" | "constant") {
+                let name = self.text(left).to_string();
+                let signature = right.map(|r| util::init_signature(self.text(r)));
+                // No isConst hook ⇒ always `variable` (UPPER_CASE constants included).
+                let row = self.create_node(
+                    "variable",
+                    &name,
+                    node,
+                    Extra { docstring, signature, ..Extra::default() },
+                );
+                if let Some(row) = row {
+                    assigned = Some((row, name));
+                }
+            }
+        }
+        // Walk the initializer ATTRIBUTED to the assigned name (#693): a
+        // module-level `app = FastAPI()` / `handler = lambda: run()` dropped
+        // every call on the right-hand side. A tuple target mints no symbol, so
+        // its RHS is walked at the enclosing scope rather than lost.
+        if let Some(right) = right {
+            match assigned {
+                Some((row, name)) => {
+                    self.stack.push(Scope { row, kind: "variable", name });
+                    self.visit_function_body(right);
+                    self.stack.pop();
+                }
+                None => self.visit_function_body(right),
+            }
         }
-        let name = self.text(left).to_string();
-        let signature = right.map(|r| util::init_signature(self.text(r)));
-        // No isConst hook ⇒ always `variable` (UPPER_CASE constants included).
-        self.create_node("variable", &name, node, Extra { docstring, signature, ..Extra::default() });
     }
 
     fn extract_import(&mut self, node: Node<'t>) {
@@ -608,6 +631,13 @@ impl<'t> Walker<'t> {
                         } else {
                             callee_name = method_name.to_string();
                         }
+                    } else if let Some(r) = receiver.filter(|r| r.kind() == "call") {
+                        // Call receiver — `d.setdefault(k, []).append(v)` (#1683):
+                        // `<inner>().<method>`, or nothing when the inner callee
+                        // is not a plain name / attribute chain. Mirrors
+                        // TreeSitterExtractor.extractCall.
+                        let Some(inner) = self.plain_inner_callee(r) else { return };
+                        callee_name = format!("{inner}().{method_name}");
                     } else {
                         callee_name = method_name.to_string();
                     }
@@ -626,6 +656,22 @@ impl<'t> Walker<'t> {
         }
     }
 
+    /// The callee of a call receiver when it is a plain identifier or attribute
+    /// chain (`make`, `d.setdefault`), whitespace stripped (#1683).
+    fn plain_inner_callee(&self, call: Node<'t>) -> Option<String> {
+        let inner = call.child_by_field_name("function")?;
+        let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect();
+        if text.is_empty() {
+            return None;
+        }
+        let ok = text.split('.').all(|seg| {
+            let mut chars = seg.chars();
+            matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
+                && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
+        });
+        if ok { Some(text) } else { None }
+    }
+
     /// extractDecoratorsFor — python decorators are PRECEDING SIBLINGS inside
     /// decorated_definition. Only bare-identifier decorators yield a target
     /// (python's `call` kind isn't `call_expression`, and `attribute` isn't in

+ 32 - 5
codegraph-kernel/src/rustlang.rs

@@ -20,8 +20,7 @@
 //!   kind is always `variable`, no signature, and EVERY direct `identifier`
 //!   child mints a node (`const MAX: u32 = OTHER;` → two nodes, `MAX` + the
 //!   phantom `OTHER`). Top-level initializer values are never body-walked.
-//! - Unit structs (`struct Unit;`, no body field) mint NO node; `mod_item`
-//!   mints no module node and adds no QN prefix.
+//! - `mod_item` mints no module node and adds no QN prefix.
 //! - Chained-call re-encode is scoped_identifier-gated (`Foo::new().bar()` →
 //!   `Foo::new().bar`); a call through a field of the enclosing type keeps
 //!   the owner-field shape (`self.inner.run()` → `self.inner.run`, #1585);
@@ -590,10 +589,12 @@ impl<'t> Walker<'t> {
         self.stack.pop();
     }
 
-    /// Extract a Rust struct or union with a body; unit structs remain skipped.
+    /// Extract a Rust struct or union — the body field is OPTIONAL. A unit
+    /// struct (`struct U;`) has no body and is still a complete definition,
+    /// so it mints a node with no members; tuple structs' ordered_field_declaration_list
+    /// is a body. Mirrors the TS reference's `allowBodilessStruct`.
     fn extract_aggregate(&mut self, node: Node<'t>, kind: &'static str) {
         stack_guard!();
-        let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -603,6 +604,10 @@ impl<'t> Walker<'t> {
         let Some(row) = self.create_node(kind, &name, node, extra) else { return };
         self.extract_inheritance(node, row);
 
+        // Unit structs have no body to walk — the node itself is the whole
+        // definition.
+        let Some(body) = node.child_by_field_name("body") else { return };
+
         self.stack.push(Scope { row, kind, name });
         for i in 0..body.named_child_count() {
             if let Some(c) = body.named_child(i) {
@@ -662,6 +667,8 @@ impl<'t> Walker<'t> {
     /// and the initializer value is never body-walked.
     fn extract_variable(&mut self, node: Node<'t>) {
         let docstring = preceding_docstring(node, self.src);
+        let name_field = node.child_by_field_name("name");
+        let mut declared: Option<(u32, String)> = None;
         for i in 0..node.named_child_count() {
             let Some(child) = node.named_child(i) else { continue };
             if child.kind() != "identifier" {
@@ -669,7 +676,7 @@ impl<'t> Walker<'t> {
             }
             let name = self.text(child).to_string();
             if !name.is_empty() {
-                self.create_node(
+                let row = self.create_node(
                     "variable",
                     &name,
                     child,
@@ -679,6 +686,26 @@ impl<'t> Walker<'t> {
                         ..Extra::default()
                     },
                 );
+                if let (Some(row), Some(nf)) = (row, name_field) {
+                    if child.start_byte() == nf.start_byte() {
+                        declared = Some((row, name));
+                    }
+                }
+            }
+        }
+        // Walk the initializer ATTRIBUTED to the declared symbol (#693):
+        // `const N: usize = compute()` and
+        // `static REGISTRY: Lazy<T> = Lazy::new(|| build())` dropped every call
+        // inside the initializer, so a handler table or a lazily-built
+        // singleton linked to nothing.
+        if let Some(value) = node.child_by_field_name("value") {
+            match declared {
+                Some((row, name)) => {
+                    self.stack.push(Scope { row, kind: "variable", name });
+                    self.visit_function_body(value);
+                    self.stack.pop();
+                }
+                None => self.visit_function_body(value),
             }
         }
     }

+ 12 - 0
codegraph-kernel/src/scala.rs

@@ -656,6 +656,18 @@ impl<'t> Walker<'t> {
                 if let (Some(row), Some(t)) = (created, type_node) {
                     self.emit_scala_type_refs(t, row);
                 }
+                // Walk the initializer ATTRIBUTED to the declared symbol
+                // (#693, the Go fix): the hook consumes this subtree and the
+                // dispatcher only fn-ref-scans it, so `val cb = () => target()`
+                // — and even a plain `val x = compute()` — emitted no call edge
+                // at all.
+                if let Some(row) = created {
+                    if let Some(value) = node.child_by_field_name("value") {
+                        self.stack.push(Scope { row, kind, name: name.clone() });
+                        self.visit_body(value);
+                        self.stack.pop();
+                    }
+                }
                 true
             }
             "enum_case_definitions" => {

+ 149 - 20
codegraph-kernel/src/tsjs/extractors.rs

@@ -20,16 +20,24 @@ impl<'t> Walker<'t> {
             .unwrap_or_else(|| self.extract_name(node));
 
         // Arrow/function-expression values: resolve the name from the parent
-        // variable_declarator (`export const useAuth = () => {}`).
+        // variable_declarator (`export const useAuth = () => {}`), or from a
+        // CommonJS export assignment (`exports.getItems = async () => {}`,
+        // #1675). Mirrors TreeSitterExtractor.extractFunction.
+        let mut common_js_export = false;
         if name_override.is_none()
             && name == "<anonymous>"
-            && matches!(node.kind(), "arrow_function" | "function_expression")
+            && matches!(node.kind(), "arrow_function" | "function_expression" | "generator_function")
         {
             if let Some(parent) = node.parent() {
                 if parent.kind() == "variable_declarator" {
                     if let Some(var_name) = parent.child_by_field_name("name") {
                         name = self.text(var_name).to_string();
                     }
+                } else if parent.kind() == "assignment_expression" {
+                    if let Some(export_name) = self.common_js_export_name(parent, node) {
+                        name = export_name;
+                        common_js_export = true;
+                    }
                 }
             }
         }
@@ -46,7 +54,7 @@ impl<'t> Walker<'t> {
             docstring: crate::docstring::preceding_docstring(node, self.src),
             signature: self.signature_of(node),
             visibility: self.visibility_of(node),
-            is_exported: Some(self.is_exported(node)),
+            is_exported: Some(common_js_export || self.is_exported(node)),
             is_async: Some(self.is_async(node)),
             is_static: self.is_static(node),
             ..Extra::default()
@@ -65,6 +73,30 @@ impl<'t> Walker<'t> {
         self.stack.pop();
     }
 
+    /// The property a CommonJS export assignment binds a function to —
+    /// `exports.NAME = <node>` / `module.exports.NAME = <node>` — or None for
+    /// any other assignment. The node must be the assignment's whole
+    /// right-hand side. Mirrors TreeSitterExtractor.commonJsExportName.
+    fn common_js_export_name(&self, assignment: Node<'t>, value: Node<'t>) -> Option<String> {
+        let right = assignment.child_by_field_name("right")?;
+        if right.start_byte() != value.start_byte() || right.end_byte() != value.end_byte() {
+            return None;
+        }
+        let left = assignment.child_by_field_name("left")?;
+        if left.kind() != "member_expression" {
+            return None;
+        }
+        let object = left.child_by_field_name("object")?;
+        let property = left.child_by_field_name("property")?;
+        if property.kind() != "property_identifier" {
+            return None;
+        }
+        if !matches!(self.text(object), "exports" | "module.exports") {
+            return None;
+        }
+        Some(self.text(property).to_string())
+    }
+
     // --- reactComponentHoc / extractReactComponentNode (#841) --------------------
 
     /// Some(inner) when the initializer is a recognized component wrapper —
@@ -270,8 +302,33 @@ impl<'t> Walker<'t> {
         let name = self.text(name_node).to_string();
 
         // TS/JS field definitions carry an explicit `type` field; the generic
-        // scan is for other languages (#808).
-        let type_text = node.child_by_field_name("type").map(|t| {
+        // scan is for other languages (#808). A `property_signature` (an
+        // interface member, #1638) carries a `type` field and no value, so it
+        // reads the type field too: the generic scan's exclusion list covers
+        // `identifier` but not the `property_identifier` an interface member is
+        // named with, so it would stop on the name and make the signature repeat
+        // it (`counts counts`) instead of naming the type. Mirrors
+        // extractProperty's isTsJsField.
+        let is_ts_js_field = matches!(
+            node.kind(),
+            "public_field_definition" | "field_definition" | "property_signature"
+        );
+        let type_node = if is_ts_js_field {
+            node.child_by_field_name("type")
+        } else {
+            (0..node.named_child_count()).filter_map(|i| node.named_child(i)).find(|c| {
+                !matches!(
+                    c.kind(),
+                    "modifier"
+                        | "modifiers"
+                        | "identifier"
+                        | "accessor_list"
+                        | "accessors"
+                        | "equals_value_clause"
+                )
+            })
+        };
+        let type_text = type_node.map(|t| {
             let raw = self.text(t);
             raw.strip_prefix(':').unwrap_or(raw).trim_start().to_string()
         });
@@ -342,9 +399,9 @@ impl<'t> Walker<'t> {
             }
             let name = self.text(name_node).to_string();
 
-            // Arrow/function values extract as functions, named by the declarator.
+            // Arrow/function/generator values extract as functions, named by the declarator.
             if let Some(v) = value {
-                if matches!(v.kind(), "arrow_function" | "function_expression") {
+                if matches!(v.kind(), "arrow_function" | "function_expression" | "generator_function") {
                     self.extract_function(v, None);
                     continue;
                 }
@@ -426,18 +483,26 @@ impl<'t> Walker<'t> {
                 }
             }
 
-            // Walk the initializer for calls — except the object/store shapes
-            // whose members are extracted method-by-method below.
+            // Walk the initializer for calls, ATTRIBUTED to the declared symbol
+            // (#693) — except the object/store shapes whose members are
+            // extracted method-by-method below (walking those too would
+            // double-count each member arrow's calls). Before this the walk ran
+            // with only the FILE on the stack (`const cfg = load()` recorded the
+            // file as load's caller) and object literals were skipped outright.
+            let members_extracted_separately = extract_object_methods
+                || rtk_endpoints.is_some()
+                || pinia_setup.is_some()
+                || !store_collections.is_empty();
             if let Some(v) = value {
-                let vk = v.kind();
-                if vk != "object"
-                    && vk != "object_expression"
-                    && !(extract_object_methods && vk == "call_expression")
-                    && rtk_endpoints.is_none()
-                    && pinia_setup.is_none()
-                    && store_collections.is_empty()
-                {
-                    self.visit_function_body(v);
+                if !members_extracted_separately {
+                    match var_row {
+                        Some(row) => {
+                            self.stack.push(Scope { row, kind, name: name.clone() });
+                            self.visit_function_body(v);
+                            self.stack.pop();
+                        }
+                        None => self.visit_function_body(v),
+                    }
                 }
             }
 
@@ -1065,6 +1130,23 @@ impl<'t> Walker<'t> {
 
     // --- extractCall (TS/JS generic tail) -------------------------------------------------
 
+    /// Identifier-rooted member chains have no inferred property type (#1566),
+    /// including host API chains (#1707). Keep the existing window namespace
+    /// escape; call-result and `this` receivers are outside this guard.
+    fn is_unresolved_member_chain(&self, receiver: Node<'t>) -> bool {
+        let mut cur = receiver;
+        if !matches!(cur.kind(), "member_expression" | "subscript_expression") {
+            return false;
+        }
+        while matches!(cur.kind(), "member_expression" | "subscript_expression") {
+            match cur.child_by_field_name("object") {
+                Some(next) => cur = next,
+                None => return false,
+            }
+        }
+        cur.kind() == "identifier" && self.text(cur) != "window"
+    }
+
     pub(super) fn extract_call(&mut self, node: Node<'t>) {
         if self.stack.is_empty() {
             return;
@@ -1092,6 +1174,14 @@ impl<'t> Walker<'t> {
                         if is_literal_receiver(r.kind()) {
                             return;
                         }
+                        // `holder.values.get()` has no inferred property type
+                        // (#1566). Dropping the receiver or merely preserving it
+                        // would allow unrelated same-name method guesses. Emit
+                        // nothing, as for host chains (#1707); argument calls are
+                        // visited independently. Mirrors extractCall in TS.
+                        if self.is_unresolved_member_chain(r) {
+                            return;
+                        }
                     }
                     let recv_ident = receiver.filter(|r| {
                         matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier")
@@ -1103,9 +1193,19 @@ impl<'t> Walker<'t> {
                         } else {
                             callee_name = method_name.to_string();
                         }
+                    } else if let Some(field) = receiver.and_then(|r| self.this_field_of(r)) {
+                        // `this.<field>.<method>()` — keep the field so the
+                        // resolver can read its declared type (#1496). Mirrors
+                        // TreeSitterExtractor.extractCall.
+                        callee_name = format!("this.{field}.{method_name}");
+                    } else if let Some(r) = receiver.filter(|r| r.kind() == "call_expression") {
+                        // Call receiver — `make().run()` (#1683): keep the inner
+                        // callee as `<inner>().<method>`, or emit nothing when it
+                        // is not a plain name / member chain. Mirrors
+                        // TreeSitterExtractor.extractCall.
+                        let Some(inner) = self.plain_inner_callee(r) else { return };
+                        callee_name = format!("{inner}().{method_name}");
                     } else {
-                        // (the call-receiver re-encode branches are other
-                        // languages'; TS/JS keeps the bare method name)
                         callee_name = method_name.to_string();
                     }
                 }
@@ -1128,6 +1228,35 @@ impl<'t> Walker<'t> {
 
     // --- extractInstantiation -----------------------------------------------------------
 
+    /// `this.<field>` as a member_expression receiver → Some(field) (#1496).
+    fn this_field_of(&self, receiver: Node<'t>) -> Option<String> {
+        if receiver.kind() != "member_expression" {
+            return None;
+        }
+        let object = receiver.child_by_field_name("object")?;
+        let property = receiver.child_by_field_name("property")?;
+        if object.kind() != "this" || property.kind() != "property_identifier" {
+            return None;
+        }
+        Some(self.text(property).to_string())
+    }
+
+    /// The callee of a call-expression receiver when it is a plain identifier
+    /// or member chain (`make`, `d.setdefault`), whitespace stripped (#1683).
+    fn plain_inner_callee(&self, call: Node<'t>) -> Option<String> {
+        let inner = call.child_by_field_name("function")?;
+        let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect();
+        if text.is_empty() {
+            return None;
+        }
+        let ok = text.split('.').all(|seg| {
+            let mut chars = seg.chars();
+            matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
+                && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
+        });
+        if ok { Some(text) } else { None }
+    }
+
     pub(super) fn extract_instantiation(&mut self, node: Node<'t>) {
         if self.stack.is_empty() {
             return;

+ 72 - 10
codegraph-kernel/src/tsjs/mod.rs

@@ -55,12 +55,34 @@ impl Variant {
 /// typescriptExtractor.methodTypes / javascriptExtractor.methodTypes.
 fn is_method_type(v: Variant, kind: &str) -> bool {
     kind == "method_definition"
-        || (v.is_ts() && kind == "public_field_definition")
+        || (v.is_ts() && matches!(kind, "public_field_definition" | "method_signature"))
         || (!v.is_ts() && kind == "field_definition")
 }
 
+/// typescriptExtractor.propertyTypes. The interface counterpart of
+/// `public_field_definition`: it carries no value, so it is always a property
+/// and never goes through classify_ts_class_member (#1638).
+fn is_property_type(v: Variant, kind: &str) -> bool {
+    v.is_ts() && kind == "property_signature"
+}
+
+/// Method node types that spell a SIGNATURE — a declaration with no body (#1638).
+///
+/// They are a method of whatever type declares them and nothing on their own, so
+/// they must not take `extract_method`'s "no class-like parent, so treat it as a
+/// free function" fallback. The other method types can: a `method_definition`
+/// outside a class really is a function. This one appears outside a class only
+/// inside a type literal (`type Handle = { stop(): void }`), whose members
+/// `extract_ts_type_alias_members` already extracts and attaches to the alias
+/// (#359) — take the fallback and the file gains a phantom top-level
+/// `function stop` beside the real `Handle::stop`. Mirrors the TS extractor's
+/// SIGNATURE_METHOD_NODE_TYPES (extraction/tree-sitter.ts).
+fn is_signature_method_type(kind: &str) -> bool {
+    kind == "method_signature"
+}
+
 fn is_function_type(kind: &str) -> bool {
-    matches!(kind, "function_declaration" | "arrow_function" | "function_expression")
+    matches!(kind, "function_declaration" | "generator_function_declaration" | "arrow_function" | "function_expression" | "generator_function")
 }
 
 fn is_class_type(v: Variant, kind: &str) -> bool {
@@ -622,7 +644,9 @@ impl<'t> Walker<'t> {
         } else if is_class_type(self.variant, kind) {
             self.extract_class(node);
             skip_children = true;
-        } else if is_method_type(self.variant, kind) {
+        } else if is_method_type(self.variant, kind)
+            && (!is_signature_method_type(kind) || self.inside_class_like())
+        {
             if classify_ts_class_member(node) == Member::Property {
                 let prop = self.extract_property(node);
                 if let (Some((row, name)), Some(value)) = (prop, node.child_by_field_name("value")) {
@@ -664,12 +688,22 @@ impl<'t> Walker<'t> {
             self.extract_call(node);
         } else if kind == "new_expression" {
             self.extract_instantiation(node);
-        } else if self.variant.is_ts()
-            && matches!(kind, "property_signature" | "method_signature")
-            && self.inside_class_like()
-        {
-            let parent = self.top_row();
-            self.extract_type_annotations(node, parent);
+        } else if is_property_type(self.variant, kind) && self.inside_class_like() {
+            // NOTE: `property_signature` / `method_signature` used to be handled
+            // here together, hanging their type annotations off the ENCLOSING
+            // INTERFACE — the only anchor available while the members themselves
+            // went unextracted. Since #1638 `method_signature` is a method type
+            // and `property_signature` a property type, so the method branch
+            // above claims the first (under the same inside_class_like guard
+            // this branch had) and this one extracts the second as a real node.
+            // The `references` edges survive — extract_method and
+            // extract_property each call extract_type_annotations — but now hang
+            // off the member, the more precise anchor: `Api::fetch → PageId`
+            // says which member wants the type, where `Api → PageId` only said
+            // the file did.
+            self.extract_property(node);
+            self.scan_fn_ref_subtree(node, 0);
+            skip_children = true;
         }
 
         if !skip_children {
@@ -718,6 +752,13 @@ impl<'t> Walker<'t> {
                 self.extract_function(node, Some(bound));
                 return;
             }
+            // `const handleClear = () => {…}` inside a body (#1669): named by
+            // its declarator, like at module scope. Mirrors
+            // TreeSitterExtractor's declaratorBoundFunction.
+            if self.declarator_bound_function(node) {
+                self.extract_function(node, None);
+                return;
+            }
         }
 
         if is_class_type(self.variant, kind) {
@@ -742,6 +783,27 @@ impl<'t> Walker<'t> {
 
     // --- name / signature / modifier helpers ------------------------------------
 
+    /// Whether an anonymous function is the whole value of a
+    /// `variable_declarator` with a plain identifier name —
+    /// `const NAME = () => {…}` / `= function () {…}`.
+    fn declarator_bound_function(&self, node: Node<'t>) -> bool {
+        if !matches!(node.kind(), "arrow_function" | "function_expression") {
+            return false;
+        }
+        let Some(declarator) = node.parent() else { return false };
+        if declarator.kind() != "variable_declarator" {
+            return false;
+        }
+        let Some(value) = declarator.child_by_field_name("value") else { return false };
+        if value.start_byte() != node.start_byte() || value.end_byte() != node.end_byte() {
+            return false;
+        }
+        declarator
+            .child_by_field_name("name")
+            .map(|n| n.kind() == "identifier")
+            .unwrap_or(false)
+    }
+
     /// The declarator name a React handler hook binds an anonymous function
     /// to — `const NAME = useCallback(<node>, [...])` (also `React.useCallback`,
     /// `useEffectEvent`, `useEvent`) — or None for any other shape. The node
@@ -792,7 +854,7 @@ impl<'t> Walker<'t> {
         if let Some(name_node) = node.child_by_field_name("name") {
             return self.text(name_node).to_string();
         }
-        if matches!(node.kind(), "arrow_function" | "function_expression") {
+        if matches!(node.kind(), "arrow_function" | "function_expression" | "generator_function") {
             return "<anonymous>".to_string();
         }
         for i in 0..node.named_child_count() {

+ 36 - 0
docs/AGENTS.md

@@ -0,0 +1,36 @@
+# AGENTS.md (docs/)
+
+Nested Codex guidance under `docs/`. Loaded with the repo-root `AGENTS.md` when cwd is under `docs/` (Codex walks root to cwd; shared `project_doc_max_bytes` budget).
+
+Root `AGENTS.md` already carries the non-negotiable retrieval principles (adapt-the-tool, explore budgets, end-to-end synthesis). This file holds the longer validation methodology and Excalidraw worked example that were moved out of root to fit the budget.
+
+### Validation methodology (REQUIRED for every new language/framework)
+
+For each **language × framework**, validate on **small, medium, and large** real repos with **≥3 different flow prompts** each:
+
+1. **Pick the canonical flow** for the framework ("how does X reach Y": state→render, request→handler→view, query→SQL, action→reducer→store…).
+2. **Deterministic probes** (`scripts/agent-eval/probe-{node,explore}.mjs` against the built `dist/`): `codegraph_explore` with the flow's symbol names connects from→to end-to-end with no break (its Flow section shows the path); **no node explosion** (`select count(*) from nodes` stable before/after re-index); synthesized-edge **precision** spot-check (`select … where provenance='heuristic'`).
+3. **Agent A/B** (`scripts/agent-eval/run-all.sh <repo> "<Q>"`): with vs without codegraph, **≥2 runs/arm** (run-to-run variance is large — never conclude from n=1). Record **duration, total tool calls, Read, Grep**. Optional forced-Read-0 sufficiency proof via the block-read hook (`scripts/agent-eval/hook-settings.json`).
+   - **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 <target> </dev/null &`; wait for `.codegraph/daemon.sock`) **and skip the startup re-exec** (`CODEGRAPH_WASM_RELAUNCHED=1`) so claude connects before the agent's first turn. Don't trust claude's `init` snapshot — it can read `status:"pending"` / 0 tools even when it then connects; judge by actual codegraph usage in `parse-run.mjs`'s `by type`. To isolate a change — **new-build vs baseline-build, both codegraph-on** (vs run-all.sh's with-vs-without) — use `scripts/agent-eval/ab-new-vs-baseline.sh <indexed-repo> "<task>" [baseline-ref]` (it bakes in the pre-warm).
+4. **Pass bar:** a normal flow question reaches **~0 Read/Grep within the repo's explore-call budget**, runs **faster** than without-codegraph, and shows **no regression on a control repo**. Record the numbers in `docs/design/dynamic-dispatch-coverage-playbook.md` (the coverage matrix).
+
+Full playbook + per-mechanism design: `docs/design/dynamic-dispatch-coverage-playbook.md` and `docs/design/callback-edge-synthesis.md`.
+
+### Worked example — Excalidraw (TS/React, medium, 643 files)
+
+The template to replicate per language/framework. Question: *"how does updating an element re-render the canvas on screen?"* (the full flow crosses three React boundaries: observer callback, `setState`→`render`, and JSX child).
+
+| Stage | duration | Read | Grep | codegraph |
+|---|---|---|---|---|
+| Without codegraph | 115–139s | 9–10 | 10–11 | 0 |
+| Broken (explore-budget regression) | 131–139s | 5–10 | 3–5 | 6–14 |
+| Fixed (budget + msgs + synthesis) | 64–112s | 0–2 | 2–4 | 3–**10** |
+| + trace-first steering | **51–74s** | **0–2** | 0–4 | **3–4** |
+
+n=4 unhooked runs/stage, same prompt. After steering flow questions to `codegraph_trace` first: **best run 0 Read / 0 Grep / 3 codegraph / 51s**; **2 of 4 fully clean** (0 Read, 0 Grep). Steering eliminated the over-drill variance — call count tightened from 3–10 to 3–4, trace adoption went 3/4 → 4/4, and the `search`+`callers` path-reconstruction floundering dropped to 0. Run-to-run variance is still real; report the range, never a single run. **Residual reads/greps are all the nonce data-flow** (`canvasNonce` — a local prop with no graph edges); that's the def-use/data-flow frontier, left deliberately uncovered (tracking every local would explode the graph). Validated: `trace(mutateElement, renderStaticScene)` connects in **6 hops** across all three boundaries (`mutateElement → triggerUpdate → [callback] triggerRender → [react-render] render → [jsx] StaticCanvas → renderStaticScene`), each hop showing inline source + the wiring site; node count stable at 9,289; 1 callback + 46 react-render + 280 jsx-render synthesized edges (no explosion, precision-checked).
+
+
+Also see: `docs/design/dynamic-dispatch-coverage-playbook.md`, `docs/design/callback-edge-synthesis.md`, `docs/benchmarks/call-sequence-analysis.md`, `docs/benchmarks/agent-eval-feedback-metrics.md`.

+ 23 - 13
docs/design/kotlin-kernel-port-checklist.md

@@ -278,10 +278,18 @@ Hooks PRESENT (port each exactly):
      but createNode's extractModifiers merge still runs, so `expect val` /
      `actual val` DO get decorators. Return true → the dispatcher runs
      `scanFnRefSubtree(node, 0)` (capture-only, halts at nested
-     function/lambda types) and NEVER descends → **property initializers
-     emit NO calls/instantiates refs anywhere** (`val SHARED = WidgetK(0)`
-     → nothing; `by lazy { compute() }` → nothing, the scan halts at the
-     lambda_literal). Consequences pinned in `extract-torture.txt`.
+     function/lambda types) and never descends on its own. **The hook itself
+     then walks the property's RHS under the property's scope** — the named
+     child after the `=` token plus a `property_delegate` — via
+     `ctx.visitFunctionBody`, so `val SHARED = WidgetK(0)`, `val cb =
+     Runnable { hit() }` and `by lazy { compute() }` all emit their calls
+     FROM the property node (Go's #693 initializer walk, ported). The
+     declaration's own children — modifiers, `val`/`var`, the name+type, an
+     extension receiver's type and type parameters, `getter`/`setter` — are
+     NOT walked: a same-line `val c get() = f()` still emits nothing, a
+     next-line accessor still attributes to the class, and a
+     hook-DECLINED destructuring RHS is still invisible.
+     Consequences pinned in `extract-torture.txt`.
   2. **`lambda_literal` after a fun-interface ERROR (:139-143)** and
   3. **fun-interface misparse recovery (:145-214)** (ERROR/
      function_declaration shapes; `isFunInterfaceNode` :46; Pattern 1 walks
@@ -391,7 +399,7 @@ Hooks ABSENT (the walker must NOT do these): `preParse`, `resolveName`,
 | `anonymous_initializer` (`init { }`) | no branch | recursed → its statements' calls → **`calls` refs FROM THE CLASS node**; its `val` locals → hook 'local' → nothing (pinned: `calls "register" from=class:WidgetK`) |
 | `secondary_constructor` | no branch | **NO constructor node**; recursed → body calls attribute to the CLASS (`calls "log" from=class:WidgetK`); the `constructor_delegation_call`'s value_arguments still feed fn-ref capture |
 | `getter`/`setter` as SIBLINGS (accessor on its own line) | no branch | recursed → accessor-body calls attribute to the CLASS (or file). See §Properties for the sibling/child split |
-| `object_literal` (`object : T { … }` initializer) | no branch anywhere | never a node; see §Body walker for the method-leak quirk |
+| `object_literal` (`object : T { … }` initializer) | no branch anywhere | never a node itself; inside a PROPERTY initializer the hook's walk reaches its `fun`s, which leak out as FUNCTIONS under the property (see §Body walker for the same method-leak quirk) |
 | `file_annotation` (`@file:JvmName("x")`) | no branch | recursed; its value_arguments feed fn-ref capture (string args → nothing). No decorates ref |
 | INSTANTIATION_KINDS (354-361) | **no kotlin member** | extractInstantiation:4610 is **UNREACHABLE** for kotlin — constructor calls `Foo()` are call_expressions → plain `calls` refs named `Foo` (capitalized). Kotlin emits **zero `instantiates` refs**, ever |
 | `impl_item`:1274 / property_signature:1282 / export_statement / swift property:1121 | never | not kotlin node kinds (the swift `property_declaration` branch at 1121-1193 is gated `language === 'swift'` — kotlin property_declarations never enter it) |
@@ -659,7 +667,8 @@ refs — kotlin emits NO instantiates, §dispatch table); backticked
 ### Static-member / value-read refs (4750-4808) — kotlin IS in STATIC_MEMBER_LANGS (345-347)
 
 Called ONLY from the body walker (5218) — top-level/class-scope reads emit
-nothing (hook-consumed property initializers doubly so).
+nothing — EXCEPT a property initializer, which the hook now walks through
+visitFunctionBody under the property's own scope (§Properties).
 `navigation_expression` ∈ MEMBER_ACCESS_TYPES (326). Mechanics:
 
 - callee-of-call skip (4772-4778): parent ∈ callTypes AND parent.namedChild(0)
@@ -850,12 +859,13 @@ unwrap/ungatedModes/addressOfOnly.
 - Capture points: visitNode:990 (top-level/class-scope call args),
   visitFunctionBody:5137, scanFnRefSubtree (hook-consumed property
   subtrees — `val x = register(::f)` captures via the inner
-  value_arguments; **the scan halts at `lambda_literal` (610), so refs
-  inside `by lazy { }`/trailing lambdas under a hook-consumed property are
-  NOT captured**). **NOT captured anywhere: property/local initializer
-  callable refs (`val m = ::caller`, `val bound = w::render`) — kotlin's
-  dispatch has NO property_declaration/varinit key** (unlike SWIFT_SPEC —
-  do not borrow it). Pinned: torture emits exactly three function_refs —
+  value_arguments; **the scan halts at `lambda_literal` (610)**, but the
+  hook's own initializer walk (§Properties) covers the same subtree with the
+  PROPERTY on the stack, so refs inside `by lazy { }`/trailing lambdas are
+  captured there — a shallow `::ref` reachable by BOTH is emitted twice, once
+  from the class and once from the property). **NOT captured anywhere:
+  local initializer callable refs — kotlin's dispatch has NO
+  property_declaration/varinit key** (unlike SWIFT_SPEC — do not borrow it). Pinned: torture emits exactly three function_refs —
   `topLevel` (definedHere), `OtherClass::handle`, `this.caller`.
 - Flush gate (639-728): generated-file skip; `this.`-prefixed +
   `::`-containing candidates always flush; bare names need definedHere
@@ -1039,7 +1049,7 @@ unwrap/ungatedModes/addressOfOnly.
    `Unit` / nullable / lambda return / `: T` generic leak; `expect fun`
    (bodiless + dec) / `actual fun`; tailrec self-call in expression body;
    top-level `val`/`var`/`const val`/`by lazy {}` (constant/variable kinds,
-   NO initializer refs, NO capture inside the delegate lambda) +
+   initializer + delegate refs attributed TO the property) +
    **destructuring (`val (a,b)` → nothing, both scopes)** + next-line-getter
    top-level `val` (getter calls → file/namespace); class with primary ctor
    (props invisible, defaults not walked), class-body val/var/computed

+ 2 - 2
docs/design/rust-lang-kernel-port-checklist.md

@@ -140,7 +140,7 @@ undefined; **no isConst means `const_item`/`static_item` extract as kind
 |---|---|---|
 | `function_item` (top level) | functionTypes, tree-sitter.ts:994 → extractFunction:1517 | not inside class-like at file scope → extractFunction; **first line of extractFunction (1522): if getReceiverType returns a value → extractMethod instead** (this is how impl-block fns become methods — impl_item does NOT push a scope) |
 | `function_signature_item` | same | in a trait body (trait pushed, class-like) → extractMethod; no `body` field → no body walk |
-| `struct_item` | structTypes:1059 → extractStruct:1869 | `body` field required: **unit structs `struct Unit;` have no body → NO node minted** (1876, `record_declaration` exemption is C#-only). Tuple structs have body `ordered_field_declaration_list` → extracted. `field_declaration` children make NO nodes (rust has no fieldTypes) — visitNode recurses into them and finds nothing |
+| `struct_item` | structTypes:1059 → extractStruct:1869 | ~~`body` field required: unit structs `struct Unit;` have no body → NO node minted~~ — **superseded: Rust now sets `allowBodilessStruct`, so `struct Unit;` mints a node with no members.** Rust has no forward declarations, so the bodiless skip (meant for C/C++) never applied here; the `record_declaration` exemption is the C# form of the same carve-out. Tuple structs have body `ordered_field_declaration_list` → extracted. `field_declaration` children make NO nodes (rust has no fieldTypes) — visitNode recurses into them and finds nothing |
 | `enum_item` | enumTypes:1064 → extractEnum:1914 | body `enum_variant_list`; `enum_variant` children → extractEnumMembers:1958 — **`name` field path: one `enum_member` node from `getChildByField(node,'name')`, then return** (variant payload bodies `B(u32)` / `C { x }` are never walked). Non-variant children (e.g. `attribute_item`) → visitNode (no-op) |
 | `trait_item` | interfaceTypes:1054 → extractInterface:1834 | kind `'trait'` (interfaceKind); extractInheritance sees the `trait_bounds` child (see below); body `declaration_list` children visited with the trait pushed → fn items become methods with QN `Trait::name` via nodeStack |
 | `impl_item` | dedicated branch:1273-1276 → extractRustImplItem:5690 | emits the implements back-reference (below); **skipChildren stays false** → the `declaration_list` is then visited normally by the loop at 1295 (that's how impl members are reached; impl pushes NOTHING on the nodeStack) |
@@ -490,7 +490,7 @@ inner `array_expression`, but `const CB: fn() = handler;` captures nothing
 ## Gates (per plan §5, no exceptions)
 
 - **Torture fixture `torture.rs`** (+ CRLF variant, derived in-memory), pinning
-  at minimum: unit struct (NO node) / tuple struct / field struct; enum with
+  at minimum: unit struct (node, no members) / tuple struct / field struct; enum with
   unit+tuple+struct variants; trait with supertraits incl. a SCOPED one
   (`fmt::Debug` — dropped) + `function_signature_item` + default method +
   associated type/const (no node; const value call attributes to trait);

+ 1 - 1
scripts/agent-eval/probe-suite-envelope.mjs

@@ -85,7 +85,7 @@ try {
       epilogueCut: text.includes('omitted for size'),
       sectionCut: text.includes('output truncated to budget'),
       notShown: text.includes('Not shown above'),
-      budgetNote: text.includes('**Explore budget:'),
+      budgetNote: text.includes('advisory only, NOT a quota'),
     });
   }
 } finally {

+ 272 - 235
src/bin/codegraph.ts

@@ -41,7 +41,7 @@ try {
 import { Command } from 'commander';
 import * as path from 'path';
 import * as fs from 'fs';
-import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, hasStructuralKeyword, extractCodeTokens } from '../directory';
+import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection } from '../directory';
 import { extractProseCandidates } from '../search/identifier-segments';
 import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
 import { createShimmerProgress } from '../ui/shimmer-progress';
@@ -59,6 +59,9 @@ import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
 // server itself is loaded lazily inside the `ui` action. See ui-server/constants.
 import { BROWSER_ENV, DEFAULT_UI_PORT } from '../ui-server/constants';
 import type { UiServerHandle } from '../ui-server';
+import { lookupSymbolNodes, describeSymbolNode, groupDefinitions } from '../graph/symbol-lookup';
+import type { Node, Edge } from '../types';
+import { isTestPath } from '../search/query-utils';
 
 // Decided once, before `--color`/`--no-color` are stripped from argv below
 // (#1281). Piped/redirected stdout, NO_COLOR, or --no-color -> plain output.
@@ -362,6 +365,27 @@ function warn(message: string): void {
   console.log(chalk.yellow(getGlyphs().warn) + ' ' + message);
 }
 
+/** "not found" (+ optional did-you-mean) when no exact symbol matches. */
+function formatSymbolNotFound(symbol: string, fuzzyNames: string[]): string {
+  const suggestions = [...new Set(fuzzyNames.filter((n) => n !== symbol))].slice(0, 3);
+  if (suggestions.length === 0) return `Symbol "${symbol}" not found`;
+  return `Symbol "${symbol}" not found — did you mean: ${suggestions.join(', ')}?`;
+}
+
+/** Compact node shape retained by the CLI's existing JSON lists. */
+function cliNode(node: Node) {
+  return { name: node.name, kind: node.kind, filePath: node.filePath, startLine: node.startLine };
+}
+
+/** Attribute a group's edges to every overload of this definition. */
+function cliDefinition(group: Node[]) {
+  const head = group[0]!;
+  return {
+    definition: { ...cliNode(head), id: head.id, qualifiedName: head.qualifiedName, language: head.language },
+    roots: group.map((node) => node.id),
+  };
+}
+
 type IndexResult = {
   success: boolean;
   filesIndexed: number;
@@ -371,6 +395,8 @@ type IndexResult = {
   edgesCreated: number;
   errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>;
   durationMs: number;
+  filesSkippedUnsupported?: number;
+  topUnsupportedExtensions?: { ext: string; count: number }[];
 };
 
 /**
@@ -378,6 +404,7 @@ type IndexResult = {
  */
 function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexResult, projectPath?: string): void {
   const hasErrors = result.filesErrored > 0;
+  const parseWarnings = result.errors.filter((e) => e.code === 'parse_error' && e.severity === 'warning');
 
   // Surface non-file-level failures (e.g. lock-acquisition failure
   // when another indexer is running) before the file-count branches.
@@ -403,6 +430,10 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
       clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`);
     }
     clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`);
+    // Warning-only parse failures keep indexing successful, but must be visible.
+    for (const warning of parseWarnings) {
+      clack.log.warn(warning.message);
+    }
     // A PARTIAL index (files silently dropped mid-pipeline) must not pass
     // as a clean run — it's the difference between "indexed the repo" and
     // "indexed most of the repo, quietly". Only the completeness
@@ -423,6 +454,20 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
     }
   } else if (hasErrors) {
     clack.log.error(`Indexing failed ${getGlyphs().dash} all ${formatNumber(result.filesErrored)} files had errors`);
+  } else if (result.filesSkippedUnsupported) {
+    // A project CodeGraph has no grammar for used to be indistinguishable from
+    // an empty one: same message, same `complete` state, same exit 0. Say which
+    // files were there and that the graph is empty on purpose, so nobody — and
+    // no agent trusting the graph — reads silence as "this code doesn't exist"
+    // (#1502).
+    const top = (result.topUnsupportedExtensions ?? [])
+      .map(e => `${e.ext} (${formatNumber(e.count)})`)
+      .join(', ');
+    clack.log.warn(
+      `No supported source files found ${getGlyphs().dash} ${formatNumber(result.filesSkippedUnsupported)} file(s) present, none in a language CodeGraph indexes`
+      + (top ? `: ${top}` : '')
+    );
+    clack.log.info('CodeGraph is inactive for this workspace — searches will return nothing. Use your own file tools here.');
   } else {
     clack.log.warn('No files found to index');
   }
@@ -791,7 +836,13 @@ program
   .option('-q, --quiet', 'Suppress progress output')
   .option('-v, --verbose', 'Show detailed worker lifecycle and memory info')
   .action(async (pathArg: string | undefined, options: { force?: boolean; quiet?: boolean; verbose?: boolean }) => {
-    const projectPath = resolveProjectPath(pathArg);
+    // An EXPLICIT path names the project to rebuild — it is never a hint to go
+    // looking for one. resolveProjectPath walks up to the nearest initialized
+    // ancestor, which is right for `codegraph query` run from a subdirectory,
+    // but for a full re-index it silently rebuilt the parent's graph under a
+    // normal "Done" when <path> had no index of its own (#1524). Only a bare
+    // `codegraph index` (cwd) may resolve upward.
+    const projectPath = pathArg ? path.resolve(pathArg) : resolveProjectPath();
 
     try {
       // Don't (re)index your home directory / a filesystem root (#845). --force
@@ -804,7 +855,12 @@ program
 
       if (!isInitialized(projectPath)) {
         error(`CodeGraph not initialized in ${projectPath}`);
-        info('Run "codegraph init" first');
+        const ancestor = pathArg ? resolveProjectPath(pathArg) : projectPath;
+        if (ancestor !== projectPath) {
+          info(`The nearest initialized project is ${ancestor} — pass that path to rebuild it, or run "codegraph init" in ${projectPath} to index it on its own.`);
+        } else {
+          info('Run "codegraph init" first');
+        }
         process.exit(1);
       }
 
@@ -1164,7 +1220,9 @@ program
 
       const limit = parseInt(options.limit || '10', 10);
       const rawResults = cg.searchNodes(search, {
-        limit,
+        // Fetch one extra row so the CLI can report a cut without changing the
+        // long-standing bare-array contract of `query --json` (#1639).
+        limit: limit + 1,
         kinds: options.kind ? [options.kind as any] : undefined,
       });
 
@@ -1172,14 +1230,18 @@ program
       // hand-written implementation before protobuf/gRPC scaffolding
       // when both share a name. See extraction/generated-detection.ts.
       const isGen = cg.generatedFilePredicate(rawResults.map((r) => r.node.filePath));
-      const results = [...rawResults].sort((a, b) => {
+      const rankedResults = [...rawResults].sort((a, b) => {
         const aGen = isGen(a.node.filePath) ? 1 : 0;
         const bGen = isGen(b.node.filePath) ? 1 : 0;
         return aGen - bGen;
       });
+      const truncated = rankedResults.length > limit;
+      const results = rankedResults.slice(0, limit);
+      const truncationMessage = `Results truncated at ${limit}; pass --limit to widen.`;
 
       if (options.json) {
         console.log(JSON.stringify(results, null, 2));
+        if (truncated) console.error(truncationMessage);
       } else {
         if (results.length === 0) {
           info(`No results found for "${search}"`);
@@ -1205,6 +1267,7 @@ program
             }
             console.log();
           }
+          if (truncated) console.log(chalk.dim(truncationMessage));
         }
       }
 
@@ -1412,8 +1475,11 @@ program
             const text = result.content[0]?.text ?? '';
             if (!result.isError && text.trim()) {
               // Cap the injection so a large-repo explore can't flood the prompt.
-              const MAX = 16000;
-              const body = text.length > MAX ? `${text.slice(0, MAX)}\n…(truncated; call codegraph_explore for the rest)` : text;
+              // Claude Code shows hook stdout inline only up to 10,000 characters;
+              // above that it persists the output to a file and the model sees a
+              // 2 KB preview (#1694). PROMPT_HOOK_INJECTION_MAX (9,000) leaves
+              // room for the wrapper and the projectPath nudge lines below.
+              const body = capPromptHookInjection(text);
               // For a front-loaded SUB-project, a follow-up explore needs its path.
               const more = plan.viaSubScan
                 ? `call codegraph_explore with projectPath: "${plan.exploreRoot}" for more`
@@ -2123,176 +2189,140 @@ program
   });
 
 /**
- * codegraph callers <symbol>
- *
- * CLI parity with the MCP graph tools (codegraph_callers/callees/impact) so the
- * traversal queries work in scripts, CI, and git hooks without a running MCP
- * server.
+ * CLI parity with MCP callers/callees: resolve once, then collect and limit
+ * within each definition. The legacy JSON list remains an explicitly labeled
+ * union, with its original total/limit/truncated contract (#1674).
  */
-program
-  .command('callers <symbol>')
-  .description('Find all functions/methods that call a specific symbol')
-  .option('-p, --path <path>', 'Project path')
-  .option('-l, --limit <number>', 'Maximum results', '20')
-  .option('-j, --json', 'Output as JSON')
-  .action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => {
-    const projectPath = resolveProjectPath(options.path);
+for (const direction of ['callers', 'callees'] as const) {
+  const title = direction === 'callers' ? 'Callers' : 'Callees';
+  program
+    .command(`${direction} <symbol>`)
+    .description(direction === 'callers'
+      ? 'Find all functions/methods that call a specific symbol'
+      : 'Find all functions/methods called by a specific symbol')
+    .option('-p, --path <path>', 'Project path')
+    .option('-f, --file <path>', 'Narrow definitions by file path or suffix (no match: show all with a note)')
+    .option('-l, --limit <number>', 'Maximum results per definition (also caps the JSON union)', '20')
+    .option('-j, --json', 'Output as JSON')
+    .action(async (symbol: string, options: { path?: string; file?: string; limit?: string; json?: boolean }) => {
+      const projectPath = resolveProjectPath(options.path);
 
-    try {
-      if (!isInitialized(projectPath)) {
-        error(`CodeGraph not initialized in ${projectPath}`);
-        process.exit(1);
-      }
-
-      const { default: CodeGraph } = await loadCodeGraph();
-      const cg = await CodeGraph.open(projectPath);
-      const limit = parseInt(options.limit || '20', 10);
-
-      const matches = cg.searchNodes(symbol, { limit: 50 });
-      if (matches.length === 0) {
-        info(`Symbol "${symbol}" not found`);
-        cg.destroy();
-        return;
-      }
-
-      const seen = new Set<string>();
-      const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = [];
-
-      for (const match of matches) {
-        const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
-        if (!exactMatch && matches.length > 1) continue;
-        for (const c of cg.getCallers(match.node.id)) {
-          if (!seen.has(c.node.id)) {
-            seen.add(c.node.id);
-            allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
-          }
-        }
-      }
-
-      // Fallback: if exact filter removed everything, use the top match
-      if (allCallers.length === 0 && matches[0]) {
-        for (const c of cg.getCallers(matches[0].node.id)) {
-          if (!seen.has(c.node.id)) {
-            seen.add(c.node.id);
-            allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
-          }
-        }
-      }
-
-      const limited = allCallers.slice(0, limit);
-
-      if (options.json) {
-        console.log(JSON.stringify({ symbol, callers: limited }, null, 2));
-      } else if (limited.length === 0) {
-        info(`No callers found for "${symbol}"`);
-      } else {
-        console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\n`));
-        for (const node of limited) {
-          const loc = node.startLine ? `:${node.startLine}` : '';
-          console.log(
-            chalk.cyan(node.kind.padEnd(12)) +
-            chalk.white(node.name)
-          );
-          console.log(chalk.dim(`  ${node.filePath}${loc}`));
-          console.log();
+      try {
+        if (!isInitialized(projectPath)) {
+          error(`CodeGraph not initialized in ${projectPath}`);
+          process.exit(1);
         }
-      }
-
-      cg.destroy();
-    } catch (err) {
-      error(`callers failed: ${err instanceof Error ? err.message : String(err)}`);
-      process.exit(1);
-    }
-  });
-
-/**
- * codegraph callees <symbol>
- */
-program
-  .command('callees <symbol>')
-  .description('Find all functions/methods that a specific symbol calls')
-  .option('-p, --path <path>', 'Project path')
-  .option('-l, --limit <number>', 'Maximum results', '20')
-  .option('-j, --json', 'Output as JSON')
-  .action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => {
-    const projectPath = resolveProjectPath(options.path);
-
-    try {
-      if (!isInitialized(projectPath)) {
-        error(`CodeGraph not initialized in ${projectPath}`);
-        process.exit(1);
-      }
 
-      const { default: CodeGraph } = await loadCodeGraph();
-      const cg = await CodeGraph.open(projectPath);
-      const limit = parseInt(options.limit || '20', 10);
-
-      const matches = cg.searchNodes(symbol, { limit: 50 });
-      if (matches.length === 0) {
-        info(`Symbol "${symbol}" not found`);
-        cg.destroy();
-        return;
-      }
-
-      const seen = new Set<string>();
-      const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = [];
-
-      for (const match of matches) {
-        const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
-        if (!exactMatch && matches.length > 1) continue;
-        for (const c of cg.getCallees(match.node.id)) {
-          if (!seen.has(c.node.id)) {
-            seen.add(c.node.id);
-            allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
+        const { default: CodeGraph } = await loadCodeGraph();
+        const cg = await CodeGraph.open(projectPath);
+        try {
+          const limit = parseInt(options.limit || '20', 10);
+          const { nodes: targets } = lookupSymbolNodes(cg, symbol);
+          if (targets.length === 0) {
+            info(formatSymbolNotFound(symbol, cg.searchNodes(symbol, { limit: 5 }).map((m) => m.node.name)));
+            return;
           }
-        }
-      }
 
-      if (allCallees.length === 0 && matches[0]) {
-        for (const c of cg.getCallees(matches[0].node.id)) {
-          if (!seen.has(c.node.id)) {
-            seen.add(c.node.id);
-            allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
+          const { groups, filteredOut } = groupDefinitions(targets, options.file);
+          const ambiguous = groups.length > 1;
+          const note = filteredOut
+            ? `no definition of "${symbol}" matches file "${options.file}" — showing all definitions instead.`
+            : undefined;
+          const collected = groups.map((group) => {
+            const nodes = new Map<string, Node>();
+            const edges = new Map<string, Edge>();
+            for (const target of group) {
+              const connections = direction === 'callers' ? cg.getCallers(target.id) : cg.getCallees(target.id);
+              for (const { node, edge } of connections) {
+                nodes.set(node.id, node);
+                edges.set(`${edge.source}->${edge.target}:${edge.kind}`, edge);
+              }
+            }
+            return { group, nodes: [...nodes.values()], edges: [...edges.values()] };
+          });
+
+          if (options.json) {
+            const definitions = collected.map(({ group, nodes, edges }) => {
+              const limited = nodes.slice(0, limit);
+              const shown = new Set(limited.map((node) => node.id));
+              return {
+                ...cliDefinition(group),
+                [direction]: limited.map((node) => ({ id: node.id, ...cliNode(node) })),
+                edges: edges.filter((edge) => shown.has(direction === 'callers' ? edge.source : edge.target)),
+                total: nodes.length,
+                limit,
+                truncated: nodes.length > limit,
+              };
+            });
+            const union = new Map<string, Node>();
+            for (const { nodes } of collected) {
+              for (const node of nodes) union.set(node.id, node);
+            }
+            const total = union.size;
+            console.log(JSON.stringify({
+              symbol,
+              targets: groups.flat().map((node) => cliDefinition([node]).definition),
+              ambiguous,
+              aggregation: ambiguous ? 'union' : 'definition',
+              file: options.file,
+              filteredOut,
+              note,
+              definitions,
+              [direction]: [...union.values()].slice(0, limit).map(cliNode),
+              total,
+              limit,
+              truncated: total > limit,
+            }, null, 2));
+          } else {
+            if (note) warn(note);
+            if (ambiguous) {
+              console.log(chalk.bold(`\n${title} of "${symbol}" — ${groups.length} distinct definitions (narrow with --file):`));
+            }
+            for (const { group, nodes } of collected) {
+              const limited = nodes.slice(0, limit);
+              const total = nodes.length;
+              const truncated = total > limit;
+              const count = truncated ? `${limited.length} of ${total}` : String(total);
+              if (ambiguous) {
+                console.log(chalk.bold(`\n${describeSymbolNode(group[0]!)} (${count}):\n`));
+              } else {
+                console.log(chalk.bold(`\n${title} of "${symbol}" (${count}):\n`));
+                console.log(chalk.dim(describeSymbolNode(group[0]!)));
+              }
+              if (total === 0) {
+                if (ambiguous) console.log(chalk.dim(`  (no ${direction})`));
+                else info(`No ${direction} found for "${symbol}"`);
+              }
+              for (const node of limited) {
+                const loc = node.startLine ? `:${node.startLine}` : '';
+                console.log(chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name));
+                console.log(chalk.dim(`  ${node.filePath}${loc}`));
+                console.log();
+              }
+              if (truncated) console.log(chalk.dim(`Showing ${limited.length} of ${total}; pass --limit to widen.`));
+            }
           }
+        } finally {
+          cg.destroy();
         }
+      } catch (err) {
+        error(`${direction} failed: ${err instanceof Error ? err.message : String(err)}`);
+        process.exit(1);
       }
-
-      const limited = allCallees.slice(0, limit);
-
-      if (options.json) {
-        console.log(JSON.stringify({ symbol, callees: limited }, null, 2));
-      } else if (limited.length === 0) {
-        info(`No callees found for "${symbol}"`);
-      } else {
-        console.log(chalk.bold(`\nCallees of "${symbol}" (${limited.length}):\n`));
-        for (const node of limited) {
-          const loc = node.startLine ? `:${node.startLine}` : '';
-          console.log(
-            chalk.cyan(node.kind.padEnd(12)) +
-            chalk.white(node.name)
-          );
-          console.log(chalk.dim(`  ${node.filePath}${loc}`));
-          console.log();
-        }
-      }
-
-      cg.destroy();
-    } catch (err) {
-      error(`callees failed: ${err instanceof Error ? err.message : String(err)}`);
-      process.exit(1);
-    }
-  });
+    });
+}
 
 /**
- * codegraph impact <symbol>
+ * codegraph impact <symbol> — one blast radius per distinct definition.
  */
 program
   .command('impact <symbol>')
   .description('Analyze what code is affected by changing a symbol')
   .option('-p, --path <path>', 'Project path')
+  .option('-f, --file <path>', 'Narrow definitions by file path or suffix (no match: show all with a note)')
   .option('-d, --depth <number>', 'Traversal depth', '2')
   .option('-j, --json', 'Output as JSON')
-  .action(async (symbol: string, options: { path?: string; depth?: string; json?: boolean }) => {
+  .action(async (symbol: string, options: { path?: string; file?: string; depth?: string; json?: boolean }) => {
     const projectPath = resolveProjectPath(options.path);
 
     try {
@@ -2303,77 +2333,89 @@ program
 
       const { default: CodeGraph } = await loadCodeGraph();
       const cg = await CodeGraph.open(projectPath);
-      const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10);
-
-      const matches = cg.searchNodes(symbol, { limit: 50 });
-      if (matches.length === 0) {
-        info(`Symbol "${symbol}" not found`);
-        cg.destroy();
-        return;
-      }
-
-      // Merge impact subgraphs across all exact-matching symbols
-      const mergedNodes = new Map<string, { name: string; kind: string; filePath: string; startLine?: number }>();
-      const seenEdges = new Set<string>();
-      let edgeCount = 0;
-
-      for (const match of matches) {
-        const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
-        if (!exactMatch && matches.length > 1) continue;
-        const impact = cg.getImpactRadius(match.node.id, depth);
-        for (const [id, n] of impact.nodes) {
-          mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine });
-        }
-        for (const e of impact.edges) {
-          const key = `${e.source}->${e.target}:${e.kind}`;
-          if (!seenEdges.has(key)) {
-            seenEdges.add(key);
-            edgeCount++;
-          }
-        }
-      }
-
-      // Fallback to top match if exact filter removed everything
-      if (mergedNodes.size === 0 && matches[0]) {
-        const impact = cg.getImpactRadius(matches[0].node.id, depth);
-        for (const [id, n] of impact.nodes) {
-          mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine });
+      try {
+        const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10);
+        const { nodes: targets } = lookupSymbolNodes(cg, symbol);
+        if (targets.length === 0) {
+          info(formatSymbolNotFound(symbol, cg.searchNodes(symbol, { limit: 5 }).map((m) => m.node.name)));
+          return;
         }
-        edgeCount = impact.edges.length;
-      }
 
-      if (options.json) {
-        console.log(JSON.stringify({
-          symbol,
-          depth,
-          nodeCount: mergedNodes.size,
-          edgeCount,
-          affected: Array.from(mergedNodes.values()),
-        }, null, 2));
-      } else if (mergedNodes.size === 0) {
-        info(`No affected symbols found for "${symbol}"`);
-      } else {
-        console.log(chalk.bold(`\nImpact of changing "${symbol}" — ${mergedNodes.size} affected symbols:\n`));
-
-        // Group by file
-        const byFile = new Map<string, Array<{ name: string; kind: string; startLine?: number }>>();
-        for (const node of mergedNodes.values()) {
-          const list = byFile.get(node.filePath) || [];
-          list.push({ name: node.name, kind: node.kind, startLine: node.startLine });
-          byFile.set(node.filePath, list);
-        }
+        const { groups, filteredOut } = groupDefinitions(targets, options.file);
+        const ambiguous = groups.length > 1;
+        const note = filteredOut
+          ? `no definition of "${symbol}" matches file "${options.file}" — showing all definitions instead.`
+          : undefined;
+        const collected = groups.map((group) => {
+          const nodes = new Map<string, Node>();
+          const edges = new Map<string, Edge>();
+          for (const target of group) {
+            const impact = cg.getImpactRadius(target.id, depth);
+            for (const [id, node] of impact.nodes) nodes.set(id, node);
+            for (const edge of impact.edges) edges.set(`${edge.source}->${edge.target}:${edge.kind}`, edge);
+          }
+          return { group, nodes, edges };
+        });
 
-        for (const [file, nodes] of byFile) {
-          console.log(chalk.cyan(file));
-          for (const node of nodes) {
-            const loc = node.startLine ? `:${node.startLine}` : '';
-            console.log(`  ${chalk.dim(node.kind.padEnd(12))}${node.name}${chalk.dim(loc)}`);
+        if (options.json) {
+          const unionNodes = new Map<string, Node>();
+          const unionEdges = new Map<string, Edge>();
+          const definitions = collected.map(({ group, nodes, edges }) => {
+            for (const [id, node] of nodes) unionNodes.set(id, node);
+            for (const [key, edge] of edges) unionEdges.set(key, edge);
+            return {
+              ...cliDefinition(group),
+              nodeCount: nodes.size,
+              edgeCount: edges.size,
+              affected: [...nodes.values()].map((node) => ({ id: node.id, ...cliNode(node) })),
+              edges: [...edges.values()],
+            };
+          });
+          console.log(JSON.stringify({
+            symbol,
+            depth,
+            targets: groups.flat().map((node) => cliDefinition([node]).definition),
+            ambiguous,
+            aggregation: ambiguous ? 'union' : 'definition',
+            file: options.file,
+            filteredOut,
+            note,
+            definitions,
+            nodeCount: unionNodes.size,
+            edgeCount: unionEdges.size,
+            affected: [...unionNodes.values()].map(cliNode),
+          }, null, 2));
+        } else {
+          if (note) warn(note);
+          if (ambiguous) {
+            console.log(chalk.bold(`\nImpact of changing "${symbol}" — ${groups.length} distinct definitions (each with its own blast radius; narrow with --file):`));
+          }
+          for (const { group, nodes } of collected) {
+            if (ambiguous) {
+              console.log(chalk.bold(`\n${describeSymbolNode(group[0]!)} — ${nodes.size} affected symbols:\n`));
+            } else {
+              console.log(chalk.bold(`\nImpact of changing "${symbol}" — ${nodes.size} affected symbols:\n`));
+              console.log(chalk.dim(describeSymbolNode(group[0]!)));
+            }
+            const byFile = new Map<string, Node[]>();
+            for (const node of nodes.values()) {
+              const list = byFile.get(node.filePath) || [];
+              list.push(node);
+              byFile.set(node.filePath, list);
+            }
+            for (const [file, affected] of byFile) {
+              console.log(chalk.cyan(file));
+              for (const node of affected) {
+                const loc = node.startLine ? `:${node.startLine}` : '';
+                console.log(`  ${chalk.dim(node.kind.padEnd(12))}${node.name}${chalk.dim(loc)}`);
+              }
+              console.log();
+            }
           }
-          console.log();
         }
+      } finally {
+        cg.destroy();
       }
-
-      cg.destroy();
     } catch (err) {
       error(`impact failed: ${err instanceof Error ? err.message : String(err)}`);
       process.exit(1);
@@ -2434,16 +2476,6 @@ program
       const cg = await CodeGraph.open(projectPath);
       const maxDepth = parseInt(options.depth || '5', 10);
 
-      // Common test file patterns
-      const defaultTestPatterns = [
-        /\.spec\./,
-        /\.test\./,
-        /\/__tests__\//,
-        /\/tests?\//,
-        /\/e2e\//,
-        /\/spec\//,
-      ];
-
       // Custom filter pattern
       let customFilter: RegExp | null = null;
       if (options.filter) {
@@ -2456,9 +2488,14 @@ program
         customFilter = new RegExp(regex);
       }
 
+      // One notion of "a test" for the whole tool (#1507): the CLI used to keep
+      // its own six regexes here, which knew `.test.` and `/tests/` but not Go's
+      // `_test.go`, Python's `test_x.py` or the JVM's `FooTest.kt` — so
+      // `affected` reported "no tests" for whole ecosystems while `search` and
+      // the MCP tools counted those very files as tests.
       function isTestFile(filePath: string): boolean {
         if (customFilter) return customFilter.test(filePath);
-        return defaultTestPatterns.some(p => p.test(filePath));
+        return isTestPath(filePath);
       }
 
       // BFS to find all transitive dependents of changed files, filtered to test files

+ 54 - 5
src/db/index.ts

@@ -83,10 +83,17 @@ export class DatabaseConnection {
    */
   private openedInode: string | null;
 
-  private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend) {
+  /**
+   * Whether FTS5 is available in this Node.js build. When false, search
+   * falls back to LIKE + fuzzy matching (#1532).
+   */
+  readonly fts5Available: boolean;
+
+  private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend, fts5Available: boolean) {
     this.db = db;
     this.dbPath = dbPath;
     this.backend = backend;
+    this.fts5Available = fts5Available;
     this.openedInode = statInode(dbPath);
   }
 
@@ -105,10 +112,41 @@ export class DatabaseConnection {
 
     configureConnection(db);
 
-    // Run schema initialization
+    // Run schema initialization, splitting FTS5 from the rest so
+    // codegraph still works when Node.js was built without FTS5 (#1532).
     const schemaPath = path.join(__dirname, 'schema.sql');
     const schema = fs.readFileSync(schemaPath, 'utf-8');
-    db.exec(schema);
+
+    const FTS5_MARKER = '-- Full-text search index on node names, docstrings, and signatures';
+    const ftsIdx = schema.indexOf(FTS5_MARKER);
+    let fts5Available = true;
+
+    if (ftsIdx >= 0) {
+      const preFts = schema.slice(0, ftsIdx);
+      // FTS ends after the update trigger; required tables and indexes follow
+      // it in schema.sql and must still be created when FTS5 is unavailable.
+      const ftsSection = schema.slice(ftsIdx).match(
+        /^[\s\S]*?CREATE TRIGGER IF NOT EXISTS nodes_au\b[\s\S]*?END;/
+      )?.[0];
+      if (!ftsSection) throw new Error('schema.sql: FTS5 update trigger not found');
+      // Execute everything before FTS5 first
+      db.exec(preFts);
+      // Try FTS5; if it fails, skip it and continue with LIKE-only search
+      try {
+        db.exec(ftsSection);
+      } catch (err: any) {
+        fts5Available = false;
+        const msg = err?.message ?? String(err);
+        console.warn(
+          `[codegraph] FTS5 not available in this Node.js build (${msg}). ` +
+          `Search will fall back to LIKE + fuzzy matching. ` +
+          `For full-text search, use a Node.js build with FTS5 enabled.`
+        );
+      }
+      db.exec(schema.slice(ftsIdx + ftsSection.length));
+    } else {
+      db.exec(schema);
+    }
 
     // Record current schema version so migrations aren't re-applied on open
     const currentVersion = getCurrentVersion(db);
@@ -118,7 +156,7 @@ export class DatabaseConnection {
       ).run(CURRENT_SCHEMA_VERSION, Date.now(), 'Initial schema includes all migrations');
     }
 
-    return new DatabaseConnection(db, dbPath, backend);
+    return new DatabaseConnection(db, dbPath, backend, fts5Available);
   }
 
   /**
@@ -133,8 +171,16 @@ export class DatabaseConnection {
 
     configureConnection(db);
 
+    // Detect FTS5 availability for search fallback (#1532)
+    let fts5Available = true;
+    try {
+      db.exec("SELECT * FROM nodes_fts LIMIT 0");
+    } catch {
+      fts5Available = false;
+    }
+
     // Check and run migrations if needed
-    const conn = new DatabaseConnection(db, dbPath, backend);
+    const conn = new DatabaseConnection(db, dbPath, backend, fts5Available);
     const currentVersion = getCurrentVersion(db);
 
     if (currentVersion < CURRENT_SCHEMA_VERSION) {
@@ -169,6 +215,7 @@ export class DatabaseConnection {
    * row written by anyone during the window is captured by the rebuild.
    */
   beginBulkNodeLoad(): void {
+    if (!this.fts5Available) return;
     for (const t of DatabaseConnection.FTS_TRIGGER_NAMES) {
       this.db.exec(`DROP TRIGGER IF EXISTS ${t}`);
     }
@@ -181,6 +228,7 @@ export class DatabaseConnection {
    * IF NOT EXISTS).
    */
   endBulkNodeLoad(): void {
+    if (!this.fts5Available) return;
     this.db.exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`);
     this.recreateFtsTriggers();
   }
@@ -355,6 +403,7 @@ export class DatabaseConnection {
 
   /** Recreate the FTS triggers + rebuild if a bulk-load window never closed. */
   private healBulkNodeLoad(): void {
+    if (!this.fts5Available) return;
     const row = this.db
       .prepare(
         `SELECT count(*) AS c FROM sqlite_master WHERE type = 'trigger' AND name IN ('nodes_ai','nodes_ad','nodes_au')`

+ 107 - 21
src/db/queries.ts

@@ -55,6 +55,24 @@ function isLowValueFile(filePath: string, generated?: ReadonlySet<string>): bool
 
 const SQLITE_PARAM_CHUNK_SIZE = 500;
 
+/**
+ * A SQL predicate: is the node aliased `alias` a member an INTERFACE declares?
+ *
+ * `method_signature` / `property_signature` enter the graph as `method` /
+ * `property` nodes hung off their interface by a `contains` edge (#1638). They
+ * have no body and originate no behaviour, so for a structural judgement about
+ * a FILE they are the interface restated, not an extra thing the file declares.
+ * See {@link QueryBuilder.getAmbientDeclarationPathsAmong}, the one caller, for
+ * why treating them as opaque would break that rule in three places at once.
+ *
+ * Seeks `idx_edges_target_kind`, so it costs a key lookup per row rather than a
+ * join over the whole edge table.
+ */
+const IS_INTERFACE_MEMBER = (alias: string): string => `EXISTS (
+  SELECT 1 FROM edges ce JOIN nodes owner ON owner.id = ce.source
+   WHERE ce.target = ${alias}.id AND ce.kind = 'contains' AND owner.kind = 'interface'
+)`;
+
 /**
  * How much of the exact-name bonus a `deprioritize`d path keeps (#982). Damped
  * rather than zeroed: a query that genuinely targets that tree must still rank
@@ -226,6 +244,9 @@ export class QueryBuilder {
   private projectNameTokens: Set<string> = new Set();
   private isDeprioritizedPath: ((filePath: string) => boolean) | undefined;
 
+  // FTS5 availability flag — detected once at construction time (#1532)
+  private _fts5Available: boolean | undefined;
+
   // Node cache for frequently accessed nodes (LRU-style, max 1000 entries)
   private nodeCache: Map<string, Node> = new Map();
   private readonly maxCacheSize = 1000;
@@ -262,6 +283,8 @@ export class QueryBuilder {
     getUnresolvedCount?: SqliteStatement;
     getUnresolvedBatch?: SqliteStatement;
     getUnresolvedBatchAfter?: SqliteStatement;
+    getUnresolvedPrerequisitesAfter?: SqliteStatement;
+    getUnresolvedDependentsAfter?: SqliteStatement;
     deleteRefsByRowIdsFull?: SqliteStatement;
     getAllFilePaths?: SqliteStatement;
     getAllNodeNames?: SqliteStatement;
@@ -320,6 +343,13 @@ export class QueryBuilder {
 
   constructor(db: SqliteDatabase) {
     this.db = db;
+    // Detect FTS5 availability once (#1532)
+    try {
+      db.prepare("SELECT * FROM nodes_fts LIMIT 0").get();
+      this._fts5Available = true;
+    } catch {
+      this._fts5Available = false;
+    }
   }
 
   /**
@@ -1280,9 +1310,9 @@ export class QueryBuilder {
     const kinds = mergedKinds;
     const languages = mergedLanguages;
 
-    // First try FTS5 with prefix matching
+    // First try FTS5 with prefix matching (skip if FTS5 not available, #1532)
     let results = text
-      ? this.searchNodesFTS(text, { kinds, languages, limit, offset })
+      ? (this._fts5Available !== false ? this.searchNodesFTS(text, { kinds, languages, limit, offset }) : [])
       // Over-fetch by 5× when running filter-only (no text). The
       // post-scoring path: + name: filters can be very selective, so
       // a smaller multiplier risks returning fewer than `limit`
@@ -2184,6 +2214,12 @@ export class QueryBuilder {
    * build script does its work on the way down the file. `instantiates` counts
    * the same way — `new Server(...)` at module scope is the same act.
    *
+   * A call made while initializing a module-level `variable` / `constant` —
+   * `const service = new Service()`, `app = FastAPI()` — is attributed to the
+   * declared name (#693), not to the file, so the file's own edges alone would
+   * miss most of what a real entry point runs. Those names are the file's
+   * top-level code too, so `tops` counts them alongside the file node.
+   *
    * Ranking multiplies the two things an entry point does: it runs (calls), and
    * it wires the project together (distinct other files its symbols reach). One
    * alone is misleading — a registration table makes hundreds of module-level
@@ -2196,12 +2232,25 @@ export class QueryBuilder {
     if (limit <= 0) return [];
     return this.db
       .prepare(
-        `WITH runs AS (
-             SELECT e.source AS id, COUNT(*) AS calls
-               FROM edges e
-               JOIN nodes n ON n.id = e.source
-              WHERE n.kind = 'file' AND e.kind IN ('calls', 'instantiates')
-           GROUP BY e.source
+        `WITH tops AS (
+             SELECT n.id AS file_id, n.id AS src
+               FROM nodes n
+              WHERE n.kind = 'file'
+             UNION ALL
+             SELECT c.source AS file_id, c.target AS src
+               FROM edges c
+               JOIN nodes f ON f.id = c.source
+               JOIN nodes v ON v.id = c.target
+              WHERE c.kind = 'contains'
+                AND f.kind = 'file'
+                AND v.kind IN ('variable', 'constant')
+         ),
+         runs AS (
+             SELECT t.file_id AS id, COUNT(*) AS calls
+               FROM tops t
+               JOIN edges e ON e.source = t.src
+              WHERE e.kind IN ('calls', 'instantiates')
+           GROUP BY t.file_id
          ),
          cand AS (
              SELECT r.id AS id, n.file_path AS fp, r.calls AS calls
@@ -2736,6 +2785,29 @@ export class QueryBuilder {
    *      restricted to the candidate list: the file that imports it is usually
    *      not itself a candidate.
    *
+   * ### Interface MEMBERS are transparent to all four conditions
+   *
+   * A `method_signature` / `property_signature` inside an interface enters the
+   * graph as a `method` / `property` node (#1638). Read literally that would
+   * break every condition here at once: condition 2 sees non-type kinds and
+   * stops flagging, and — worse, because it is silent — condition 4 starts
+   * seeing inbound `calls` edges the moment a call site through the shim's API
+   * finally has a signature to land on. An ambient `.d.ts` would quietly lose
+   * its damping precisely BECAUSE the platform API it declares is widely used.
+   *
+   * So an interface-owned member is treated the way `parameter` already is: it
+   * neither qualifies, disqualifies, nor counts as inbound dependency. That is
+   * not a new judgement call, it is what keeps the rule measuring what it was
+   * measured on — before #1638 these nodes did not exist, so excluding them
+   * reproduces the 0–4% flag rate the thresholds above were tuned against. It
+   * is also the semantically right answer: a signature with no body is on the
+   * same side of the line as the interface that owns it, and a call edge
+   * landing on one is still not a file that can answer a flow question.
+   *
+   * The interface ITSELF is untouched: the `references` edges an importing
+   * module aims at `UploadStorage` still disqualify the file under (4), which
+   * is what keeps a depended-on `types.ts` out of the flag.
+   *
    * Bounded-lookup like {@link getGeneratedPathsAmong}: callers hold a ranked
    * candidate list, so this is a partial-index probe over a handful of paths.
    */
@@ -2751,14 +2823,15 @@ export class QueryBuilder {
       // things the file declares, so they neither qualify nor disqualify.
       const rows = this.db
         .prepare(`
-          SELECT file_path,
-                 SUM(CASE WHEN kind NOT IN ('file','import','export','parameter')
+          SELECT n.file_path AS file_path,
+                 SUM(CASE WHEN n.kind NOT IN ('file','import','export','parameter')
+                           AND NOT ${IS_INTERFACE_MEMBER('n')}
                           THEN 1 ELSE 0 END) AS declared,
-                 SUM(CASE WHEN kind IN ('interface','type_alias','enum','enum_member','namespace')
+                 SUM(CASE WHEN n.kind IN ('interface','type_alias','enum','enum_member','namespace')
                           THEN 1 ELSE 0 END) AS typeDeclared
-          FROM nodes
-          WHERE file_path IN (${placeholders})
-          GROUP BY file_path
+          FROM nodes n
+          WHERE n.file_path IN (${placeholders})
+          GROUP BY n.file_path
         `)
         .all(...chunk) as Array<{ file_path: string; declared: number; typeDeclared: number }>;
       let candidates = rows
@@ -2775,17 +2848,22 @@ export class QueryBuilder {
         );
         candidates = candidates.filter((p) => !hit.has(p));
       };
-      // (3) originates behaviour
+      // (3) originates behaviour — a signature has no body to originate from,
+      // so an edge attributed to one is not evidence about this file.
       disqualify(`
         SELECT DISTINCT n.file_path AS file_path
         FROM edges e JOIN nodes n ON n.id = e.source
         WHERE e.kind IN ('calls','instantiates') AND n.file_path IN ($IN$)
+          AND NOT ${IS_INTERFACE_MEMBER('n')}
       `);
-      // (4) something outside the file depends on it
+      // (4) something outside the file depends on it — but a call that lands on
+      // an interface's own signature is a use of the API, not a dependency on
+      // this file's structure. The edges aimed at the interface still count.
       disqualify(`
         SELECT DISTINCT t.file_path AS file_path
         FROM edges e JOIN nodes t ON t.id = e.target JOIN nodes s ON s.id = e.source
         WHERE t.file_path IN ($IN$) AND s.file_path <> t.file_path
+          AND NOT ${IS_INTERFACE_MEMBER('t')}
       `);
       for (const path of candidates) found.add(path);
     }
@@ -3068,13 +3146,21 @@ export class QueryBuilder {
    * (§7a.2) — while the seek is O(batch) forever. `id` is the rowid alias, so
    * the enumeration order is identical to the OFFSET reader's.
    */
-  getUnresolvedReferencesBatchAfter(afterRowId: number, limit: number): UnresolvedReference[] {
-    if (!this.stmts.getUnresolvedBatchAfter) {
-      this.stmts.getUnresolvedBatchAfter = this.db.prepare(
-        "SELECT * FROM unresolved_refs WHERE status = 'pending' AND id > ? ORDER BY id LIMIT ?"
+  getUnresolvedReferencesBatchAfter(afterRowId: number, limit: number, prerequisites?: boolean): UnresolvedReference[] {
+    // Resolution prerequisites must be committed before dependent calls,
+    // even when an interrupted sync queued their rows in a different order
+    // from a clean index (#1577). Each phase still seeks by row id in bounded
+    // memory; the default preserves the public reader's original enumeration.
+    const key = prerequisites === undefined ? 'getUnresolvedBatchAfter'
+      : prerequisites ? 'getUnresolvedPrerequisitesAfter' : 'getUnresolvedDependentsAfter';
+    if (!this.stmts[key]) {
+      const filter = prerequisites === undefined ? ''
+        : ` AND reference_kind ${prerequisites ? 'IN' : 'NOT IN'} ('imports', 'extends', 'implements')`;
+      this.stmts[key] = this.db.prepare(
+        `SELECT * FROM unresolved_refs WHERE status = 'pending' AND id > ?${filter} ORDER BY id LIMIT ?`
       );
     }
-    const rows = this.stmts.getUnresolvedBatchAfter.all(afterRowId, limit) as UnresolvedRefRow[];
+    const rows = this.stmts[key]!.all(afterRowId, limit) as UnresolvedRefRow[];
     return rows.map((row) => ({
       fromNodeId: row.from_node_id,
       referenceName: row.reference_name,

+ 72 - 19
src/db/wal-valve.ts

@@ -42,10 +42,39 @@
  * the next pass covers everything, the WAL wraps on the following commit,
  * and the pause is the disk's honest catch-up cost — the correct terminal
  * mode when hardware genuinely can't keep up with the append rate.
+ *
+ * Fail-closed (#1539): if parked backfills cannot progress (a reader pinning
+ * frames) while the WAL is past the hard/file caps, the valve throws
+ * {@link WalValveAbortError} instead of releasing the writer. The previous
+ * "futility latch" disabled parking for 60s after consecutive give-ups so a
+ * pinned reader would not churn checkpoint workers — but that also let the
+ * WAL grow without a bound (observed 64 GiB on a kernel-scale daemon catch-up
+ * with the query pool holding read marks). Aborting with a clear error is the
+ * safe terminal mode; the caller closes readers / retries once the pin clears.
  */
 
 import type { DatabaseConnection } from './index';
 
+/**
+ * Thrown when the valve cannot checkpoint past its documented caps while a
+ * reader pins WAL frames (#1539). Callers (index/sync) should surface this and
+ * stop writing rather than risk unbounded disk growth.
+ */
+export class WalValveAbortError extends Error {
+  readonly code = 'WAL_VALVE_ABORT' as const;
+  readonly walBytes: number;
+  readonly fileCapBytes: number;
+  readonly hardBytes: number;
+
+  constructor(message: string, sizes: { walBytes: number; fileCapBytes: number; hardBytes: number }) {
+    super(message);
+    this.name = 'WalValveAbortError';
+    this.walBytes = sizes.walBytes;
+    this.fileCapBytes = sizes.fileCapBytes;
+    this.hardBytes = sizes.hardBytes;
+  }
+}
+
 /** Soft WAL-growth threshold (MB) that triggers an off-thread passive checkpoint. */
 const DEFAULT_WAL_VALVE_MB = 256;
 /** Hard cap = this × soft threshold; past it the writer pauses for a full backfill. */
@@ -93,17 +122,10 @@ export class WalCheckpointValve {
   private readonly fileCapBytes: number;
 
   /**
-   * Futility latch: consecutive backfill give-ups (a reader pinning the WAL)
-   * disable further writer pauses for a cooldown, so a pinned phase degrades
-   * to the pre-valve behavior (unbounded WAL, folded when the pinner exits)
-   * instead of burning a 20-pass checkpoint attempt — each pass a worker
-   * thread + fresh connection — at EVERY over-cap boundary. That churn is
-   * what turned a pinned kernel-scale resolution from slow into OOM-killed
-   * (§7a.1 run 1: 22GB WAL, exit 137 at an envelope the pre-fix build
-   * survived).
+   * Consecutive parked-backfill give-ups. Used only for diagnostics in the
+   * abort message — parking is never disabled (#1539 fail-closed).
    */
   private consecutiveGiveUps = 0;
-  private futileUntil = 0;
 
   constructor(
     private readonly db: DatabaseConnection,
@@ -173,7 +195,6 @@ export class WalCheckpointValve {
    */
   backpressure(): Promise<void> | null {
     if (this.pause) return this.pause;
-    if (Date.now() < this.futileUntil) return null; // pinned reader — parking is churn, not progress
     // Two independent triggers:
     //  - growth: un-backfilled BACKLOG past the hard cap (the original valve).
     //  - file size: a WAL can stay fully backfilled and still grow without
@@ -219,15 +240,31 @@ export class WalCheckpointValve {
   /**
    * With the writer parked on the returned promise, loop passive passes until
    * one reports the entire WAL backfilled (typically the second: the first
-   * drains the pass that was already running against a stale snapshot). Gives
-   * up after a bounded number of passes — e.g. a reader pinning the WAL —
-   * because unbounded WAL growth degrades; a wedged writer never recovers.
+   * drains the pass that was already running against a stale snapshot). After
+   * a bounded number of passes without a full backfill — e.g. a reader
+   * pinning the WAL — throws {@link WalValveAbortError} when still past the
+   * hard/file caps (#1539 fail-closed). Soft give-up under those caps is
+   * reserved for foldNow on a modest backlog that could not complete.
    */
   private async backfillFully(): Promise<void> {
     for (let i = 0; i < MAX_PAUSED_BACKFILL_PASSES; i++) {
       if (this.inflight) await this.inflight; // fold in the stale in-flight pass first
       const res = await this.db.checkpointWalPassive();
-      if (!res) return; // checkpoint machinery unavailable — don't spin
+      if (!res) {
+        // Machinery unavailable: fail closed past the documented caps (#1539),
+        // otherwise soft-return so a non-WAL / closing connection does not abort.
+        const walBytes = this.db.getWalSizeBytes();
+        const growth = this.growthBytes();
+        if (walBytes > this.fileCapBytes || growth > this.hardBytes) {
+          throw new WalValveAbortError(
+            `WAL checkpoint machinery unavailable while over the documented cap ` +
+              `(wal=${this.mb(walBytes)}, fileCap=${this.mb(this.fileCapBytes)}). ` +
+              `Aborting to avoid unbounded disk growth.`,
+            { walBytes, fileCapBytes: this.fileCapBytes, hardBytes: this.hardBytes }
+          );
+        }
+        return;
+      }
       this.log(`backfill pass ${i + 1}: busy=${res.busy} log=${res.log} checkpointed=${res.checkpointed} wal=${this.mb(this.db.getWalSizeBytes())}`);
       if (res.busy === 0 && res.log === res.checkpointed) {
         // Backfill complete AND we are at a parked barrier (backfillFully only
@@ -240,21 +277,37 @@ export class WalCheckpointValve {
         if (trunc) this.log(`truncate: busy=${trunc.busy} wal=${this.mb(this.db.getWalSizeBytes())}`);
         this.sizeAtLastFullBackfill = this.db.getWalSizeBytes();
         this.consecutiveGiveUps = 0;
-        this.futileUntil = 0;
         return;
       }
     }
     this.consecutiveGiveUps++;
-    if (this.consecutiveGiveUps >= 2) {
-      this.futileUntil = Date.now() + 60_000;
-    }
-    const msg = `backfill gave up after ${MAX_PAUSED_BACKFILL_PASSES} passes (streak ${this.consecutiveGiveUps}${this.futileUntil ? ', parking disabled 60s' : ''}) — a reader is pinning the WAL`;
+    const walBytes = this.db.getWalSizeBytes();
+    const growth = this.growthBytes();
+    const msg =
+      `backfill gave up after ${MAX_PAUSED_BACKFILL_PASSES} passes ` +
+      `(streak ${this.consecutiveGiveUps}) — a reader is pinning the WAL ` +
+      `(wal=${this.mb(walBytes)} growth=${this.mb(growth)} ` +
+      `hard=${this.mb(this.hardBytes)} fileCap=${this.mb(this.fileCapBytes)})`;
     this.log(msg);
     // Give-ups are rare and load-bearing for §7a.1-class diagnosis — surface
     // them on any timing-instrumented run, not just valve-debug ones.
     if (process.env.CODEGRAPH_SYNTH_TIMINGS && !process.env.CODEGRAPH_WAL_VALVE_DEBUG) {
       console.error(`[wal-valve] ${msg}`);
     }
+    // Fail closed (#1539): never release the writer past the documented caps
+    // when checkpoints cannot progress. The old futility latch disabled
+    // parking for 60s and allowed unbounded growth (64 GiB observed).
+    if (walBytes > this.fileCapBytes || growth > this.hardBytes) {
+      throw new WalValveAbortError(
+        `WAL checkpoint cannot progress while a reader pins frames ` +
+          `(wal=${this.mb(walBytes)}, growth=${this.mb(growth)}, ` +
+          `fileCap=${this.mb(this.fileCapBytes)}, hard=${this.mb(this.hardBytes)}, ` +
+          `give-ups=${this.consecutiveGiveUps}). Aborting to avoid unbounded disk growth. ` +
+          `Close concurrent readers (for example the MCP query pool) and retry, ` +
+          `or raise CODEGRAPH_WAL_VALVE_MB if the threshold is too tight for this project.`,
+        { walBytes, fileCapBytes: this.fileCapBytes, hardBytes: this.hardBytes }
+      );
+    }
   }
 
   private fire(): void {

+ 29 - 0
src/directory.ts

@@ -213,6 +213,8 @@ export function findIndexedSubprojectRoots(
   root: string,
   opts: { maxDepth?: number; max?: number } = {},
 ): string[] {
+  // A stray workspace manifest must not enable scanning home or broader roots (#1454).
+  if (unsafeIndexRootReason(root) !== null) return [];
   const maxDepth = opts.maxDepth ?? 4;
   const max = opts.max ?? 64;
   const out: string[] = [];
@@ -568,6 +570,33 @@ export function isStructuralPrompt(prompt: string): boolean {
   return hasStructuralKeyword(prompt) || extractCodeTokens(prompt).length > 0;
 }
 
+/**
+ * Claude Code persists `UserPromptSubmit` hook stdout above this many
+ * characters to a file and shows the model a ~2 KB preview instead (#1694).
+ * Measured on Claude Code 2.1.261; documented in the hooks reference as a
+ * 10,000-character cap on hook output strings.
+ */
+export const CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT = 10_000;
+
+/**
+ * Max characters of explore text injected by `codegraph prompt-hook` before
+ * truncation. Must stay under {@link CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT} so
+ * the host delivers the payload inline. 9,000 leaves ~1k for the
+ * `<codegraph_context>` wrapper and the `projectPath` nudge lines appended
+ * after the cap is applied.
+ */
+export const PROMPT_HOOK_INJECTION_MAX = 9_000;
+
+/**
+ * Cap explore text for the prompt-hook injection, preserving the existing
+ * "call codegraph_explore for the rest" notice when truncated.
+ */
+export function capPromptHookInjection(text: string, max = PROMPT_HOOK_INJECTION_MAX): string {
+  return text.length > max
+    ? `${text.slice(0, max)}\n…(truncated; call codegraph_explore for the rest)`
+    : text;
+}
+
 /**
  * What the front-load hook should do for a prompt issued from a directory.
  */

+ 15 - 1
src/extraction/cfml-extractor.ts

@@ -356,6 +356,13 @@ export class CfmlExtractor {
         .filter((e) => e.kind === 'contains' && e.source === innerFileNodeId)
         .map((e) => e.target)
     );
+    // Snippet-top-level non-callables: `var x = …` locals of the enclosing
+    // function that the fragment-as-module parse mints as declarations.
+    const localVarIds = new Set(
+      result.nodes
+        .filter((n) => topLevelIds.has(n.id) && (n.kind === 'variable' || n.kind === 'constant'))
+        .map((n) => n.id)
+    );
     for (const node of result.nodes) {
       if (node.kind === 'file') continue;
       node.startLine += startLine;
@@ -385,7 +392,14 @@ export class CfmlExtractor {
       // top-level script in a .cfm template, or any statement directly in
       // the snippet body) attribute to the filtered-out snippet file node by
       // default — redirect those (and any genuinely unset ones) to parentId.
-      if ((!ref.fromNodeId || ref.fromNodeId === innerFileNodeId) && parentId) ref.fromNodeId = parentId;
+      // Same for a snippet-top-level `var x = helper()`: the inner extractor
+      // parses the fragment as a whole module, so it mints a variable node and
+      // attributes the initializer's calls to it — but this fragment is a
+      // FUNCTION BODY, so `x` is a local and `helper` is the enclosing
+      // function's callee. Snippet-top-level FUNCTIONS keep their own calls.
+      if ((!ref.fromNodeId || ref.fromNodeId === innerFileNodeId || localVarIds.has(ref.fromNodeId)) && parentId) {
+        ref.fromNodeId = parentId;
+      }
       this.unresolvedReferences.push(ref);
     }
     for (const error of result.errors) {

+ 286 - 43
src/extraction/index.ts

@@ -99,6 +99,16 @@ export interface IndexResult {
    * counts. Only set by full-index runs (indexAll), not indexFiles/sync.
    */
   filesDiscovered?: number;
+  /**
+   * Files the scan saw but has no grammar for, tallied by extension. Only the
+   * degenerate case needs it: a project of unsupported files otherwise looks
+   * exactly like an empty one (0 files, state `complete`), so nothing tells the
+   * user — or an agent — that there was code here CodeGraph could not read
+   * (#1502). Counted during the scan's existing walk.
+   */
+  filesSkippedUnsupported?: number;
+  /** The most common unsupported extensions, biggest first. */
+  topUnsupportedExtensions?: { ext: string; count: number }[];
   nodesCreated: number;
   edgesCreated: number;
   errors: ExtractionError[];
@@ -301,17 +311,117 @@ function readGitignorePatterns(giPath: string): string {
   return kept.join('\n');
 }
 
+/**
+ * Resolve the repository GIT_DIR for `repoRoot` (a `.git` directory, or the
+ * target of a `.git` file pointer). Null when this isn't a git checkout.
+ */
+function resolveGitDir(repoRoot: string): string | null {
+  const gitPath = path.join(repoRoot, '.git');
+  let st: fs.Stats;
+  try {
+    st = fs.statSync(gitPath);
+  } catch {
+    return null;
+  }
+  if (st.isDirectory()) return gitPath;
+  if (!st.isFile()) return null;
+  try {
+    const raw = fs.readFileSync(gitPath, 'utf8').match(/^gitdir:\s*(.+)$/m)?.[1]?.trim();
+    if (!raw) return null;
+    return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(repoRoot, raw);
+  } catch {
+    return null;
+  }
+}
+
+/** Expand a leading `~/` the way git does for `core.excludesFile`. */
+function expandUserPath(p: string): string {
+  if (p === '~') return os.homedir();
+  if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
+  return p;
+}
+
+/**
+ * Root-relative exclude patterns from git sources that are NOT the root
+ * `.gitignore`: `.git/info/exclude` and `core.excludesFile`. Same semantics as
+ * the root `.gitignore`, so they merge into {@link buildDefaultIgnore}. Without
+ * these, the watcher / FS-walk scope silently diverged from
+ * `git ls-files --exclude-standard` (#1728).
+ */
+function readGitExcludeExtraPatterns(rootDir: string): string {
+  const chunks: string[] = [];
+  const gitDir = resolveGitDir(rootDir);
+  if (gitDir) {
+    const excludePath = path.join(gitDir, 'info', 'exclude');
+    if (fs.existsSync(excludePath)) {
+      const patterns = readGitignorePatterns(excludePath);
+      if (patterns) chunks.push(patterns);
+    }
+  }
+  try {
+    const configured = execFileSync(
+      'git',
+      ['-C', rootDir, 'config', '--get', 'core.excludesFile'],
+      { encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] },
+    ).trim();
+    if (configured) {
+      const abs = expandUserPath(configured);
+      if (fs.existsSync(abs)) {
+        const patterns = readGitignorePatterns(abs);
+        if (patterns) chunks.push(patterns);
+      }
+    }
+  } catch {
+    // No git, unset, or timeout — leave extras empty.
+  }
+  return chunks.join('\n');
+}
+
+/**
+ * Directories `git ls-files -o -i --exclude-standard --directory` reports as
+ * ignored-untracked. Seeded into {@link ScopeIgnore} so nested `.gitignore`
+ * effects (and any exclude-standard rule the flat matcher might miss) prune the
+ * watcher the same way the indexer skips them (#1728).
+ */
+function listGitIgnoredDirectories(rootDir: string): string[] {
+  try {
+    const out = execFileSync(
+      'git',
+      ['-C', rootDir, 'ls-files', '-z', '-o', '-i', '--exclude-standard', '--directory'],
+      {
+        encoding: 'utf8',
+        timeout: 60_000,
+        maxBuffer: 50 * 1024 * 1024,
+        stdio: ['ignore', 'pipe', 'ignore'],
+      },
+    );
+    const dirs: string[] = [];
+    for (const entry of out.split('\0')) {
+      if (!entry) continue;
+      dirs.push(entry.endsWith('/') ? entry : `${entry}/`);
+    }
+    return dirs;
+  } catch {
+    return [];
+  }
+}
+
 /**
  * An `ignore` matcher seeded with the built-in defaults, merged with the project's
- * root .gitignore so a negation there (e.g. `!vendor/`) overrides a default. Shared
- * by both enumeration paths so behavior is identical with or without git — and so
- * the defaults apply to tracked files too (committing a dependency dir doesn't make
- * it project code; the explicit `.gitignore` negation is the only opt-in).
+ * root .gitignore so a negation there (e.g. `!vendor/`) overrides a default, plus
+ * git's other root-relative exclude files (`.git/info/exclude`, `core.excludesFile`)
+ * so watcher / FS-walk scope matches `git ls-files --exclude-standard` (#1728).
+ * Shared by both enumeration paths so behavior is identical with or without git —
+ * and so the defaults apply to tracked files too (committing a dependency dir
+ * doesn't make it project code; the explicit `.gitignore` negation is the only
+ * opt-in).
  */
 export function buildDefaultIgnore(rootDir: string): Ignore {
   const ig = ignore().add(DEFAULT_IGNORE_PATTERNS);
   const rootGitignore = path.join(rootDir, '.gitignore');
   if (fs.existsSync(rootGitignore)) ig.add(readGitignorePatterns(rootGitignore));
+  const extra = readGitExcludeExtraPatterns(rootDir);
+  if (extra) ig.add(extra);
   return ig;
 }
 
@@ -630,15 +740,42 @@ function findNestedGitRepos(absDir: string, relPrefix: string): string[] {
 
 /**
  * Workspace-scope ignore matcher. Ordinary paths get the root's matcher
- * (built-in defaults + root `.gitignore`); paths inside an EMBEDDED repo get
- * that repo's own matcher (defaults + its root `.gitignore`) — the parent's
- * `.gitignore` hides a child repo from git, not from the index (#514). A
- * directory path (trailing slash) that is an ANCESTOR of an embedded root is
- * never ignored, so directory-pruning callers (the Linux per-directory
- * watcher) still descend to reach the embedded repos.
+ * (built-in defaults + root `.gitignore` + `.git/info/exclude` +
+ * `core.excludesFile`, plus directories `git ls-files --exclude-standard`
+ * reports as ignored); paths inside an EMBEDDED repo get that repo's own
+ * matcher — the parent's `.gitignore` hides a child repo from git, not from
+ * the index (#514). A directory path (trailing slash) that is an ANCESTOR of
+ * an embedded root is never ignored, so directory-pruning callers (the Linux
+ * per-directory watcher) still descend to reach the embedded repos.
  *
- * Single source of truth for indexer and watcher scope — they must not diverge.
+ * Shared by the indexer (scoped sync / skip checks) and the watcher so their
+ * scope cannot diverge from each other or from `git ls-files --exclude-standard`
+ * (#1728).
  */
+
+/**
+ * The grammars to preload for a file set.
+ *
+ * Path-only detection calls every `.h` file C, but parse-time detection reads
+ * the source and can reclassify it as C++ or Objective-C (`detectLanguage`
+ * with a `source` argument). Workers only ever get the grammars named here, so
+ * a header that turns out to be Objective-C in a project with no `.m` file
+ * found no parser and failed with `Failed to get parser for language: objc`
+ * (#1628). C++ was already covered; Objective-C was not.
+ */
+export function preloadLanguagesForFiles(
+  files: string[],
+  overrides?: Record<string, Language>
+): Language[] {
+  const languages = [...new Set(files.map((f) => detectLanguage(f, undefined, overrides)))];
+  if (languages.includes('c')) {
+    for (const ambiguous of ['cpp', 'objc'] as const) {
+      if (!languages.includes(ambiguous)) languages.push(ambiguous);
+    }
+  }
+  return languages;
+}
+
 export class ScopeIgnore {
   private embedded: Array<{ root: string; matcher: Ignore }>;
   private defaults: Ignore = defaultsOnlyIgnore();
@@ -710,8 +847,15 @@ export class ScopeIgnore {
 export function buildScopeIgnore(rootDir: string, embeddedRoots?: Iterable<string>): ScopeIgnore {
   const roots = embeddedRoots ? [...embeddedRoots] : discoverEmbeddedRepoRoots(rootDir);
   const include = loadIncludeMatcher(rootDir);
+  // Root matcher already has defaults + root `.gitignore` + info/exclude +
+  // core.excludesFile. Seed ignored-untracked directories from git so nested
+  // `.gitignore` effects prune the watcher identically to the indexer (#1728).
+  const rootMatcher = buildDefaultIgnore(rootDir);
+  for (const dir of listGitIgnoredDirectories(rootDir)) {
+    rootMatcher.add(dir);
+  }
   return new ScopeIgnore(
-    buildDefaultIgnore(rootDir),
+    rootMatcher,
     roots.map((root) => ({ root, matcher: buildDefaultIgnore(path.join(rootDir, root)) })),
     loadExcludeMatcher(rootDir),
     include,
@@ -791,9 +935,7 @@ export function discoverEmbeddedRepoRoots(rootDir: string): string[] {
     // same way collectGitFiles does, keeping watcher scope == indexer scope.
     // (#1031, #1033)
     try {
-      const staged = execFileSync(
-        'git',
-        ['ls-files', '-z', '-s', '--recurse-submodules'],
+      const staged = lsFilesStaged(
         { cwd: repoAbs, encoding: 'utf-8', timeout: 30000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
       );
       const repoIgnore = buildDefaultIgnore(repoAbs);
@@ -906,6 +1048,30 @@ function findIgnoredEmbeddedRepos(repoDir: string, includeIgnored: Ignore | null
   return repos;
 }
 
+/**
+ * `git ls-files -z -s`, expanding submodules where git allows it.
+ *
+ * `--recurse-submodules` could not be combined with `-s` before git 2.36:
+ * `builtin/ls-files.c` listed `show_stage` among the modes that die, and the
+ * check is unconditional — it does not look at whether the repo actually has
+ * submodules, so every call fails on older git. Ubuntu 22.04 LTS (2.34.1) and
+ * Debian 11 (2.30.2) are both below that line.
+ *
+ * Letting the throw escape cost far more than submodule expansion: it unwound
+ * the whole git-visible pass, so `includeIgnored`, gitlink recursion and the
+ * `codegraph.json` include allowlist silently stopped applying and files went
+ * missing from the index with no error (#1549). Retry without the flag instead
+ * — `-s` is the part that matters here, since gitlink detection reads the mode
+ * bits, and embedded repos are reached through the gitlink recursion anyway.
+ */
+function lsFilesStaged(gitOpts: Parameters<typeof execFileSync>[2]): string {
+  try {
+    return execFileSync('git', ['ls-files', '-z', '-s', '--recurse-submodules'], gitOpts) as unknown as string;
+  } catch {
+    return execFileSync('git', ['ls-files', '-z', '-s'], gitOpts) as unknown as string;
+  }
+}
+
 /**
  * Collect git-visible files (tracked + untracked, .gitignore-respected) from the
  * git repository rooted at `repoDir`, adding each to `files` with `prefix`
@@ -951,7 +1117,7 @@ function collectGitFiles(repoDir: string, prefix: string, files: Set<string>, em
   // on disk → those files are silently dropped from the index. (#541) With -s the
   // path follows a TAB after the `<mode> <object> <stage>` prefix.
   const gitlinkRels: string[] = [];
-  const tracked = execFileSync('git', ['ls-files', '-z', '-s', '--recurse-submodules'], gitOpts);
+  const tracked = lsFilesStaged(gitOpts);
   for (const entry of tracked.split('\0')) {
     if (!entry) continue;
     const tab = entry.indexOf('\t');
@@ -1050,6 +1216,10 @@ function getGitVisibleFiles(rootDir: string): Set<string> | null {
           { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
         );
         // Directory is gitignored by parent repo — fall back to filesystem walk
+        logDebug('project root is gitignored by a parent repo — falling back to filesystem walk', {
+          rootDir,
+          gitRoot,
+        });
         return null;
       } catch {
         // Not ignored — safe to use git ls-files
@@ -1074,7 +1244,20 @@ function getGitVisibleFiles(rootDir: string): Set<string> | null {
     // Git, but still wanted in the graph.)
     for (const f of collectIncludedFilesForRoot(rootDir)) visible.add(f);
     return visible;
-  } catch {
+  } catch (error) {
+    // Any failure here (git missing, a `git rev-parse`/`ls-files` timeout or
+    // buffer overrun under load, an unreadable repo, unsupported flag combo on
+    // older git, etc.) silently sent every caller to `scanDirectoryWalk` with
+    // zero signal that the fast git-delegated path was skipped — making reports
+    // like #1567 (nested-`.gitignore`-excluded `node_modules` walked into)
+    // hard to triage, since both ignore implementations look correct in
+    // isolation but there was no way to tell which one ran. Log it under the
+    // existing CODEGRAPH_DEBUG gate so a future report can confirm or rule out
+    // the fallback in one step.
+    logDebug('git-based file listing unavailable — falling back to filesystem walk', {
+      rootDir,
+      error: error instanceof Error ? error.message : String(error),
+    });
     return null;
   }
 }
@@ -1235,9 +1418,30 @@ export function scanDirectory(
  * Async variant of scanDirectory that yields to the event loop periodically,
  * allowing worker threads to receive and render progress messages.
  */
+/**
+ * What a scan saw but could not index, tallied by extension.
+ *
+ * Filled during the walk the scan already performs — a project of unsupported
+ * files is otherwise indistinguishable from an empty one, because unsupported
+ * extensions are filtered out at discovery and never counted anywhere (#1502).
+ */
+export interface ScanSkipStats {
+  /** Lowercased extension (with dot) → how many files carried it. */
+  unsupportedByExtension: Map<string, number>;
+}
+
+/** Record one file the scan declined to index. */
+function tallySkip(stats: ScanSkipStats | undefined, rel: string): void {
+  if (!stats) return;
+  const ext = path.extname(rel).toLowerCase();
+  if (!ext) return;
+  stats.unsupportedByExtension.set(ext, (stats.unsupportedByExtension.get(ext) ?? 0) + 1);
+}
+
 export async function scanDirectoryAsync(
   rootDir: string,
-  onProgress?: (current: number, file: string) => void
+  onProgress?: (current: number, file: string) => void,
+  stats?: ScanSkipStats
 ): Promise<string[]> {
   // Custom extension → language overrides from the project's codegraph.json.
   const overrides = loadExtensionOverrides(rootDir);
@@ -1255,12 +1459,14 @@ export async function scanDirectoryAsync(
         if (count % 100 === 0) {
           await new Promise<void>(r => setImmediate(r));
         }
+      } else {
+        tallySkip(stats, filePath);
       }
     }
     return files;
   }
 
-  return scanDirectoryWalk(rootDir, onProgress);
+  return scanDirectoryWalk(rootDir, onProgress, stats);
 }
 
 /**
@@ -1268,7 +1474,8 @@ export async function scanDirectoryAsync(
  */
 function scanDirectoryWalk(
   rootDir: string,
-  onProgress?: (current: number, file: string) => void
+  onProgress?: (current: number, file: string) => void,
+  stats?: ScanSkipStats
 ): string[] {
   const files: string[] = [];
   let count = 0;
@@ -1351,10 +1558,14 @@ function scanDirectoryWalk(
               walk(fullPath, active);
             }
           } else if (stat.isFile()) {
-            if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, overrides)) {
-              files.push(relativePath);
-              count++;
-              onProgress?.(count, relativePath);
+            if (!isIgnored(fullPath, false, active)) {
+              if (isSourceFile(relativePath, overrides)) {
+                files.push(relativePath);
+                count++;
+                onProgress?.(count, relativePath);
+              } else {
+                tallySkip(stats, relativePath);
+              }
             }
           }
         } catch {
@@ -1368,10 +1579,14 @@ function scanDirectoryWalk(
           walk(fullPath, active);
         }
       } else if (entry.isFile()) {
-        if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, overrides)) {
-          files.push(relativePath);
-          count++;
-          onProgress?.(count, relativePath);
+        if (!isIgnored(fullPath, false, active)) {
+          if (isSourceFile(relativePath, overrides)) {
+            files.push(relativePath);
+            count++;
+            onProgress?.(count, relativePath);
+          } else {
+            tallySkip(stats, relativePath);
+          }
         }
       }
     }
@@ -1475,10 +1690,19 @@ export class ExtractionOrchestrator {
    * same lifecycle the watcher's own matcher already has.
    */
   private scopedSyncMatcher(): ScopeIgnore {
-    const key = [PROJECT_CONFIG_FILENAME, '.gitignore']
+    // Bust when any root-level exclude source the matcher reads may have
+    // changed. Nested `.gitignore` edits force a full watcher sync, which
+    // clears this cache (see the full-reconcile branch in sync()).
+    const gitDir = resolveGitDir(this.rootDir);
+    const key = [
+      PROJECT_CONFIG_FILENAME,
+      '.gitignore',
+      gitDir ? path.join(gitDir, 'info', 'exclude') : '',
+    ]
       .map((name) => {
+        if (!name) return '-';
         try {
-          return String(fs.statSync(path.join(this.rootDir, name)).mtimeMs);
+          return String(fs.statSync(path.isAbsolute(name) ? name : path.join(this.rootDir, name)).mtimeMs);
         } catch {
           return '-';
         }
@@ -1609,6 +1833,7 @@ export class ExtractionOrchestrator {
     // early-run 5-10s single stalls were observed on 95k-file repos but never
     // attributed — these labels settle scan vs framework-detect vs grammars.
     const tScan = Date.now();
+    const skipStats: ScanSkipStats = { unsupportedByExtension: new Map() };
     const files = await scanDirectoryAsync(this.rootDir, (current, file) => {
       onProgress?.({
         phase: 'scanning',
@@ -1616,8 +1841,20 @@ export class ExtractionOrchestrator {
         total: 0,
         currentFile: file,
       });
-    });
+    }, skipStats);
     if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] scan: ${Date.now() - tScan}ms (${files.length} files)`);
+    /** Only meaningful when nothing was indexable — see IndexResult (#1502). */
+    const skipSummary = (): Pick<IndexResult, 'filesSkippedUnsupported' | 'topUnsupportedExtensions'> => {
+      let total = 0;
+      for (const n of skipStats.unsupportedByExtension.values()) total += n;
+      if (total === 0) return {};
+      const top = [...skipStats.unsupportedByExtension.entries()]
+        .map(([ext, count]) => ({ ext, count }))
+        .sort((a, b) => b.count - a.count || a.ext.localeCompare(b.ext))
+        .slice(0, 5);
+      return { filesSkippedUnsupported: total, topUnsupportedExtensions: top };
+    };
+
 
     // A re-index over an existing DB skips unchanged-hash files at the store,
     // which would preserve wiped zero-node rows (#1541) — drop them first so
@@ -1662,11 +1899,7 @@ export class ExtractionOrchestrator {
     await new Promise(resolve => setImmediate(resolve));
 
     // Detect needed languages and load grammars in the parse worker
-    const neededLanguages = [...new Set(files.map((f) => detectLanguage(f, undefined, overrides)))];
-    // .h files default to 'c' but may be C++ — ensure cpp grammar is loaded when c is needed
-    if (neededLanguages.includes('c') && !neededLanguages.includes('cpp')) {
-      neededLanguages.push('cpp');
-    }
+    const neededLanguages = preloadLanguagesForFiles(files, overrides);
 
     // Parse files on a pool of worker threads (keeps the main thread free for UI
     // and uses every core). Falls back to in-process parsing when the compiled
@@ -2013,6 +2246,7 @@ export class ExtractionOrchestrator {
         filesSkipped,
         filesErrored,
         filesDiscovered: total,
+        ...skipSummary(),
         nodesCreated: totalNodes,
         edgesCreated: totalEdges,
         errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
@@ -2169,6 +2403,7 @@ export class ExtractionOrchestrator {
       filesSkipped,
       filesErrored,
       filesDiscovered: total,
+      ...skipSummary(),
       nodesCreated: totalNodes,
       edgesCreated: totalEdges,
       errors,
@@ -2695,7 +2930,13 @@ export class ExtractionOrchestrator {
      * set is not exactly known (directory removals, event overflow): the full
      * scan-diff remains the ground truth those cases need (#1285).
      */
-    scopedPaths?: string[]
+    scopedPaths?: string[],
+    /**
+     * Writer-side WAL pressure valve (#1539). Called after every changed file
+     * is stored, when no extraction transaction is open, so a checkpoint can
+     * safely catch up before the next file grows the WAL further.
+     */
+    backpressure?: () => Promise<void> | null
   ): Promise<SyncResult> {
     await initGrammars(); // Initialize WASM runtime (grammars loaded lazily below)
     const startTime = Date.now();
@@ -2761,6 +3002,10 @@ export class ExtractionOrchestrator {
       filesChecked = unique.length;
       if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scoped: ${Date.now() - tSyncScan}ms (${unique.length} paths, ${trackedFiles.length} tracked)`);
     } else {
+      // Full reconcile: drop the memoized scope matcher so a nested
+      // `.gitignore` / exclude-standard change that forced this full sync is
+      // visible to the next scoped sync (#1728).
+      this.scopedMatcher = null;
       currentFiles = await scanDirectoryAsync(this.rootDir);
       if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scan: ${Date.now() - tSyncScan}ms (${currentFiles.length} files)`);
       filesChecked = currentFiles.length;
@@ -2874,12 +3119,7 @@ export class ExtractionOrchestrator {
     // Load only grammars needed for changed files
     if (filesToIndex.length > 0) {
       const overrides = loadExtensionOverrides(this.rootDir);
-      const neededLanguages = [...new Set(filesToIndex.map((f) => detectLanguage(f, undefined, overrides)))];
-      // .h files default to 'c' but may be C++ — ensure cpp grammar is loaded
-      if (neededLanguages.includes('c') && !neededLanguages.includes('cpp')) {
-        neededLanguages.push('cpp');
-      }
-      await loadGrammarsForLanguages(neededLanguages);
+      await loadGrammarsForLanguages(preloadLanguagesForFiles(filesToIndex, overrides));
     }
 
     // Index changed files
@@ -2895,6 +3135,9 @@ export class ExtractionOrchestrator {
 
       const result = await this.indexFile(filePath);
       nodesUpdated += result.nodes.length;
+
+      const pause = backpressure?.();
+      if (pause) await pause;
     }
 
     // Names whose definition set this sync changed: a `file\0name` pair present

+ 163 - 12
src/extraction/languages/c-cpp.ts

@@ -166,6 +166,35 @@ export function stripCppTemplateArgs(name: string): string {
   return out.trim();
 }
 
+
+/**
+ * Is this C++ `field_declaration` a pure-virtual method (`virtual int read(int key) = 0;`)?
+ * tree-sitter-cpp shapes those as a field_declaration whose declarator unwraps to a
+ * `function_declarator`, with the pure-virtual `= 0` as a DIRECT `number_literal` "0"
+ * child of the field_declaration (default-arg `= 0` lives inside parameter_declaration
+ * and must not match). Bodiless method prototypes (`int foo();`) and data members
+ * (`int x = 0;`) are excluded — prototypes usually have an out-of-line definition that
+ * already mints the method node; pure virtuals never do (#1727).
+ */
+export function isCppPureVirtualMethodDecl(node: SyntaxNode): boolean {
+  if (node.type !== 'field_declaration') return false;
+  let declarator: SyntaxNode | null = getChildByField(node, 'declarator');
+  if (!declarator) return false;
+  while (
+    declarator.type === 'pointer_declarator' ||
+    declarator.type === 'reference_declarator'
+  ) {
+    const inner: SyntaxNode | null =
+      getChildByField(declarator, 'declarator') || declarator.namedChild(0);
+    if (!inner) return false;
+    declarator = inner;
+  }
+  if (declarator.type !== 'function_declarator') return false;
+  return node.namedChildren.some(
+    (c: SyntaxNode) => c.type === 'number_literal' && c.text === '0'
+  );
+}
+
 /**
  * A function/method's return type lives in the `function_definition`'s `type`
  * field (`Metrics& Metrics::instance()` → `Metrics`). Constructors, destructors,
@@ -451,6 +480,54 @@ export function blankMetalAttributes(source: string): string {
   return source.replace(METAL_ATTRIBUTE_RE, (m) => ' '.repeat(m.length));
 }
 
+/**
+ * Hide C++ raw literals from the offset-preserving preParse scans (#1505).
+ * Neither macro-shaped text in a raw body nor its `)delim"` closer is code.
+ * Mask the whole literal with non-whitespace, non-paren tokens, keeping line
+ * endings, so even line-based scanners treat a multiline raw argument as opaque.
+ * Restore untouched bytes afterward; a real enclosing annotation can still be
+ * removed in full. The pipeline masks once, and individual paren blankers also
+ * use this helper so they are safe when called directly.
+ */
+function maskCppRawStrings(source: string): { source: string; restore: (blanked: string) => string } {
+  const unchanged = { source, restore: (blanked: string): string => blanked };
+  if (source.indexOf('R"') === -1) return unchanged;
+  // Skip comments and ordinary literals before looking for a raw opener. The
+  // char-literal boundary leaves numeric digit separators (1'000) alone.
+  const re = /\/\/[^\r\n]*|\/\*[\s\S]*?(?:\*\/|$)|\b(?:u8|[LuU])?R"([^ \t\v\f\r\n()\\]{0,16})\(|"(?:\\[\s\S]|[^"\\])*(?:"|$)|(?<!\w)(?:u8|[LuU])?'(?:\\[\s\S]|[^'\\])*(?:'|$)/g;
+  const spans: Array<{ start: number; end: number }> = [];
+  const parts: string[] = [];
+  let last = 0;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(source)) !== null) {
+    if (m[1] === undefined) continue;
+    const closer = `)${m[1]}"`;
+    const close = source.indexOf(closer, re.lastIndex);
+    // An unterminated raw literal owns the rest of the file too.
+    const end = close < 0 ? source.length : close + closer.length;
+    spans.push({ start: m.index, end });
+    parts.push(source.slice(last, m.index), source.slice(m.index, end).replace(/[^\r\n]/g, '\0'));
+    last = re.lastIndex = end;
+  }
+  if (spans.length === 0) return unchanged;
+  parts.push(source.slice(last));
+  const masked = parts.join('');
+  return {
+    source: masked,
+    restore(blanked): string {
+      // A length-changing rewrite cannot be restored at the original offsets.
+      if (blanked === masked || blanked.length !== source.length) return source;
+      const chars = blanked.split('');
+      for (const { start, end } of spans) {
+        for (let i = start; i < end; i++) {
+          if (chars[i] === '\0') chars[i] = source[i] as string;
+        }
+      }
+      return chars.join('');
+    },
+  };
+}
+
 /**
  * Blank annotation-style macro invocations that decorate a declaration but carry
  * NO terminating semicolon — the pervasive Unreal-Engine reflection markup
@@ -490,6 +567,8 @@ export function blankMetalAttributes(source: string): string {
  */
 export function blankCppAnnotationMacroCalls(source: string): string {
   if (!/^[ \t]*[A-Z][A-Z0-9_]{2,}\s*\(/m.test(source)) return source;
+  const rawStrings = maskCppRawStrings(source);
+  source = rawStrings.source;
   const chars = source.split('');
   const re = /^([ \t]*)([A-Z][A-Z0-9_]{2,})(\s*)\(/gm;
   let m: RegExpExecArray | null;
@@ -527,7 +606,7 @@ export function blankCppAnnotationMacroCalls(source: string): string {
     }
     re.lastIndex = end;
   }
-  return chars.join('');
+  return rawStrings.restore(chars.join(''));
 }
 
 /**
@@ -641,6 +720,8 @@ export function blankCppApiPrefixMacros(source: string): string {
 const CPP_INLINE_ANNOTATION_RE = /\b(?:UMETA|UPARAM|UE_DEPRECATED\w*)\s*\(/g;
 export function blankCppInlineAnnotationMacros(source: string): string {
   if (!/\b(?:UMETA|UPARAM|UE_DEPRECATED)/.test(source)) return source;
+  const rawStrings = maskCppRawStrings(source);
+  source = rawStrings.source;
   const chars = source.split('');
   const re = new RegExp(CPP_INLINE_ANNOTATION_RE.source, 'g');
   let m: RegExpExecArray | null;
@@ -671,7 +752,7 @@ export function blankCppInlineAnnotationMacros(source: string): string {
     }
     re.lastIndex = end;
   }
-  return chars.join('');
+  return rawStrings.restore(chars.join(''));
 }
 
 /**
@@ -795,6 +876,8 @@ function restoreDirectiveLines(original: string, blanked: string): string {
  * or by content, for CUDA living in `.h`/`.hpp` headers). Offset-preserving;
  * directive lines are restored at the end (see restoreDirectiveLines). */
 function preParseCppSource(source: string, filePath?: string): string {
+  const rawStrings = maskCppRawStrings(source);
+  source = rawStrings.source;
   // blankCLeadingAttrMacros runs AFTER the api-prefix blank so a stacked
   // `FMT_NORETURN FMT_API void f(…)` reduces to the `MACRO Ret name(` shape
   // it matches (the _API token is already spaces by then).
@@ -813,7 +896,7 @@ function preParseCppSource(source: string, filePath?: string): string {
   } else if (lower.endsWith('.cu') || lower.endsWith('.cuh') || looksLikeCudaSource(source)) {
     blanked = blankCudaConstructs(blanked);
   }
-  return restoreDirectiveLines(source, blanked);
+  return rawStrings.restore(restoreDirectiveLines(source, blanked));
 }
 
 /**
@@ -941,6 +1024,8 @@ const C_STMT_MACRO_KEYWORDS = new Set([
   'if', 'while', 'for', 'switch', 'return', 'do', 'else', 'sizeof',
 ]);
 export function blankCStatementMacroCalls(source: string): string {
+  const rawStrings = maskCppRawStrings(source);
+  source = rawStrings.source;
   const lines = source.split('\n');
   let changed = false;
   const content = (l: string): string => l.replace(/\r$/, '').trim();
@@ -1044,7 +1129,7 @@ export function blankCStatementMacroCalls(source: string): string {
     }
     changed = true;
   }
-  return changed ? lines.join('\n') : source;
+  return rawStrings.restore(changed ? lines.join('\n') : source);
 }
 
 /**
@@ -1215,8 +1300,8 @@ const C_PARAM_ANNOTATION_RE = new RegExp(
 );
 export function blankCParameterizedAnnotationMacros(source: string): string {
   if (source.indexOf('__') === -1) return source;
-  C_PARAM_ANNOTATION_RE.lastIndex = 0;
-  if (!C_PARAM_ANNOTATION_RE.test(source)) return source;
+  const rawStrings = maskCppRawStrings(source);
+  source = rawStrings.source;
   C_PARAM_ANNOTATION_RE.lastIndex = 0;
   let result = '';
   let last = 0;
@@ -1236,7 +1321,7 @@ export function blankCParameterizedAnnotationMacros(source: string): string {
     result += source.slice(last, start) + source.slice(start, end).replace(/[^\n\r]/g, ' ');
     last = end;
   }
-  return result + source.slice(last);
+  return rawStrings.restore(result + source.slice(last));
 }
 
 /**
@@ -1279,6 +1364,8 @@ const C_TYPE_ARG_OPENER_RE = /^(struct|union|enum)([ \t\r\n]+)([A-Za-z_]\w*)([ \
 const C_TYPE_ARG_SCAN_CAP = 600;
 export function blankCTypeKeywordArgs(source: string): string {
   if (!/\b(?:struct|union|enum)[ \t\r\n]/.test(source)) return source;
+  const rawStrings = maskCppRawStrings(source);
+  source = rawStrings.source;
   let chars: string[] | null = null;
   C_TYPE_ARG_HEAD_RE.lastIndex = 0;
   let m: RegExpExecArray | null;
@@ -1346,7 +1433,7 @@ export function blankCTypeKeywordArgs(source: string): string {
       atArgStart = false;
     }
   }
-  return chars ? chars.join('') : source;
+  return rawStrings.restore(chars ? chars.join('') : source);
 }
 
 /**
@@ -1374,6 +1461,8 @@ export function blankCTypeKeywordArgs(source: string): string {
 const C_PREFIXED_DECL_MACRO_RE = /^[ \t]*(?:static|extern)[ \t]+[A-Z][A-Z0-9_]{2,}[ \t]*\(/;
 export function blankCFileScopePrefixedDeclMacros(source: string): string {
   if (!/^[ \t]*(?:static|extern)[ \t]+[A-Z]/m.test(source)) return source;
+  const rawStrings = maskCppRawStrings(source);
+  source = rawStrings.source;
   const lines = source.split('\n');
   let changed = false;
   for (let i = 0; i < lines.length; i++) {
@@ -1408,7 +1497,7 @@ export function blankCFileScopePrefixedDeclMacros(source: string): string {
     lines[i] = line.replace(/[^\n\r]/g, ' ');
     changed = true;
   }
-  return changed ? lines.join('\n') : source;
+  return rawStrings.restore(changed ? lines.join('\n') : source);
 }
 
 /**
@@ -1515,8 +1604,60 @@ export function blankCNamedVariadicDefineDots(source: string): string {
  * C-detected headers in CUDA projects (llm.c keeps `__device__` helpers and
  * kernel prototypes in plain `.h`) — the same content-gated CUDA blank as
  * C++. Offset-preserving. */
+/**
+ * Blank the argument list of a statement-level `MACRO( … );` call whose
+ * arguments are designated initializers — betaflight's
+ *
+ *     RESET_CONFIG(pidProfile_t, pidProfile,
+ *         .pid = { [PID_ROLL] = PID_ROLL_DEFAULT, … },
+ *         .pidSumLimit = PIDSUM_LIMIT,
+ *         …
+ *     );
+ *
+ * tree-sitter-c has no rule for `.field = value` as a call argument. Even a
+ * small statement-level `M(a, b, .x = 1, .y = { 1, 2 },);` with a trailing
+ * comma recovers by extending the enclosing `function_definition` to EOF —
+ * the next function vanishes and later ones nest under the first (#1729 —
+ * 310 functions in 73 files on a betaflight tree, which name matching then
+ * treated as unreachable closures). Emptying the argument list to spaces,
+ * newlines kept, leaves `RESET_CONFIG(\n\n…\n);` — a call the grammar parses
+ * cleanly — at the cost of the references inside the initializer, which the
+ * broken parse was not yielding either. Statement-level only (`);` follows),
+ * macro-cased name only, offsets preserved. Runs before the kernel route
+ * point, so both the wasm and kernel C arms see the same bytes.
+ */
+export function blankCDesignatedMacroArgs(source: string): string {
+  if (source.indexOf('=') === -1) return source;
+  const rawStrings = maskCppRawStrings(source);
+  source = rawStrings.source;
+  const out = source.split('');
+  const re = /^[ \t]*([A-Z_][A-Z0-9_]*)\s*\(/gm;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(source))) {
+    const open = m.index + m[0].length - 1;
+    let depth = 1;
+    let i = open + 1;
+    for (; i < source.length && depth > 0; i++) {
+      const c = source[i];
+      if (c === '(') depth++;
+      else if (c === ')') depth--;
+    }
+    if (depth !== 0) continue;
+    const close = i - 1;
+    const args = source.slice(open + 1, close);
+    // A designator at argument depth: `.name =` or `[index] =`.
+    if (!/(^|[,{(\s])(\.[A-Za-z_]\w*|\[[^\]]+\])\s*=[^=]/.test(args)) continue;
+    if (!/^\s*;/.test(source.slice(close + 1))) continue;
+    for (let k = open + 1; k < close; k++) if (out[k] !== '\n') out[k] = ' ';
+    re.lastIndex = close;
+  }
+  return rawStrings.restore(out.join(''));
+}
+
 function preParseCSource(source: string): string {
-  const inner = blankCKernelAnnotations(blankCCplusplusGuardBodies(source));
+  const rawStrings = maskCppRawStrings(source);
+  source = rawStrings.source;
+  const inner = blankCDesignatedMacroArgs(blankCKernelAnnotations(blankCCplusplusGuardBodies(source)));
   let blanked = blankCLeadingAttrMacros(
     blankLoneMacroLines(
       blankCStatementMacroCalls(
@@ -1541,7 +1682,7 @@ function preParseCSource(source: string): string {
   if (looksLikeCudaSource(blanked)) blanked = blankCudaConstructs(blanked);
   // The named-variadic `#define` pass runs AFTER the directive restore — it
   // deliberately edits directive lines (see its doc comment).
-  return blankCNamedVariadicDefineDots(restoreDirectiveLines(source, blanked));
+  return rawStrings.restore(blankCNamedVariadicDefineDots(restoreDirectiveLines(source, blanked)));
 }
 
 export const cppExtractor: LanguageExtractor = {
@@ -1559,7 +1700,17 @@ export const cppExtractor: LanguageExtractor = {
   // get picked as the blast-radius representative over — the single real
   // definition, exactly as bodiless struct/enum specifiers are already skipped. (#1093)
   skipBodilessClass: true,
-  methodTypes: ['function_definition'],
+  // `function_definition` covers inline / out-of-line bodies; `field_declaration`
+  // covers pure-virtual methods (`virtual int read(int key) = 0;`), which have no
+  // body and would otherwise mint no node — so calls through the abstract base and
+  // cpp-override synthesis had nothing to attach to (#1727). classifyMethodNode
+  // keeps ordinary data members / prototypes on the children-walk path.
+  methodTypes: ['function_definition', 'field_declaration'],
+  classifyMethodNode: (node) => {
+    if (node.type !== 'field_declaration') return 'method';
+    return isCppPureVirtualMethodDecl(node) ? 'method' : 'skip';
+  },
+  isAbstract: (node) => (isCppPureVirtualMethodDecl(node) ? true : undefined),
   interfaceTypes: [],
   structTypes: ['struct_specifier'],
   // C++ unions additionally carry member functions, which extract through the

+ 1 - 1
src/extraction/languages/javascript.ts

@@ -3,7 +3,7 @@ import type { LanguageExtractor } from '../tree-sitter-types';
 import { classifyTsClassMember } from './typescript';
 
 export const javascriptExtractor: LanguageExtractor = {
-  functionTypes: ['function_declaration', 'arrow_function', 'function_expression'],
+  functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'],
   classTypes: ['class_declaration'],
   methodTypes: ['method_definition', 'field_definition'],
   // JS `field_definition` ≙ TS `public_field_definition`: plain fields are

+ 161 - 29
src/extraction/languages/kotlin.ts

@@ -1,5 +1,5 @@
 import type { Node as SyntaxNode } from 'web-tree-sitter';
-import { getNodeText, getChildByField } from '../tree-sitter-helpers';
+import { getNodeText } from '../tree-sitter-helpers';
 import type { LanguageExtractor } from '../tree-sitter-types';
 
 /** Kotlin return types that can't be a chained-call receiver (no class to chain on). */
@@ -42,6 +42,99 @@ function extractKotlinReturnType(node: SyntaxNode, source: string): string | und
   return undefined;
 }
 
+/**
+ * A property's CODE children: the named child right after the `=` token, a
+ * `property_delegate` (`by lazy { … }`), and an accessor the grammar nested
+ * under the declaration (`val x: Int get() = compute()` — written on ONE line;
+ * an accessor on its own line parses as a SIBLING of the property and is not
+ * reachable from here). What stays unwalked is the declaration itself —
+ * modifiers, the `val`/`var` keyword, the name+type, and an extension
+ * receiver's type and type parameters. (Go's #693 fix walks the `value` field
+ * for the same reason; tree-sitter-kotlin exposes no fields at all, hence the
+ * `=` anchor.)
+ */
+function kotlinPropertyInitializers(node: SyntaxNode): SyntaxNode[] {
+  const out: SyntaxNode[] = [];
+  let afterEq = false;
+  for (let i = 0; i < node.childCount; i++) {
+    const c = node.child(i);
+    if (!c) continue;
+    if (!c.isNamed) {
+      if (c.type === '=') afterEq = true;
+      continue;
+    }
+    if (afterEq) {
+      out.push(c);
+      afterEq = false;
+    } else if (c.type === 'property_delegate' || c.type === 'getter' || c.type === 'setter') {
+      out.push(c);
+    }
+  }
+  return out;
+}
+
+
+/**
+ * A property's node kind, or null when the declaration mints no node at all:
+ * destructuring (`val (a, b) = …`), an unreadable name, or a local (one inside
+ * a function body / `init` block / lambda / accessor). Kind by enclosing scope:
+ * a singleton `object` / `companion object` — and a top-level property — holds
+ * *shared* values, so `val`→`constant` and `var`→`variable` (the Scala-object
+ * rule; a `const val` is just a val). A `class`/`interface`/`enum` instance
+ * `val`/`var` is per-instance state → `field` (never a value-ref target, like a
+ * Java instance `final`).
+ */
+function kotlinPropertyKind(
+  node: SyntaxNode,
+  source: string
+): 'field' | 'constant' | 'variable' | null {
+  const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration');
+  const nameNode = varDecl?.namedChildren.find((c) => c.type === 'simple_identifier');
+  if (!nameNode || !getNodeText(nameNode, source)) return null;
+
+  let scope: 'local' | 'const' | 'instance' = 'const';
+  for (let p = node.parent; p; p = p.parent) {
+    const pt = p.type;
+    if (
+      pt === 'function_body' || pt === 'function_declaration' ||
+      pt === 'lambda_literal' || pt === 'anonymous_initializer' ||
+      pt === 'control_structure_body' || pt === 'getter' || pt === 'setter'
+    ) { scope = 'local'; break; }
+    if (pt === 'companion_object' || pt === 'object_declaration') { scope = 'const'; break; }
+    if (pt === 'class_declaration') { scope = 'instance'; break; }
+  }
+  if (scope === 'local') return null;
+
+  const binding = node.namedChildren.find((c) => c.type === 'binding_pattern_kind');
+  const isVal = binding != null && getNodeText(binding, source) === 'val';
+  return scope === 'instance' ? 'field' : isVal ? 'constant' : 'variable';
+}
+
+/**
+ * Accessors written on their OWN line parse as SIBLINGS of the property, not as
+ * children of it (same-line ones nest — see kotlinPropertyInitializers). Walking
+ * back over any accessors between us and the declaration finds the property an
+ * accessor belongs to; null when this accessor stands alone (a grammar
+ * accident, or an accessor on a destructured/local declaration).
+ */
+function kotlinAccessorOwner(node: SyntaxNode): SyntaxNode | null {
+  for (let p = node.previousNamedSibling; p; p = p.previousNamedSibling) {
+    if (p.type === 'getter' || p.type === 'setter') continue;
+    return p.type === 'property_declaration' ? p : null;
+  }
+  return null;
+}
+
+/** The sibling accessors that follow a property declaration, in source order. */
+function kotlinFollowingAccessors(node: SyntaxNode): SyntaxNode[] {
+  const out: SyntaxNode[] = [];
+  for (let n = node.nextNamedSibling; n; n = n.nextNamedSibling) {
+    if (n.type !== 'getter' && n.type !== 'setter') break;
+    out.push(n);
+  }
+  return out;
+}
+
 /** Check if a node matches the `fun interface` misparse pattern */
 function isFunInterfaceNode(node: SyntaxNode): boolean {
   let hasFun = false;
@@ -88,48 +181,70 @@ export const kotlinExtractor: LanguageExtractor = {
     // Kotlin properties (`val` / `var` / `const val`). The name nests as
     // property_declaration → variable_declaration → simple_identifier, which the
     // generic variable/field path can't read — so nothing was extracted before.
-    // Kind by enclosing scope: a singleton `object` / `companion object` (and a
-    // top-level property) holds *shared* values — `val`→`constant`,
-    // `var`→`variable` (the Scala-object rule; a `const val` is a `val`). A
-    // `class`/`interface`/`enum` instance `val`/`var` is per-instance state →
-    // `field` (never a value-ref target, like a Java instance `final`). A
-    // property inside a function body / `init` block / lambda is a local and is
-    // skipped entirely.
+    // Kind comes from kotlinPropertyKind.
     if (node.type === 'property_declaration') {
       const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration');
       const nameNode = varDecl?.namedChildren.find((c) => c.type === 'simple_identifier');
-      if (!nameNode) return false; // destructuring `val (a,b)` etc. — leave to default
+      // Destructuring (`val (a, b) = makePair()`): no symbol is minted for the
+      // destructured names either way — declining just routes the node to
+      // extractField/extractVariable, which both find nothing for Kotlin and
+      // end in the same fn-ref scan. But the RHS is CODE, and it was vanishing
+      // whole. Consume the node here and walk it at the ENCLOSING scope (there
+      // is no symbol of its own to attribute it to).
+      if (!nameNode) {
+        for (const init of kotlinPropertyInitializers(node)) ctx.visitFunctionBody(init, '');
+        return true;
+      }
       const name = getNodeText(nameNode, ctx.source);
       if (!name) return false;
 
-      // Walk to the nearest enclosing definition: a function body / init / lambda
-      // means it's a local; `object`/`companion object` is a constant scope; a
-      // `class_declaration` (covers class/interface/enum) is an instance scope.
-      let scope: 'local' | 'const' | 'instance' = 'const';
-      for (let p = node.parent; p; p = p.parent) {
-        const pt = p.type;
-        if (
-          pt === 'function_body' || pt === 'function_declaration' ||
-          pt === 'lambda_literal' || pt === 'anonymous_initializer' ||
-          pt === 'control_structure_body' || pt === 'getter' || pt === 'setter'
-        ) { scope = 'local'; break; }
-        if (pt === 'companion_object' || pt === 'object_declaration') { scope = 'const'; break; }
-        if (pt === 'class_declaration') { scope = 'instance'; break; }
+      const kind = kotlinPropertyKind(node, ctx.source);
+      if (kind == null) {
+        // A local — no node is minted, but the initializer is still code. Walk
+        // it at the ENCLOSING scope: an `init { }` block's `val q = load()` is
+        // the CLASS calling load, and it used to disappear entirely (only the
+        // block's bare statements survived).
+        for (const init of kotlinPropertyInitializers(node)) ctx.visitFunctionBody(init, '');
+        return true;
       }
-      if (scope === 'local') return true; // a local — don't extract
 
       const binding = node.namedChildren.find((c) => c.type === 'binding_pattern_kind');
       const isVal = binding != null && getNodeText(binding, ctx.source) === 'val';
-      const kind = scope === 'instance' ? 'field' : isVal ? 'constant' : 'variable';
-
       const typeNode = node.childForFieldName('type');
       const sig = typeNode
         ? `${isVal ? 'val' : 'var'} ${name}: ${getNodeText(typeNode, ctx.source)}`
         : undefined;
-      ctx.createNode(kind, name, node, { signature: sig });
+      const created = ctx.createNode(kind, name, node, { signature: sig });
+      // Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go
+      // fix, ported to Kotlin): the hook consumes this subtree, so without an
+      // explicit walk a lambda / SAM / object initializer
+      // (`private val cb = Runnable { target() }` — the idiomatic Android
+      // callback field) contributed NO call edge at all, and everything reached
+      // only through such a callback looked like it had no callers.
+      // The property also OWNS any accessor written on its own line, which the
+      // grammar makes a following SIBLING rather than a child; those bodies used
+      // to attribute to the enclosing class. Consumed here so the accessor
+      // branch below can skip them without any cross-node state.
+      const inits = created
+        ? [...kotlinPropertyInitializers(node), ...kotlinFollowingAccessors(node)]
+        : [];
+      if (created && inits.length > 0) {
+        ctx.pushScope(created.id);
+        for (const init of inits) ctx.visitFunctionBody(init, created.id);
+        ctx.popScope();
+      }
       return true;
     }
 
+    // An own-line accessor already walked by its owning property above. The
+    // ownership test re-derives the property's kind rather than remembering it:
+    // a destructured or local declaration mints no node, so its accessors were
+    // NOT consumed and must keep falling through to the normal recursion.
+    if (node.type === 'getter' || node.type === 'setter') {
+      const owner = kotlinAccessorOwner(node);
+      return owner != null && kotlinPropertyKind(owner, ctx.source) != null;
+    }
+
     // Handle Kotlin `fun interface` declarations.
     // Tree-sitter-kotlin doesn't support `fun interface` syntax (Kotlin 1.4+).
     // It produces two different misparse patterns:
@@ -275,9 +390,26 @@ export const kotlinExtractor: LanguageExtractor = {
     return undefined;
   },
   getSignature: (node, source) => {
-    // Kotlin function signature: fun name(params): ReturnType
-    const params = getChildByField(node, 'function_value_parameters');
-    const returnType = getChildByField(node, 'type');
+    // Kotlin function signature: fun name(params): ReturnType. tree-sitter-kotlin
+    // exposes no field names, so both parts are found positionally, the way
+    // extractKotlinReturnType does (#1495): the `function_value_parameters`
+    // child, then the type node that follows it before the body.
+    let params: SyntaxNode | null = null;
+    let returnType: SyntaxNode | null = null;
+    for (let i = 0; i < node.namedChildCount; i++) {
+      const child = node.namedChild(i);
+      if (!child) continue;
+      if (child.type === 'function_value_parameters') {
+        params = child;
+        continue;
+      }
+      if (!params) continue;
+      if (child.type === 'function_body' || child.type === 'type_constraints') break;
+      if (child.type === 'user_type' || child.type === 'nullable_type' || child.type === 'function_type') {
+        returnType = child;
+        break;
+      }
+    }
     if (!params) return undefined;
     let sig = getNodeText(params, source);
     if (returnType) {

+ 4 - 1
src/extraction/languages/lua.ts

@@ -75,7 +75,10 @@ export const luaExtractor: LanguageExtractor = {
   typeAliasTypes: [],
   importTypes: [], // `require` is a function_call — handled in visitNode below
   callTypes: ['function_call'],
-  variableTypes: ['variable_declaration'], // see the `lua` branch in extractVariable
+  // Top-level assignments can introduce module members just as declarations do:
+  // `M.run = function() ... end`. The Lua branch in extractVariable ignores
+  // non-callable member assignments, but extracts function-valued targets.
+  variableTypes: ['variable_declaration', 'assignment_statement'],
   nameField: 'name',
   bodyField: 'body',
   paramsField: 'parameters',

+ 3 - 0
src/extraction/languages/rust.ts

@@ -81,6 +81,9 @@ export const rustExtractor: LanguageExtractor = {
   methodTypes: ['function_item', 'function_signature_item'],
   interfaceTypes: ['trait_item'],
   structTypes: ['struct_item'],
+  // `struct Unit;` is a unit struct — a complete definition with no body
+  // field, not a forward declaration. Rust has no forward declarations.
+  allowBodilessStruct: true,
   // Unions share struct member syntax and impl attachment, but retain their
   // distinct semantic kind in the graph.
   unionTypes: ['union_item'],

+ 10 - 0
src/extraction/languages/scala.ts

@@ -166,6 +166,16 @@ export const scalaExtractor: LanguageExtractor = {
 
       const created = ctx.createNode(kind, name, node, { signature: sig, visibility: extractVisibility(node) });
       if (created && typeNode) emitScalaTypeRefs(typeNode, created.id, ctx, ctx.source);
+      // Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go
+      // fix): the hook consumes this subtree and the dispatcher only scans it
+      // for function-as-value candidates, so `val cb = () => target()` — and
+      // even a plain `val x = compute()` — emitted no call edge at all.
+      const valueNode = node.childForFieldName('value');
+      if (created && valueNode) {
+        ctx.pushScope(created.id);
+        ctx.visitFunctionBody(valueNode, created.id);
+        ctx.popScope();
+      }
       return true;
     }
 

+ 9 - 2
src/extraction/languages/typescript.ts

@@ -39,10 +39,17 @@ export function classifyTsClassMember(node: SyntaxNode): 'method' | 'property' {
 }
 
 export const typescriptExtractor: LanguageExtractor = {
-  functionTypes: ['function_declaration', 'arrow_function', 'function_expression'],
+  functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'],
   classTypes: ['class_declaration', 'abstract_class_declaration'],
-  methodTypes: ['method_definition', 'public_field_definition'],
+  // `method_signature` is the interface/type-literal form of a method; without it
+  // an interface's members never enter the graph, so a `.d.ts` platform API has
+  // no declaration node for call sites to attach to (#1638). Java/C# don't need
+  // an equivalent — their grammars reuse `method_declaration`.
+  methodTypes: ['method_definition', 'public_field_definition', 'method_signature'],
   classifyMethodNode: classifyTsClassMember,
+  // The interface counterpart of `public_field_definition`. It carries no value,
+  // so it is always a property and never needs classifyMethodNode.
+  propertyTypes: ['property_signature'],
   interfaceTypes: ['interface_declaration'],
   structTypes: [],
   enumTypes: ['enum_declaration'],

+ 21 - 2
src/extraction/tree-sitter-types.ts

@@ -160,6 +160,8 @@ export interface LanguageExtractor {
   isAsync?: (node: SyntaxNode) => boolean;
   /** Check if node is static */
   isStatic?: (node: SyntaxNode) => boolean;
+  /** Check if a method/class is abstract (C++ pure virtual, Java abstract, …). Return true to set; undefined/false leaves the flag unset. */
+  isAbstract?: (node: SyntaxNode) => boolean | undefined;
   /** Check if variable declaration is a constant (const vs let/var) */
   isConst?: (node: SyntaxNode) => boolean;
   /**
@@ -185,6 +187,19 @@ export interface LanguageExtractor {
    * bodiless class IS complete (Kotlin `class Empty`, Scala `case object`). (#1093)
    */
   skipBodilessClass?: boolean;
+  /**
+   * Keep a bodiless struct node — it IS a complete definition, not a forward
+   * declaration. Set only for languages where a bodiless `struct` is complete:
+   * Rust's unit struct (`struct Unit;`). Leave unset for C/C++, where
+   * `struct Foo;` is a forward declaration.
+   *
+   * Opposite polarity from `skipBodilessClass` (#1093) because the defaults
+   * differ: a bodiless CLASS is kept unless a language opts into skipping,
+   * a bodiless STRUCT is skipped unless a language opts into keeping. The
+   * hardcoded C# `record_declaration` carve-out (#831) is the same situation
+   * predating this flag.
+   */
+  allowBodilessStruct?: boolean;
   /** NodeKind to use for interface-like declarations (Rust: 'trait'). Default: 'interface' */
   interfaceKind?: NodeKind;
 
@@ -221,9 +236,13 @@ export interface LanguageExtractor {
    * both callable and data members (#808): TS/JS class FIELDS
    * (`public_field_definition` / `field_definition`) are methods only when
    * their value is callable (`onClick = () => {}`); a plain field
-   * (`public fonts: Fonts;`, `count = 0`) is a property. Default: 'method'.
+   * (`public fonts: Fonts;`, `count = 0`) is a property. C++ also lists
+   * `field_declaration` in methodTypes so pure-virtual methods (`= 0`) can
+   * mint nodes (#1727); non-callable field_declarations return `'skip'` so
+   * the walker still descends (data-member initializers keep their call
+   * edges). Default: 'method'.
    */
-  classifyMethodNode?: (node: SyntaxNode) => 'method' | 'property';
+  classifyMethodNode?: (node: SyntaxNode) => 'method' | 'property' | 'skip';
 
   /**
    * Resolve the body node for a function/method/class when it's not a child field.

+ 396 - 45
src/extraction/tree-sitter.ts

@@ -52,6 +52,20 @@ const RTK_HOOK_NAME_RE = /^use[A-Z][A-Za-z0-9]*(?:Query|Mutation)$/;
  *  initialized with one of these is a component, not a constant (#841). */
 const REACT_COMPONENT_HOCS = new Set(['forwardRef', 'memo', 'React.forwardRef', 'React.memo']);
 
+/**
+ * Method node types that spell a SIGNATURE — a declaration with no body (#1638).
+ *
+ * They are a method of whatever type declares them and nothing on their own, so
+ * they must not take `extractMethod`'s "no class-like parent, so treat it as a
+ * free function" fallback. The other `methodTypes` can: a `method_definition`
+ * outside a class really is a function. This one appears outside a class only
+ * inside a type literal (`type Handle = { stop(): void }`), whose members
+ * `extractTypeAlias` already extracts and attaches to the alias (#359) — take
+ * the fallback and the file gains a phantom top-level `function stop` beside
+ * the real `Handle::stop`.
+ */
+const SIGNATURE_METHOD_NODE_TYPES = new Set(['method_signature']);
+
 /** Vue store collections whose object-literal members are the symbols an agent
  *  looks for. Extracted as function nodes so `actions`/`mutations`/`getters` are
  *  findable + readable (the foundation under any later dispatch-bridge synth). */
@@ -171,7 +185,7 @@ function extractNameRaw(node: SyntaxNode, source: string, extractor: LanguageExt
   // not from identifiers in their body. Without this, single-expression arrow
   // functions like `const fn = () => someIdentifier` get named "someIdentifier"
   // instead of "fn", because the fallback below finds the body identifier.
-  if (node.type === 'arrow_function' || node.type === 'function_expression') {
+  if (node.type === 'arrow_function' || node.type === 'function_expression' || node.type === 'generator_function') {
     return '<anonymous>';
   }
 
@@ -388,6 +402,28 @@ const LITERAL_RECEIVER_TYPES = new Set([
   'dictionary', 'dict_literal', 'object', 'tuple', 'set',
 ]);
 
+/**
+ * Languages whose member calls go through the TS/JS grammars.
+ */
+const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']);
+
+/** Receiver node types (TS/JS grammars) that continue a member chain downward. */
+const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']);
+
+/**
+ * Identifier-rooted member chains have no inferred property type (#1566),
+ * including host API chains (#1707). Keep the existing `window.MyNamespace`
+ * escape for project globals; call-result and `this` receivers have their own
+ * paths and are outside this guard.
+ */
+function isUnresolvedTsJsChain(node: SyntaxNode, source: string): boolean {
+  let cur: SyntaxNode | null = node;
+  while (cur && TS_JS_CHAIN_RECEIVER_TYPES.has(cur.type)) {
+    cur = getChildByField(cur, 'object');
+  }
+  return !!cur && cur.type === 'identifier' && getNodeText(cur, source) !== 'window';
+}
+
 /**
  * React hooks that bind a NAME to a handler function (`const onPress =
  * useCallback(() => {…}, [])`). The arrow inside is extracted as a function
@@ -548,6 +584,18 @@ export class TreeSitterExtractor {
 
       if (packageNodeId) this.nodeStack.pop();
       this.nodeStack.pop();
+
+      // hasError is routine for several grammars; warn only when no symbols survived.
+      const symbolCount = this.nodes.filter((n) => n.kind !== 'file').length;
+      if (this.tree?.rootNode.hasError && symbolCount === 0) {
+        this.errors.push({
+          message:
+            `${this.filePath}: parse produced no symbols (tree has errors) — ` +
+            `the file is indexed but contributes nothing to the graph`,
+          severity: 'warning',
+          code: 'parse_error',
+        });
+      }
     } catch (error) {
       const msg = error instanceof Error ? error.message : String(error);
 
@@ -613,6 +661,7 @@ export class TreeSitterExtractor {
     const nodeType = node.type;
     if (depth > 0 && (
       this.extractor?.functionTypes.includes(nodeType) ||
+      ((this.language === 'lua' || this.language === 'luau') && nodeType === 'function_definition') ||
       nodeType === 'arrow_function' ||
       nodeType === 'function_expression' ||
       nodeType === 'lambda_literal' ||
@@ -1037,12 +1086,23 @@ export class TreeSitterExtractor {
       this.extractClass(node);
       skipChildren = true;
     }
-    // Check for method declarations (only if not already handled by functionTypes)
-    else if (this.extractor.methodTypes.includes(nodeType)) {
+    // Check for method declarations (only if not already handled by functionTypes).
+    // A bodiless SIGNATURE only counts as one where a type declares it — see
+    // SIGNATURE_METHOD_NODE_TYPES for what falling through would otherwise mint.
+    else if (
+      this.extractor.methodTypes.includes(nodeType)
+      && (!SIGNATURE_METHOD_NODE_TYPES.has(nodeType) || this.isInsideClassLikeNode())
+    ) {
       // TS/JS class fields parse as a methodTypes node; only function-valued
       // fields are methods — a plain field (`public fonts: Fonts;`) is a
-      // property (#808). classifyMethodNode is absent for other languages.
-      if (this.extractor.classifyMethodNode?.(node) === 'property') {
+      // property (#808). C++ lists `field_declaration` so pure-virtual methods
+      // mint nodes (#1727); non-callable ones return 'skip' and fall through to
+      // the children walk. classifyMethodNode is absent for other languages.
+      const methodClass = this.extractor.classifyMethodNode?.(node) ?? 'method';
+      if (methodClass === 'skip') {
+        // Not a method — leave skipChildren false so data-member initializers
+        // still contribute call/instantiation edges under the enclosing class.
+      } else if (methodClass === 'property') {
         const propNode = this.extractProperty(node);
         // Walk the initializer so its calls/instantiations attribute to the
         // property (`history = createHistory()` → history calls
@@ -1293,22 +1353,16 @@ export class TreeSitterExtractor {
     else if (nodeType === 'impl_item') {
       this.extractRustImplItem(node);
     }
-    // TypeScript interface members: property_signature (`foo: T`, `foo?: T`)
-    // and method_signature (`foo(arg: A): R`) both carry type annotations the
-    // interface walker would otherwise drop. Extract them as `references`
-    // edges from the interface so resolvers can wire callers/impact for
-    // types that only appear in interface members.
-    else if (
-      (nodeType === 'property_signature' || nodeType === 'method_signature') &&
-      this.isInsideClassLikeNode() &&
-      this.TYPE_ANNOTATION_LANGUAGES.has(this.language)
-    ) {
-      const parentId = this.nodeStack[this.nodeStack.length - 1];
-      if (parentId) {
-        this.extractTypeAnnotations(node, parentId);
-      }
-      // don't skipChildren — nested signatures still need traversal
-    }
+    // NOTE: `property_signature` / `method_signature` used to be handled here,
+    // hanging their type annotations off the ENCLOSING INTERFACE — the only
+    // anchor available while the members themselves went unextracted. Since
+    // #1638 they are in the TS extractor's `methodTypes` / `propertyTypes`, so
+    // the branches above claim them first (under the same `isInsideClassLikeNode`
+    // guard this branch had, so nothing it used to reach is now missed) and this
+    // one was dead. The `references` edges survive — `extractMethod` and
+    // `extractProperty` each call `extractTypeAnnotations` — but now hang off
+    // the member, which is the more precise anchor: `Api::fetch → PageId` says
+    // which member wants the type, where `Api → PageId` only said the file did.
 
     // Visit children (unless the extract method already visited them)
     if (!skipChildren) {
@@ -1549,6 +1603,8 @@ export class TreeSitterExtractor {
     // — SvelteKit actions). Inline-object arrows reached by the general walker
     // get no override, so they still fall through to the <anonymous> skip below.
     let name = nameOverride ?? extractName(node, this.source, this.extractor);
+    // A CommonJS export assignment names the function it holds — see below.
+    let commonJsExport = false;
     // For arrow functions and function expressions assigned to variables,
     // resolve the name from the parent variable_declarator.
     // e.g. `export const useAuth = () => { ... }` — the arrow_function node
@@ -1556,7 +1612,7 @@ export class TreeSitterExtractor {
     if (
       !nameOverride &&
       name === '<anonymous>' &&
-      (node.type === 'arrow_function' || node.type === 'function_expression')
+      (node.type === 'arrow_function' || node.type === 'function_expression' || node.type === 'generator_function')
     ) {
       const parent = node.parent;
       if (parent?.type === 'variable_declarator') {
@@ -1564,6 +1620,18 @@ export class TreeSitterExtractor {
         if (varName) {
           name = getNodeText(varName, this.source);
         }
+      } else if (parent?.type === 'assignment_expression') {
+        // `exports.getItems = async (req, res) => {…}` / `module.exports.x =
+        // function () {…}` — the CommonJS controller style. The function is
+        // anonymous only syntactically: the export property is the name every
+        // `router.get('/items', getItems)` resolves. Without a node the handler
+        // is invisible to callers/impact and its calls attribute to the file
+        // (#1675). Same treatment `const X = () => {}` already gets.
+        const exportName = this.commonJsExportName(parent, node);
+        if (exportName) {
+          name = exportName;
+          commonJsExport = true;
+        }
       }
     }
     if (name === '<anonymous>') {
@@ -1594,7 +1662,7 @@ export class TreeSitterExtractor {
     const docstring = getPrecedingDocstring(node, this.source);
     const signature = this.extractor.getSignature?.(node, this.source);
     const visibility = this.extractor.getVisibility?.(node);
-    const isExported = this.extractor.isExported?.(node, this.source);
+    const isExported = commonJsExport || this.extractor.isExported?.(node, this.source);
     const isAsync = this.extractor.isAsync?.(node);
     const isStatic = this.extractor.isStatic?.(node);
     const returnType = this.extractor.getReturnType?.(node, this.source);
@@ -1798,6 +1866,9 @@ export class TreeSitterExtractor {
     const visibility = this.extractor.getVisibility?.(node);
     const isAsync = this.extractor.isAsync?.(node);
     const isStatic = this.extractor.isStatic?.(node);
+    // Only persist abstract when true — a false return must not mint `isAbstract: false`
+    // on every ordinary method (breaks kernel↔wasm parity JSON equality).
+    const isAbstract = this.extractor.isAbstract?.(node) ? true : undefined;
     const returnType = this.extractor.getReturnType?.(node, this.source);
     const extraProps: Partial<Node> = {
       docstring,
@@ -1805,6 +1876,7 @@ export class TreeSitterExtractor {
       visibility,
       isAsync,
       isStatic,
+      isAbstract,
       returnType,
     };
     if (receiverType) {
@@ -1902,8 +1974,16 @@ export class TreeSitterExtractor {
     // Skip forward declarations and type references (no body = not a definition)
     // — EXCEPT C# positional records (`record struct M(decimal Amount);`),
     // complete definitions with no body block. (#831)
+    //
+    // `allowBodilessStruct` is the per-language escape hatch for the same
+    // situation: a bodiless struct that IS a complete definition (Rust's unit
+    // struct `struct Unit;`). Opposite polarity from `skipBodilessClass`
+    // (#1093) because the two defaults differ — a bodiless CLASS is kept
+    // unless a language opts into skipping, a bodiless STRUCT is skipped
+    // unless a language opts into keeping.
     const body = getChildByField(node, this.extractor.bodyField);
-    if (!body && node.type !== 'record_declaration') return;
+    if (!body && node.type !== 'record_declaration' && !this.extractor.allowBodilessStruct)
+      return;
 
     const name = extractName(node, this.source, this.extractor);
     const docstring = getPrecedingDocstring(node, this.source);
@@ -2036,8 +2116,18 @@ export class TreeSitterExtractor {
     // and the initializer VALUE, which the generic finder below would
     // wrongly pick — so fields use the type field only (#808). Other
     // languages (C# property_declaration) keep the generic scan.
+    //
+    // A `property_signature` (an interface member, #1638) carries a `type`
+    // field and no value, so it reads the type field too. It cannot take the
+    // generic scan: that scan's exclusion list covers `identifier` but not the
+    // `property_identifier` an interface member is named with, so it stops on
+    // the name and `interface Stats { counts: Record<string, number> }` yields
+    // `signature: "counts counts"` instead of the type. Named explicitly
+    // rather than folded into the field test so no other language's
+    // `property_declaration` moves off the generic scan.
     const isTsJsField =
-      node.type === 'public_field_definition' || node.type === 'field_definition';
+      node.type === 'public_field_definition' || node.type === 'field_definition'
+      || node.type === 'property_signature';
     const typeNode = isTsJsField
       ? getChildByField(node, 'type')
       : node.namedChildren.find(
@@ -2167,6 +2257,21 @@ export class TreeSitterExtractor {
           // and the language-aware path in `extractTypeAnnotations` descends
           // into that wrapper (#381).
           this.extractTypeAnnotations(node, fieldNode.id);
+          // Walk the initializer ATTRIBUTED to the declared field (#693, the
+          // Go fix; same shape as the TS/JS class-field walk above). The
+          // dispatcher only scanned this subtree for function-as-value
+          // candidates, so a lambda / method reference / anonymous class in
+          // `private final Runnable r = () -> target();` contributed NO call
+          // edge at all and `target` looked callerless. Keyed on the `value`
+          // FIELD, which only Java's `variable_declarator` carries — C#,
+          // VB.NET and PHP spell their initializer differently and are
+          // deliberately untouched here.
+          const valueNode = getChildByField(decl, 'value');
+          if (valueNode) {
+            this.nodeStack.push(fieldNode.id);
+            this.visitFunctionBody(valueNode, fieldNode.id);
+            this.nodeStack.pop();
+          }
         }
       }
     } else {
@@ -2623,7 +2728,7 @@ export class TreeSitterExtractor {
             }
             const name = getNodeText(nameNode, this.source);
             // Arrow functions / function expressions: extract as function instead of variable
-            if (valueNode && (valueNode.type === 'arrow_function' || valueNode.type === 'function_expression')) {
+            if (valueNode && (valueNode.type === 'arrow_function' || valueNode.type === 'function_expression' || valueNode.type === 'generator_function')) {
               this.extractFunction(valueNode);
               continue;
             }
@@ -2728,19 +2833,24 @@ export class TreeSitterExtractor {
               storeCollections.push(objectOfFns);
             }
 
-            // Visit the initializer body for calls — EXCEPT object literals (their
-            // function-valued properties are extracted below) and the store-factory
-            // / createApi / store-collection call whose nested objects we extract
-            // method-by-method below (walking the whole call would re-visit those
-            // method arrows and mis-attribute their inner calls to the file scope).
-            if (valueNode &&
-                valueNode.type !== 'object' &&
-                valueNode.type !== 'object_expression' &&
-                !(extractObjectMethods && valueNode.type === 'call_expression') &&
-                !rtkEndpoints &&
-                !piniaSetup &&
-                storeCollections.length === 0) {
+            // Visit the initializer body for calls, ATTRIBUTED to the declared
+            // symbol (#693) — EXCEPT the shapes whose members are extracted
+            // one-by-one below (the store-factory / createApi / store-collection
+            // objects), where walking the whole initializer would re-visit each
+            // member arrow and double-count its calls.
+            //
+            // Two things were wrong here before. The walk ran with only the FILE
+            // on the stack, so `const cfg = load()` recorded the FILE as load's
+            // caller — the exact leak Go's #693 fixed. And an object literal was
+            // skipped outright, so `const obj = { handler: () => target() }`
+            // contributed nothing at all unless the const was exported (only then
+            // does extractObjectLiteralFunctions mint the members).
+            const membersExtractedSeparately =
+              extractObjectMethods || !!rtkEndpoints || !!piniaSetup || storeCollections.length > 0;
+            if (valueNode && !membersExtractedSeparately) {
+              if (varNode) this.nodeStack.push(varNode.id);
               this.visitFunctionBody(valueNode, '');
+              if (varNode) this.nodeStack.pop();
             }
 
             if (extractObjectMethods && objectOfFns) {
@@ -2765,6 +2875,7 @@ export class TreeSitterExtractor {
 
       // Ruby constant assignments (`MAX = 3`) have a `constant`-typed LHS, not
       // `identifier`; without this they were never extracted as symbols at all.
+      let assigned: Node | null = null;
       if (left && (left.type === 'identifier' || left.type === 'constant')) {
         const name = getNodeText(left, this.source);
         // Skip if name starts with lowercase and looks like a function call result
@@ -2772,11 +2883,23 @@ export class TreeSitterExtractor {
         const initValue = right ? getNodeText(right, this.source).slice(0, 100) : undefined;
         const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
 
-        this.createNode(kind, name, node, {
+        assigned = this.createNode(kind, name, node, {
           docstring,
           signature: initSignature,
         });
       }
+      // Walk the initializer ATTRIBUTED to the assigned name (#693). A
+      // module-level `app = FastAPI()` / `ENGINE = create_engine(url)` /
+      // `handler = lambda: run()` dropped every call on the right-hand side, so
+      // whatever the module builds at import time linked to nothing. A tuple
+      // target (`a, b = f(), g()`) mints no symbol, so its RHS is walked at the
+      // enclosing scope rather than lost. Python only: Ruby shares this branch
+      // and gets its own turn.
+      if (this.language === 'python' && right) {
+        if (assigned) this.nodeStack.push(assigned.id);
+        this.visitFunctionBody(right, '');
+        if (assigned) this.nodeStack.pop();
+      }
     } else if (this.language === 'go') {
       // Go: var_declaration, short_var_declaration, const_declaration
       // These can have multiple identifiers on the left
@@ -2846,14 +2969,28 @@ export class TreeSitterExtractor {
       const varList = assign.namedChildren.find((c) => c.type === 'variable_list');
       const exprList = assign.namedChildren.find((c) => c.type === 'expression_list');
       const values = exprList ? exprList.namedChildren : [];
-      const names = varList ? varList.namedChildren.filter((c) => c.type === 'identifier') : [];
-      names.forEach((nameNode, i) => {
-        const name = getNodeText(nameNode, this.source);
-        if (!name) return;
+      const targets = varList ? varList.namedChildren : [];
+      targets.forEach((nameNode, i) => {
         const valueNode = values[i];
+        const target = this.luaAssignmentTarget(nameNode);
+        if (!target) return;
+
+        if (valueNode?.type === 'function_definition') {
+          this.extractLuaFunctionValue(valueNode, target.name, target.receiver, docstring);
+          return;
+        }
+
+        if (valueNode?.type === 'table_constructor') {
+          this.extractLuaTableFunctions(valueNode, target.fullName);
+        }
+
+        // A dotted assignment updates a table member; it is not a standalone
+        // variable node. Function-valued members were handled above.
+        if (target.receiver || node.type === 'assignment_statement') return;
+
         const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
         const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
-        this.createNode(kind, name, nameNode, { docstring, signature: initSignature, isExported });
+        this.createNode(kind, target.name, nameNode, { docstring, signature: initSignature, isExported });
       });
     } else if (this.language === 'c') {
       // C: a `declaration` node's name nests inside the `declarator` field —
@@ -2915,6 +3052,8 @@ export class TreeSitterExtractor {
     } else {
       // Generic fallback for other languages
       // Try to find identifier children
+      const nameField = getChildByField(node, 'name');
+      let declared: Node | null = null;
       for (let i = 0; i < node.namedChildCount; i++) {
         const child = node.namedChild(i);
         if (child?.type === 'identifier' || child?.type === 'variable_declarator') {
@@ -2923,13 +3062,101 @@ export class TreeSitterExtractor {
             : extractName(child, this.source, this.extractor);
 
           if (name && name !== '<anonymous>') {
-            this.createNode(kind, name, child, {
+            const created = this.createNode(kind, name, child, {
               docstring,
               isExported,
             });
+            if (created && nameField && child.startIndex === nameField.startIndex) {
+              declared = created;
+            }
           }
         }
       }
+      // Walk the initializer ATTRIBUTED to the declared symbol (#693). Rust
+      // only for now: `const N: usize = compute()` and
+      // `static REGISTRY: Lazy<T> = Lazy::new(|| build())` dropped every call
+      // inside the initializer, so a handler table or a lazily-built singleton
+      // linked to nothing. The other languages sharing this fallback spell
+      // their initializer differently and get their own turn.
+      if (this.language === 'rust') {
+        const valueNode = getChildByField(node, 'value');
+        if (valueNode) {
+          if (declared) this.nodeStack.push(declared.id);
+          this.visitFunctionBody(valueNode, '');
+          if (declared) this.nodeStack.pop();
+        }
+      }
+    }
+  }
+
+  /** Resolve a Lua assignment target into its callable name and optional table receiver. */
+  private luaAssignmentTarget(node: SyntaxNode): { name: string; receiver?: string; fullName: string } | null {
+    if (node.type === 'identifier') {
+      const name = getNodeText(node, this.source).trim();
+      return name ? { name, fullName: name } : null;
+    }
+    if (
+      node.type !== 'dot_index_expression' &&
+      node.type !== 'method_index_expression' &&
+      node.type !== 'bracket_index_expression'
+    ) return null;
+    const table = getChildByField(node, 'table');
+    const field = getChildByField(node, 'field') ?? getChildByField(node, 'method');
+    if (!table || !field) return null;
+    const receiver = getNodeText(table, this.source).trim();
+    const name = this.luaStaticFieldName(field, node.type === 'bracket_index_expression');
+    if (!receiver || !name) return null;
+    return { name, receiver, fullName: `${receiver}.${name}` };
+  }
+
+  /** A statically-known Lua field name; dynamic bracket keys are not callable identities. */
+  private luaStaticFieldName(node: SyntaxNode, bracketed: boolean): string {
+    if (node.type === 'identifier') {
+      return bracketed ? '' : getNodeText(node, this.source).trim();
+    }
+    if (node.type === 'string') {
+      const content = node.namedChildren.find((child) => child.type === 'string_content');
+      return content ? getNodeText(content, this.source).trim() : '';
+    }
+    return '';
+  }
+
+  /** Extract an anonymous Lua function using the name supplied by its assignment target. */
+  private extractLuaFunctionValue(
+    node: SyntaxNode,
+    name: string,
+    receiver?: string,
+    docstring?: string
+  ): void {
+    if (!this.extractor) return;
+    const signature = this.extractor.getSignature?.(node, this.source);
+    const extra: Partial<Node> = { docstring, signature };
+    if (receiver) extra.qualifiedName = this.composeReceiverQualifiedName(receiver, name);
+    else extra.isExported = this.extractor.isExported?.(node, this.source);
+
+    const functionNode = this.createNode(receiver ? 'method' : 'function', name, node, extra);
+    if (!functionNode) return;
+    this.nodeStack.push(functionNode.id);
+    const body = getChildByField(node, this.extractor.bodyField);
+    if (body) this.visitFunctionBody(body, functionNode.id);
+    this.nodeStack.pop();
+  }
+
+  /** Extract function-valued keyed fields from a Lua table, including nested tables. */
+  private extractLuaTableFunctions(table: SyntaxNode, receiver: string): void {
+    for (const field of table.namedChildren) {
+      if (field.type !== 'field') continue;
+      const nameNode = getChildByField(field, 'name');
+      const valueNode = getChildByField(field, 'value');
+      if (!nameNode || !valueNode) continue;
+      const bracketed = getNodeText(field, this.source).trimStart().startsWith('[');
+      const name = this.luaStaticFieldName(nameNode, bracketed);
+      if (!name) continue;
+      if (valueNode.type === 'function_definition') {
+        this.extractLuaFunctionValue(valueNode, name, receiver);
+      } else if (valueNode.type === 'table_constructor') {
+        this.extractLuaTableFunctions(valueNode, `${receiver}.${name}`);
+      }
     }
   }
 
@@ -4573,6 +4800,55 @@ export class TreeSitterExtractor {
               // scope keywords: such calls previously emitted a bare method
               // name, which either failed to resolve or resolved ambiguously.
               calleeName = `${getNodeText(receiver, this.source)}.${methodName}`;
+            } else if (
+              (this.language === 'typescript' ||
+                this.language === 'javascript' ||
+                this.language === 'tsx' ||
+                this.language === 'jsx') &&
+              receiver &&
+              receiver.type === 'member_expression' &&
+              getChildByField(receiver, 'object')?.type === 'this' &&
+              getChildByField(receiver, 'property')?.type === 'property_identifier'
+            ) {
+              // TS/JS call through a field of the enclosing class —
+              // `this.mailer.send()` (#1496). Keep the `this.<field>` prefix:
+              // the resolver reads the field's declared type off the class's
+              // own declaration (`private mailer: Mailer`, `mailer = new
+              // Mailer()`) and resolves the method on THAT type — or leaves the
+              // ref unresolved when the type is external or unknown. Previously
+              // this collapsed to the bare method name, which exact-matched
+              // whichever same-named method was nearest — the calling method
+              // itself when the two share a name, a self-edge not in the
+              // source. Same discipline as Rust's `self.<field>` (#1585).
+              // Mirrored in the kernel's extract_call (tsjs/extractors.rs).
+              const fieldName = getNodeText(getChildByField(receiver, 'property')!, this.source);
+              calleeName = `this.${fieldName}.${methodName}`;
+            } else if (
+              (this.language === 'typescript' ||
+                this.language === 'javascript' ||
+                this.language === 'tsx' ||
+                this.language === 'jsx' ||
+                this.language === 'python') &&
+              receiver &&
+              (receiver.type === 'call_expression' || receiver.type === 'call')
+            ) {
+              // Receiver that is itself a call — `d.setdefault(k, []).append(v)`,
+              // `make().run()`, `res.json().data` (#1683). The bare method name
+              // this used to emit exact-matched any top-level project symbol of
+              // that name and fabricated a call edge from an unrelated function
+              // (`append`, `get`, `run`…). Keep the inner callee, encoded as
+              // `<inner>().<method>` like the Java/Kotlin/C++ chains: the
+              // marker never appears in an ordinary ref, so nothing name-matches
+              // it, and a chain resolver can later infer the receiver's type
+              // from what the inner call returns. An inner callee that is not a
+              // plain name or member chain (`(await x)()`, `arr[0]()`) has no
+              // static receiver at all — emit nothing: a silent miss, never a
+              // wrong edge. The inner call is visited on its own either way.
+              // Mirrored in the kernel (tsjs/extractors.rs, python.rs).
+              const innerFn = getChildByField(receiver, 'function');
+              const innerCallee = innerFn ? getNodeText(innerFn, this.source).replace(/\s+/g, '') : '';
+              if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(innerCallee)) return;
+              calleeName = `${innerCallee}().${methodName}`;
             } else if (
               this.language === 'go' &&
               receiver &&
@@ -4588,6 +4864,20 @@ export class TreeSitterExtractor {
               // Go receivers resolve strictly via validated field-hop
               // inference (see matchGoFieldChainCall) or stay unresolved.
               calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`;
+            } else if (
+              TS_JS_CHAIN_LANGUAGES.has(this.language) &&
+              receiver &&
+              TS_JS_CHAIN_RECEIVER_TYPES.has(receiver.type) &&
+              isUnresolvedTsJsChain(receiver, this.source)
+            ) {
+              // `holder.values.get()` has no inferred property type (#1566).
+              // Emitting bare `get` exact-matches an unrelated project method;
+              // preserving the chain alone would still allow receiver guessing.
+              // Emit nothing until the property type can be established. This
+              // also covers host chains such as `chrome.storage.local.get()`
+              // (#1707). Calls inside arguments are visited independently.
+              // Mirrored in the kernel's extract_call (tsjs/extractors.rs).
+              return;
             } else {
               calleeName = methodName;
             }
@@ -5222,6 +5512,55 @@ export class TreeSitterExtractor {
     targets.add(target);
   }
 
+  /**
+   * Whether an anonymous function is the whole value of a `variable_declarator`
+   * with a plain identifier name — `const NAME = () => {…}` / `= function () {…}`.
+   * JS-family only.
+   */
+  private declaratorBoundFunction(node: SyntaxNode): boolean {
+    if (
+      this.language !== 'typescript' &&
+      this.language !== 'javascript' &&
+      this.language !== 'tsx' &&
+      this.language !== 'jsx'
+    ) {
+      return false;
+    }
+    if (node.type !== 'arrow_function' && node.type !== 'function_expression') return false;
+    const declarator = node.parent;
+    if (!declarator || declarator.type !== 'variable_declarator') return false;
+    const value = getChildByField(declarator, 'value');
+    if (!value || value.startIndex !== node.startIndex || value.endIndex !== node.endIndex) return false;
+    return getChildByField(declarator, 'name')?.type === 'identifier';
+  }
+
+  /**
+   * The property a CommonJS export assignment binds a function to —
+   * `exports.NAME = <node>` or `module.exports.NAME = <node>` — or null for
+   * any other assignment. JS-family only; the node must be the assignment's
+   * whole right-hand side.
+   */
+  private commonJsExportName(assignment: SyntaxNode, value: SyntaxNode): string | null {
+    if (
+      this.language !== 'typescript' &&
+      this.language !== 'javascript' &&
+      this.language !== 'tsx' &&
+      this.language !== 'jsx'
+    ) {
+      return null;
+    }
+    const right = getChildByField(assignment, 'right');
+    if (!right || right.startIndex !== value.startIndex || right.endIndex !== value.endIndex) return null;
+    const left = getChildByField(assignment, 'left');
+    if (!left || left.type !== 'member_expression') return null;
+    const object = getChildByField(left, 'object');
+    const property = getChildByField(left, 'property');
+    if (!object || !property || property.type !== 'property_identifier') return null;
+    const objectText = getNodeText(object, this.source);
+    if (objectText !== 'exports' && objectText !== 'module.exports') return null;
+    return getNodeText(property, this.source);
+  }
+
   /**
    * The declarator name a React handler hook binds an anonymous function to —
    * `const NAME = useCallback(<node>, [...])` — or null for any other shape.
@@ -5389,6 +5728,18 @@ export class TreeSitterExtractor {
           this.extractFunction(node, hookBound);
           return;
         }
+        // `const handleClear = () => {…}` inside a body (#1669) — the same
+        // binding that names a function at module scope names one here, and in
+        // a React component it is how every handler that skips `useCallback`
+        // is written. Without a node the handler is absent from callers /
+        // impact ("Symbol not found" reads like "no callers") and its calls
+        // attribute to the component. extractFunction resolves the name from
+        // the declarator; a destructuring or otherwise unnamed binding stays
+        // anonymous and falls through.
+        if (this.declaratorBoundFunction(node)) {
+          this.extractFunction(node);
+          return;
+        }
       }
 
       // Extract structural nodes found inside function bodies.

+ 53 - 109
src/graph/named-symbol-flow.ts

@@ -36,97 +36,18 @@ import type CodeGraph from '../index';
 import type { Node, Edge } from '../types';
 import { isTestFile } from '../search/query-utils';
 
-/**
- * Rust path roots that have no file-system equivalent — `crate` is the
- * current crate, `super` is the parent module, `self` is the current
- * module. Used by `matchesSymbol` to strip these before file-path
- * matching so `crate::configurator::stage_apply::run` resolves the
- * same as `configurator::stage_apply::run`.
- */
-export const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
-
-/**
- * Last `::` / `.` / `/`-separated segment of a qualified symbol. An Erlang
- * arity tail (`mod::fn/3`, `fn/3`) is stripped first — the useful last segment
- * is the function name, never the digits (#1610).
- */
-export function lastQualifierPart(symbol: string): string {
-  const noArity = symbol.replace(/\/\d{1,3}$/, '') || symbol;
-  const parts = noArity.split(/::|[./]/).filter((p) => p.length > 0);
-  return parts[parts.length - 1] ?? symbol;
-}
+import { lastQualifierPart, matchesSymbol } from './symbol-lookup';
 
-/**
- * Check if a node matches a symbol query.
- *
- * Accepts simple names (`run`) and three flavors of qualifier:
- *   - dotted     `Session.request`         (TS/JS/Python)
- *   - colon-pair `stage_apply::run`        (Rust, C++, Ruby)
- *   - slash      `configurator/stage_apply` (path-ish)
- *
- * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
- * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
- * the canonical `crate::module::symbol` form resolves.
- *
- * Resolution order, last part must always equal `node.name`:
- *   1. Suffix-match against `qualifiedName` (handles class-scoped methods
- *      where the extractor builds the qualified name from the AST stack)
- *   2. File-path containment (handles file-derived modules in Rust/
- *      Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
- */
-export function matchesSymbol(node: Node, symbol: string): boolean {
-  // Erlang arity spelling (`fn/3`, `mod:fn/3` → normalized `mod.fn/3`): when
-  // the node's qualifiedName carries an arity (`mod::fn/3`, #1610), the
-  // written arity must match it exactly; the remaining comparison then runs
-  // on the arity-less spelling. A node with no arity in its qualifiedName
-  // keeps the original symbol (a `/` there means a path-ish name instead).
-  const aritySpelling = /^(.+)\/(\d{1,3})$/.exec(symbol);
-  if (aritySpelling) {
-    const nodeArity = /\/(\d{1,3})$/.exec(node.qualifiedName ?? '')?.[1];
-    if (nodeArity !== undefined) {
-      if (nodeArity !== aritySpelling[2]) return false;
-      symbol = aritySpelling[1]!;
-    }
-  }
-  // Simple name match
-  if (node.name === symbol) return true;
-  // File basename match (e.g., "product-card" matches "product-card.liquid")
-  if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
-
-  // Qualified-name lookups: split on any supported separator. `\w` keeps
-  // identifier chars (incl. `_`) intact; everything else is treated as
-  // a separator we tolerate.
-  if (!/[.\/]|::/.test(symbol)) return false;
-  const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
-  if (parts.length < 2) return false;
-
-  const lastPart = parts[parts.length - 1]!;
-  if (node.name !== lastPart) return false;
-
-  // Stage 1: qualified-name suffix match. The extractor joins the
-  // semantic hierarchy with `::`, so `Session.request` and
-  // `Session::request` both become `Session::request` here.
-  const colonSuffix = parts.join('::');
-  if (node.qualifiedName.includes(colonSuffix)) return true;
-
-  // Stage 2: file-path containment. Rust modules and Python packages
-  // are not in `qualifiedName` — they're encoded in the file path. So
-  // `stage_apply::run` matches a `run` in any file whose path
-  // contains a `stage_apply` segment (with or without an extension).
-  //
-  // Filter out Rust path prefixes that have no file-system equivalent.
-  const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
-  if (containerHints.length === 0) return false;
-
-  const segments = node.filePath.split('/').filter((s) => s.length > 0);
-  return containerHints.every((hint) =>
-    segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
-  );
-}
+// Preserve the existing imports while sharing the matcher with the CLI and MCP.
+export { RUST_PATH_PREFIXES, lastQualifierPart, matchesSymbol } from './symbol-lookup';
 
 /**
  * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
  * results across all matching symbols (e.g., multiple classes with an `execute` method).
+ *
+ * Exact matches only (#1473): a missing / mistyped name must NOT silently
+ * resolve to the top fuzzy FTS hit under the caller's typed label. Closest
+ * hits may appear in `note` as a did-you-mean hint when `nodes` is empty.
  */
 export function findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
   // Nix option paths: the declaration is stored as `options.<path>` and
@@ -149,42 +70,57 @@ export function findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[];
       return { nodes, note: '' };
     }
   }
-  let results = cg.searchNodes(symbol, { limit: 50 });
-
-  // Mirror the fallback in `findSymbol` for qualified queries — FTS
-  // strips colons, so a module-qualified lookup needs a second pass
-  // by the bare last part.
-  if (results.length === 0 && /[.\/]|::/.test(symbol)) {
-    const tail = lastQualifierPart(symbol);
-    if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
-  }
 
-  if (results.length === 0) {
-    return { nodes: [], note: '' };
+  const isQualified = /[.\/]|::/.test(symbol);
+  let exactNodes: Node[];
+
+  if (!isQualified) {
+    // Direct index — every exact-name overload, case-sensitive. Avoids FTS
+    // ranking a differently-cased sibling above the real node (#1473 Fetch).
+    exactNodes = cg.getNodesByName(symbol);
+  } else {
+    let results = cg.searchNodes(symbol, { limit: 50 });
+    // Mirror findSymbolMatches — FTS strips colons, so re-search by bare tail.
+    if (results.length === 0) {
+      const tail = lastQualifierPart(symbol);
+      if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
+    }
+    exactNodes = results
+      .filter((r) => matchesSymbol(r.node, symbol))
+      .map((r) => r.node);
   }
 
-  const exactMatches = results.filter(r => matchesSymbol(r.node, symbol));
+  if (exactNodes.length === 0) {
+    const fuzzy = cg.searchNodes(symbol, { limit: 5 });
+    const suggestions = [
+      ...new Set(fuzzy.map((r) => r.node.name).filter((n) => n !== symbol)),
+    ].slice(0, 3);
+    const note =
+      suggestions.length > 0
+        ? `\n\n> **Note:** no symbol named "${symbol}". Did you mean: ${suggestions.join(', ')}?`
+        : '';
+    return { nodes: [], note };
+  }
 
-  if (exactMatches.length <= 1) {
-    const node = exactMatches[0]?.node ?? results[0]!.node;
-    return { nodes: [node], note: '' };
+  if (exactNodes.length === 1) {
+    return { nodes: exactNodes, note: '' };
   }
 
   // Same generated-file down-rank as findSymbol — keeps callers/callees
   // /impact aggregation aligned (a query against "Send" returns the
   // hand-written implementations before the protobuf scaffold).
-  const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
-  const ranked = [...exactMatches].sort((a, b) => {
-    const aGen = isGen(a.node.filePath) ? 1 : 0;
-    const bGen = isGen(b.node.filePath) ? 1 : 0;
+  const isGen = cg.generatedFilePredicate(exactNodes.map((n) => n.filePath));
+  const ranked = [...exactNodes].sort((a, b) => {
+    const aGen = isGen(a.filePath) ? 1 : 0;
+    const bGen = isGen(b.filePath) ? 1 : 0;
     return aGen - bGen;
   });
 
-  const locations = ranked.map(r =>
-    `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
+  const locations = ranked.map(
+    (n) => `${n.kind} at ${n.filePath}:${n.startLine}`
   );
   const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
-  return { nodes: ranked.map(r => r.node), note };
+  return { nodes: ranked, note };
 }
 
 /** Node kinds that can sit on a call chain. */
@@ -360,8 +296,16 @@ export function resolveNamedTokens(
   const segPool = new Set<string>();
   for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
 
+  // RAW edges, not getCallers/getCallees: those return one row per NEIGHBOUR
+  // (the #1086 de-dup), so when a pair is joined by BOTH a static and a
+  // synthesized edge the static one wins and the synthesized one becomes
+  // invisible — which is exactly what happens once a thunk's `dispatch(x)`
+  // is walked statically. The question here is about the graph, not about
+  // callers, so ask the edges directly.
   const hasHeuristicEdge = (id: string): boolean =>
-    [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
+    [...cg.getIncomingEdges(id), ...cg.getOutgoingEdges(id)].some(
+      (e) => e.provenance === 'heuristic'
+    );
 
   for (const t of tokens) {
     const hits = findAllSymbols(cg, t).nodes;

+ 204 - 0
src/graph/symbol-lookup.ts

@@ -0,0 +1,204 @@
+/**
+ * Symbol Lookup — the single "what did the user mean by this name?" path.
+ *
+ * Every verb that takes a symbol NAME from a human (or an agent) has to turn
+ * that string into node(s). `codegraph_node` and `codegraph_explore` went
+ * through the matcher below; the `callers` / `callees` / `impact` CLI verbs
+ * carried their own ad-hoc filter instead:
+ *
+ *     node.name === symbol || node.name.endsWith('.' + symbol)
+ *
+ * which compares the query against the BARE name only. That produced two
+ * opposite failures in the same repository:
+ *
+ *   - a bare name over-reported: `callers group` silently merged the callers of
+ *     every distinct symbol named `group` — in any language — into one list
+ *     headed "Callers of group", with nothing saying they were different
+ *     symbols;
+ *   - a qualified name under-reported: `Foo.Bar.baz` can never equal a bare
+ *     `baz`, 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 are fixed by routing all of them through one resolver, which this module
+ * owns so the CLI and the MCP tools cannot drift apart again.
+ */
+
+import type { Node } from '../types';
+
+/** Rust path prefixes that name no directory (`crate::x`, `super::y`). */
+export const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
+
+/** Does this query carry any scope qualifier at all? */
+export function isQualifiedSymbol(symbol: string): boolean {
+  return /[.\/]|::/.test(symbol);
+}
+
+/** The bare identifier at the end of a qualified query (arity spelling stripped). */
+export function lastQualifierPart(symbol: string): string {
+  const noArity = symbol.replace(/\/\d{1,3}$/, '') || symbol;
+  const parts = noArity.split(/::|[./]/).filter((p) => p.length > 0);
+  return parts[parts.length - 1] ?? symbol;
+}
+
+/**
+ * Rewrite every scope separator to `.` so a query and a stored qualifiedName
+ * written in different conventions can be compared directly. The extractors
+ * join hierarchy with `::` while users type the language's own spelling
+ * (`Session.request`, `stage_apply::run`, `pkg/mod.Fn`).
+ */
+function canonicalScope(text: string): string {
+  return text.replace(/::/g, '.').replace(/\//g, '.');
+}
+
+/**
+ * Does `node` satisfy the user's symbol query?
+ *
+ * Bare queries match the name. Qualified queries are checked against the
+ * qualifiedName under both separator conventions, then — for languages whose
+ * hierarchy lives in the file path rather than the name (Rust modules, Python
+ * packages) — against the path.
+ */
+export function matchesSymbol(node: Node, symbol: string): boolean {
+  // Erlang arity spelling (`fn/3`, `mod:fn/3`): when the node's qualifiedName
+  // carries an arity (#1610) the written arity must match exactly, and the rest
+  // of the comparison runs on the arity-less spelling. A node with no arity
+  // keeps the original symbol (a `/` there means a path-ish name instead).
+  const aritySpelling = /^(.+)\/(\d{1,3})$/.exec(symbol);
+  if (aritySpelling) {
+    const nodeArity = /\/(\d{1,3})$/.exec(node.qualifiedName ?? '')?.[1];
+    if (nodeArity !== undefined) {
+      if (nodeArity !== aritySpelling[2]) return false;
+      symbol = aritySpelling[1]!;
+    }
+  }
+
+  if (node.name === symbol) return true;
+  // File basename match ("product-card" matches "product-card.liquid").
+  if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
+
+  if (!isQualifiedSymbol(symbol)) return false;
+  const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
+  if (parts.length < 2) return false;
+
+  const lastPart = parts[parts.length - 1]!;
+  if (node.name !== lastPart) return false;
+
+  // Stage 1: qualified-name containment under the extractor's `::` convention.
+  if (node.qualifiedName.includes(parts.join('::'))) return true;
+
+  // Stage 1b: boundary-aligned suffix under a canonical separator.
+  //
+  // Splitting on EVERY separator assumes no scope component contains one —
+  // false for any language whose module names are themselves dotted (Elixir
+  // `AppWeb.Format`, a Java/C# package, a Python dotted module). There the
+  // stored qualifiedName is `AppWeb.Format::group`, so the stage-1 spelling
+  // `AppWeb::Format::group` cannot match and a perfectly precise query
+  // resolved to nothing. Canonicalising both sides and requiring the match to
+  // land on a separator boundary handles both conventions with one rule, and
+  // is strictly tighter than the `includes` above.
+  const canonicalQuery = canonicalScope(symbol);
+  const canonicalNode = canonicalScope(node.qualifiedName);
+  if (canonicalNode === canonicalQuery || canonicalNode.endsWith(`.${canonicalQuery}`)) {
+    return true;
+  }
+
+  // Stage 2: file-path containment. Rust modules and Python packages are not in
+  // qualifiedName — they are encoded in the path — so `stage_apply::run`
+  // matches a `run` in any file with a `stage_apply` path segment.
+  const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
+  if (containerHints.length === 0) return false;
+  const segments = node.filePath.split('/').filter((s) => s.length > 0);
+  return containerHints.every((hint) =>
+    segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
+  );
+}
+
+/** The slice of CodeGraph a symbol lookup needs — keeps this module testable. */
+export interface SymbolLookupHost {
+  getNodesByName(name: string): Node[];
+  searchNodes(query: string, options?: { limit?: number }): Array<{ node: Node }>;
+  generatedFilePredicate(paths: string[]): (path: string) => boolean;
+}
+
+export interface SymbolLookupResult {
+  /** Every definition the query names, keepers before generated stubs. */
+  nodes: Node[];
+  /**
+   * The query named more than one distinct definition. Callers that aggregate
+   * across all of them MUST surface this — an aggregate presented as one
+   * symbol's answer is the over-reporting failure described at the top.
+   */
+  ambiguous: boolean;
+}
+
+/**
+ * One group per (filePath, qualifiedName): same-file overloads stay together,
+ * while unrelated definitions keep their own edges. Shared by CLI and MCP.
+ * A non-matching file hint keeps all definitions and must be disclosed.
+ */
+export function groupDefinitions(
+  nodes: Node[],
+  fileFilter?: string
+): { groups: Node[][]; filteredOut: boolean } {
+  let pool = nodes;
+  let filteredOut = false;
+  if (fileFilter) {
+    const wanted = fileFilter.replace(/^\.\//, '');
+    const narrowed = pool.filter(
+      (n) => n.filePath === wanted || n.filePath.endsWith(wanted) || n.filePath.endsWith(`/${wanted}`)
+    );
+    if (narrowed.length > 0) pool = narrowed;
+    else filteredOut = true;
+  }
+  const byDef = new Map<string, Node[]>();
+  for (const n of pool) {
+    const key = `${n.filePath}|${n.qualifiedName}`;
+    const group = byDef.get(key);
+    if (group) group.push(n);
+    else byDef.set(key, [n]);
+  }
+  return { groups: [...byDef.values()], filteredOut };
+}
+
+/**
+ * Resolve a user-supplied symbol name to the definitions it names.
+ *
+ * The exact-name index is consulted FIRST and is authoritative: it is complete
+ * and uncapped, whereas FTS ranks and truncates, and tokenises away `::` — so
+ * a qualified query could miss a symbol that exists, or land on whatever
+ * happened to rank first. FTS candidates still have to satisfy the matcher;
+ * partial or mistyped names must never select the top fuzzy hit (#1473).
+ */
+export function lookupSymbolNodes(cg: SymbolLookupHost, symbol: string): SymbolLookupResult {
+  const qualified = isQualifiedSymbol(symbol);
+
+  // Exact-name index, then filter by the qualifier the user actually wrote.
+  const tail = qualified ? lastQualifierPart(symbol) : symbol;
+  let nodes = tail ? cg.getNodesByName(tail) : [];
+  if (qualified) nodes = nodes.filter((n) => matchesSymbol(n, symbol));
+
+  if (nodes.length === 0) {
+    const hits = cg.searchNodes(symbol, { limit: 50 }).map((h) => h.node);
+    const exact = hits.filter((n) => matchesSymbol(n, symbol));
+    if (exact.length > 0) {
+      nodes = exact;
+    }
+    // Any query with no exact match resolves to NOTHING rather than a
+    // misleading fuzzy hit (#1473; qualified lookups already did this in #173).
+  }
+
+  if (nodes.length === 0) return { nodes: [], ambiguous: false };
+
+  // Keepers before generated stubs (.pb.go and friends), stable otherwise.
+  const isGenerated = cg.generatedFilePredicate(nodes.map((n) => n.filePath));
+  const ranked = [...nodes].sort(
+    (a, b) => (isGenerated(a.filePath) ? 1 : 0) - (isGenerated(b.filePath) ? 1 : 0)
+  );
+  return { nodes: ranked, ambiguous: groupDefinitions(ranked).groups.length > 1 };
+}
+
+/** One-line "kind at path:line" label used when disclosing an ambiguous query. */
+export function describeSymbolNode(node: Node): string {
+  return `${node.kind} ${node.qualifiedName || node.name} (${node.language}) — ${node.filePath}:${node.startLine}`;
+}

+ 11 - 3
src/index.ts

@@ -816,7 +816,13 @@ export class CodeGraph {
           try { return this.queries.isNameSegmentVocabEmpty(); } catch { return false; }
         })();
 
-        const result = await this.orchestrator.sync(options.onProgress, options.paths);
+        // Writer-side backstop for deferred WAL checkpointing (#1539): sync
+        // previously armed the valve but never called backpressure(), so the
+        // hard/file caps were never enforced during daemon catch-up — only
+        // timer-driven PASSIVE checkpoints ran, and a query-pool reader could
+        // pin frames while the WAL grew without a bound.
+        const backpressure = walValve ? () => walValve!.backpressure() : undefined;
+        const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure);
 
         // Fold the store phase's WAL BEFORE the post-store reads below
         // (resolution reads on the main thread) — same rationale as
@@ -914,7 +920,8 @@ export class CodeGraph {
                   current: done,
                   total: totalPasses,
                 });
-              }
+              },
+              backpressure
             );
           }
         }
@@ -980,7 +987,8 @@ export class CodeGraph {
                 current: done,
                 total: totalPasses,
               });
-            }
+            },
+            backpressure
           );
         }
 

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません