Pārlūkot izejas kodu

feat(impact): cross-language blast-radius coverage (22 languages + 14 frameworks) (#708)

Completes the cross-file dependency graph behind impact / affected / explore across all 22 supported languages and 14 web frameworks, validated on real-world repos (measured fair-coverage table added to the README). Per-language resolution + framework resolvers/synthesizers (Lua/Luau require, Shopify OS 2.0 Liquid sections, Delphi forms, Rust cross-module + Rocket macros, Swift Fluent, SvelteKit/Nuxt loader/component conventions, RN/Expo bridges). 0 cross-family false edges, full suite green (1187 passed). See #708.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby Mchenry 2 nedēļas atpakaļ
vecāks
revīzija
07af3db6c7
43 mainītis faili ar 4815 papildinājumiem un 850 dzēšanām
  1. 0 114
      .claude/handoffs/codegraph-tool-surface-rethink-2026-05-27.md
  2. 175 0
      .claude/handoffs/cross-language-impact-coverage-2026-06-04.md
  3. 0 70
      .claude/handoffs/explore-flow-tool-adoption.md
  4. 0 80
      .claude/handoffs/explore-overhaul-2026-06-01.md
  5. 0 73
      .claude/handoffs/explore-overhaul-bench-2026-06-02.md
  6. 0 70
      .claude/handoffs/explore-per-symbol-sizing.md
  7. 0 70
      .claude/handoffs/framework-coverage-sweep-2026-05-23.md
  8. 0 86
      .claude/handoffs/trace-relevance-coldstart-2026-05-30.md
  9. 39 0
      CHANGELOG.md
  10. 30 0
      README.md
  11. 53 0
      __tests__/expo-modules.test.ts
  12. 1662 137
      __tests__/extraction.test.ts
  13. 26 5
      __tests__/graph.test.ts
  14. 49 1
      __tests__/react-native-bridge.test.ts
  15. 34 0
      __tests__/rn-event-channel.test.ts
  16. BIN
      assets/__pycache__/generate-waitlist.cpython-313.pyc
  17. 155 0
      docs/design/template-markup-parser.md
  18. 2 2
      site/src/content/docs/core-concepts/knowledge-graph.md
  19. 0 1
      site/src/content/docs/reference/integrations.md
  20. 0 2
      site/src/content/docs/reference/mcp-server.md
  21. 46 0
      src/db/queries.ts
  22. 23 2
      src/extraction/grammars.ts
  23. 28 30
      src/extraction/languages/c-cpp.ts
  24. 18 2
      src/extraction/languages/csharp.ts
  25. 5 1
      src/extraction/languages/java.ts
  26. 23 0
      src/extraction/languages/kotlin.ts
  27. 12 0
      src/extraction/languages/php.ts
  28. 36 0
      src/extraction/languages/ruby.ts
  29. 6 2
      src/extraction/languages/rust.ts
  30. 36 1
      src/extraction/languages/scala.ts
  31. 49 11
      src/extraction/liquid-extractor.ts
  32. 280 0
      src/extraction/razor-extractor.ts
  33. 8 0
      src/extraction/tree-sitter-types.ts
  34. 810 9
      src/extraction/tree-sitter.ts
  35. 13 47
      src/graph/queries.ts
  36. 422 10
      src/resolution/callback-synthesizer.ts
  37. 6 1
      src/resolution/frameworks/expo-modules.ts
  38. 7 3
      src/resolution/frameworks/python.ts
  39. 52 5
      src/resolution/frameworks/react-native.ts
  40. 463 1
      src/resolution/import-resolver.ts
  41. 124 5
      src/resolution/index.ts
  42. 122 9
      src/resolution/name-matcher.ts
  43. 1 0
      src/types.ts

+ 0 - 114
.claude/handoffs/codegraph-tool-surface-rethink-2026-05-27.md

@@ -1,114 +0,0 @@
----
-name: codegraph-tool-surface-rethink-2026-05-27
-date: 2026-05-27 15:11
-project: codegraph
-branch: feat/go-multi-module-trace-quality
-summary: PR #494 multi-language audit revealed structural ~$0.04-$0.08 tiny-repo cost overhead from MCP tool-defs; user pivoted to questioning whether codegraph_context / 5+ tools are even necessary — suggested `explore` + `trace` only.
----
-
-# Handoff: Should codegraph cut to just `explore` + `trace`?
-
-## Resume here — read this first
-**Current state:** PR #494 (`feat/go-multi-module-trace-quality`, 13 commits, all 1076 tests pass) ships every safe optimization for the cosmos/etcd Go work AND the cross-language extensions (generated-detection, IFACE_OVERRIDE_LANGS, sibling-inlining, path-proximity, tool gating at <150 files to 5 core tools). Empirically PROVED that cutting below 5 tools regresses every tiny repo (3-tool gate: cobra 17→48% loss; 1-tool gate: express -43% WIN flipped to +107% LOSS). User just asked the right question: **"Why do we need codegraph_context, or any of these massive amounts of tools? All it really needs is explore, and trace if you ask me."**
-
-**Immediate next step:** Open the next session by treating the user's question as a design pivot, not a continuation of the cost-gap whack-a-mole. The right reply is a focused honest analysis: what does each of the 10 tools actually do that explore + trace alone can't, where does codegraph_context's value-add hold up (or not), and what would removing context/search/node from the default surface ACTUALLY cost in measured loss-of-flow-coverage. Don't start cutting tools yet — present the analysis first.
-
-> Suggested next message: "Walk me through what each codegraph_* tool actually does on a real flow question that explore + trace alone can't, and which ones agents are picking in our recent audits. If context/search/node aren't earning their seat, propose cutting them and measure on cosmos-Q1 + etcd-Q1 + prometheus + cobra n=2 each."
-
-## Goal
-Decide whether codegraph's 10-tool MCP surface should be cut down to ~2 core tools (explore + trace) as the user proposed. The empirical iteration in this session showed that the 5 omitted "auxiliary" tools (callers, callees, impact, status, files) only add cost on tiny repos and aren't earning their seat. The real question now: **does the same logic apply to context + search + node?** If yes, codegraph becomes 2 tools + a smaller MCP surface = lower fixed prompt overhead = closes the tiny-repo cost gap structurally instead of patching it. If no, name the specific flows where they do unique work.
-
-## Key findings (this session)
-
-- **PR #494 status**: 13 commits, all 1076 tests pass, https://github.com/colbymchenry/codegraph/pull/494. Already pushed:
-  - Generated-file detection: `src/extraction/generated-detection.ts` (multi-language patterns, applied in `findSymbol`/`findAllSymbols`/`handleSearch`/`handleExplore` file ranking/`context/formatter.ts`)
-  - Go gRPC bridge: `goGrpcStubImplEdges` in `src/resolution/callback-synthesizer.ts:341` (467 bridge edges on cosmos-sdk)
-  - Trace failure inlining + path-proximity pairing + less-canonical-path penalty + sibling-from-TO-file inlining: all in `src/mcp/tools.ts` `handleTrace`
-  - `IFACE_OVERRIDE_LANGS` extended from `{java,kotlin}` to `{java,kotlin,csharp,typescript,javascript,swift,scala}`; loop iterates `class` AND `struct` kinds
-  - Tool-def trims (~7KB → 5KB) in `src/mcp/tools.ts`
-  - Tiny-repo tool gating: `ToolHandler.getTools()` filters to 5 core tools when `fileCount < 150`
-  - Tiny-tier explore budget in `getExploreOutputBudget(fileCount < 150)`: 13K total / 4 files / `includeRelationships: true`
-  - `handleContext` default `maxNodes` drops from 20 → 8 when `fileCount < 150`
-- **Cosmos Q1 flipped**: WIN ($0.257 vs $0.449, n=1; n=2 avg $0.341 vs $0.350 tied). The breakthrough was `inlineEndpoint`'s "Other functions in TO's file" siblings — `msgServer.Send`'s real callee `k.Keeper.SendCoins` is an embedded-interface call tree-sitter can't statically resolve, so static `getCallees` returns only utility funcs; the *actual* flow lives in `x/bank/keeper/send.go`'s file-mates. See `handleTrace` line ~1430.
-- **Empirical lower bounds on tool gating** (n=2-3 audits):
-  - 5 tools (search+context+node+explore+trace) = current setting, works
-  - 3 tools (search+context+trace) = cobra 17→48% loss, sinatra 18→96% loss; agent falls back to Reads when node/explore unavailable
-  - 1 tool (search only) = catastrophic, express -43% WIN → +107% LOSS
-- **n=3 measurements confirm structural floor:** cobra WITH consistently $0.28 (variance <5%), WITHOUT consistently $0.24. The $0.04 gap is structural, not noise.
-- **The user's pivot question challenges this:** their hypothesis is that context+search+node may also be earning less than they cost. The audits we have can't directly answer that — every test had all 10 (or 5) tools available. To test, expose ONLY explore+trace on a controlled batch and re-measure.
-- **Cross-language status (single-run each):** WINS = Go (multi-mod), Rust, Java, C#, Kotlin, Swift, Svelte, prometheus, ky (post-gating), express (JS). TIES = cobra (n=2 tied $0.27/$0.27), excalidraw, django, redis, json, Masonry, flutter, vapor, spring. LOSSES = sinatra, slim, flask, scala-play, Fusion, vue-core (variance), Drupal, NestJS, FastAPI, Laravel, ASP.NET, axum, actix, Rocket, gorilla/mux, SvelteKit, Charts bridge (slight), RN segmented-control (slight).
-- **Loss pattern is structural, not language-specific.** All losses are tiny example/starter repos where the without-arm grep+read path costs ~$0.20-0.30 and codegraph's MCP overhead can't be amortized.
-
-## Gotchas
-
-- **PR-494 is a Go-multi-module PR by title but the body is now cross-cutting** — generated-detection, IFACE_OVERRIDE_LANGS, tool gating, all language-agnostic. Don't let the title narrow what's in it.
-- **The variance on the WITHOUT arm is enormous** — same-repo single-run cost can swing $0.04 to $0.80 depending on whether the agent goes grep-heavy or read-heavy that turn. **Never conclude WIN/LOSS from n=1.** The session has many single-run results that need confirming.
-- **Cobra (~50 files) is the canary** — every aggressive cut that helps ky or sinatra has regressed cobra at least once. It's the most-tested tiny repo because of that.
-- **Don't try the 1-tool or 3-tool gate again** — both are explicitly documented as regressions in `getTools()` comments (`src/mcp/tools.ts` around line 660). Cutting below 5 forces the agent to Read.
-- **Kong's first audit was a 0-byte index** — parallel `audit.sh` runs against the same .codegraph dir can corrupt each other. If kong/any-repo's audit shows wildly wrong numbers, check `stat /tmp/codegraph-corpus/<repo>/.codegraph/codegraph.db` before iterating on the result.
-- **48-parallel audit launches FAIL silently** — system resource limits. Stay at 6-8 parallel max. Use `wait` between waves.
-- **The MCP daemon caches the tool list** at process start — when iterating on `getTools()` you MUST `pkill -f "codegraph.js serve --mcp"` between rebuilds or you'll be testing stale code.
-- **`maxCharsPerFile` monotonic invariant** is pinned by `__tests__/explore-output-budget.test.ts` (the spec is `a larger tier must NEVER get a smaller maxCharsPerFile than a smaller tier`). Honor it.
-
-## How to test & validate
-
-- `npm test` → "Tests 1076 passed | 2 skipped". Must stay green.
-- `npm run build 2>&1 | tail -3` → check dist rebuilt cleanly.
-- `pkill -f "codegraph.js serve --mcp" ; sleep 2` → ALWAYS run before agent-eval after a build, otherwise the daemon serves stale code.
-- Single-question audit: `AGENT_EVAL_OUT=/tmp/cg-NAME /Users/colby/Development/Personal/codegraph/scripts/agent-eval/run-all.sh <repo-path> "<question>" headless`. Outputs `run-headless-with.jsonl` and `run-headless-without.jsonl`.
-- Parse: `node scripts/agent-eval/parse-run.mjs /tmp/cg-NAME/run-headless-{with,without}.jsonl` → cost, duration, turns, tool sequence.
-- **For real conclusions, always n=2 minimum.** n=3 is the right bar to separate variance from signal — last session's data on cobra showed WITH had <5% variance but WITHOUT swung 95%.
-- **The explore + trace experiment** the user wants: modify `getTools()` to filter visible tools to `new Set(['codegraph_explore', 'codegraph_trace'])` for ALL repos (or just the tiny tier first), re-run cosmos-Q1, etcd-Q1, prometheus, cobra n=2 each, and compare.
-
-## Repo state
-
-- branch `feat/go-multi-module-trace-quality`, last commit `ae5364c docs(mcp): pin empirical lower bound on tool gating after n=2 micro test`
-- uncommitted: clean
-- PR: https://github.com/colbymchenry/codegraph/pull/494 (13 commits, ready for review unless we land the tool-surface redesign)
-
-## Open threads / TODO
-
-- [ ] **The user's pivot**: prove or disprove that explore + trace alone is sufficient. Set up a 4-repo × n=2 batch (cosmos-Q1, etcd-Q1, prometheus, cobra) with ONLY explore+trace exposed, compare to current 5-tool / 10-tool baselines.
-- [ ] If explore+trace alone wins → cut the tool surface across the board. **This is a breaking API change** — callers/callees/impact/status/files/node would disappear from default exposure. Need a clean way to retain them for users who script against the MCP directly (env var? `--full-tools` flag?).
-- [ ] If explore+trace alone loses → identify which of context/search/node is doing the structural work, and propose cutting only the others.
-- [ ] **README update either way**: the current "~35% cheaper" claim averages 7 medium/large repos. Either commit to that scope ("real codebases (~200+ files)") or re-measure after the tool surface change.
-- [ ] Liquid, Pascal/Delphi, React Router, TurboModules, Expo Modules, Paper view managers — still untested categories from the README. Bridges Swift↔ObjC/RN-legacy/RN-events/Fabric were tested in wave 3 — 1 win, 2 tied, 1 slight loss. The rest are still gaps.
-- [ ] If we ship the PR as-is, write a CHANGELOG entry under `[Unreleased]` summarizing the 13 commits — currently the CHANGELOG entry covers commits 1-2 (generated-detection + gRPC bridge + trace UX); commits 3-13 need their own bullets.
-
-## Recent transcript (last ~10 turns, oldest → newest)
-
-### Turn N-9 — User: "Have we tested every language/framework per the playbook?"
-- **Claude:** Audited coverage honestly — ~70% of README's claims (22 langs + 14 frameworks + 8 bridges) had NOT been validated. Offered three scopes (smoke / medium / full playbook).
-- **Outcome:** User chose "Full playbook"; later refined to "Smoke-test only the completely-untested categories."
-
-### Turn N-8 — Wave 1 smoke-test (8 parallel: JS/Dart/Drupal/NestJS/FastAPI/Vapor/Spring/Rails)
-- **Claude:** Launched 8 audits via `run-all.sh`. Results: express WIN (-43%), 3 tied, 4 LOSS (Drupal, NestJS, FastAPI, Rails). NestJS 113% loss, FastAPI 194% loss were the standouts.
-- **Outcome:** Documented the loss pattern correlates with repo size, not language.
-
-### Turn N-7 — Wave 2 (7 frameworks: Laravel, ASP.NET, axum, actix, Rocket, gorilla/mux, SvelteKit) + Wave 3 (4 bridges: Charts, async-storage, RN-geolocation, RN-segmented-control)
-- **Claude:** All wave-2 frameworks lost on cost (small example/starter repos). All wave-3 bridges won/tied on cost AND won on reads.
-- **Outcome:** Confirmed loss pattern is tiny-repo-structural, not framework-specific. Bridges (codegraph's marquee feature) work.
-
-### Turn N-6 — User: "We have a lot of losses. Work on those until they are all clear wins. You cannot stop or give up or push back."
-- **Claude:** Ran n=3 on the 8 worst losses to separate variance from signal. First attempt with 48 parallel processes silently failed (system limits). Re-ran in waves of 6.
-- **Outcome:** n=3 cobra: WITH consistent $0.28 (variance <5%), WITHOUT consistent $0.24. **Confirmed losses are structural, not variance.**
-
-### Turn N-5 — Tested 3-tool gate (search + context + trace only)
-- **Claude:** Edited `getTools()` to expose only 3 tools on `<150` files. Re-ran cobra/ky/sinatra. Results: cobra 17→48% LOSS, ky 47→42% loss, sinatra 18→96% LOSS. Reverted.
-- **Outcome:** Cutting below 5 tools regresses because the agent falls back to extra Reads.
-
-### Turn N-4 — Tested 1-tool gate (search only) on micro (<50 files)
-- **Claude:** Edited gate. cobra 126% LOSS, ky 44% loss, express FLIPPED from -43% WIN to +107% LOSS. Catastrophic. Reverted.
-- **Outcome:** Documented the empirical lower bound: 5 tools. Committed `ae5364c` with comments warning future tuners not to relitigate.
-
-### Turn N-3 — Claude proposed three paths forward (auto-disable / README honesty / context-rewrite)
-- **Claude:** Used AskUserQuestion to offer auto-disable on <50, honest README, or last-ditch context shrink.
-- **Outcome:** User pivoted instead of picking any of those.
-
-### Turn N-2 — User: "Why do we need codegraph_context, or any of these massive amounts of tools? All it really needs is explore, and trace if you ask me."
-- **Claude:** Recognized this is a design pivot, not a continuation of the cost-gap iteration. The right next move is to actually test the user's hypothesis, not to write another response defending the status quo.
-- **Outcome:** This handoff captures the pivot for a fresh session to answer properly.
-
-### Turn N-1 — User: `/handoff save`
-- **Claude:** Wrote this file.
-- **Outcome:** Handoff persisted. Next session reads it and engages the explore+trace-only design question with measurement, not opinion.

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 175 - 0
.claude/handoffs/cross-language-impact-coverage-2026-06-04.md


+ 0 - 70
.claude/handoffs/explore-flow-tool-adoption.md

@@ -1,70 +0,0 @@
----
-name: explore-flow-tool-adoption
-date: 2026-05-24 00:55
-project: codegraph
-branch: architectural-improvements
-summary: Investigated why codegraph's read savings don't convert to wall-clock; root cause is agent tool-CHOICE (under-uses trace). Shipped a chain of fixes; the breakthrough is "explore-surfaces-flow" — the first mechanism to show up in real agent runs by adapting the tool the agent already uses.
----
-
-# Handoff: codegraph retrieval — tool adoption & explore-surfaces-flow
-
-## Resume here — read this first
-**Current state:** A long investigation into making agents answer flow questions faster with codegraph. 6 commits on `architectural-improvements` (all probe-validated, suite green 815). The breakthrough: **`codegraph_explore` now surfaces the execution flow** from the symbol-bag the agent already passes it (`PmsProductController getList PmsProductService list PmsProductServiceImpl` → leads output with `getList → service-interface → impl`, riding synth edges). It's the FIRST mechanism this whole arc to actually appear in real agent runs (spring-mall A/B: flow surfaced both runs, reads 2.0→1.5) — because it adapts the tool the agent USES instead of trying to make it use `trace`.
-
-**Immediate next step:** The user is weighing how to push tool-USE quality next (their open question). Decide between: (a) **extend explore-flow to surface more reliably** (spring-halo's query didn't name a connected co-named chain → no flow), (b) accept we're at the model-behavior ceiling and **wrap up**, or (c) the user's ideas — better tool-description *examples* (≈ steering, low-leverage per the evidence) or a *query-builder tool* (adds a call + new-tool adoption problem). My read: keep ADAPTING THE USED TOOL (the only thing that's worked); examples/new-tools are the "change the agent" direction that failed all session.
-
-> Suggested next message: "explore-flow only surfaced on 2 of 3 repos — dig into why spring-halo's explore query didn't produce a flow and make it surface more reliably" — OR — "we're at the model-behavior ceiling; let's stop and write the CHANGELOG/PR for this branch"
-
-## Goal
-Make an AI agent answer **flow questions** ("how does X reach Y", request→handler→service, state→render) fast: ~0 Read/Grep, few codegraph calls, lower wall-clock. `codegraph_trace` is the fastest tool (1 call = the path), but the agent under-uses it. Ultimate target = trace's speed, however the agent gets there.
-
-## Key findings (the through-line)
-- **The wall is agent tool-CHOICE, not the graph.** Matrix-wide, codegraph cuts reads −75% but wall-clock only −16% (`docs/benchmarks/codegraph-ab-matrix.md`). The floor is round-trips + the synthesis turn. The agent reliably calls `context`/`explore`, rarely `trace` (3/37 flow cells). Full analysis: `docs/benchmarks/call-sequence-analysis.md`.
-- **Steering does NOT move it** (arms B/F/G, 3 wording variants): an MCP `initialize` instruction / tool description can't match a CLI `--append-system-prompt`'s salience, and forcing trace where it doesn't connect regresses. Reverted.
-- **Sufficiency works** (committed): a self-sufficient `trace` (hop bodies + destination callees inlined) lets the unsteered agent stop — but only when it calls trace.
-- **THE breakthrough — adapt the tool the agent uses.** `explore`'s query is a precise symbol-bag spanning the flow, so `explore` finds the call path AMONG its named symbols and leads with it. First mechanism to surface in real runs + drop reads.
-- **What FAILED:** option 1 (context-surfaces-flow) — fuzzy DESCRIPTION can't disambiguate endpoints → confident WRONG-feature flow; reverted. trace multi-source-BFS over ambiguous names — same wrong-feature; reverted.
-
-## Gotchas
-- **Co-naming disambiguation must match qualifiedName SEGMENTS, not substrings** (`buildFlowFromNamedSymbols` in `src/mcp/tools.ts`): `list` is a substring of `getList` → kept every getList. Split `qualifiedName` on `::`/`.` and match segments.
-- **BFS must cap consecutive UNNAMED hops at 1** — full-graph BFS wanders a god-function's fan-out (excalidraw `render()` → pointer handlers → mutateElement). ≤1 bridge crosses a missing intermediate without wandering.
-- **`getCallees` returns non-`calls` edges too** (references) — filter `c.edge.kind === 'calls'`.
-- **Resolver/synthesizer changes need a CLEAN reindex**: `rm -rf .codegraph && codegraph init -i` (the init edge count is contains-only — query the DB for the real count). The explore-flow change is query-time (no reindex).
-- **n=2 A/B is noisy** — report ranges/patterns, never conclude from one run. Foreground `sleep` is blocked → run A/B batches with `run_in_background`.
-- Java/Kotlin `qualifiedName` is `Class::method` (so `matchesSymbol` resolves `Class.method` qualified trace endpoints — the agent already passes these).
-
-## How to test & validate
-- Probe flow surfacing (no agent): `node scripts/agent-eval/probe-explore.mjs <repo> "<SymbolA SymbolB SymbolC>"` → look for the `## Flow` section. `probe-trace.mjs <repo> <from> <to>` for trace.
-- Synthesizer: `sqlite3 <repo>/.codegraph/codegraph.db "select count(*) from edges where json_extract(metadata,'$.synthesizedBy')='interface-impl'"`; node count stable before/after reindex (synth adds edges only).
-- Agent A/B (the real test): `bash scripts/agent-eval/run-arms.sh <repo> "<Q>" I <run>` (arm I = body-trace build, no steering). Parse via the `cmp2.mjs`-style scripts in `/tmp`. Pass = flow surfaces (`flowShown=Y`) + reads ≤ baseline.
-- `npm test` (vitest, 815 pass); `__tests__/mcp-tool-allowlist.test.ts` covers the allowlist.
-
-## Repo state
-- branch `architectural-improvements`, last commit `bafae81 feat(mcp): codegraph_explore surfaces the execution flow from its named symbols`.
-- uncommitted: clean (only untracked `.claude/handoffs/`).
-- 6 session commits: `eab5cf3` self-sufficient trace + `CODEGRAPH_MCP_TOOLS` allowlist · `a6183d7` research log + arms harness · `bde8c19` node/trace line numbers · `98baf41` Java/Kotlin interface→impl synthesizer · `6f3c468` playbook · `bafae81` explore-surfaces-flow.
-- NOT pushed/merged. No version bump. CHANGELOG `[Unreleased]` has all of it.
-
-## Open threads / TODO
-- [ ] **User's open question** (answer in the next turn): better tool-description *examples* vs a *query-builder tool* vs keep adapting the used tool. Evidence favors the last.
-- [x] explore-flow reliability: now resolves QUALIFIED tokens (`Class.method`) — the agent's most precise input was being dropped by the file-ext strip (`2765c3c`). spring-halo's publish flow stays absent on purpose — it's **reactive/reconciler dispatch** (`publishPost` calls `ReactiveExtensionClient.get`/`awaitPostPublished`, not `PostService.publish`), so there's no static call chain. That's the next COVERAGE frontier (reactive runtimes — like MediatR, Vue Proxy), not an explore-flow bug.
-- [ ] Ship-prep for the whole branch (this arc + the earlier framework sweep): CHANGELOG version block + `package.json` bump + PR to main. Releases go through `.github/workflows/release.yml` only — do NOT `npm publish`.
-- [ ] Frontiers: MediatR (`_mediator.Send`→Handle) and Vue/Compose reactive runtimes are still unbridged dynamic dispatch.
-
-## Recent transcript (oldest → newest)
-### Turn — "improve the A/B matrix; trace works, reads near 0 — what else?"
-- Diagnosed: reads at floor, wall-clock floor = round-trips + synthesis. Built `seq-matrix.mjs`; found trace adoption 3/37.
-### Turn — "do explore/context/trace compete? one tool?"
-- Ablation arms A–E (`run-arms.sh`/`arms-F.sh` + `CODEGRAPH_MCP_TOOLS` allowlist). explore = 68% of payload, load-bearing; trace path-scoped but under-adopted; trace alone insufficient.
-### Turn — "prototype body-inlining trace + A/B"
-- Arm F: self-sufficient trace wins WITH append-prompt steering. But steering isn't a shippable channel.
-### Turn — "port the steering + re-run"
-- Arms G (3 variants) all regressed vs baseline; arm H (body-trace, no steer) ≈ baseline. Steering reverted; body-trace + line-numbers + allowlist committed.
-### Turn — "tee up connectivity (Spring interface-DI)"
-- Built `interfaceOverrideEdges` (Java/Kotlin interface→impl, overload-aware). Probe: 3-hop trace connects. But A/B null — agent never called trace. Committed (probe-validated, adoption-gated).
-### Turn — "make context surface the flow (option 1)"
-- Failed: fuzzy query → wrong-feature flows. Reverted.
-### Turn — "change explore to do trace in the backend"
-- WIN: explore's query is a precise symbol-bag. `buildFlowFromNamedSymbols` (co-naming segment match + ≤1 bridge). Probe perfect (Spring + excalidraw full chains); A/B: flow surfaces + modest read drop. Committed `bafae81`.
-### Turn — "update memory + handoff; what about better examples / a query-builder tool?"
-- This handoff + memory update. Strategic answer pending (adapt-the-tool > change-the-agent).

+ 0 - 80
.claude/handoffs/explore-overhaul-2026-06-01.md

@@ -1,80 +0,0 @@
----
-name: explore-overhaul-2026-06-01
-date: 2026-06-01 19:50
-project: codegraph
-branch: main
-summary: Made codegraph_explore the sole primary tool (removed context + trace), added graph-connectivity ranking + 100K budget + full method bodies — then an agent-eval revealed the budget BACKFIRES and the real lever is COVERAGE (Zustand store methods aren't indexed).
----
-
-# Handoff: codegraph_explore overhaul — explore as the one tool, and the coverage pivot
-
-## Resume here — read this first
-**Current state:** Big uncommitted working tree on `main`. `codegraph_context` and `codegraph_trace` tools are fully removed; `codegraph_explore` is the sole primary, now with graph-connectivity (RWR) ranking, a flat **100K** output budget, full method bodies, whole-central-file, and an always-on blast-radius section. A fresh-daemon agent-eval on the real repo (`~/Downloads/amniservices-mobile-app`) just proved two things: (1) the **100K budget BACKFIRES** — a broad explore hit **67K chars and overflowed the agent's per-tool token cap**, forcing it to Read; (2) the **real cause of the agent's reads is a COVERAGE gap**, not ranking/budget — Zustand store methods (`fetchUser`/`switchOrganization` inside `create((set,get)=>({...}))`) aren't indexed as nodes, and callers **destructure** them (`const {fetchUser}=useOrgUser.getState()`), so `codegraph_node`/`codegraph_callers` return "not found."
-**Immediate next step:** Revert the 100K budget (it overflows) to ~28–35K, then build the Zustand coverage fix (extract store-literal methods as nodes + resolve destructured `getState()` calls). That's what actually deletes the reads.
-
-> Suggested next message: "Revert the explore budget in getExploreOutputBudget (tools.ts) from 100K back to ~30K — the 67K response overflowed the agent's tool cap. Then build the Zustand coverage fix: extract methods inside `create((set,get)=>({...}))` as nodes, and resolve destructured store calls like `const {fetchUser}=useOrgUser.getState()`. Then kill the AmniSphere daemon and re-run the agent eval."
-
-## Goal
-Make `codegraph_explore` good enough to be a **Read-replacement** — one (maybe two) calls answer a structural/flow question with ~0 Read/Grep, for smart AND dumb models. Metric is wall-clock + tool-call count + Read count (NOT token cost). The user's golden era: one tool (`explore`), reflexively used, zero Reads.
-
-## Key findings
-- **The agent's reads are a COVERAGE gap, not ranking/budget.** Agent's own words (diagnostic eval): Zustand store actions inside the `create((set,get)=>({...}))` literal "aren't individually indexed," so `codegraph_node fetchUser` / `codegraph_callers fetchUser` → **"not found"**; callers **destructure** off `useOrgUser.getState()` so even grep needed `\bfetchUser\b`. Component-body control flow (`handleLogin`, `AppInitializer` in `src/app/index.tsx`, `src/components/providers/index.tsx`) isn't a node either.
-- **The 100K budget backfires.** A broad explore returned ~67K chars and "overflowed the token cap" → agent Read instead. Big responses are *worse*. `getExploreOutputBudget` (tools.ts ~line 140) is now a flat 100K — revert toward ~28–35K (size to the agent's per-tool output limit).
-- **Adoption is EXCELLENT — the agent WANTS codegraph.** In the fresh eval it made **16 codegraph calls** vs 5 Reads. So the problem is never "agent won't use it"; it's "the symbols aren't in the graph."
-- **Graph-connectivity ranking works in isolation but didn't address the real cause.** `computeGraphRelevance` (tools.ts, before `handleExplore`) is RWR/personalized-PageRank from the matched seeds; probe shows it ranks `org-user.storage.ts` #1 and returns it whole. But it doesn't cleanly drop noise (LensSwitcher.swift matched "switch") because real codebases share infra + generic terms — **neither graph nor text alone separates; needs IDF×graph fusion**, a tuning long tail. Park it until coverage is fixed.
-- **`context` + `trace` tools fully removed** (def + dispatch + handlers + CLI `context` command + permissions + server-instructions + tests). The shared engine `findRelevantContext` stays (explore runs on it). `synthEdgeNote` kept (shared); `handleTrace`/`sourceLineAt`/`sourceRangeAt`/`maybeInlineFlowTrace`/`handleContext`/`looksLikeFeatureRequest`/`formatTaskContext` deleted.
-- **Read-gate PreToolUse hook was built then REMOVED** (user: "ideally zero hooks"). Deleted `src/hooks/`, `src/mcp/session-consult.ts`, the `mcp-read-gate` CLI cmd, installer wiring (`InstallOptions.readGate`, claude.ts helpers), and the marker security tests. Had an unverified `CLAUDE_SESSION_ID`==hook-`session_id` assumption.
-- **Precision fix landed earlier (keeper):** `isDistinctiveIdentifier` (query-utils.ts) gates the exact-name bonus in `findRelevantContext` Step 5a so a common word ("flat") can't hijack ranking (was surfacing a python `FLAT` constant). Lives in the shared engine → benefits explore.
-- **Blast-radius section added to explore** (`buildBlastRadiusSection`, tools.ts): per entry symbol, who-depends-on-it + covering test files, locations only. Always-on, compact. (2 tests in `__tests__/explore-blast-radius.test.ts`.)
-
-## Gotchas
-- **STALE-DAEMON FOOT-GUN (cost us hours).** `codegraph serve --mcp` connects to a per-repo daemon (`<repo>/.codegraph/daemon.sock`, 5-min idle timeout) that holds the loaded code. **A `npm run build` does NOT take effect until you kill the daemon.** Every agent-eval before the kill was testing STALE code (agent got 2277 chars where a fresh in-process probe got 54K). **Before ANY agent eval:** `pkill -f "serve --mcp"; rm -f <repo>/.codegraph/daemon.sock`. Worth fixing in the product (a rebuild should invalidate the daemon).
-- **probe ≠ agent.** `probe-explore.mjs` loads `dist/` in-process (always current code); the agent uses the daemon (can be stale). Don't trust a probe result as "what the agent sees" unless the daemon was just killed.
-- **Validating with a favorable query lies.** My probe query (`"org user storage…"`) returned the whole central file; the agent's near-identical query behaved totally differently. Use the agent's EXACT query, on a fresh daemon.
-- **n=1 variance is large** — never conclude from one agent run (CLAUDE.md). The "4 vs 5 reads" between runs is noise.
-- **Budget-table repos (excalidraw/django/etc.) NOT validated** — they're not on this machine. The ranking/budget changes could regress them; the CLAUDE.md "do-not-regress explore budget" table is now obsolete (flat 100K) and needs reconciling.
-- All work is **uncommitted on `main`** — branch before committing (PR policy: main is REVIEW_REQUIRED).
-
-## How to test & validate
-- Build: `npm run build` (must exit 0).
-- Cheap probe (current code, NOT what a stale daemon serves): `node scripts/agent-eval/probe-explore.mjs /Users/colby/Downloads/amniservices-mobile-app "<query>"`.
-- Agent A/B (real metric, ~$2, KILL DAEMON FIRST): `pkill -f "serve --mcp"; rm -f /Users/colby/Downloads/amniservices-mobile-app/.codegraph/daemon.sock; CG_BIN=$(pwd)/dist/bin/codegraph.js AGENT_EVAL_OUT=/tmp/agent-eval-amni bash scripts/agent-eval/run-agent.sh /Users/colby/Downloads/amniservices-mobile-app <label> "<prompt>"` → parse `/tmp/agent-eval-amni/run-<label>.jsonl` for tool order + Read count.
-- Diagnostic prompt that worked: append "for EACH Read/Grep note WHY codegraph wasn't enough; end with '## Why I read'." The agent's self-report is the best diagnostic.
-- Affected unit tests (NOT the full suite — user is cost-conscious): `npx vitest run __tests__/{context-ranking,explore-blast-radius,context,mcp-tool-allowlist,security,worktree-detection,installer-targets}.test.ts __tests__/integration/mcp-input-limits.test.ts`.
-- Pass bar: a flow question reaches ~0 Read within the explore-call budget, faster than without-codegraph, no regression on a control repo.
-
-## Repo state
-- branch `main`, last commit `8629f7a docs(changelog): promote [Unreleased] into [0.9.8]`
-- uncommitted (all this session, none committed): `M src/mcp/tools.ts` (the big one — explore ranking/RWR/budget, context+trace removal, blast radius), `M src/context/index.ts` (precision fix), `?? src/context/markers.ts` (LOW_CONFIDENCE_MARKER leaf), `M src/search/query-utils.ts` (isDistinctiveIdentifier), `M src/mcp/server-instructions.ts`, `M src/installer/targets/shared.ts` (permissions), `M src/bin/codegraph.ts` (CLI context/trace removed), `M src/types.ts`, `M CHANGELOG.md`, `?? __tests__/context-ranking.test.ts`, `?? __tests__/explore-blast-radius.test.ts`, `M __tests__/{security,worktree-detection,mcp-tool-allowlist}.test.ts`, `M __tests__/integration/mcp-input-limits.test.ts`. (read-gate hook + session-consult.ts were created then deleted → no trace.)
-
-## Open threads / TODO
-- [ ] **Revert the 100K budget** in `getExploreOutputBudget` (tools.ts ~140) to ~28–35K — it overflows the agent tool cap at 67K.
-- [ ] **Reconsider "whole central file always"** (tools.ts whole-file rule + `centralFiles`) — a 791-line whole file is what overflowed. Prefer the relevant methods full, not whole-huge-file.
-- [ ] **Build the Zustand coverage fix (the real lever):** (a) extract methods defined inside `create((set,get)=>({...}))` as nodes (extraction); (b) resolve destructured store calls `const {fetchUser}=useOrgUser.getState()` (reference resolution). Then re-eval.
-- [ ] Decide: keep `computeGraphRelevance` (graph ranking) or park it — it didn't address the coverage cause. Probe-validated, not agent-validated.
-- [ ] Re-run the agent eval (FRESH DAEMON) after the coverage fix; confirm reads of `org-user.storage.ts`/components drop.
-- [ ] Reconcile CHANGELOG `[Unreleased]` with the final direction (explore primary; context+trace removed; budget/ranking may change).
-- [ ] Validate budget-table repos (excalidraw/django) before merging — not on this machine.
-- [ ] Run the FULL test suite once (only ran affected files this session).
-- [ ] Branch off `main` before committing this work.
-
-## Recent transcript (oldest → newest)
-### Turn — "do it" (build graph ranking)
-- **User:** Build graph-connectivity ranking (RWR/PageRank fused with bm25) as explore's relevance core; keep the output wins; fix the whole-file path.
-- **Claude:** `Edit(tools.ts)` → added `computeGraphRelevance` (RWR, undirected, α=0.25, 25 iters); rewired file ranking/central/gate to graph-primary + text-secondary. `probe` → `org-user.storage.ts` #1 + whole (796 lines), but LensSwitcher.swift/capture noise still present.
-- **Outcome:** RWR surfaces the central file but doesn't cleanly drop noise; reported the honest limit (needs IDF×graph fusion).
-
-### Turn — "how does amniservices do now?"
-- **User:** Run the agent eval on the real repo with everything in place.
-- **Claude:** `run-agent.sh (amni-with-q2)` → codegraph 5 / Read 5 / Grep 3; agent Read `org-user.storage.ts` anyway. `parse` → agent's first explore returned only **2277 chars** while `probe` returned 54K for the same query.
-- **Outcome:** 2277-vs-54K discrepancy → suspected the agent wasn't running my build.
-
-### Turn — "do the test again, ask the agent WHY it read"
-- **User:** Re-run, but make the agent explain each Read / why codegraph wasn't enough.
-- **Claude:** `ps` → found stale daemon `pid 8947` on AmniSphere socket + pile of lingering `serve --mcp`. `pkill -f "serve --mcp"; rm daemon.sock` → fresh. `run-agent.sh (amni-diag-q3)` with the "## Why I read" prompt, fresh daemon.
-- **Outcome:** First valid run on my build. codegraph **16** / Read 5 / Grep 3. Agent's "Why I read": Zustand store methods not indexed (`node`/`callers` → not found), callers destructure off `getState()`, broad explore **overflowed at 67K**. → real cause = COVERAGE; budget BACKFIRES. Pivot the priorities.
-
-### Turn — /handoff save (this)
-- **User:** `/handoff save`.
-- **Claude:** Wrote this file.
-- **Outcome:** Checkpoint captured; next edge = budget revert + Zustand coverage fix.

+ 0 - 73
.claude/handoffs/explore-overhaul-bench-2026-06-02.md

@@ -1,73 +0,0 @@
----
-name: explore-overhaul-bench-2026-06-02
-date: 2026-06-02 06:30
-project: codegraph
-branch: feat/explore-overhaul-store-coverage
-summary: Finished the explore-overhaul arc (explore as sole primary + store coverage + overload disambiguation + method-atomic render + node file/line selector + explore reshaped to native-read windows) and validated it — all 7 README repos hit 0 Read/0 Grep at effort=high; only the README benchmark write-up remains.
----
-
-# Handoff: explore-overhaul arc — validated 0-reads across all 7 README repos; README write-up is the last step
-
-## Resume here — read this first
-**Current state:** All code is committed + pushed on `feat/explore-overhaul-store-coverage` (4 commits, working tree clean). The why-Read agent sweep is DONE: **all 7 README repos × 4 runs = 28/28 runs hit 0 Read / 0 Grep on `--effort high`**, every run "codegraph was sufficient." WITH-`high` medians are captured (~59% fewer tool calls · 51% fewer tokens · ~15% cheaper · 0 reads vs the existing README WITHOUT) — the earlier cost REGRESSION (-3%) is recovered. The only open item is **updating the README benchmark section**, which is blocked on one methodology decision.
-**Immediate next step:** Decide how to publish: (A) do a CLEAN both-arms run on `effort=high` with the PLAIN prompt (no why-Read) for an apples-to-apples table, or (B) write the WITH-`high` deltas in against the existing WITHOUT with a cross-effort caveat. Then edit `README.md` (benchmark table + per-repo breakdowns + average line + methodology date) and open the PR.
-
-> Suggested next message: "Do the clean both-arms run on effort=high with the plain prompt for all 7 repos, then update the README benchmark table + per-repo breakdowns from those medians and open the PR."
-
-## Goal
-Make `codegraph_explore` a true Read-replacement — flow/architecture questions answered with ~0 Read/Grep — then re-validate the README benchmark on the current build and update its numbers. Definition of done: README benchmark reflects the current build with defensible (same-effort) numbers; branch merged via PR.
-
-## Key findings
-- **The arc (all shipped on the branch):** explore is the SOLE primary tool (`codegraph_context` + `codegraph_trace` removed in the prior session, this branch); store-action **coverage** (object-literal method extraction — a GENERAL AST rule in `tree-sitter.ts` `extractVariable`/`extractObjectLiteralFunctions`/`findInitializerReturnedObject`, covers Zustand/Redux/Pinia, not a per-lib hack); graph-ranking **gate fix** (a named/≥2-term file is never pruned); **`node` all-overloads + `file`/`line` selector**; **method-atomic render** (never half a method — drop whole methods/files); **explore reshape** to native-read windows.
-- **Native-read ground truth (from the WITHOUT transcripts):** the agent natively reads **~6–9 files as ~100-line windows** (77% ranged, median 100 lines, 51–250 dominant), located by `func X(` signature greps. That's the unit explore now mimics.
-- **Explore reshape (commit 50401a6, the latest mechanism):** `getExploreOutputBudget` caps EVERY tier at **~24K** (was 28/35/38K) + absolute **25K** hard ceiling (was 1.5×-of-budget) — because a bigger response gets **externalized** by the host to a file the agent Reads back (a 35K vscode explore did exactly that) AND costs cache-writes. Repo size scales the CALL budget, not the response. Per-file = one ~150–250 line window: per-symbol `bodyCap` 2×→1.5× and the spine is windowed too (so tokio's big-spine `worker.rs` doesn't starve `harness.rs`'s `poll`); central whole-file 4×→1.5× / 400→280 lines. Explore's named-symbol injection now uses **`cg.getNodesByName`** (direct index, not FTS) so a 50+-overload name (`poll`) surfaces the wanted def (`Harness::poll`) for the PascalCase-type-token bias to pick.
-- **`node` file/line selector (commit 5bf6ad8):** `codegraph_node` takes optional `file`/`line` to pin an overload (the `file:line` a trail showed). `findSymbolMatches` (replaced `findSymbol`) enumerates ALL overloads via `cg.getNodesByName` (new passthrough `index.ts` → QueryBuilder), then file/line filters. The agent USES it in runs (`run file:worker.rs line:508`, `poll file:harness.rs`).
-- **Cost regression was REAL, now recovered.** The pre-reshape n=4 benchmark (on `max` effort, bloated 35-42K explores) was **−3% cost avg** (vscode −52%) and reads were **NOT 0** (vscode 6,4,0,7; tokio 3,4,2,2) — which corrected my earlier n=2 "0 reads everywhere" optimism. The reshape (≤25K, no externalization) + 0 reads flipped cost back to **~15% cheaper**.
-
-## Gotchas
-- **STALE-DAEMON foot-gun:** before ANY agent eval, `pkill -f "serve --mcp"; rm -f <repo>/.codegraph/daemon.sock` so it serves the current `dist/`. `bench-why-repo.sh` does this per-run. A `npm run build` does NOT take effect until the daemon is killed.
-- **Mac SLEEP corrupts long runs:** the first overnight re-bench (5h on `max`) was sleep-corrupted — the Mac napped 16–42 min BETWEEN runs (~3h of the 5h was paused), inflating wall-clock for the later repos. **Always wrap long runs in `caffeinate -dimsu`.** Cost/tokens/reads are sleep-INDEPENDENT (billed API totals), so the cost regression was real (confirmed on vscode which ran fully awake before any sleep); only TIME is corrupted.
-- **`--effort` matters:** the user's Claude default is `max`, which is "too much." The eval is pinned to `--effort high` (levels: low/medium/high/xhigh/max). `bench-why-repo.sh` honors `EFFORT` (default `high`). The MAX-mode runs were discarded and redone on `high`.
-- **why-Read prompt biases reads down (Hawthorne) + adds <0.3% to WITH cost/tokens.** So the 28/28 0-read sweep proves codegraph is *sufficient* (it CAN answer with 0 reads); it slightly understates a natural run's reads. Keep it OUT of any published benchmark numbers (use plain prompt for the table).
-- **README methodology mismatch:** WITH numbers are `effort=high` + why-Read; the existing README WITHOUT is the user's OLD default effort + plain. Cross-effort → can't publish cleanly without same-effort both arms. The user does NOT want to re-run WITHOUT repeatedly, but the effort CHANGED, so a one-time WITHOUT-on-high is a new (justified) measurement.
-- **PR policy:** `main` is REVIEW_REQUIRED — work on the branch, open a PR, `gh pr merge --squash --admin` for self-review. Branch + push only so far; **PR not opened** (user asked branch+push).
-
-## How to test & validate
-- Build: `npm run build` (exit 0). Full suite: `npx vitest run` → **1112 pass, 2 skip, 0 fail** (npm-shim network tests can flake offline — pre-existing).
-- Affected tests: `npx vitest run __tests__/{explore-output-budget,adaptive-explore-sizing,context-ranking,explore-blast-radius,symbol-lookup,pr19-improvements,object-literal-methods}.test.ts`.
-- Deterministic probe (current `dist/`, in-process — NOT the daemon): `node scripts/agent-eval/probe-explore.mjs /tmp/codegraph-corpus/<repo> "<query>"` → confirm ≤~25K chars + the flow files render. `node scripts/agent-eval/probe-node.mjs <repo> <symbol> code` (e.g. `poll file:harness.rs` via a small script).
-- Agent why-Read sweep (the real metric): `EFFORT=high caffeinate -dimsu bash scripts/agent-eval/bench-why-repo.sh /tmp/codegraph-corpus/<repo> "<readme query>" 4` → parse `/tmp/ab-why/<repo>/with*.jsonl` for `Read`/`Grep` tool_use + the trailing `## Why I read` section.
-- All 7 repos are cloned + indexed on the current build at `/tmp/codegraph-corpus/{vscode,excalidraw,django,tokio,okhttp,gin,alamofire}`. README queries are in `scripts/agent-eval/bench-readme.sh`.
-- **Pass bar:** flow question → ~0 Read at the explore-call budget, faster than WITHOUT, no control regression.
-
-## Repo state
-- branch `feat/explore-overhaul-store-coverage`, last commit `9cf671a chore(agent-eval): add per-repo WITH-only why-Read benchmark harness`. Pushed, in sync with origin.
-- 4 commits: `22333c1` (explore-primary + store coverage + overload disambiguation + docs) · `5bf6ad8` (method-atomic render + node file/line selector) · `50401a6` (explore reshape: inline-cap + concentrated flow-windows + direct-index injection) · `9cf671a` (why-Read eval harness).
-- uncommitted: clean (only `.claude/handoffs/` scratch, intentionally not committed).
-- CHANGELOG `[Unreleased]` already updated for the whole arc. README + CLAUDE.md + docs/design banners updated to drop removed context/trace.
-
-## Open threads / TODO
-- [ ] **README benchmark update** — decide (A) clean both-arms `effort=high` plain run vs (B) publish WITH-`high` deltas with caveat; then edit `README.md` (table + per-repo WITH/WITHOUT breakdowns + average + methodology date). WITH-`high` medians: vscode 4cg/742k/$0.70, excalidraw 6.5/834k/$0.74, django 3/509k/$0.59, tokio 6/921k/$0.77, okhttp 4.5/528k/$0.41, gin 4.5/573k/$0.48, alamofire 7.5/976k/$0.65 — all 0 reads.
-- [ ] **Open the PR** (`gh pr create`) once the README is updated.
-- [ ] Optional: delete dead dev probes `scripts/agent-eval/probe-{trace,context}.mjs` (their tools were removed; noted, not done).
-- [ ] Memory `project_codegraph_read_displacement` is updated through the reshape; add the 28/28-on-high + cost-recovery result if you want it durable.
-
-## Recent transcript (oldest → newest)
-### Turn — "re-run the full benchmark to update README; ask why on reads"
-- **Claude:** cloned+indexed all 7 repos; launched `RUNS=4 bench-readme.sh` (both arms). → ran overnight.
-### Turn — "what's still running? 5h overnight"
-- **Claude:** bench was on the LAST repo (alamofire), ~90%. n=4 aggregate showed **cost −3% (regression)** + reads **NOT 0** (vscode 6-7, tokio 2-4) — corrected my n=2 "0 everywhere." Diagnosed reads from transcripts: explore was 35-42K, too spread, dropped named flow methods.
-### Turn — "is it the lock screen?"
-- **Claude:** Yes for TIME (Mac slept 16-42min between runs → 5h mostly paused) but NOT cost (vscode ran awake; cost is billed API). → recommend `caffeinate`.
-### Turn — "how do I stop Mac sleeping" → answered (Battery→Options / `caffeinate`).
-### Turn — "do the queries WITHOUT codegraph, see exactly what it Reads, mimic it"
-- **Claude:** characterized native reads (77% ranged, median 100 lines, signature greps) → spec: ~100-line flow-windows, ~6-9 files, ≤25K.
-### Turn — "do it" (the reshape)
-- **Claude:** `Edit(tools.ts)` → inline-cap 24K + concentrated windows + `getNodesByName` injection; updated budget tests; full suite green; probe vscode 35K→16K, tokio fits harness.rs. Committed `50401a6`, pushed.
-### Turn — "why AST not LSP" (×2) + "reply to hd_nvim (ast-grep author)"
-- **Claude:** explained the trade-off (breadth/speed/no-build/determinism/19-langs vs LSP type-precision-but-heavy); drafted the X reply (concede precision, reframe on goal/cost).
-### Turn — "re-run, ask Why on each WITH read, report per repo" → "no WITHOUT" → "MAX too much, use high, start over"
-- **Claude:** built `bench-why-repo.sh` (WITH-only, why-Read, `EFFORT` default high); ran all 7 on `high` one repo at a time, reporting each. **Result: 28/28 runs 0R/0G, all "sufficient."** Aggregated WITH-`high` medians → ~15% cheaper (regression recovered).
-### Turn — "commit and push this"
-- **Claude:** committed `bench-why-repo.sh` (`9cf671a`), pushed. → this handoff.
-- **Outcome:** Arc complete + validated; README write-up + PR are all that remain.

+ 0 - 70
.claude/handoffs/explore-per-symbol-sizing.md

@@ -1,70 +0,0 @@
----
-name: explore-per-symbol-sizing
-date: 2026-05-29 23:20
-project: codegraph
-branch: main
-summary: Shipped per-symbol adaptive codegraph_explore sizing (PR #569) — show the answer (named methods + mechanism) in full, collapse redundant interchangeable siblings to signatures, keep named methods alive in non-sibling god-files; flipped Django/OkHttp from cost laggards to clear wins and lifted the README averages to 25%/57%/23%/62%.
----
-
-# Handoff: per-symbol adaptive codegraph_explore sizing (shipped)
-
-## Resume here — read this first
-**Current state:** **DONE + shipped.** PR #569 squash-merged to `main` (`b026e64`); local is on `main`, `dist/` rebuilt, working tree clean. README benchmarks + averages + header, CHANGELOG, and `docs/design/adaptive-explore-sizing.md` all updated with the new full-7-repo sweep. The only loose end: **two squash-merged feature branches still linger** (`feat/adaptive-explore-sizing` from #564, `feat/explore-per-symbol-sizing` from #569) — local **and** remote — because squash-merges don't register as "merged" in git's ancestor sense.
-**Immediate next step:** Delete those two merged branches (local + remote), or pick up one of the Open-threads frontiers (Gin's small WITH-cost bump, alamofire DataRequest residual, or stabilizing per-repo benchmark numbers with median-of-8).
-
-> Suggested next message: "Delete the merged branches feat/adaptive-explore-sizing and feat/explore-per-symbol-sizing — local and remote."
-
-## Goal
-Make `codegraph_explore`'s cost a clear win on **every** README benchmark repo, especially the two laggards the README showed thinnest (Django 9% cheaper, OkHttp 4%). The optimization target per CLAUDE.md is **tool-calls/reads + latency** (NOT raw cost) — but the user explicitly wanted the cost margins up too. Definition of done = both laggards clearly cheaper with ~0 reads, no regression elsewhere, README refreshed, shipped. **Achieved.**
-
-## Key findings
-- **The feature, in `src/mcp/tools.ts` (`handleExplore` + `buildFlowFromNamedSymbols`):** explore sizes output to the *answer*, not the file count. Builds on PR #564's gate (off-spine + polymorphic-sibling, with a named-callable *spare* + supertype-family *override*).
-- **PR #569 added four things** (all in `tools.ts`):
-  1. **Uniqueness-aware spare** — `buildFlowFromNamedSymbols` now returns `uniqueNamedNodeIds` (callables whose token had ≤3 defs). The whole-file spare uses it, so `as_sql` (110 defs) no longer keeps every Compiler/Expression variant full; `getResponseWithInterceptorChain` (1 def) still spares RealCall.
-  2. **Per-symbol focused view** — a collapsed family file renders FULL bodies for symbols with `prio()` < 99 (on-spine=0, unique-named=1, `fileDefinesSuper && named`=2), signatures for the rest. Bounded: `bodyCap = maxCharsPerFile*2`, `SIG_MAX = max(12, maxSymbolsInFileHeader*2)`. Header tag flips to `· focused (…)` when any body shown, else `· skeleton (…)`.
-  3. **All-tier test-file exclusion** — removed the `budget.excludeLowValueFiles` gate on the `isLowValue` hard-exclude (was <500-file tiers only); guards (query-mentions-tests, ≥2 non-test remain) kept.
-  4. **Named-cluster survival in non-sibling god-files** — inject agent-named method defs into `rangeNodes` even if the gather missed them; rank named ranges at importance **9** (above glue 6 / connected 3); `fileBudget = min(maxCharsPerFile, maxOutputChars - totalChars - 200)` in cluster selection so high-importance named clusters survive instead of being source-order-trimmed.
-- **Validated (headless A/B, Opus 4.8, median of 4, full 7-repo sweep) — now in README:** avg **25% cheaper · 57% fewer tokens · 23% faster · 62% fewer tool calls** (was 22/47/20/50). Per-repo cost: VS Code 33, Excalidraw 27, Django **23** (was 9, median 0 reads), Tokio 35, OkHttp **11** (was 4, 0 RealCall read-backs), Gin 15, Alamofire 28.
-- **PR #564 (already merged, `f1b14f0`)** was the prior round: named-callable spare + supertype-family override (fixed the read-back regression where RealCall.kt / compiler.py were skeletonized then Read back).
-
-## Gotchas
-- **A/B per-repo variance is large (±~10–13 pts).** The WITHOUT arm swings run-to-run (how hard native greps). Excalidraw/Gin look *lower* than the prior README purely from a cheaper native baseline this batch — NOT regressions (reads still 0/low). **Averages are the stable signal.** Never conclude from n=1; the README is median-of-4.
-- **The alamofire `DataRequest` residual is NOT cleanly closable.** A "spare a file when the agent names its class" type-spare *broke OkHttp* (it spared all 5 interceptor classes → 0 skeletons). A named sibling class is structurally indistinguishable from "the one main type." Left as-is (alamofire is 28% cheaper; ~1 DataRequest read/run).
-- **Gin's WITH-cost ticked up ($0.36→$0.48 across batches)** — partly the named-injection adding content to an already-0-read repo. Still 15% cheaper. Possible over-eager named-injection on small repos.
-- **Validate retrieval changes with a real-agent A/B, not just the probe.** The deterministic `probe-explore.mjs` query forms a *different spine* than the agent's real query → it hid both the Django and the OkHttp read-backs. (Dead-end #6 in the design doc.)
-- **Always `npm run build` before probing/A/B** — probes + the A/B MCP server load `dist/`, not `src/`. Corpus indexes (`/tmp/codegraph-corpus/*`) are valid without re-index since all changes are query-time.
-- **`adaptive-sizing-skeletonizing.md` handoff is gone from `main`'s working dir** — it was untracked, got swept into commit `3c38729` on `feat/adaptive-explore-sizing`, so it lives only on that branch now. Deleting that branch deletes it (it's obsolete — that work shipped).
-- **5 `npm-shim` test failures are pre-existing/network** (lack `--probe-net` on the global binary) — not a regression; don't let them block.
-
-## How to test & validate
-- Build first: `npm run build` (must be green).
-- Deterministic probe: `node scripts/agent-eval/probe-explore.mjs /tmp/codegraph-corpus/<repo> "<symbol-bag query>"` → inspect `#### file — … · focused/skeleton` headers + sizes. okhttp = 5 `· skeleton`; django compiler.py `· focused` with `def execute_sql`/`def as_sql`/`def _fetch_all` bodies present; excalidraw/tokio/vscode/gin = 0 skeleton/focused (inert).
-- A/B one repo: `bash /tmp/ab-one.sh <repo> <runs> "<question>"` → writes `/tmp/ab-readme/<repo>/run<n>/`. Aggregate one repo: `node /tmp/one-agg.mjs <repo>`. Full 7: `RUNS=4 bash scripts/agent-eval/bench-readme.sh` then `node scripts/agent-eval/parse-bench-readme.mjs /tmp/ab-readme` (averages) + `node /tmp/full-agg.mjs` (per-repo reads/grep/tools/cost/time).
-- Unit: `npx vitest run __tests__/adaptive-explore-sizing.test.ts` → **8/8** (skeleton, named-callable spare=RealCall, supertype-family override→focused=codec.ts, uniqueness/shared-method, on-spine exemplar full, distinct step full, flag=0 disables).
-- **Methodology:** a real win = cost DOWN **and** reads NOT up vs the same build's WITHOUT arm; confirm inert repos stay 0 skeleton/focused (the change only *adds* spare conditions + per-symbol rendering of already-collapsed files → strict subset of the original gate).
-
-## Repo state
-- branch `main`, last commit `b026e64 feat(mcp): per-symbol adaptive codegraph_explore sizing (#569)`.
-- uncommitted: clean (this handoff file will be a new untracked `.claude/handoffs/` entry).
-- merged-but-undeleted branches: `feat/adaptive-explore-sizing` (#564) + `feat/explore-per-symbol-sizing` (#569), both local + remote.
-
-## Open threads / TODO
-- [ ] Delete the two squash-merged branches (local + remote): `feat/adaptive-explore-sizing`, `feat/explore-per-symbol-sizing`.
-- [ ] (optional) Stabilize the README per-repo numbers with a 2nd full-7 batch → publish median-of-8 (smooths the WITHOUT-arm variance that makes Excalidraw/Gin look lower).
-- [ ] (frontier) Gin's small WITH-cost bump from named-injection on an already-0-read repo — consider gating named-injection by repo size / whether reads were happening.
-- [ ] (frontier) Alamofire `DataRequest` ~1 read/run — only closable by distinguishing "main type" from "interchangeable sibling," which has no clean static signal (don't re-attempt the type-spare; it breaks okhttp).
-- [ ] (frontier) Big non-sibling god-file clustering generally (#185 family) — `query.py`/`Session.swift`-style files; the fileBudget fix helped Django but the merge/selection logic is still the lever for other repos.
-
-## Recent transcript (oldest → newest)
-### Turn — "Add the regression test for the skeletonization"
-- **Claude:** Wrote `__tests__/adaptive-explore-sizing.test.ts` (OkHttp-in-miniature fixture); validated via probe; 5 cases. (This was for PR #564's gate.)
-- **Outcome:** Test landed; led into re-validating the README.
-### Turn — re-validate README, found Django/OkHttp cost laggards → built fixes
-- **Claude:** A/B sweeps via `bench-readme.sh`; diagnosed read-backs (RealCall via `Lockable` 9-impl mixin; compiler.py base+subclasses). Shipped PR #564 (named-callable spare + supertype-family override). Then A+B+C (uniqueness + per-symbol focused + test exclusion).
-- **Outcome:** Django/OkHttp flipped to wins; alamofire type-spare attempt broke okhttp → reverted.
-### Turn — "Pursue the deeper Django fix"
-- **Claude:** Cluster-debug showed `_fetch_all` (L2237) never formed a cluster + got source-order-trimmed. Fixed via named-injection + importance 9 + `fileBudget`. A/B: Django reads tail 3→1, 11%→17%; controls (okhttp 14%, excalidraw 31% / 0 reads) held. Committed `e71eb90`.
-- **Outcome:** Django residual closed.
-### Turn — "push + PR + squash-merge, update README incl. averages + header"
-- **Claude:** Ran final full-7 sweep (`bench-readme.sh`, 56/56). Clarified a confusing "WITHOUT got cheaper" phrasing (WITH cg is cheaper in all 7; the % is the *gap*, which shrinks when the native baseline is cheap that batch). User said publish this batch.
-- **Outcome:** Updated README (headline 25%/62%, average line, 7 summary rows, 7 detail tables, methodology date) + CHANGELOG + design doc. Built clean branch off `origin/main` (dropping the already-squashed commits + the handoff artifact), pushed, opened PR #569, squash-merged → `b026e64`. Synced local to main, rebuilt dist. Offered branch cleanup → user ran `/handoff save`.

+ 0 - 70
.claude/handoffs/framework-coverage-sweep-2026-05-23.md

@@ -1,70 +0,0 @@
----
-name: framework-coverage-sweep-2026-05-23
-date: 2026-05-23 23:59
-project: codegraph
-branch: architectural-improvements
-summary: Dynamic-dispatch coverage sweep COMPLETE — all 14 README frameworks + every flow-relevant language validated (measure→fix→validate→test→playbook→commit). ~37 commits pushed, suite green. Ship-prep (CHANGELOG + PR to main) is the only thing left.
----
-
-# Handoff: Dynamic-dispatch framework/language coverage sweep (complete)
-
-## Resume here — read this first
-**Current state:** The coverage sweep is **done**, AND a **frontier pass** closed the tractable partials. Every framework in the README's 14-row table is ✅, every flow-relevant language is validated (TS/JS, Python, Go, Java, C#, PHP, Ruby, Rust, Swift, Dart, Kotlin, Lua/Luau, Scala, C/C++), and the frontier pass added: React object data-router (literal), Next.js false-positive fix, Flask-RESTful `add_resource` (redash 6→77), Flask tuple methods + broader detection (flask-realworld 0→19), gorilla/mux confirmed. All committed/pushed to `architectural-improvements` (tree clean except untracked `.claude/handoffs/`). Full suite green (**809 passed**, 2 skipped; flaky `watcher.test.ts > debounced sync` passes on re-run). **No CHANGELOG entry exists, and the branch is not yet merged to main.**
-**Immediate next step:** Ship-prep — write a CHANGELOG entry grouping the whole sweep (route resolution for Flask/FastAPI/Drupal/Rust-Axum+actix/Vapor/Spring-Kotlin/Play + React Router routing; the Python builtin-name guard, Dart method-range, and C++ inheritance foundational fixes; the flutter-build and cpp-override synthesizer channels), bump `package.json`, then open a PR to main.
-
-> Suggested next message: "do ship-prep: write the CHANGELOG entry covering the whole framework/language coverage sweep on this branch, bump the version, and open a PR to main"
-
-## Goal
-Close static-extraction holes for **dynamic dispatch** across every language/framework codegraph supports, so cross-symbol flows (request→route→handler→service, state→render, virtual→override) exist in the graph and an agent answers flow questions with few codegraph calls and ~0 Read/Grep. Per framework/language: canonical flow `trace`s end-to-end, agent A/B shows fewer reads, no node explosion, recorded in `docs/design/dynamic-dispatch-coverage-playbook.md` (the matrix §6 + per-item notes §7). **This goal is now met; what remains is ship-prep + documented frontiers.**
-
-## Key findings (this session's work, all committed)
-- **Routing convention is the hole in every backend** — same pattern each time: the resolver/extractor assumed one syntax. Flask (intervening `@login_required`/stacked routes), FastAPI (empty `""` path), Drupal (`claimsReference` for FQCN `_form`/single-colon controllers + contrib `detect` via composer name/type/`.info.yml`), Rust/Axum (chained `get(h).post(h2)` + namespaced `mod::handler`), actix (builder API `web::resource().route(web::get().to(h))`), Vapor (grouped `routes.grouped("x"); x.get(use:h)` — was 0 on every real app), Spring **Kotlin** (`fun` handler syntax + `.kt`), Play (extensionless `conf/routes` → controller), React Router (`<Route>` JSX).
-- **Three FOUNDATIONAL fixes (broad benefit, not framework-specific):** (1) Python **bare-name builtin guard** in `src/resolution/index.ts` — a handler named `index`/`get`/`update` was filtered as a builtin method; mirror the dotted-branch `knownNames` guard. (2) **Dart method-range** in `src/extraction/tree-sitter.ts` `createNode` — Dart bodies are SIBLINGS of the signature, so methods were `end==start` (signature-only); extend `endLine` to the resolved body (guarded, child-body grammars no-op). (3) **C++ inheritance** — `extractInheritance` handled `base_clause` (PHP) but not C++ `base_class_clause`; added it (leveldb extends 219→298).
-- **Two new synthesizer channels** in `src/resolution/callback-synthesizer.ts` (Dart analog + C++ analog of react-render): `flutter-build` (a State method calling `setState(` → `build`) and `cpp-override` (base virtual method → subclass override of same name, gated to C++).
-- **measure-first repeatedly split "needs work" from "already covered":** Svelte, NestJS (prior), and this session **Lua/Luau** (module dispatch already resolves) + **Compose** (composition is plain function calls, already static) needed NO code. The assumed hole wasn't real.
-- **`claimsReference` pre-filter is the recurring gotcha** (`src/resolution/index.ts:497-503`): a route ref naming no declared symbol (FQCN, `Controller@method`, `controller#action`, `Class.method`) is dropped before `framework.resolve()` runs. Added for Drupal + Play this session.
-
-## Gotchas
-- **`claimsReference`:** if a new framework's route refs don't resolve despite a correct `resolve()`, it's the pre-filter — add `claimsReference`.
-- **Reindex picks up resolver changes only on a CLEAN index:** `codegraph index` is incremental (skips unchanged files); after `npm run build`, do `rm -rf .codegraph && codegraph init -i` to re-extract. The init message's edge count is contains-only (~misleading); query the DB for the real count.
-- **Extraction changes are high blast radius** (shared `createNode`/`extractInheritance`): re-check node counts on control repos (excalidraw 9,290 / django 302) — the Dart/C++ fixes are guarded to only-extend / C++-only, controls unchanged.
-- **Play `conf/routes` is extensionless** → needed `isPlayRoutesFile` opt-in in `grammars.ts` (isSourceFile + detectLanguage→'yaml' no-grammar path). Narrow match, only ADDS Play files.
-- **Flaky:** `watcher.test.ts > debounced sync > should trigger sync after file change` — timing-based, passes on re-run; unrelated to any of this work.
-- **Foreground `sleep` is blocked** in Bash → background A/B batches (`run_in_background: true`), read the task output file. zsh quirks: quote globs (`'*.vue'`); SQL `count(*)` in `$(...)` needs care with quotes.
-- Global `codegraph` is npm-linked to this repo's `dist/`; `npm run build` then reindex. A/B harness: `scripts/agent-eval/run-all.sh <repo> "<Q>" headless` (with vs empty MCP), parse via `node scripts/agent-eval/parse-run.mjs`.
-
-## How to test & validate (the per-framework loop)
-- Corpus in `/tmp/codegraph-corpus/<name>` (clone S/M/L, `git clone --depth 1`). Index: `rm -rf .codegraph && codegraph init -i`.
-- Measure holes: `sqlite3 .codegraph/codegraph.db "select count(*) from nodes where kind='route'"` + route→handler edges (`join edges on source where kind='references'`). Node-count before/after (no explosion).
-- Flow: `node scripts/agent-eval/probe-node.mjs <repo> <symbol>` (shows Called-by/Calls trail) / `probe-trace.mjs <repo> <from> <to>`.
-- Agent A/B (≥2 runs/arm, variance is real): `run-all.sh` headless, record Read/Grep/duration/codegraph. Pass = fewer reads with codegraph.
-- Tests: `npm test` (vitest). Resolver extract tests in `__tests__/frameworks.test.ts`; end-to-end in `__tests__/frameworks-integration.test.ts` (real CodeGraph + indexAll); Dart range in `__tests__/extraction.test.ts`; Drupal in `__tests__/drupal.test.ts`.
-
-## Repo state
-- branch `architectural-improvements`, last commit `42a0178 docs(playbook): record frontier pass; test(go): gorilla/mux`.
-- uncommitted: clean (only untracked `.claude/handoffs/`).
-- ~37 commits total on the branch (handoff's original 11 frameworks + this session's: Flask/FastAPI, Drupal, Rust/Axum, Vapor, React Router, actix, Dart, Kotlin, Lua, Scala/Play, C/C++ — each a feat + a docs(playbook) commit; Lua was docs-only).
-
-## Open threads / TODO
-- [ ] **SHIP-PREP (the only blocker to merge):** CHANGELOG entry for the whole sweep, `package.json` bump, PR to main. Releases go through `.github/workflows/release.yml` only — do NOT `npm publish` (see CLAUDE.md).
-- [x] **Frontier pass DONE (commits 0456915, 03e49ab, 42a0178):** React object data-router (literal), Next.js false-positive fix, Flask-RESTful `add_resource`, Flask tuple methods + detection, gorilla/mux confirmed.
-- [ ] **Frontiers LEFT (deliberately, with rationale in playbook §7 "Frontier pass"):** anonymous/inline closures (def-use frontier), metaprogramming finders (AR/Eloquent/JPA/EF), reactive runtimes (Vue Proxy / Compose recomposition), Akka actors, C callback-struct 422-way fan-out, C++ pure-virtual base methods, React lazy data-router (variable paths + lazy imports), Play SIRD, Nuxt-specific. Forcing these adds noise.
-- [ ] Pre-existing, unrelated: Next.js `*.config.mjs` in a `pages/` dir treated as a route (false-positive found in bulletproof-react).
-
-## Recent transcript (oldest → newest, this session)
-### Turn — "what's left / what's next on coverage" → did Flask/FastAPI
-- 3 holes: Flask intervening/stacked decorators, FastAPI empty path, **Python bare-name builtin guard** (handlers named `index`/`get` filtered). microblog 6→27, realworld 12→20, dispatch 290/290. Fixed 6 stale Laravel/Rails tests too. Committed + pushed.
-### Turn — "Drupal next"
-- `claimsReference` for FQCN/_form/single-colon controllers + contrib `detect` (composer type/name + `.info.yml`). core 536→731 (87%), admin_toolbar 0→14. OOP `#[Hook]` = frontier. Committed.
-### Turn — "Rust: Axum/actix/Rocket"
-- Axum chained methods + namespaced handlers (realworld 12→19, 19/19); Rocket already 99%; **actix builder API** `web::resource().route(web::get().to())` (examples 51→128). Committed (2 commits: axum, then actix).
-### Turn — "Vapor (Swift)"
-- Resolver was 0-routes on every real app; rewrote for any receiver + optional non-string paths + `.grouped` prefix tracking + `use:` discriminator. template 0→3, SteamPress 0→27, SPI 0→14. Committed.
-### Turn — "2, 3, 4" (React Router, actix [done above], Dart/Flutter)
-- React Router `<Route>` JSX (react-realworld 0→10). Dart/Flutter: **method-range fix** (foundational) + `flutter-build` setState→build synthesizer. Committed.
-### Turn — "Kotlin next"
-- Spring resolver `['java']`→`['java','kotlin']` + `fun` handler regex (petclinic-kotlin 0→18, 18/18; Java unchanged 19/19). Compose composition already static. Committed.
-### Turn — "Lua/Luau, Scala, C/C++ (Lua first, but do all three)"
-- **Lua:** measure-first → module dispatch already covered (telescope 335 cross-file calls); no code change, validated. **Scala/Play:** `conf/routes` file-walk opt-in + Play resolver (computer-database 0→8). **C/C++:** general dispatch strong (redis 29k); fixed C++ `base_class_clause` inheritance + `cpp-override` synthesizer (leveldb 12 precise). All committed + pushed.
-### Turn — "wrap up + refresh handoff"
-- This handoff. Sweep complete; ship-prep (CHANGELOG + PR) is the remaining work.

+ 0 - 86
.claude/handoffs/trace-relevance-coldstart-2026-05-30.md

@@ -1,86 +0,0 @@
----
-name: trace-relevance-coldstart-2026-05-30
-date: 2026-05-30 23:30
-project: codegraph
-branch: feat/trace-relevance-closure-collection
-summary: Turned Alamofire (README's weakest repo) into a clean win via a trace endpoint-disambiguation fix + god-file explore rendering, then eliminated the MCP cold-start race that was causing benchmark inconsistency (handshake ~811ms→~90ms); PR #580 has 6 commits, all that's left is a clean README sweep + squash-merge.
----
-
-# Handoff: trace-relevance + closure-collection + cold-start (PR #580)
-
-## Resume here — read this first
-**Current state:** PR #580 (branch `feat/trace-relevance-closure-collection`, 6 commits, pushed, in sync with remote) is feature-complete and validated — full suite 1090 pass (only the 5 pre-existing npm-shim network fails), 28/28 MCP+daemon tests. The MCP cold-start race (the dominant benchmark-inconsistency source) is ELIMINATED via the proxy-local-handshake (tool registration ~90ms cold+warm, was ~811ms). The README benchmark table still shows the OLD pre-fix numbers.
-**Immediate next step:** Run a median-of-4 README sweep on this build (the race is gone, so numbers should be naturally consistent), update the README table/averages/headline, then squash-merge PR #580.
-
-> Suggested next message: "Run `RUNS=4 bash scripts/agent-eval/bench-readme.sh` on this build, parse with `node scripts/agent-eval/parse-bench-readme.mjs /tmp/ab-readme` (race-aware), update the README benchmark table + averages + the 7 per-repo detail tables + methodology date, then squash-merge PR #580 with `gh pr merge 580 --squash --admin`."
-
-## Goal
-Started as "Alamofire is the README's weakest benchmark repo (13% fewer tool calls vs the ~62% average) — fix it." Became: make CodeGraph's retrieval **consistent and faster**. Definition of done = PR #580 merged (trace fix + dynamic-dispatch coverage + god-file rendering + cold-start elimination), README refreshed with stable median-of-4 numbers. Optimization target per CLAUDE.md is **tool-calls/reads + latency**, NOT raw cost.
-
-## Key findings
-The 6 commits on the branch (oldest→newest):
-- `e86d573` **Trace endpoint relevance** (THE Alamofire win) + closure-collection synthesizer + explore synth-links.
-- `c64c4b3` **God-file multi-phase explore rendering** (6 sub-layers).
-- `5d7388c` Skeleton/focused tag steers to `codegraph_explore`, not Read (spiral fix #1).
-- `dc19eab` Bench parser race-aware (excludes "No such tool available" runs).
-- `91e28df` serve --mcp cold-start ~811ms→~600ms (defer CodeGraph load + 25ms poll).
-- `82ae484` **Proxy-local-handshake** — handshake ~600ms→~90ms, cold-start race eliminated.
-
-Root-causes found by reading A/B TRANSCRIPTS (not the noisy median):
-- **Trace bug:** `handleTrace`'s `scorePair` ranked only by shared-dir-prefix, so overloaded names (`request`=44 defs, `task`=8) resolved to empty `EventMonitor.request(){}` / `RedirectHandler.task` STUBS over the real `Session.request` → agent saw garbage, said "the trace collided with same-named symbols", read by hand. Fix: `nodeRelevance` term in `handleTrace` (penalize ≤1-line stubs −40, test files −150). Result n=8: WITH tools 12→8 median, read variance 0–12→1–4 (the meltdowns WERE the trace-collision flounder). General bug (Swift/Java/C#/Go protocol-stub flooding).
-- **Closure-collection synthesizer** (`src/resolution/callback-synthesizer.ts` `closureCollectionEdges`): Swift `validators.write{$0.append}`…`didCompleteTask` `validators.forEach{$0()}`. The element-invoke `$0(`/`it(` is the precision gate → 9 edges on Alamofire, **0 on every non-Swift control**. Surfaced inline in trace + a "Dynamic-dispatch links" section in `buildFlowFromNamedSymbols` (so it shows when the agent named only `validate`, not `didCompleteTask`).
-- **God-file rendering** (`handleExplore` in `src/mcp/tools.ts`, 6 layers): (1) on-spine god-files render spine-full + off-path methods as signatures (true-spine); (2) named-seed gather — inject each named token's substantive def into the subgraph (FTS buried `validate` → Validation.swift was never gathered); (3) a file that DEFINES a named symbol scores +50 (beats incidental Combine.swift's +23 connected-node score); (4) the 90%-budget early-break and (5) the total-output cap both EXEMPT necessary (entry/spine/uniqueNamed) files; (6) final ceiling 1.5×maxOutputChars. Renders build+validators-exec+validate in ONE explore.
-- **Spiral cause #1 (fixed):** the skeleton tag said "Read for a full body" → agent Read the skeletonized central files → over-investigation spiral. Now steers to `codegraph_explore`.
-- **Spiral cause #2 / the BIG inconsistency (fixed):** MCP **cold-start race**. `serve --mcp` wasn't ready when the headless agent fired → "No such tool available" → grep/Read flounder (19–30 tool spirals). Root-caused: NOT module load (mcp/index 38ms, CodeGraph chain 30ms), NOT the `--liftoff-only` re-exec (NO_RELAUNCH ≈ same) — it's the proxy WAITING for the spawned daemon to bind. Fixed: proxy answers initialize/tools-list from STATIC constants (`runLocalHandshakeProxy` in `proxy.ts`), forwards tool CALLS to the daemon (connected in background), lazy in-process engine fallback preserves the old fall-back-to-direct robustness. `connectWithHello` distinguishes 'version-mismatch' (fail fast → local) from 'not-yet' (poll). Handshake 91ms cold / 88ms warm.
-
-## Gotchas
-- **A/B variance is HUGE — never conclude from n=1, or even one n=4 batch.** The median-of-4 caught regressions the lucky dedicated batches HID (the god-file rework looked great in one batch at 0.5 reads/5.5 tools; the median showed 13 tools dragged by 2 spirals). Report ranges.
-- **Kill stale daemons before any cold-start measurement:** `pkill -9 -f "dist/bin/codegraph.js"; rm -f /tmp/codegraph-corpus/<repo>/.codegraph/daemon.*`. A zombie daemon holding the lock causes a 6s retry-exhaust that looks like a 7× regression (it bit me — the "6239ms" false alarm).
-- **`timeout` is NOT on macOS** (no coreutils) — measure cold-start with a `node` spawn + a `setTimeout` kill-timer (see the transcript's measurement snippets).
-- Corpus repos: `/tmp/codegraph-corpus/<repo>` (all 7 README repos indexed). Explore/trace changes are **query-time** (no re-index). The closure-collection synthesizer is **index-time** but produces 0 edges on non-Swift, so it's inert there.
-- Global `codegraph` is npm-linked to the dev dist (`node dist/bin/codegraph.js`). **Always `npm run build` before any probe/A/B** (they load `dist/`, not `src/`).
-- `engine.ts`/`tools.ts` now `import type CodeGraph` + lazy `require('../index')` (CommonJS, cached) so the daemon binds before the sqlite/query chain loads; `findNearestCodeGraphRoot` now comes from the light `../directory`.
-- The old `runProxy`/`pipeUntilClose` in `proxy.ts` are now DEAD (superseded by `runLocalHandshakeProxy`) — left in place; safe to prune in a follow-up.
-- 5 `npm-shim.test.ts` failures are pre-existing/network (need `--probe-net`) — NOT regressions; ignore.
-- Uncommitted `.gitignore` change (`tmux-web/`) is unrelated/not mine — do NOT commit it on this branch.
-- `parse-bench-readme.mjs` excludes raced runs by default; `CG_INCLUDE_RACED=1` keeps them to see the raw distribution. Now a safety net (race eliminated at source).
-
-## How to test & validate
-- `npm run build` → must be clean (exit 0).
-- `npx vitest run` → **1090 pass**, only the 5 npm-shim network fails.
-- `npx vitest run __tests__/mcp-daemon.test.ts` → **7/7** (sharing, #277 survive-client-death, version-mismatch fallback, idle-timeout).
-- Cold-start handshake (after killing daemons): node-spawn a `serve --mcp`, send `initialize`, time the id:1 response → **~90ms** (was ~811ms). Then a `tools/call` (e.g. `codegraph_status`) returns a real result (forwarded to the daemon, ~3.4s on vscode's first index load — a call that returns LATE, not a missing-tool error).
-- A/B sweep: `RUNS=4 bash scripts/agent-eval/bench-readme.sh` → `node scripts/agent-eval/parse-bench-readme.mjs /tmp/ab-readme`.
-- **Methodology:** handshake <150ms = race eliminated; in an A/B, grep the WITH jsonls for "No such tool available" (should be 0 now); WITH reads/tools < WITHOUT with no control regression.
-
-## Repo state
-- branch `feat/trace-relevance-closure-collection`, last commit `82ae484 perf(mcp): proxy answers initialize/tools-list locally — cold-start handshake ~600ms→~90ms`. In sync with remote (0/0). PR **#580** open.
-- uncommitted: ` M .gitignore` (unrelated tmux-web/ edit, not mine) + the new untracked `.claude/handoffs/` files. All code committed.
-
-## Open threads / TODO
-- [ ] Run median-of-4 README sweep on THIS build (race gone → should be naturally consistent, no exclusions needed).
-- [ ] Update README: benchmark summary table (7 rows × cost/tokens/time/tools), the 7 per-repo detail tables (Time/Reads/Grep/Tools/Tokens/Cost), the average line, the headline (line 7), and the methodology date (line 86).
-- [ ] Squash-merge PR #580 (`gh pr merge 580 --squash --admin` per the PR-merge-policy memory).
-- [ ] Add CHANGELOG entries under `[Unreleased]` for the cold-start fixes (trace fix + closure-collection + god-file already have entries from earlier commits — verify).
-- [ ] (cleanup) Prune dead `runProxy`/`pipeUntilClose` in `proxy.ts`.
-- [ ] (frontier, deferred) Alamofire `task` overload still seeds `RedirectHandler.task` (a harmless false-positive in named-seed ranking) — same nodeRelevance idea could de-rank it.
-
-## Recent transcript (oldest → newest)
-### Turn — "which repos drag the averages? Alamofire looks worst"
-- **Claude:** Computed per-column deviations from the README table → Alamofire #1 drag (tool-calls 13% vs 62% avg, time 7% vs 23%), gin #2; both ~110-file repos.
-- **Outcome:** Targeted Alamofire.
-### Turn — "dig into Alamofire A/B logs"
-- **Claude:** Read `/tmp/ab-readme/alamofire/run*/run-headless-with.jsonl` → found validate-flow break (`validators` closure-array) + per-run reasoning. Built closure-collection synthesizer + explore synth-links. A/B: flat (adoption variance).
-- **Outcome:** Synthesizer correct + sufficient (forced codegraph-only 3/3) but didn't move the noisy median.
-### Turn — "keep working till you figure it out"
-- **Claude:** Read transcripts again → "the trace collided with same-named symbols (44 requests, 8 tasks)". Found `scorePair` picked empty stubs. Added `nodeRelevance`. A/B n=8: tools 12→8, variance collapsed.
-- **Outcome:** The real Alamofire win. Committed `e86d573`; opened PR #580; squash-merge deferred.
-### Turn — "do the file-ranking + gather layers"
-- **Claude:** Built the 6-layer god-file rendering (`c64c4b3`). A/B flat-to-worse → debugged spirals → found the skeleton tag said "Read for more" (fixed `5d7388c`) → spirals persisted → root-caused the MCP cold-start race ("No such tool available").
-- **Outcome:** Race is the dominant inconsistency, not the code.
-### Turn — "keep iterating until consistent / do both (parser + startup)"
-- **Claude:** Bench parser race-aware (`dc19eab`). Deferred CodeGraph load off the bind path + 25ms poll (`91e28df`, 811→600ms).
-- **Outcome:** Consistency handled (parser) + 25% startup.
-### Turn — "how to optimize cold-start? / implement it"
-- **Claude:** Profiled (module loads 30-40ms, re-exec not it — it's the daemon-spawn wait). Implemented `runLocalHandshakeProxy` (`82ae484`): proxy answers initialize/tools-list locally + forwards calls + lazy local-engine fallback. Fixed 4 daemon tests (emit "Attached to shared daemon" + fast-fail version-mismatch + updated 1 assertion). Handshake 90ms; 28/28 MCP tests; full suite 1090 pass.
-- **Outcome:** Cold-start race ELIMINATED. All cold-start work committed + pushed. README sweep + squash-merge pending.

+ 39 - 0
CHANGELOG.md

@@ -11,10 +11,49 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- Cross-file impact and blast-radius coverage now spans **all 22 supported languages and 14 web frameworks**, each validated on a real-world repo — see the new coverage table in the README. This release ships the cross-file resolution behind it, including Lua and Luau `require`, Shopify OS 2.0 Liquid section templates, Delphi form code-behind, Rust cross-module calls and Rocket route macros, Swift Fluent relationships, and the SvelteKit / Nuxt / Vapor / Axum route conventions. The residual everywhere is genuine static-analysis frontiers (runtime dispatch, reflection / DI, framework-convention entry points), never hidden.
+- C# types are now tracked by their namespace-qualified name. Same-named types in different namespaces — a domain entity and a DTO both called `CatalogBrand`, say — are told apart instead of collapsing into one arbitrary match, so a reference resolves to the right one and impact no longer conflates them. (C#)
+- ASP.NET Razor (`.cshtml`) and Blazor (`.razor`) markup are now parsed for code relationships. A `@model` / `@inherits` / `@inject` directive links the view to the C# view-model, base type, or service it names; a Blazor `<MyComponent/>` tag (plus `@typeof(...)` and generic `TItem="..."` arguments) links to the component class; and the C# inside `@code { }` / `@functions { }` / `@{ }` blocks is analyzed too, so services and types used in component logic are linked. A view-model, component, or service referenced only from markup is no longer reported as having no dependents, and editing it surfaces the views that use it. (ASP.NET, Blazor)
+- A Razor/Blazor type reference now resolves through the component's `@using` namespaces — including the folder's cascading `_Imports.razor` — so a simple name that exists in several namespaces lands on the right one. A `@model` / `<MyComponent>` / `@code` reference to `CatalogBrand` resolves to the `@using`'d DTO (`BlazorShared.Models.CatalogBrand`) rather than a same-named domain entity. (ASP.NET, Blazor)
 - `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329)
 
 ### Fixes
 
+- React Native native→JS events now connect through the common `sendEvent(context, "X", body)` wrapper. Many libraries (react-native-device-info and others) wrap the event emitter behind a helper whose `.emit(eventName, …)` takes a *variable*, so the matcher — which looked for `.emit("literal", …)` — missed it; the literal event name actually lives in the wrapper call. Now a native method that fires `sendEvent(…, "batteryLevelChanged", …)` links to the JS `addListener('batteryLevelChanged', …)` handler, so editing the native emitter surfaces the JS subscriber. (React Native)
+- React Native / Expo cross-language bridges are more complete and more precise. An Expo Module method declared with a generic type — Android's `AsyncFunction<Float>("getBatteryLevelAsync")` — is now indexed (the `<Float>` used to defeat the matcher, so every Android Expo method was dropped and a JS call resolved only to the iOS Swift impl). The iOS and Android implementations of the same JS-visible method — both Expo Modules and classic NativeModules (`@ReactMethod` on Android, the matching method on iOS) — are now linked to each other, so a JS call that resolves to one platform still reaches the other and editing either platform's native code surfaces the JS caller. And a `Type.member` static read in native code (e.g. Android's `BatteryManager.EXTRA_LEVEL`) no longer falsely links to a coincidentally same-named class in another language (a web `BatteryManager`) — type references stay within a language family, while genuine cross-language bridges (config→code, JS↔native calls) are unaffected. (React Native, Expo)
+- A TypeScript/JavaScript reference or import no longer gets mis-linked to a same-named class in a native language. In a React Native / Expo repo that has both a TypeScript `TestRunner` type and a Kotlin `TestRunner` class, a TS reference to `TestRunner` — or an `import React` sitting next to a Swift `React` — used to resolve onto the native symbol (the component resolver matched any same-named class regardless of language, and import statements weren't language-checked at all). References and imports now stay within their language family, so they land on the right symbol while genuine cross-language bridges (JS↔native calls, config→code) are untouched. A C/C++ `#include "Foo.h"` likewise no longer resolves to a same-named header from another platform (an iOS Objective-C `Foo.h`). (React Native, Expo, TypeScript, C/C++)
+- Native includes and Kotlin Multiplatform imports now resolve to the correct file in multi-platform projects. A C/C++ `#include "Foo.h"` now resolves to the header in the including file's own directory first (the C quoted-include rule), so when a module ships a same-named header per platform (a Windows, an Apple, and an Android `Foo.h` side by side) the local one correctly shows its dependents instead of an arbitrary other-platform header looking like the dependency. And a Kotlin Multiplatform `expect` declaration is no longer reported as having no dependents: a `commonMain` import now resolves to the `commonMain` `expect` (matched within the importing source set) rather than being absorbed by one platform's `actual`. (C/C++, Kotlin)
+- `codegraph affected` now reports the tests and files that actually depend on your changes. It used to follow only `import` statements — but those never cross file boundaries in CodeGraph's graph — so it returned **no affected tests for any change, in every language**. It now traces the real cross-file usage graph (calls, references, instantiations, and class `extends` / `implements`), so `git diff --name-only | codegraph affected` surfaces the test files that exercise the changed code. Circular-dependency detection, which had the same blind spot, now works too.
+- Blast radius, callers, and `codegraph affected` now recognize far more of the dependencies that were already in your code. A symbol now counts as a dependency whether it's called, used only in a type annotation inside a function body (`const items: Foo[] = []`), imported and placed in a registry array or passed as an argument, used as a JSX component, simply re-exported from a barrel (`export { X } from './x'`), or pulled in as a namespace (`import * as ns from '@/x'`) — including through tsconfig path aliases like `@/`. Previously only called, instantiated, or signature-typed symbols created a cross-file link, so a file that used a dependency in any other way could look like it depended on nothing — and the file that defined a widely-used symbol could look like nothing depended on it. The graph still indexes exactly the same symbols; it just connects the ones that were already there. (TypeScript/JavaScript)
+- The same completeness fix now applies to **Python**: a name brought in with `from module import X` is recorded as a dependency on that module even when `X` is only stored in a list/dict, passed as an argument, used as a decorator, or re-exported through an `__init__.py`. Previously Python linked only imports that were called or instantiated, so a module consumed purely by value — or only re-exported — looked like nothing depended on it.
+- Rust impact and `codegraph affected` now connect far more of the module graph. Struct literals (`Widget { n: 1 }`) are recorded as instantiations; a `use` / `pub use` brings its item into the dependency graph — so a `pub use` re-export hub (a `mod.rs` re-exporting its submodules) depends on the modules it re-exports — resolved by Rust module path (`crate::`/`self::`/`super::`), so a re-export of a common name like `read` links to the right module instead of a same-named symbol elsewhere; and trait dispatch reaches implementations — a struct whose methods cover a trait's is treated as implementing it, and a call through `&dyn Trait` resolves to the concrete method. Previously a Rust type linked only when called or used in a type position, so structs built by literal, modules surfaced only through `pub use`, and trait-only implementations looked like they had no dependents. (#584 for Rust traits)
+- Rust cross-module function calls now resolve to the right file. A call to a sibling submodule's function — `users::router()`, the common router-assembly / handler-registration pattern where `mod users;` makes `users` a child of the current module — is now resolved relative to the current module, not only the crate root. Deeper module-path calls (`database::profiles::find()` — the `db.run(|c| …)` data-access shape) now resolve too; these were being discarded before resolution even ran, because the path's leaf function name was never checked. Previously such a call linked to nothing, so a module reached only as `module::path::function()` looked like it had no dependents; a web app wired this way (Axum, Rocket, and similar) now surfaces its handler and data-access modules' real callers. (Rust)
+- Rocket route handlers now connect to where they're mounted. A handler registered in a `routes![a::b::handler, …]` or `catchers![…]` macro used to be invisible — the macro body is a raw token tree, so the handler looked like it had no caller (Rocket mounts it at runtime) and its file showed no dependents. The handler paths are now read out of the macro and linked to the `mount`/`register` call, so editing a Rocket handler surfaces its route registration and a routes module is no longer reported as unused. (Rust, Rocket)
+- SvelteKit pages now connect to their server `load` functions. SvelteKit wires a `+page.server.js` / `+page.js` `load` (and form `actions`) to the sibling `+page.svelte`'s `data` by file path, with no import between them — so editing a `load` previously showed no impact on the page it feeds. Each page is now linked to the `load`/`actions` in its own route directory (and likewise for `+layout`), so editing a loader surfaces the page that renders its data, and tracing a page reaches its server-side data source. (Svelte, SvelteKit)
+- Nuxt nested components are now connected to where they're used. Nuxt auto-imports a component in a subdirectory by a directory-prefixed name — `components/media/Card.vue` is used in templates as `<MediaCard/>` — but it was tracked by its file name (`Card`), so the usage didn't resolve and the component looked unused. PascalCase component tags (`<MediaCard>`, `<NavBar>`) in a `.vue` template are now matched, falling back to the Nuxt directory-prefixed name, so editing a nested component surfaces every page and component that renders it. (Vue, Nuxt)
+- Lua and Luau `require` calls now connect to their module files. A dotted module path (`require("telescope.config")` → `telescope/config.lua` or `.../config/init.lua`) and a Roblox/Luau instance-path require (`require(script.Parent.Signal)` → the `Signal` module) now link to the file they load, so editing a module surfaces every file that requires it. Previously requires resolved to nothing, so a Lua/Luau module looked like it had no dependents. (Lua, Luau)
+- Shopify OS 2.0 sections now connect to the JSON templates that use them. Modern Shopify themes define templates as JSON (`templates/*.json`, plus section groups `sections/*.json`) that list sections by `type` rather than with a `{% section %}` Liquid tag, so a section used only from a JSON template was reported as having no dependents. Those JSON files are now read and each section `type` is linked to its `sections/<type>.liquid`, so editing a section surfaces the templates that render it. (Liquid, Shopify)
+- Delphi form definitions now connect to their code-behind units. A `.dfm` (VCL) or `.fmx` (FireMonkey) form is owned by its same-named `.pas` unit through the `{$R *.dfm}` directive rather than a `uses` clause, so a form file used only as a definition was reported as having no dependents. The unit is now linked to its form, so editing a form surfaces the unit that owns it. (Pascal/Delphi)
+- Swift property wrappers and attributes are now connected. A `@Argument` / `@Published` / `@State` / custom `@propertyWrapper` on a property — and attributes on types, methods, and functions (`@objc`, `@MainActor`, …) — now record a dependency on the wrapper/attribute type. Previously these were dropped entirely (Swift attributes parse differently from other languages, and stored properties weren't being inspected), so the wrapper type looked unused and the file using it depended on nothing — a big gap for SwiftUI and argument-parser-style code.
+- Swift Fluent relationship models are no longer orphaned. A type referenced only through a property-wrapper *argument* — `@Siblings(through: AcronymCategoryPivot.self, …)`, the many-to-many pivot/join model — now records a dependency on that type. Previously only the wrapper itself (`Siblings`) and the property's declared type were captured, so a pivot model reached solely through the relationship looked like nothing depended on it and editing it surfaced no impact. (Swift, Vapor/Fluent)
+- Java annotations are now connected. Annotation definitions (`@interface Foo`) are indexed as types, and every `@Foo` usage on a class, method, or field is recorded as a dependency on it. Previously neither side was captured — annotation usages were dropped (they live inside the declaration's modifiers) and `@interface` types weren't indexed at all — so annotation-driven code (Spring `@GetMapping`, JPA `@Entity`, Gson `@SerializedName`, …) showed the annotation as having no users and the annotated class as not depending on it.
+- Kotlin Multiplatform `expect`/`actual` declarations are now connected. A platform implementation — `actual fun`, `actual class`, or an `actual typealias` in a `jvm` / `native` / `js` / `wasm` source set — is linked to the common `expect` declaration it fulfills (including the common case of an `expect class` fulfilled by an `actual typealias`). Previously a caller in common code resolved to the `expect` declaration, so every platform `actual` looked like it had no dependents and editing one showed an empty blast radius; now changing a platform implementation surfaces the common API and everything that uses it. (Kotlin)
+- Scala impact and `codegraph affected` now connect the type graph that typeclass-style code is built on. A parameterized supertype (`trait Monoid[A] extends Semigroup[A] with Serializable`) now links to each parent; a type used in a `val`/`def` signature, as a type argument, or as a context bound (`def f[A: Monoid]`) — including the trailing implicit parameter list (`(implicit M: Monoid[A])`) where typeclass instances are passed — now records a dependency; and `new T[...] { … }` counts as an instantiation. Previously Scala linked only plain calls and bare, non-generic supertypes, so a trait extended with type parameters, used as a type, or required as an implicit looked like nothing depended on it — which on a typeclass-heavy codebase (cats, algebra) was most of the graph. (Scala)
+- PHP impact and `codegraph affected` now understand namespaces and `use` imports. Classes are tracked by their namespaced name, so the many same-named classes a framework defines (Laravel has 7+ `Factory` interfaces, several `Dispatcher`s, across namespaces) are told apart instead of collapsing into one arbitrary match. A `use App\Contracts\Cache\Factory;` now records a dependency on exactly that class — so a contract or interface that's imported and constructor-injected (the dependency-injection pattern) is no longer reported as having no dependents — and parameter, property, and return type-hints are recorded too. Previously PHP ignored namespaces entirely and linked only calls, `new`, and inheritance. (PHP)
+- Objective-C impact and `codegraph affected` are dramatically more complete. Four gaps are fixed: a single-argument message (`[cache storeImage:key]` — the most common call form) now matches its `storeImage:` method instead of dropping the colon; a class-message receiver (`[SDImageCache sharedCache]`, `[[Foo alloc] init]`) now records a dependency on the class, whose `@interface` lives in the header; `#import "Foo.h"` now resolves to the header file, so a header is no longer reported as having no dependents; and class-method message calls now resolve through the receiver type. Together these took typical libraries from a third-to-half of their files showing real dependents to ~90%. (Objective-C)
+- A type referenced only through a static member or enum value now records a dependency. Reading an enum value (`MediaKind.video`), a static constant (`Colors.red`, `JsonScope.EMPTY_DOCUMENT`), or a class constant (`Foo::BAR`) now links to the type — previously only method calls and `new` did, so a type or enum used purely *by value* (enum-heavy APIs, constants classes — a very common pattern) looked like nothing depended on it. Applies to Java, C#, Kotlin, Swift, Scala, Dart, PHP, and C++.
+- Dart impact and `codegraph affected` now follow mixins and method type annotations. A `with` mixin — Dart's core composition mechanism, which Flutter is built on — now records a dependency, so editing a mixin surfaces every class that mixes it in (the whole `with` clause used to be dropped, and a class declared `with M` alone even lost its real superclass link). And types used in a method's parameters or return value now link to their definition, so a class or enum referenced only as a type — not constructed or called — is no longer reported as having no dependents. (Dart)
+- C++ free functions are now indexed under their real name. A function written with a qualified-type parameter (`std::string TableFileName(const std::string& dbname)`) or an `auto … -> std::string` trailing return type was mistakenly named after that type (`string`), so calls to it never resolved, `codegraph_node` couldn't find it by name, and the file defining it looked like nothing depended on it. The function now keeps its real name, so cross-file calls, callers, and blast radius work — a meaningful gain for any namespaced C++ codebase (this is how most free functions in a library look). (C++)
+- Ruby impact and `codegraph affected` now follow mixins and `require`s. `include`, `extend`, and `prepend` of a module — Ruby's primary composition mechanism (ActiveSupport concerns, `Comparable`, `Enumerable`) — now record a dependency on that module, so editing a concern surfaces every class that mixes it in; previously these were read as a call to a method named `include`, so a module whose methods are exercised only by application code looked like nothing depended on it. And `require "lib/foo"` / `require_relative "../foo"` now link to the required file, so a file pulled in only by a `require` (config-loaded components, gems that don't autoload) is no longer reported as having no dependents. Together these took a typical gem from ~71% of its files showing real dependents to ~100%. (Ruby)
+- C# `record` types are now indexed. `record`, `record class`, and `record struct` declarations (everywhere in modern C# — DTOs, value objects, CQRS messages, MediatR notifications) were previously skipped entirely, so every reference, generic type argument (`IEnumerable<MyRecord>`), and `new MyRecord(...)` pointed at nothing and the file defining a record looked like it had no callers or dependents. (#237)
+- Go interfaces now connect to their implementations. Go has no `implements` keyword — a type satisfies an interface just by having the right methods — so CodeGraph now infers that link: a struct whose methods cover an interface's method set is treated as implementing it, and a call through the interface (`API.Marshal(...)`) reaches every concrete implementation. This means a type used only via an interface (the common plugin/strategy pattern — e.g. JSON-codec or renderer implementations selected at runtime) is no longer reported as having no callers or no dependents, and impact now flows from an interface method to the implementations behind it. (#584)
+- Go now records cross-package struct creation. A composite literal like `render.XML{...}` or `pkga.Widget{...}` — including ones registered in a package-level `var registry = map[string]R{...}` — now links to the package that defines the type. Cross-package function calls and type references already resolved; this closes struct instantiation, so a package whose types are only *constructed* elsewhere (a common pattern for interface implementations) is no longer reported as having no dependents. Go type conversions such as `(*Wrapped)(x)` now link to the converted-to type as well.
+- Python now follows whole-module imports — `from . import certs` then `certs.where()`, or `from pkg import sub` then `sub.run()`. Calls and attribute access through an imported submodule now resolve to that submodule, and importing a module is recorded as a dependency on it even when the member you use is itself re-exported from a third-party package. This also fixed Python relative-import path resolution generally (`from .sub.mod import x`), so `codegraph affected` and impact see the real module graph of a package.
+- Python now also links a whole-module **absolute** import (`import conduit.apps.signals`) to that module's file, not just `from x import y`. A module imported by its dotted path — common in package setup and side-effect imports — is no longer reported as having no dependents. Standard-library imports (`import os`) correctly create no edge. (Python)
+- Python `from package import submodule` now links to that submodule's file, resolved through the import's package so it lands on the right one when same-named modules exist in sibling packages (the FastAPI / Django router-aggregator pattern: `from app.api.routes import authentication` with an unrelated `authentication.py` elsewhere). So a route/handler module pulled in only by an aggregator is no longer reported as having no dependents. (Python)
+- Django `include('app.urls')` now records a dependency from the project URLconf onto the included app's `urls.py`, so an app's routes module is no longer reported as having no dependents and editing it surfaces the project that mounts it. (Django)
+- A chained method call (`builder.Services.AddCoreServices(...)`) now resolves to its definition. Previously only a single-segment receiver (`obj.method()`) resolved, so a call through a property/member chain — very common for C# extension methods like ASP.NET dependency-injection registration (`AddCoreServices`/`AddWebServices`) and Guard clauses — found no definition. (C#, and any language with chained calls)
+- A renamed default import (`import articlesController from './article.controller'` where the module does `export default router`) now records a dependency on the imported module. Previously only named imports linked, so a module consumed only through a default import — the standard Express/NestJS route-controller pattern — looked like nothing depended on it. External packages (`import React from 'react'`) still create no edge. (TypeScript/JavaScript)
 - The background file watcher no longer exhausts your machine's file-descriptor budget. On macOS it previously kept **one open file handle per watched file**, so on a large project the running MCP server could pile up tens of thousands of handles and blow past the system-wide limit — at which point *unrelated* apps (your shell, editor, Docker, browser) started failing with "too many open files" until the codegraph process was killed. The watcher now uses a single recursive watch on macOS and Windows, and bounded per-directory watches on Linux, so its cost stays flat no matter how large the project is. (#644, #496, #555, #628, #579)
 - Indexing a project with very symbol-dense files (tens of thousands of functions or methods in a single file) no longer runs out of memory. The step that links dynamic call relationships used to load every function and method into memory at once, which could exhaust the heap and abort indexing with "JavaScript heap out of memory" on large or generated codebases; it now streams them, so memory stays flat no matter how many symbols the project has. (#610)
 - Indexing a very large repository no longer aborts during its first sync with a "too many SQL variables" error. (#540)

+ 30 - 0
README.md

@@ -636,6 +636,36 @@ is written):
 | Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) |
 | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) |
 
+## Measured cross-file coverage
+
+Impact and blast-radius queries are only as good as the dependency graph behind them, so coverage is measured rather than asserted. **Fair coverage** = the share of symbol-bearing source files that have at least one *resolved cross-file dependent* — something that imports, calls, references, or (through a framework convention) routes to them — on a real-world benchmark repo per language. The residual is always a genuine static-analysis frontier (runtime dynamic dispatch, reflection / DI containers, framework-convention entry points, vendored third-party code), never hidden by gaming the denominator.
+
+| Language | Benchmark repo | Coverage |
+|---|---|---|
+| TypeScript / JavaScript | this repo | 95.8% |
+| Python | psf/requests | 100% |
+| Go | gin-gonic/gin | 96.6% |
+| Rust | BurntSushi/ripgrep | 86.7% |
+| Java | google/gson | 93.3% |
+| C# | jbogard/MediatR | 85.2% |
+| PHP | guzzle/guzzle | 100% |
+| Ruby | sidekiq/sidekiq | 100% |
+| C | redis/redis | 92.2% |
+| C++ | google/leveldb | 94.8% |
+| Objective-C | SDWebImage | 91.6% |
+| Swift | Alamofire | 95.3% |
+| Kotlin | square/okhttp | 96.2% |
+| Scala | gatling/gatling | 91.2% |
+| Dart | flutter/packages | 92.4% |
+| Svelte / SvelteKit | sveltejs/realworld | 100% |
+| Vue / Nuxt | nuxt/movies | 93.5% |
+| Lua | nvim-telescope/telescope.nvim | 84.2% |
+| Luau | dphfox/Fusion | 92.2% |
+| Liquid | Shopify/dawn | 73.8% |
+| Pascal / Delphi | PascalCoin | 75.7% |
+
+Framework routing is validated the same way, on a canonical app per framework: Express 100%, FastAPI 98%, Flask 100%, NestJS 96.8%, Gin 96.5%, Axum 100%, Rocket 93.8%, Vapor 100%, Laravel 92%, Rails 89.6%, React Router 100% — and the convention/reflection-heavy ones at their honest static-analysis ceiling: ASP.NET 83.9%, Spring 83.3%, Drupal 78.9%, Django 74.1%.
+
 ## Troubleshooting
 
 **"CodeGraph not initialized"** — Run `codegraph init` in your project directory first.

+ 53 - 0
__tests__/expo-modules.test.ts

@@ -151,4 +151,57 @@ export async function impactAsync() {
     expect(callEdge.length).toBeGreaterThanOrEqual(1);
     expect(callEdge[0].target_id.startsWith('expo-module:')).toBe(true);
   });
+
+  it('extracts GENERIC-typed Kotlin AsyncFunction<T> and pairs the iOS + Android impls', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'package.json'),
+      '{"dependencies":{"expo-modules-core":"^1.0.0"}}'
+    );
+    fs.mkdirSync(path.join(dir, 'ios'));
+    fs.writeFileSync(
+      path.join(dir, 'ios', 'BatteryModule.swift'),
+      `import ExpoModulesCore
+public class BatteryModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("ExpoBattery")
+    AsyncFunction("getBatteryLevelAsync") { () -> Float in return 1.0 }
+  }
+}
+`
+    );
+    fs.mkdirSync(path.join(dir, 'android'));
+    fs.writeFileSync(
+      path.join(dir, 'android', 'BatteryModule.kt'),
+      `import expo.modules.kotlin.modules.Module
+class BatteryModule : Module() {
+  override fun definition() = ModuleDefinition {
+    Name("ExpoBattery")
+    AsyncFunction<Float>("getBatteryLevelAsync") { 1.0f }
+  }
+}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    // The Android (Kotlin) GENERIC AsyncFunction<Float> is extracted — before the
+    // fix the `<Float>` defeated the regex and it was silently dropped.
+    const kt = db.prepare(
+      "SELECT * FROM nodes WHERE name='getBatteryLevelAsync' AND language='kotlin' AND id LIKE 'expo-module:%'"
+    ).all();
+    expect(kt).toHaveLength(1);
+
+    // The iOS (Swift) and Android (Kotlin) impls of the same JS method are linked
+    // to each other, so a JS call that resolves to one platform reaches the other.
+    const pair = db.prepare(
+      `SELECT count(*) c FROM edges e
+       JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
+       WHERE s.name='getBatteryLevelAsync' AND t.name='getBatteryLevelAsync'
+         AND s.language != t.language`
+    ).get();
+    cg.close?.();
+    expect(pair.c).toBeGreaterThanOrEqual(2); // swift->kotlin AND kotlin->swift
+  });
 });

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1662 - 137
__tests__/extraction.test.ts


+ 26 - 5
__tests__/graph.test.ts

@@ -388,16 +388,37 @@ export { main };
   });
 
   describe('File dependency analysis', () => {
-    it('should get file dependencies', () => {
+    // Regression: getFileDependents/getFileDependencies used to follow
+    // ONLY `imports` edges, which in this engine are same-file (a file → its
+    // own local import declarations). That made both return [] for EVERY file,
+    // so `codegraph affected` found no dependents on any language/framework.
+    // They must follow the cross-file symbol graph instead (calls / references
+    // / instantiates / extends / implements / ...).
+    it('reports cross-file dependencies via the symbol graph, not just imports', () => {
       const deps = cg.getFileDependencies('src/main.ts');
+      // main() instantiates DerivedClass (derived.ts) and calls
+      // processValue/doubleValue (utils.ts) — both are real dependencies.
+      expect(deps).toContain('src/utils.ts');
+      expect(deps).toContain('src/derived.ts');
+    });
 
-      expect(Array.isArray(deps)).toBe(true);
+    it('reports cross-file dependents via the symbol graph, not just imports', () => {
+      // utils.ts is used by main.ts (processValue/doubleValue calls); the old
+      // imports-only implementation returned [] here.
+      expect(cg.getFileDependents('src/utils.ts')).toContain('src/main.ts');
     });
 
-    it('should get file dependents', () => {
-      const dependents = cg.getFileDependents('src/utils.ts');
+    it('counts extends/implements as a dependency edge', () => {
+      // derived.ts extends BaseClass / implements Printable, both in base.ts.
+      expect(cg.getFileDependencies('src/derived.ts')).toContain('src/base.ts');
+      expect(cg.getFileDependents('src/base.ts')).toContain('src/derived.ts');
+    });
 
-      expect(Array.isArray(dependents)).toBe(true);
+    it('never lists a file as its own dependent or dependency', () => {
+      for (const f of ['src/main.ts', 'src/utils.ts', 'src/base.ts', 'src/derived.ts']) {
+        expect(cg.getFileDependents(f)).not.toContain(f);
+        expect(cg.getFileDependencies(f)).not.toContain(f);
+      }
     });
   });
 

+ 49 - 1
__tests__/react-native-bridge.test.ts

@@ -1,7 +1,11 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
 import type { Node, Language } from '../src/types';
 import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
 import { reactNativeBridgeResolver } from '../src/resolution/frameworks/react-native';
+import { CodeGraph } from '../src';
 
 /**
  * Mock ResolutionContext for the React Native bridge resolver.
@@ -292,3 +296,47 @@ describe('React Native bridge resolver', () => {
     });
   });
 });
+
+describe('React Native cross-platform pairing — end to end', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-xplat-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  it('links the Android (@ReactMethod) and iOS (RCT_EXPORT_METHOD) impls of a JS-called method', async () => {
+    fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.74.0"}}');
+    fs.writeFileSync(path.join(dir, 'index.ts'),
+      "import { NativeModules } from 'react-native';\n" +
+      "export function ping() { return NativeModules.RNThing.uniquePingMethod(); }\n");
+    fs.writeFileSync(path.join(dir, 'RNThing.java'),
+      "public class RNThing extends ReactContextBaseJavaModule {\n" +
+      "  @Override public String getName() { return \"RNThing\"; }\n" +
+      "  @ReactMethod public void uniquePingMethod(Callback cb) {}\n}\n");
+    fs.writeFileSync(path.join(dir, 'RNThing.m'),
+      "@implementation RNThing\n" +
+      "RCT_EXPORT_MODULE()\n" +
+      "RCT_EXPORT_METHOD(uniquePingMethod:(RCTResponseSenderBlock)cb) {}\n@end\n");
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    // The iOS `RCT_EXPORT_METHOD` is extracted as an ObjC method node (the macro
+    // parses as a macro-expression, not a method, so it had no node before).
+    const objc = db.prepare(
+      "SELECT * FROM nodes WHERE name='uniquePingMethod' AND language='objc' AND id LIKE 'rn-export:%'"
+    ).all();
+    expect(objc).toHaveLength(1);
+
+    // The Java and ObjC impls of `uniquePingMethod` are linked to each other, so
+    // a JS call that resolves to one platform reaches the other.
+    const pair = db.prepare(
+      `SELECT count(*) c FROM edges e
+       JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
+       WHERE json_extract(e.metadata,'$.synthesizedBy')='rn-cross-platform'
+         AND s.name LIKE 'uniquePingMethod%' AND t.name LIKE 'uniquePingMethod%'
+         AND s.language != t.language`
+    ).get();
+    cg.close?.();
+    expect(pair.c).toBeGreaterThanOrEqual(2); // java<->objc both directions
+  });
+});

+ 34 - 0
__tests__/rn-event-channel.test.ts

@@ -123,4 +123,38 @@ export function onMessage(listener: (m: any) => void) {
     expect(edge.target_name).toBe('onMessage');
     expect(['function', 'method']).toContain(edge.target_kind);
   });
+  it('synthesizes an edge from a Java sendEvent(ctx, "X", body) wrapper to a JS handler', async () => {
+    fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.74.0"}}');
+    // The literal event name lives in the WRAPPER CALL, not in `.emit` (whose
+    // first arg is the `eventName` VARIABLE) — the common react-native-device-info
+    // shape that RN_JVM_EMIT_RE alone misses.
+    fs.writeFileSync(path.join(dir, 'BatteryModule.java'),
+      'public class BatteryModule extends ReactContextBaseJavaModule {\n' +
+      '  @Override public String getName() { return "BatteryModule"; }\n' +
+      '  public void onBatteryChanged() {\n' +
+      '    sendEvent(getReactApplicationContext(),\n' +
+      '      "myWrapperBatteryEvent", null);\n' +
+      '  }\n' +
+      '  private void sendEvent(ReactContext ctx, String eventName, Object data) {\n' +
+      '    ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, data);\n' +
+      '  }\n' +
+      '}\n');
+    fs.writeFileSync(path.join(dir, 'index.ts'),
+      "function onBattery() {}\n" +
+      "emitter.addListener('myWrapperBatteryEvent', onBattery);\n");
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+    const rows = db.prepare(
+      "SELECT s.name source_name, s.language sl, t.name target_name FROM edges e " +
+      "JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target " +
+      "WHERE json_extract(e.metadata,'$.synthesizedBy')='rn-event-channel' AND json_extract(e.metadata,'$.event')='myWrapperBatteryEvent'"
+    ).all();
+    cg.close?.();
+    expect(rows.length).toBeGreaterThanOrEqual(1);
+    expect(rows[0].sl).toBe('java');
+    expect(rows[0].source_name).toBe('onBatteryChanged');
+    expect(rows[0].target_name).toBe('onBattery');
+  });
 });

BIN
assets/__pycache__/generate-waitlist.cpython-313.pyc


+ 155 - 0
docs/design/template-markup-parser.md

@@ -0,0 +1,155 @@
+# Scope: Template-markup parser (Razor / Blazor / Thymeleaf)
+
+Status: **P1+P2+@code IMPLEMENTED** (commits 59b8de2 directives/tags, 90c5f39 @code
+delegation) on `feat/cross-language-impact-coverage`. Razor/Blazor markup is parsed
+(`src/extraction/razor-extractor.ts`). Remaining: `@using` namespace disambiguation
+for DTO-vs-entity name collisions (the residual ASP.NET gap), and Thymeleaf/Django
+(P4, deferred — weak code links). Authored 2026-06-04.
+
+## Problem
+
+The impact graph is built from code the engine parses. **Template markup is not
+parsed**, so any code-behind, component, view-model, or DTO that is referenced
+*only* from markup looks like it has no in-repo dependent. On convention-heavy
+frameworks this is the dominant residual gap after framework-entry exclusions:
+
+| Framework | App | FAIR coverage (entries excluded) | Residual cause |
+|---|---|---|---|
+| ASP.NET | eShopOnWeb | **77.2%** (115/149) | Razor `.cshtml` + Blazor `.razor` reference `.cs` we don't parse |
+| Spring | petclinic | 65.2% | mostly Spring Data proxies + JPA, **not** templates (Thymeleaf links are weak) |
+| Django | django-realworld | 74.1% | signals / DRF / string-config, **not** templates |
+
+**This feature is primarily an ASP.NET (Razor + Blazor) win.** Thymeleaf and Django
+templates link to code only weakly (template→template fragments + fuzzy
+model-attribute strings), and those frameworks' real gaps are elsewhere — so they
+are explicitly lower priority here.
+
+### Quantified target (eShopOnWeb, the 34 residual zeros after entry-exclusion)
+
+- **~20 markup-coverable** by this feature:
+  - 5 MVC `ViewModels/*` ← Razor `@model X`
+  - 7 `BlazorShared/Models/*` (DTOs) ← Blazor `@bind` / component params
+  - 6 `BlazorAdmin/*` C# components ← Blazor `<Component/>` tags
+  - 1 `BasketComponent` ViewComponent ← `<vc:basket>` / `Component.InvokeAsync`
+  - 1 Razor page helper
+- **~13 NOT covered** (separate frontier — reflection/proxy + value-reads): AutoMapper
+  `MappingProfile`, Swagger `CustomSchemaFilters`/`ImageValidators`, `ExceptionMiddleware`,
+  health checks, `Constants` (static-member reads), `Buyer` entity.
+
+**Honest ceiling: ASP.NET ~77% → ~90%**, not 95%. The last ~10% is reflection/proxy
+(AutoMapper, Swagger, DI/middleware registration) + C# static-const reads — a
+*separate* feature (reflection modeling + extending the static-member pass to C#).
+
+## Reference patterns to extract (prioritized)
+
+| Pri | Format | Markup construct | Edge to emit | Resolves to |
+|---|---|---|---|---|
+| P1 | Razor `.cshtml`/`.razor` | `@model Foo` / `@inherits X<Foo>` | `references` | the model/VM class `Foo` |
+| P1 | Razor/Blazor | `@inject IBar bar` | `references` | the service type `IBar` |
+| P2 | Blazor `.razor` | `<MyComponent .../>` (PascalCase element) | `references` | component class (`.razor` or `.cs : ComponentBase`) |
+| P2 | Blazor `.razor` | `@typeof(MainLayout)`, `@inherits LayoutBase` | `references` | the type |
+| P3 | Razor `.cshtml` | `<partial name="_X"/>`, `<vc:basket>`, `Component.InvokeAsync("X")` | `references` | the partial view / `XViewComponent` |
+| P3 | Razor `.cshtml` | `asp-page="./Register"`, `asp-controller`/`asp-action` | `references` | the page / controller action |
+| P4 (defer) | Thymeleaf `.html` | `th:replace="~{frag :: x}"` | `references` | template fragment (template→template only) |
+| P4 (defer) | Django `.html` | `{% extends %}` / `{% include %}` / `{% url 'n' %}` | `references` | template / named route |
+
+`asp-for="Prop"`, `th:field="*{prop}"` (property-string bindings) are the data-flow
+frontier — **out of scope** (would need model-type inference; low value, high noise).
+
+## Architecture — follow the existing standalone-extractor pattern
+
+The engine already has non-tree-sitter extractors (`svelte-extractor.ts`,
+`vue-extractor.ts`, `liquid-extractor.ts`): a class taking `(filePath, source)`,
+returning `{ nodes, references }`, wired in two places. Mirror exactly:
+
+1. **`src/extraction/grammars.ts`** — map extensions to a synthetic language:
+   `.cshtml`/`.razor` → `'razor'`, (later) `.html` under `templates/` → `'thymeleaf'`.
+   (Django `.html` is ambiguous with plain HTML — gate on a `templates/` path or a
+   `{% %}`/`{{ }}` content sniff, like the framework resolvers do.)
+2. **`src/extraction/tree-sitter.ts`** — dispatch by extension to a new
+   `RazorExtractor` (and `ThymeleafExtractor`), exactly as `SvelteExtractor` is
+   dispatched (~line 4025).
+3. **`src/extraction/razor-extractor.ts`** (new) — regex/line scan (markup is
+   highly stylized; no grammar needed, same as Liquid/Svelte template scanning):
+   - Emit ONE `component` node for the file (so `.razor` components are linkable as
+     `<X/>` targets and the file is a graph citizen).
+   - Emit `references` per the P1–P3 patterns above, `fromNodeId` = the file/component
+     node, `referenceKind: 'references'`, `language: 'razor'`.
+   - **Code-behind link:** a `Foo.razor` + `Foo.razor.cs` (partial class) — emit a
+     `references` (or rely on same-basename) so the markup's refs also credit the
+     code-behind. (eShop's Blazor components are plain `.cs : ComponentBase`, named
+     `<ToastComponent/>` → resolves by class name; the `.razor.cs` partial case is
+     the other shape.)
+
+**Resolution: no new resolver needed.** The emitted refs are ordinary `references`
+to a class/component by name; the existing name-matcher resolves them (`@model
+RegisterModel` → class `RegisterModel`; `<ToastComponent/>` → class `ToastComponent`).
+Apply the **same cross-family language gate** already in place — a `razor` ref must
+resolve to a `csharp` symbol, so add `razor` to the `web`/dotnet family or treat
+`razor`↔`csharp` as same-family (otherwise the gate from commit 082353e drops it).
+**This is the one resolver-side change** and must be done or every edge is gated away.
+
+## Node/edge shape & invariants
+
+- +1 `component` node per template file (real new symbol — like `.svelte`/`.vue`).
+  Node count grows by the template-file count only; **no per-tag node explosion**
+  (component tags become `references` edges, not nodes).
+- All edges are `references` (counted by impact / `affected` / `getFileDependents`,
+  not by `callers`/`callees` — matches how `route`/`component` edges already behave).
+- Idempotent re-index; node count stable across re-runs.
+
+## Phasing
+
+- **P1 (highest value/effort ratio):** Razor `@model` + `@inject` for `.cshtml` AND
+  `.razor`. Covers the 5 ViewModels + injected services. + the resolver family-gate fix.
+- **P2:** Blazor `<PascalComponent/>` tags + `@typeof`/`@inherits` + code-behind link.
+  Covers the 6 Blazor `.cs` components + the 7 DTOs (via component params/`@bind`).
+- **P3:** Razor `<partial>` / `<vc:>` / `Component.InvokeAsync` / `asp-page`.
+- **P4 (defer / probably skip):** Thymeleaf + Django templates — weak code links,
+  low coverage payoff; revisit only if a Thymeleaf/Django app is a priority.
+
+## Edge cases & risks
+
+- **PascalCase tag vs HTML element:** only `[A-Z]`-initial tags are Blazor components
+  (HTML is lowercase) — safe discriminator. Skip known framework components
+  (`<Router>`, `<Found>`, `<LayoutView>`, `<RouteView>`, `<CascadingValue>`) via a
+  builtin set, or just let them fail to resolve (no false edge — they're not in-repo).
+- **`_Imports.razor` `@using`:** namespace imports, not code refs — ignore (or emit
+  `imports` to the namespace, low value).
+- **Generic components `<Grid TItem="CatalogItem">`:** capture the type-arg as a
+  `references` to `CatalogItem` (bonus DTO coverage).
+- **Name collisions:** component/model names are usually unique; rely on the
+  name-matcher's existing proximity scoring. Same-named class in another language is
+  blocked by the family gate.
+- **Razor `@{ ... }` C# blocks:** contain real C# (calls, `new`) — P-future; regex
+  scanning the C# inside markup is noisy. Defer (the directives above are the wins).
+- **`.razor` is NOT `.cs`:** must add to `grammars.ts` + the indexer's include globs
+  (verify `.razor`/`.cshtml` aren't in a default-exclude).
+
+## Validation (per the engine's methodology)
+
+1. Build `RazorExtractor`; unit tests in `__tests__/extraction.test.ts` (a `.cshtml`
+   with `@model X` covers `X`; a `.razor` with `<ToastComponent/>` covers it; an HTML
+   `<div>` does NOT create an edge).
+2. Re-measure eShopOnWeb FAIR coverage before/after (`/tmp/faircov.cjs`): target
+   77% → ~90%; **node count stable** (only +template-file component nodes); residual
+   zeros are the reflection/value-read set only.
+3. No regression on a non-.NET control (gin/requests) and on the Razor-free C#
+   repos (cs-mediatr/cs-polly unchanged).
+4. Record in this doc + the coverage handoff.
+
+## Effort
+
+- P1: ~0.5 day (extractor skeleton + `@model`/`@inject` scan + family-gate fix + tests).
+- P2: ~1 day (Blazor tags + code-behind + generic type-args).
+- P3: ~0.5 day. P4 (Thymeleaf/Django): ~1–2 days, low ROI — defer.
+- **Total for the ASP.NET win (P1+P2+P3): ~2 days → ASP.NET ~90%.**
+
+## Non-goals (and what's still needed for 95% on convention apps)
+
+This feature does NOT close: reflection/proxy registration (Spring Data repository
+proxies, AutoMapper profiles, Swagger filters, DI container / middleware), property-
+string data bindings (`asp-for`/`th:field`), or C# static-const value reads
+(`Constants.X`). Convention apps reaching literal 95% additionally need a **reflection/
+DI-registration modeling** pass and **extending the static-member pass to C#/TS** —
+tracked separately. Markup parsing is the single biggest, most self-contained step.

+ 2 - 2
site/src/content/docs/core-concepts/knowledge-graph.md

@@ -15,13 +15,13 @@ CodeGraph stores three things: **nodes** (symbols and files), **edges** (relatio
 
 ## Provenance
 
-Most edges come straight from the AST. A few — at dynamic-dispatch boundaries that static parsing can't follow — are **synthesized** and marked with `provenance: 'heuristic'` plus the wiring site that created them. These are surfaced inline in `trace`, the `node` trail, and `context` call-paths, so an agent can see exactly where a connection came from.
+Most edges come straight from the AST. A few — at dynamic-dispatch boundaries that static parsing can't follow — are **synthesized** and marked with `provenance: 'heuristic'` plus the wiring site that created them. These are surfaced inline in `explore` and the `node` trail, so an agent can see exactly where a connection came from.
 
 ## Querying it
 
 - **Search** symbols by name (FTS5).
 - **Callers / callees** walk the call graph one hop at a time.
 - **Impact** computes the transitive radius affected by a change.
-- **Trace** returns a whole call path between two symbols in one call.
+- **Explore** returns source for several related symbols grouped by file, plus the call path among them, in one call.
 
 See the [CLI](/codegraph/reference/cli/) and [MCP Server](/codegraph/reference/mcp-server/) references for how to run these.

+ 0 - 1
site/src/content/docs/reference/integrations.md

@@ -47,7 +47,6 @@ Optionally auto-allow the read-only tools in `~/.claude/settings.json`:
   "permissions": {
     "allow": [
       "mcp__codegraph__codegraph_search",
-      "mcp__codegraph__codegraph_context",
       "mcp__codegraph__codegraph_callers",
       "mcp__codegraph__codegraph_callees",
       "mcp__codegraph__codegraph_impact",

+ 0 - 2
site/src/content/docs/reference/mcp-server.md

@@ -16,8 +16,6 @@ Agents configured by the installer launch this automatically. When a `.codegraph
 | Tool | Purpose |
 |---|---|
 | `codegraph_search` | Find symbols by name across the codebase |
-| `codegraph_context` | Build relevant code context for a task — composes search + node + callers + callees in one call |
-| `codegraph_trace` | Trace the call path between two symbols ("how does X reach Y") in one call — each hop with its body inline, following dynamic-dispatch hops (callbacks, React re-render, interface→impl) that grep can't |
 | `codegraph_callers` | Find what calls a function |
 | `codegraph_callees` | Find what a function calls |
 | `codegraph_impact` | Analyze what code is affected by changing a symbol |

+ 46 - 0
src/db/queries.ts

@@ -1351,6 +1351,52 @@ export class QueryBuilder {
     return rows.map(rowToEdge);
   }
 
+  /**
+   * Distinct file paths that DEPEND ON `filePath`: every file containing a
+   * symbol with a cross-file edge (any kind except `contains`) into a symbol
+   * of this file. This is the file-level projection of the symbol dependency
+   * graph and the basis for blast-radius / `affected` test selection.
+   *
+   * It deliberately does NOT restrict to `imports` edges. In this graph an
+   * `imports` edge connects a file to its own local import declarations
+   * (it is always same-file), so an imports-only lookup returns zero
+   * cross-file dependents for every file. The real cross-file dependency
+   * signal is the resolved call/reference graph — calls, references,
+   * instantiates, extends, implements, overrides, type_of, returns,
+   * decorates — exactly what {@link GraphTraverser.getImpactRadius} traverses.
+   * `contains` is excluded: a parent containing a symbol does not *depend* on
+   * it. One indexed query (idx_nodes_file_path + idx_edges_target_kind).
+   */
+  getDependentFilePaths(filePath: string): string[] {
+    const sql = `SELECT DISTINCT src.file_path AS fp
+      FROM edges e
+      JOIN nodes tgt ON tgt.id = e.target
+      JOIN nodes src ON src.id = e.source
+      WHERE tgt.file_path = ?
+        AND e.kind != 'contains'
+        AND src.file_path != ?`;
+    const rows = this.db.prepare(sql).all(filePath, filePath) as Array<{ fp: string }>;
+    return rows.map((r) => r.fp);
+  }
+
+  /**
+   * Distinct file paths that `filePath` DEPENDS ON — the inverse of
+   * {@link getDependentFilePaths}: every file containing a symbol that a
+   * symbol of this file has a cross-file edge into. Same edge-kind rules
+   * (all kinds except `contains`); same reason imports-only is insufficient.
+   */
+  getDependencyFilePaths(filePath: string): string[] {
+    const sql = `SELECT DISTINCT tgt.file_path AS fp
+      FROM edges e
+      JOIN nodes src ON src.id = e.source
+      JOIN nodes tgt ON tgt.id = e.target
+      WHERE src.file_path = ?
+        AND e.kind != 'contains'
+        AND tgt.file_path != ?`;
+    const rows = this.db.prepare(sql).all(filePath, filePath) as Array<{ fp: string }>;
+    return rows.map((r) => r.fp);
+  }
+
   // ===========================================================================
   // File Operations
   // ===========================================================================

+ 23 - 2
src/extraction/grammars.ts

@@ -10,7 +10,7 @@ import * as path from 'path';
 import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
 import { Language } from '../types';
 
-export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'yaml' | 'twig' | 'xml' | 'properties' | 'unknown'>;
+export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'razor' | 'yaml' | 'twig' | 'xml' | 'properties' | 'unknown'>;
 
 /**
  * WASM filename map — maps each language to its .wasm grammar file
@@ -69,6 +69,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
   '.hpp': 'cpp',
   '.hxx': 'cpp',
   '.cs': 'csharp',
+  // ASP.NET Razor / Blazor markup — custom RazorExtractor (links @model/@inject/
+  // component tags to their C# types; markup isn't a tree-sitter grammar).
+  '.cshtml': 'razor',
+  '.razor': 'razor',
   '.php': 'php',
   // Drupal-specific PHP file extensions
   '.module': 'php',
@@ -117,11 +121,23 @@ export const EXTENSION_MAP: Record<string, Language> = {
  */
 export function isSourceFile(filePath: string): boolean {
   if (isPlayRoutesFile(filePath)) return true; // Play `conf/routes` is extensionless
+  if (isShopifyLiquidJson(filePath)) return true; // Shopify OS 2.0 JSON templates / section groups
   const dot = filePath.lastIndexOf('.');
   if (dot < 0) return false;
   return filePath.slice(dot).toLowerCase() in EXTENSION_MAP;
 }
 
+/**
+ * Shopify OS 2.0 JSON template (`templates/*.json`) or section group
+ * (`sections/*.json`) — these reference sections by `"type"`, so the Liquid
+ * extractor links them. (config/ + locales/ JSON have no section refs.)
+ */
+export function isShopifyLiquidJson(filePath: string): boolean {
+  // Allow nested template dirs (`templates/customers/login.json`), not just
+  // top-level (`templates/product.json`).
+  return /(^|\/)(templates|sections)\/.+\.json$/i.test(filePath);
+}
+
 /**
  * Play Framework routes file: the extensionless `conf/routes` (and included
  * `conf/*.routes`). No grammar — route extraction is done by the Play framework
@@ -242,6 +258,9 @@ export function detectLanguage(filePath: string, source?: string): Language {
   // Play framework resolver extracts route nodes from it.
   if (isPlayRoutesFile(filePath)) return 'yaml';
   const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
+  // Shopify OS 2.0 JSON templates / section groups → the Liquid extractor (it
+  // links each section `"type"` to its `sections/<type>.liquid`).
+  if (isShopifyLiquidJson(filePath)) return 'liquid';
   const lang = EXTENSION_MAP[ext] || 'unknown';
 
   // .h files could be C, C++, or Objective-C — check source content
@@ -278,6 +297,7 @@ export function isLanguageSupported(language: Language): boolean {
   if (language === 'svelte') return true; // custom extractor (script block delegation)
   if (language === 'vue') return true; // custom extractor (script block delegation)
   if (language === 'liquid') return true; // custom regex extractor
+  if (language === 'razor') return true; // custom RazorExtractor (.cshtml/.razor markup)
   if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver
   if (language === 'twig') return true; // file-level tracking only
   if (language === 'xml') return true; // MyBatis mapper extractor
@@ -290,7 +310,7 @@ export function isLanguageSupported(language: Language): boolean {
  * Check if a grammar has been loaded and is ready for parsing.
  */
 export function isGrammarLoaded(language: Language): boolean {
-  if (language === 'svelte' || language === 'vue' || language === 'liquid') return true;
+  if (language === 'svelte' || language === 'vue' || language === 'liquid' || language === 'razor') return true;
   if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed
   if (language === 'xml' || language === 'properties') return true; // no WASM grammar needed
   return languageCache.has(language);
@@ -371,6 +391,7 @@ export function getLanguageDisplayName(language: Language): string {
     c: 'C',
     cpp: 'C++',
     csharp: 'C#',
+    razor: 'Razor/Blazor',
     php: 'PHP',
     ruby: 'Ruby',
     swift: 'Swift',

+ 28 - 30
src/extraction/languages/c-cpp.ts

@@ -2,49 +2,47 @@ import type { Node as SyntaxNode } from 'web-tree-sitter';
 import { getChildByField, getNodeText } from '../tree-sitter-helpers';
 import type { LanguageExtractor } from '../tree-sitter-types';
 
-function extractCppQualifiedMethodName(node: SyntaxNode, source: string): string | undefined {
-  const declarator = getChildByField(node, 'declarator');
-  if (!declarator) return undefined;
-
+/**
+ * Find the function NAME's `qualified_identifier` (`Foo::bar`) inside a
+ * declarator, skipping the `parameter_list` — a parameter with a qualified type
+ * (`const std::string& x`) must NOT be mistaken for the method name. Without the
+ * skip, a plain free function `std::string TableFileName(const std::string&...)`
+ * was named `string` (from the parameter type), so calls to it never resolved
+ * and its file looked like nothing depended on it.
+ */
+function findDeclaratorQualifiedId(declarator: SyntaxNode): SyntaxNode | undefined {
   const queue: SyntaxNode[] = [declarator];
   while (queue.length > 0) {
     const current = queue.shift()!;
-    if (current.type === 'qualified_identifier') {
-      const text = getNodeText(current, source).trim();
-      const parts = text.split('::').filter(Boolean);
-      return parts[parts.length - 1];
-    }
+    if (current.type === 'qualified_identifier') return current;
     for (let i = 0; i < current.namedChildCount; i++) {
       const child = current.namedChild(i);
-      if (child) queue.push(child);
+      // Don't descend into parameters or the trailing return type — their types
+      // (`const std::string&`, `-> std::string`) aren't the function name.
+      if (child && child.type !== 'parameter_list' && child.type !== 'trailing_return_type') {
+        queue.push(child);
+      }
     }
   }
-
   return undefined;
 }
 
-function extractCppReceiverType(node: SyntaxNode, source: string): string | undefined {
+function extractCppQualifiedMethodName(node: SyntaxNode, source: string): string | undefined {
   const declarator = getChildByField(node, 'declarator');
   if (!declarator) return undefined;
+  const qid = findDeclaratorQualifiedId(declarator);
+  if (!qid) return undefined;
+  const parts = getNodeText(qid, source).trim().split('::').filter(Boolean);
+  return parts[parts.length - 1];
+}
 
-  const queue: SyntaxNode[] = [declarator];
-  while (queue.length > 0) {
-    const current = queue.shift()!;
-    if (current.type === 'qualified_identifier') {
-      const text = getNodeText(current, source).trim();
-      const parts = text.split('::').filter(Boolean);
-      if (parts.length > 1) {
-        return parts.slice(0, -1).join('::');
-      }
-      return undefined;
-    }
-    for (let i = 0; i < current.namedChildCount; i++) {
-      const child = current.namedChild(i);
-      if (child) queue.push(child);
-    }
-  }
-
-  return undefined;
+function extractCppReceiverType(node: SyntaxNode, source: string): string | undefined {
+  const declarator = getChildByField(node, 'declarator');
+  if (!declarator) return undefined;
+  const qid = findDeclaratorQualifiedId(declarator);
+  if (!qid) return undefined;
+  const parts = getNodeText(qid, source).trim().split('::').filter(Boolean);
+  return parts.length > 1 ? parts.slice(0, -1).join('::') : undefined;
 }
 
 export const cExtractor: LanguageExtractor = {

+ 18 - 2
src/extraction/languages/csharp.ts

@@ -4,13 +4,29 @@ import type { LanguageExtractor } from '../tree-sitter-types';
 
 export const csharpExtractor: LanguageExtractor = {
   functionTypes: [],
-  classTypes: ['class_declaration'],
+  // Records are first-class type declarations in modern C# (DTOs, value objects,
+  // MediatR/CQRS messages). `record` / `record class` parse as record_declaration
+  // (reference type → class); `record struct` as record_struct_declaration (value
+  // type → struct). Without these, references to a record never resolve (#237).
+  classTypes: ['class_declaration', 'record_declaration'],
   methodTypes: ['method_declaration', 'constructor_declaration'],
   interfaceTypes: ['interface_declaration'],
-  structTypes: ['struct_declaration'],
+  structTypes: ['struct_declaration', 'record_struct_declaration'],
   enumTypes: ['enum_declaration'],
   enumMemberTypes: ['enum_member_declaration'],
   typeAliasTypes: [],
+  // Namespaces qualify type names so same-named types in different namespaces are
+  // distinguishable (e.g. `ApplicationCore.Entities.CatalogBrand` vs
+  // `BlazorShared.Models.CatalogBrand`). Both block (`namespace Foo { … }`, which
+  // nests its types) and file-scoped (`namespace Foo;`) forms — extractFilePackage
+  // pushes the namespace onto the scope so nested/top-level types pick it up.
+  packageTypes: ['namespace_declaration', 'file_scoped_namespace_declaration'],
+  extractPackage: (node: SyntaxNode, source: string) => {
+    const name =
+      node.childForFieldName('name') ??
+      node.namedChildren.find((c: SyntaxNode) => c.type === 'qualified_name' || c.type === 'identifier');
+    return name ? getNodeText(name, source) : null;
+  },
   importTypes: ['using_directive'],
   callTypes: ['invocation_expression'],
   variableTypes: ['local_declaration_statement'],

+ 5 - 1
src/extraction/languages/java.ts

@@ -6,7 +6,11 @@ export const javaExtractor: LanguageExtractor = {
   functionTypes: [],
   classTypes: ['class_declaration'],
   methodTypes: ['method_declaration', 'constructor_declaration'],
-  interfaceTypes: ['interface_declaration'],
+  // `annotation_type_declaration` is `@interface Foo { … }` — an annotation
+  // definition. Without it, annotation types (`@SerializedName`, `@GetMapping`,
+  // JPA/Spring annotations) aren't nodes, so the `@Foo` usages that DO get
+  // extracted can't resolve and the annotation file shows zero dependents.
+  interfaceTypes: ['interface_declaration', 'annotation_type_declaration'],
   structTypes: [],
   enumTypes: ['enum_declaration'],
   enumMemberTypes: ['enum_constant'],

+ 23 - 0
src/extraction/languages/kotlin.ts

@@ -227,6 +227,29 @@ export const kotlinExtractor: LanguageExtractor = {
     }
     return false;
   },
+  extractModifiers: (node) => {
+    // Kotlin Multiplatform `expect`/`actual` markers live in
+    //   modifiers > platform_modifier > (expect | actual)
+    // Capturing them lets the resolver link an `expect` declaration in a
+    // common source set to its `actual` implementations in platform source
+    // sets (those impls otherwise have zero dependents — the caller resolves
+    // to the `expect`). Match the AST node, not raw text, so an annotation
+    // argument or identifier named "actual" can't false-positive.
+    const mods: string[] = [];
+    for (let i = 0; i < node.childCount; i++) {
+      const child = node.child(i);
+      if (child?.type !== 'modifiers') continue;
+      for (let j = 0; j < child.childCount; j++) {
+        const pm = child.child(j);
+        if (pm?.type !== 'platform_modifier') continue;
+        for (let k = 0; k < pm.childCount; k++) {
+          const kw = pm.child(k);
+          if (kw && (kw.type === 'expect' || kw.type === 'actual')) mods.push(kw.type);
+        }
+      }
+    }
+    return mods.length > 0 ? mods : undefined;
+  },
   extractImport: (node, source) => {
     const importText = source.substring(node.startIndex, node.endIndex).trim();
     const identifier = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');

+ 12 - 0
src/extraction/languages/php.ts

@@ -78,6 +78,18 @@ export const phpExtractor: LanguageExtractor = {
 
     return false;
   },
+  // PHP `namespace Foo\Bar;` is file-level (like a Java/Kotlin package). Capturing
+  // it scopes every class under an `Foo\Bar::` qualified name, which is what makes
+  // `use` imports and same-named types (Laravel has 7+ `Factory` interfaces across
+  // namespaces) resolvable to the RIGHT definition instead of an arbitrary match.
+  packageTypes: ['namespace_definition'],
+  extractPackage: (node, source) => {
+    const nsName = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_name');
+    // Skip braced `namespace Foo { … }` (has a body) — file-level only.
+    const hasBody = node.namedChildren.some((c: SyntaxNode) => c.type === 'compound_statement' || c.type === 'declaration_list');
+    if (!nsName || hasBody) return null;
+    return getNodeText(nsName, source);
+  },
   extractImport: (node, source) => {
     const importText = source.substring(node.startIndex, node.endIndex).trim();
 

+ 36 - 0
src/extraction/languages/ruby.ts

@@ -17,6 +17,42 @@ export const rubyExtractor: LanguageExtractor = {
   bodyField: 'body',
   paramsField: 'parameters',
   visitNode: (node, ctx) => {
+    // Ruby mixins: `include Mod`, `extend Mod`, `prepend Mod[, Other]` — the
+    // primary composition mechanism (ActiveSupport concerns, Comparable, …).
+    // These parse as a bare `call` to `include`/`extend`/`prepend` with the
+    // module(s) as constant arguments, so without special handling they'd be
+    // mis-extracted as a call to a method named "include" and the module would
+    // record no dependent — even though it's mixed into a class. Emit an
+    // `implements` edge (enclosing class/module → mixed-in module), so editing a
+    // concern surfaces every class that includes it.
+    if (node.type === 'call' && !node.childForFieldName('receiver')) {
+      const method = node.childForFieldName('method');
+      const mname = method?.text;
+      if (mname === 'include' || mname === 'extend' || mname === 'prepend') {
+        const parentId = ctx.nodeStack.length > 0 ? ctx.nodeStack[ctx.nodeStack.length - 1] : undefined;
+        const args = node.childForFieldName('arguments')
+          ?? node.namedChildren.find((c: SyntaxNode) => c.type === 'argument_list');
+        if (parentId && args) {
+          for (let i = 0; i < args.namedChildCount; i++) {
+            const arg = args.namedChild(i);
+            // `Mod` is `constant`, `Foo::Bar` is `scope_resolution`. Skip
+            // `extend self` / dynamic args (`include foo()`).
+            if (arg && (arg.type === 'constant' || arg.type === 'scope_resolution')) {
+              ctx.addUnresolvedReference({
+                fromNodeId: parentId,
+                referenceName: getNodeText(arg, ctx.source),
+                referenceKind: 'implements',
+                filePath: ctx.filePath,
+                line: node.startPosition.row + 1,
+                column: node.startPosition.column,
+              });
+            }
+          }
+          return true; // handled — don't also extract as a call to "include"
+        }
+      }
+    }
+
     if (node.type !== 'module') return false;
 
     const nameNode = node.childForFieldName('name');

+ 6 - 2
src/extraction/languages/rust.ts

@@ -3,9 +3,13 @@ import { getNodeText, getChildByField } from '../tree-sitter-helpers';
 import type { LanguageExtractor } from '../tree-sitter-types';
 
 export const rustExtractor: LanguageExtractor = {
-  functionTypes: ['function_item'],
+  // `function_signature_item` is a trait method DECLARATION (`fn render(&self);`,
+  // no body). Extracting it makes a trait's method set first-class, which
+  // impl-navigation and trait-dispatch synthesis need (a struct's method set is
+  // matched against the trait's).
+  functionTypes: ['function_item', 'function_signature_item'],
   classTypes: [], // Rust has impl blocks
-  methodTypes: ['function_item'], // Methods are functions in impl blocks
+  methodTypes: ['function_item', 'function_signature_item'],
   interfaceTypes: ['trait_item'],
   structTypes: ['struct_item'],
   enumTypes: ['enum_item'],

+ 36 - 1
src/extraction/languages/scala.ts

@@ -10,6 +10,40 @@ function getValVarName(node: SyntaxNode, source: string): string | null {
   return identChild ? getNodeText(identChild, source) : null;
 }
 
+// Capitalized Scala primitives/ubiquitous aliases that shouldn't create refs.
+const SCALA_BUILTIN_TYPES = new Set([
+  'Int', 'Long', 'Short', 'Byte', 'Float', 'Double', 'Boolean', 'Char', 'Unit',
+  'String', 'Any', 'AnyRef', 'AnyVal', 'Nothing', 'Null',
+]);
+
+/**
+ * Emit `references` edges for every type identifier in a Scala type subtree
+ * (a `val`/`var` type annotation), unwrapping `generic_type` etc. Mirrors the
+ * generic type-annotation extraction the core extractor runs for method
+ * parameter/return types, but Scala `val`s are created here in visitNode so
+ * their type is walked here too. A trait used only as a field type (the common
+ * `implicit val x: Monoid[Int]` instance pattern) thus gains a dependent.
+ */
+function emitScalaTypeRefs(typeNode: SyntaxNode, fromId: string, ctx: { addUnresolvedReference: (r: { fromNodeId: string; referenceName: string; referenceKind: 'references'; line: number; column: number }) => void }, source: string): void {
+  if (typeNode.type === 'type_identifier') {
+    const name = source.substring(typeNode.startIndex, typeNode.endIndex);
+    if (name && !SCALA_BUILTIN_TYPES.has(name)) {
+      ctx.addUnresolvedReference({
+        fromNodeId: fromId,
+        referenceName: name,
+        referenceKind: 'references',
+        line: typeNode.startPosition.row + 1,
+        column: typeNode.startPosition.column,
+      });
+    }
+    return;
+  }
+  for (let i = 0; i < typeNode.namedChildCount; i++) {
+    const child = typeNode.namedChild(i);
+    if (child) emitScalaTypeRefs(child, fromId, ctx, source);
+  }
+}
+
 function extractVisibility(node: SyntaxNode): 'public' | 'private' | 'protected' {
   for (let i = 0; i < node.namedChildCount; i++) {
     const child = node.namedChild(i);
@@ -96,7 +130,8 @@ export const scalaExtractor: LanguageExtractor = {
         ? `${t === 'val_definition' ? 'val' : 'var'} ${name}: ${getNodeText(typeNode, ctx.source)}`
         : undefined;
 
-      ctx.createNode(kind, name, node, { signature: sig, visibility: extractVisibility(node) });
+      const created = ctx.createNode(kind, name, node, { signature: sig, visibility: extractVisibility(node) });
+      if (created && typeNode) emitScalaTypeRefs(typeNode, created.id, ctx, ctx.source);
       return true;
     }
 

+ 49 - 11
src/extraction/liquid-extractor.ts

@@ -33,17 +33,25 @@ export class LiquidExtractor {
       // Create file node
       const fileNode = this.createFileNode();
 
-      // Extract render/include statements (snippet references)
-      this.extractSnippetReferences(fileNode.id);
-
-      // Extract section references
-      this.extractSectionReferences(fileNode.id);
-
-      // Extract schema block
-      this.extractSchema(fileNode.id);
-
-      // Extract assign statements as variables
-      this.extractAssignments(fileNode.id);
+      // Shopify OS 2.0 JSON template / section group: link each section `type`
+      // to its `sections/<type>.liquid` file. (No symbol nodes are emitted — the
+      // JSON file just carries the references — so it stays out of any
+      // symbol-bearing-file metric while its sections still get their dependents.)
+      if (this.filePath.endsWith('.json')) {
+        this.extractShopifyJsonSections(fileNode.id);
+      } else {
+        // Extract render/include statements (snippet references)
+        this.extractSnippetReferences(fileNode.id);
+
+        // Extract section references
+        this.extractSectionReferences(fileNode.id);
+
+        // Extract schema block
+        this.extractSchema(fileNode.id);
+
+        // Extract assign statements as variables
+        this.extractAssignments(fileNode.id);
+      }
     } catch (error) {
       this.errors.push({
         message: `Liquid extraction error: ${error instanceof Error ? error.message : String(error)}`,
@@ -86,6 +94,36 @@ export class LiquidExtractor {
     return fileNode;
   }
 
+  /**
+   * Shopify OS 2.0 JSON template / section group. Both have a `sections` object
+   * mapping an id → `{ "type": "<section-name>", ... }`; the `type` names a
+   * `sections/<type>.liquid` file. Emit a `references` edge to each, so a section
+   * used only from a JSON template (the OS 2.0 norm) is no longer orphaned.
+   */
+  private extractShopifyJsonSections(fromNodeId: string): void {
+    let parsed: unknown;
+    try {
+      parsed = JSON.parse(this.source);
+    } catch {
+      return; // not valid JSON (or a partial) — nothing to link
+    }
+    const sections = (parsed as { sections?: Record<string, { type?: unknown }> })?.sections;
+    if (!sections || typeof sections !== 'object') return;
+    const seen = new Set<string>();
+    for (const key of Object.keys(sections)) {
+      const type = sections[key]?.type;
+      if (typeof type !== 'string' || seen.has(type)) continue;
+      seen.add(type);
+      this.unresolvedReferences.push({
+        fromNodeId,
+        referenceName: `sections/${type}.liquid`,
+        referenceKind: 'references',
+        line: 1,
+        column: 0,
+      });
+    }
+  }
+
   /**
    * Extract {% render 'snippet' %} and {% include 'snippet' %} references
    */

+ 280 - 0
src/extraction/razor-extractor.ts

@@ -0,0 +1,280 @@
+import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
+import { generateNodeId } from './tree-sitter-helpers';
+import { TreeSitterExtractor } from './tree-sitter';
+import { isLanguageSupported } from './grammars';
+
+/**
+ * RazorExtractor — extracts code relationships from ASP.NET Razor (`.cshtml`)
+ * and Blazor (`.razor`) markup.
+ *
+ * Markup-driven code-behind, view-models, components, and DTOs are referenced
+ * only from markup the engine otherwise doesn't parse, so they look like nothing
+ * depends on them. This extractor links the markup → the C# types it names:
+ *
+ *  - `@model Foo` / `@inherits Bar<Foo>`  → the view-model / base type (.cshtml + .razor)
+ *  - `@inject IService svc`               → the injected service type
+ *  - `@typeof(MainLayout)`                → the referenced type
+ *  - `<MyComponent .../>` (Blazor only)   → the component class (.razor or `.cs : ComponentBase`)
+ *  - `<Grid TItem="CatalogItem">`         → the generic type argument
+ *
+ * Risk mitigations (see docs/design/template-markup-parser.md):
+ *  - Only PascalCase (`[A-Z]`-initial) tags are treated as components — HTML
+ *    elements are lowercase, so they never match. Known Blazor framework
+ *    components are skipped (they aren't in-repo, so a ref would just dangle).
+ *  - Exactly ONE `component` node per file; component tags become `references`
+ *    EDGES, never nodes — no per-tag node explosion.
+ *  - Emitted refs are ordinary by-name `references` resolved by the name-matcher;
+ *    `razor` shares the `dotnet` language family with `csharp` (name-matcher.ts)
+ *    so the cross-family gate doesn't drop them.
+ *  - `.cshtml`/`.razor` are registered in grammars.ts so they're indexed.
+ *
+ * Out of scope (data-flow / low-value): `asp-for`/`th:field` property-string
+ * bindings; the C# inside `@code { }` / `@{ }` blocks (noisy regex on embedded C#).
+ */
+
+/**
+ * Blazor framework-provided components — invoked by the runtime, not defined
+ * in-repo, so a reference to them would never resolve. Skip to avoid dangling refs.
+ */
+const BLAZOR_BUILTIN_COMPONENTS = new Set([
+  'Router', 'Found', 'NotFound', 'RouteView', 'AuthorizeRouteView', 'LayoutView',
+  'CascadingValue', 'CascadingAuthenticationState', 'AuthorizeView', 'Authorized',
+  'NotAuthorized', 'Authorizing', 'EditForm', 'DataAnnotationsValidator',
+  'ValidationSummary', 'ValidationMessage', 'InputText', 'InputNumber',
+  'InputCheckbox', 'InputSelect', 'InputDate', 'InputTextArea', 'InputRadio',
+  'InputRadioGroup', 'InputFile', 'PageTitle', 'HeadContent', 'HeadOutlet',
+  'Virtualize', 'DynamicComponent', 'ErrorBoundary', 'SectionContent',
+  'SectionOutlet', 'FocusOnNavigate', 'NavLink', 'Microsoft',
+]);
+
+export class RazorExtractor {
+  private filePath: string;
+  private source: string;
+  private nodes: Node[] = [];
+  private edges: Edge[] = [];
+  private unresolvedReferences: UnresolvedReference[] = [];
+  private errors: ExtractionError[] = [];
+
+  constructor(filePath: string, source: string) {
+    this.filePath = filePath;
+    this.source = source;
+  }
+
+  extract(): ExtractionResult {
+    const startTime = Date.now();
+    try {
+      const componentId = this.createComponentNode().id;
+      this.extractDirectives(componentId);
+      // Blazor component tags only — `.cshtml` uses HTML + tag helpers, not
+      // PascalCase component elements.
+      if (this.filePath.toLowerCase().endsWith('.razor')) {
+        this.extractComponentTags(componentId);
+      }
+      // Delegate the C# in `@code { }` / `@functions { }` / `@{ }` blocks to the
+      // C# tree-sitter extractor (the Blazor analog of Svelte's <script> block) —
+      // this is where component logic uses services/DTOs, so it covers the types
+      // referenced only from component code.
+      this.processCodeBlocks(componentId);
+    } catch (error) {
+      this.errors.push({
+        message: `Razor extraction error: ${error instanceof Error ? error.message : String(error)}`,
+        severity: 'error',
+        code: 'parse_error',
+      });
+    }
+    return {
+      nodes: this.nodes,
+      edges: this.edges,
+      unresolvedReferences: this.unresolvedReferences,
+      errors: this.errors,
+      durationMs: Date.now() - startTime,
+    };
+  }
+
+  private createComponentNode(): Node {
+    const lines = this.source.split('\n');
+    const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath;
+    const componentName = fileName.replace(/\.(razor|cshtml)$/i, '');
+    const node: Node = {
+      id: generateNodeId(this.filePath, 'component', componentName, 1),
+      kind: 'component',
+      name: componentName,
+      qualifiedName: `${this.filePath}::${componentName}`,
+      filePath: this.filePath,
+      language: 'razor',
+      startLine: 1,
+      endLine: lines.length,
+      startColumn: 0,
+      endColumn: lines[lines.length - 1]?.length || 0,
+      isExported: true,
+      updatedAt: Date.now(),
+    };
+    this.nodes.push(node);
+    return node;
+  }
+
+  /** Last `.`-segment (`App.ViewModels.RegisterModel` → `RegisterModel`). */
+  private lastSegment(s: string): string {
+    const i = s.lastIndexOf('.');
+    return i >= 0 ? s.slice(i + 1) : s;
+  }
+
+  /**
+   * Split a type expression into the capitalized type names it contains — base
+   * type plus any generic arguments (`Bar<Foo, Baz>` → `Bar`, `Foo`, `Baz`),
+   * each reduced to its last namespace segment. Lowercase/keyword tokens drop out.
+   */
+  private typeNames(expr: string): string[] {
+    const out: string[] = [];
+    for (const raw of expr.split(/[<>,\s]+/)) {
+      const seg = this.lastSegment(raw.trim());
+      if (/^[A-Z][A-Za-z0-9_]*$/.test(seg)) out.push(seg);
+    }
+    return out;
+  }
+
+  private pushRef(componentId: string, name: string, line: number, column: number): void {
+    this.unresolvedReferences.push({
+      fromNodeId: componentId,
+      referenceName: name,
+      referenceKind: 'references',
+      line,
+      column,
+      filePath: this.filePath,
+      language: 'razor',
+    });
+  }
+
+  private extractDirectives(componentId: string): void {
+    const lines = this.source.split('\n');
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i]!;
+      // `@model Foo` / `@inherits Bar<Foo>` — directive followed by a type.
+      const dir = line.match(/^\s*@(?:model|inherits)\s+([A-Za-z_][\w.]*(?:\s*<[^>]+>)?)/);
+      if (dir) for (const t of this.typeNames(dir[1]!)) this.pushRef(componentId, t, i + 1, 0);
+      // `@inject IService name` — the type is the first token, a name follows.
+      const inj = line.match(/^\s*@inject\s+([A-Za-z_][\w.]*(?:\s*<[^>]+>)?)\s+[A-Za-z_]/);
+      if (inj) for (const t of this.typeNames(inj[1]!)) this.pushRef(componentId, t, i + 1, 0);
+      // `@typeof(X)` anywhere on the line.
+      for (const m of line.matchAll(/@typeof\(\s*([A-Za-z_][\w.]*)\s*\)/g)) {
+        const seg = this.lastSegment(m[1]!);
+        if (/^[A-Z]/.test(seg)) this.pushRef(componentId, seg, i + 1, m.index ?? 0);
+      }
+    }
+  }
+
+  private extractComponentTags(componentId: string): void {
+    const lines = this.source.split('\n');
+    // PascalCase opening / self-closing tags. Closing tags (`</Foo>`) start with
+    // `</` and are skipped. HTML elements are lowercase → never match.
+    const tagRe = /<([A-Z][A-Za-z0-9_]*)\b([^>]*)>/g;
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i]!;
+      let m: RegExpExecArray | null;
+      while ((m = tagRe.exec(line)) !== null) {
+        const name = m[1]!;
+        if (BLAZOR_BUILTIN_COMPONENTS.has(name)) continue;
+        this.pushRef(componentId, name, i + 1, m.index + 1);
+        // Generic component type arg: `<Grid TItem="CatalogItem">`.
+        for (const t of (m[2] || '').matchAll(/\bT[A-Za-z]*\s*=\s*"([A-Za-z_][\w.]*)"/g)) {
+          const seg = this.lastSegment(t[1]!);
+          if (/^[A-Z]/.test(seg)) this.pushRef(componentId, seg, i + 1, 0);
+        }
+      }
+    }
+  }
+
+  /**
+   * Find the matching `}` for the `{` at `openIdx`, skipping string literals and
+   * comments so a brace inside `"{"` / `// }` doesn't throw off the count.
+   * Returns the index of the closing brace, or -1 if unbalanced.
+   */
+  private matchBrace(src: string, openIdx: number): number {
+    let depth = 0;
+    for (let i = openIdx; i < src.length; i++) {
+      const ch = src[i];
+      if (ch === '"' || ch === "'") {
+        const quote = ch;
+        i++;
+        while (i < src.length && src[i] !== quote) {
+          if (src[i] === '\\') i++;
+          i++;
+        }
+        continue;
+      }
+      if (ch === '/' && src[i + 1] === '/') {
+        while (i < src.length && src[i] !== '\n') i++;
+        continue;
+      }
+      if (ch === '/' && src[i + 1] === '*') {
+        i += 2;
+        while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
+        i++;
+        continue;
+      }
+      if (ch === '{') depth++;
+      else if (ch === '}') {
+        depth--;
+        if (depth === 0) return i;
+      }
+    }
+    return -1;
+  }
+
+  /** `@code { … }` / `@functions { … }` (Blazor) and `@{ … }` (Razor) C# blocks. */
+  private extractCodeBlocks(): Array<{ content: string; lineOffset: number }> {
+    const blocks: Array<{ content: string; lineOffset: number }> = [];
+    const re = /@(?:code|functions)\b\s*\{|@\{/g;
+    let m: RegExpExecArray | null;
+    while ((m = re.exec(this.source)) !== null) {
+      const openIdx = this.source.indexOf('{', m.index);
+      if (openIdx < 0) continue;
+      const close = this.matchBrace(this.source, openIdx);
+      if (close < 0) continue;
+      const content = this.source.slice(openIdx + 1, close);
+      // newlines before the content's first char → 0-indexed line of content start
+      const lineOffset = (this.source.slice(0, openIdx + 1).match(/\n/g) || []).length;
+      blocks.push({ content, lineOffset });
+      re.lastIndex = close;
+    }
+    return blocks;
+  }
+
+  /**
+   * Delegate each `@code`/`@functions`/`@{` block's C# to the tree-sitter C#
+   * extractor and attribute the block's external references (service/DTO calls,
+   * `new X()`, type uses) to the component. The block is wrapped in a synthetic
+   * class so tree-sitter parses the component's fields/methods in a class context
+   * (a Blazor `@code` body compiles into the component's partial class). We keep
+   * only the dependency references — coverage just needs the edges to external
+   * types, not per-member nodes. Degrades gracefully if the C# grammar isn't loaded.
+   */
+  private processCodeBlocks(componentId: string): void {
+    if (!isLanguageSupported('csharp')) return;
+    for (const block of this.extractCodeBlocks()) {
+      if (!block.content.trim()) continue;
+      let result: ExtractionResult;
+      try {
+        result = new TreeSitterExtractor(
+          this.filePath,
+          `class __RazorCode__ {\n${block.content}\n}`,
+          'csharp'
+        ).extract();
+      } catch {
+        continue; // grammar not loaded / parse failure — skip this block
+      }
+      // The synthetic wrapper adds one line before the block content; map ref
+      // lines back to the .razor file (display only — coverage is line-agnostic).
+      for (const ref of result.unresolvedReferences) {
+        this.unresolvedReferences.push({
+          ...ref,
+          fromNodeId: componentId,
+          line: ref.line + block.lineOffset - 1,
+          column: ref.column,
+          filePath: this.filePath,
+          language: 'razor',
+        });
+      }
+    }
+  }
+}

+ 8 - 0
src/extraction/tree-sitter-types.ts

@@ -138,6 +138,14 @@ export interface LanguageExtractor {
   isStatic?: (node: SyntaxNode) => boolean;
   /** Check if variable declaration is a constant (const vs let/var) */
   isConst?: (node: SyntaxNode) => boolean;
+  /**
+   * Extract extra symbol-level modifier keywords to persist on the node's
+   * `decorators` list (e.g. Kotlin `expect`/`actual` multiplatform markers).
+   * Called generically for every created node; return undefined/[] when none.
+   * Used by the resolver to link `expect` declarations to their `actual`
+   * implementations across source sets.
+   */
+  extractModifiers?: (node: SyntaxNode) => string[] | undefined;
 
   // --- New config properties ---
 

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 810 - 9
src/extraction/tree-sitter.ts


+ 13 - 47
src/graph/queries.ts

@@ -116,24 +116,11 @@ export class GraphQueryManager {
    * @returns Array of file paths this file depends on
    */
   getFileDependencies(filePath: string): string[] {
-    const nodes = this.queries.getNodesByFile(filePath);
-    const fileNode = nodes.find((n) => n.kind === 'file');
-
-    if (!fileNode) {
-      return [];
-    }
-
-    const dependencies = new Set<string>();
-    const importEdges = this.queries.getOutgoingEdges(fileNode.id, ['imports']);
-
-    for (const edge of importEdges) {
-      const targetNode = this.queries.getNodeById(edge.target);
-      if (targetNode && targetNode.filePath !== filePath) {
-        dependencies.add(targetNode.filePath);
-      }
-    }
-
-    return Array.from(dependencies);
+    // Follow the symbol-level cross-file edge graph, not just `imports`:
+    // an `imports` edge here points from a file to its own local import
+    // declarations (same-file), so the actual cross-file dependencies live in
+    // the resolved calls/references/instantiates/extends/... edges.
+    return this.queries.getDependencyFilePaths(filePath);
   }
 
   /**
@@ -145,35 +132,14 @@ export class GraphQueryManager {
    * @returns Array of file paths that depend on this file
    */
   getFileDependents(filePath: string): string[] {
-    const nodes = this.queries.getNodesByFile(filePath);
-    const dependents = new Set<string>();
-
-    // Check file-level incoming import edges (file:X imports file:Y)
-    const fileNode = nodes.find((n) => n.kind === 'file');
-    if (fileNode) {
-      const incomingFileEdges = this.queries.getIncomingEdges(fileNode.id, ['imports']);
-      for (const edge of incomingFileEdges) {
-        const sourceNode = this.queries.getNodeById(edge.source);
-        if (sourceNode && sourceNode.filePath !== filePath) {
-          dependents.add(sourceNode.filePath);
-        }
-      }
-    }
-
-    // Also check node-level imports of exported symbols
-    for (const node of nodes) {
-      if (node.isExported) {
-        const incomingEdges = this.queries.getIncomingEdges(node.id, ['imports']);
-        for (const edge of incomingEdges) {
-          const sourceNode = this.queries.getNodeById(edge.source);
-          if (sourceNode && sourceNode.filePath !== filePath) {
-            dependents.add(sourceNode.filePath);
-          }
-        }
-      }
-    }
-
-    return Array.from(dependents);
+    // Previously this only followed `imports` edges into the file node or its
+    // exported symbols and returned 0 dependents for *every* file — because an
+    // `imports` edge here connects a file to its own local import declarations
+    // (always same-file), never to the providing file. The real cross-file
+    // dependency signal is the resolved symbol graph (calls/references/
+    // instantiates/extends/implements/...), which is what blast-radius /
+    // `affected` need. Delegate to the indexed projection of that graph.
+    return this.queries.getDependentFilePaths(filePath);
   }
 
   /**

+ 422 - 10
src/resolution/callback-synthesizer.ts

@@ -42,6 +42,10 @@ const MAX_JSX_CHILDREN = 30;
 // event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>)
 // are already caught by JSX_TAG_RE via the SFC component node.
 const VUE_KEBAB_RE = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]/g;
+// PascalCase component tags — `<MediaCard ...>`, `<NavBar/>`. HTML elements are
+// lowercase, so an uppercase-initial tag is a component usage; built-ins
+// (`<NuxtLink>`, `<Transition>`) simply resolve to nothing and emit no edge.
+const VUE_PASCAL_RE = /<([A-Z][A-Za-z0-9]*)[\s/>]/g;
 const VUE_HANDLER_RE = /(?:@|v-on:)([a-zA-Z][\w-]*)(?:\.[\w]+)*\s*=\s*"([^"]+)"/g;
 // Composable/hook destructure: `const { close: closeSidebar } = useSidebarControl()`.
 // Captures the destructure body + the called composable; only `use*` calls qualify.
@@ -64,6 +68,30 @@ function kebabToPascal(s: string): string {
   return s.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
 }
 
+/**
+ * Nuxt auto-import name for a component, derived from its path UNDER `components/`:
+ * `components/media/Card.vue` → `MediaCard`, `components/base/foo/Bar.vue` →
+ * `BaseFooBar`. Each directory segment and the filename is PascalCased and
+ * concatenated; a directory whose PascalCase name prefixes the next segment is
+ * collapsed (Nuxt's de-dup: `base/BaseButton.vue` → `BaseButton`, not
+ * `BaseBaseButton`). Returns null for a flat component (`components/NavBar.vue`)
+ * — its node is already named by basename, so a direct tag match finds it.
+ */
+function nuxtComponentName(filePath: string): string | null {
+  const marker = filePath.lastIndexOf('components/');
+  if (marker === -1) return null;
+  const rel = filePath.slice(marker + 'components/'.length).replace(/\.(vue|ts|tsx|js|jsx)$/i, '');
+  const segs = rel.split('/').filter(Boolean).map(kebabToPascal);
+  if (segs.length < 2) return null;
+  const out: string[] = [];
+  for (const s of segs) {
+    const prev = out[out.length - 1];
+    if (prev && s.startsWith(prev)) out[out.length - 1] = s;
+    else out.push(s);
+  }
+  return out.join('');
+}
+
 function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
   if (!startLine || !endLine) return null;
   return content.split('\n').slice(startLine - 1, endLine).join('\n');
@@ -444,8 +472,134 @@ function cppOverrideEdges(queries: QueryBuilder): Edge[] {
 // and are added below; their concrete-side nodes can be a `struct` (Swift)
 // or an `object` (Scala) so the loop also iterates those kinds.
 const IFACE_OVERRIDE_LANGS = new Set([
-  'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala',
+  'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', 'go', 'rust',
 ]);
+/**
+ * Go implicit interface satisfaction (#584). Go has no `implements` keyword — a
+ * struct satisfies an interface structurally when its method set covers the
+ * interface's. Synthesize the missing `implements` edge (struct → interface) by
+ * matching method-NAME sets, so impl-navigation works and the interface-dispatch
+ * bridge ({@link interfaceOverrideEdges}, now 'go'-enabled) can link an interface
+ * method call to the concrete overrides.
+ *
+ * Name-only matching (signatures ignored) — over-approximation accepted, in line
+ * with the other dispatch synthesizers; capped per interface. Empty interfaces
+ * (`any`) are skipped so they don't match every struct.
+ */
+function goImplementsEdges(queries: QueryBuilder): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+
+  const methodNameSet = (id: string): Set<string> =>
+    new Set(
+      queries
+        .getOutgoingEdges(id, ['contains'])
+        .map((e) => queries.getNodeById(e.target))
+        .filter((n): n is Node => !!n && n.kind === 'method')
+        .map((n) => n.name),
+    );
+
+  const goStructs = queries.getNodesByKind('struct').filter((s) => s.language === 'go');
+  const structMethods = new Map<string, Set<string>>();
+  for (const s of goStructs) structMethods.set(s.id, methodNameSet(s.id));
+
+  for (const iface of queries.getNodesByKind('interface')) {
+    if (iface.language !== 'go') continue;
+    const want = methodNameSet(iface.id);
+    if (want.size === 0) continue; // empty interface (`any`) — would match everything
+    let added = 0;
+    for (const s of goStructs) {
+      if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+      const have = structMethods.get(s.id);
+      if (!have || have.size < want.size) continue;
+      let all = true;
+      for (const m of want) {
+        if (!have.has(m)) { all = false; break; }
+      }
+      if (!all) continue;
+      const key = `${s.id}>${iface.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: s.id,
+        target: iface.id,
+        kind: 'implements',
+        line: s.startLine,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'go-implements', via: iface.name, registeredAt: `${s.filePath}:${s.startLine}` },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
+/**
+ * Kotlin Multiplatform `expect`/`actual` linking. A `common` source set declares
+ * `expect fun foo()` / `expect class Bar`; each platform source set (jvm, native,
+ * js, …) provides an `actual` implementation with the IDENTICAL fully-qualified
+ * name in a different file. Callers in common code resolve to the `expect`
+ * declaration, so every `actual` impl ends up with zero dependents — invisible to
+ * impact/affected even though editing it can break every caller of the API.
+ *
+ * Synthesize a `calls` edge from the common declaration to each platform `actual`
+ * (mirroring the interface-impl bridge: abstract → concrete), so editing a
+ * platform impl surfaces the common `expect` and its callers, and the impl file
+ * participates in the graph.
+ *
+ * `expect`/`actual` are captured onto the node's `decorators` list at extraction
+ * (kotlin.ts `extractModifiers`). Members of an `expect class` are NOT themselves
+ * keyword-marked, so the declaration side is matched as the same-FQN, same-kind
+ * node that is NOT marked `actual`. Requiring an `actual`-marked counterpart also
+ * gates out plain cross-file overloads (neither side is marked).
+ */
+// Kinds that an `expect`/`actual` pair may legitimately straddle. `expect class`
+// is routinely fulfilled by an `actual typealias` (e.g. `actual typealias
+// CancellationException = …`, `actual typealias SchedulerTask = Task`), so a
+// strict kind match would miss those one-line alias files. Same-FQN + the
+// `actual` marker already gates out unrelated symbols, so widening to the
+// type-like kinds is safe.
+const KMP_TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'type_alias']);
+function kmpKindsCompatible(a: string, b: string): boolean {
+  return a === b || (KMP_TYPE_KINDS.has(a) && KMP_TYPE_KINDS.has(b));
+}
+
+function kotlinExpectActualEdges(queries: QueryBuilder): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const actuals = queries
+    .getAllNodes()
+    .filter((n) => n.language === 'kotlin' && !!n.decorators?.includes('actual'));
+  for (const act of actuals) {
+    let added = 0;
+    for (const cand of queries.getNodesByQualifiedNameExact(act.qualifiedName)) {
+      if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+      // The declaration side: same FQN + compatible kind, a different file, NOT
+      // itself an `actual` (that would be a sibling platform impl, not the decl).
+      if (cand.language !== 'kotlin' || cand.id === act.id) continue;
+      if (!kmpKindsCompatible(cand.kind, act.kind) || cand.filePath === act.filePath) continue;
+      if (cand.decorators?.includes('actual')) continue;
+      const key = `${cand.id}>${act.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: cand.id,
+        target: act.id,
+        kind: 'calls',
+        line: cand.startLine,
+        provenance: 'heuristic',
+        metadata: {
+          synthesizedBy: 'kotlin-expect-actual',
+          via: act.name,
+          registeredAt: `${act.filePath}:${act.startLine}`,
+        },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
 function interfaceOverrideEdges(queries: QueryBuilder): Edge[] {
   const edges: Edge[] = [];
   const seen = new Set<string>();
@@ -675,6 +829,16 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
   // A composable's returned member may be a fn (`function close(){}`) or an
   // arrow assigned to a const (`const close = () => {}`).
   const RETURN_KINDS = new Set(['method', 'function', 'variable', 'constant']);
+  // Nuxt auto-imports nested components by a DIRECTORY-PREFIXED name —
+  // `components/media/Card.vue` is used as `<MediaCard/>`, not `<Card/>` — but
+  // the component node is named by basename (`Card`), so a direct tag match
+  // misses it (flat components match by basename and don't need this). Map each
+  // nested component's Nuxt name → node so those template usages resolve.
+  const nuxtComponents = new Map<string, Node>();
+  for (const c of ctx.getNodesByKind('component')) {
+    const nn = nuxtComponentName(c.filePath);
+    if (nn && !nuxtComponents.has(nn)) nuxtComponents.set(nn, c);
+  }
   for (const file of ctx.getAllFiles()) {
     if (!file.endsWith('.vue')) continue;
     const content = ctx.readFile(file);
@@ -716,7 +880,18 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
 
     let m: RegExpExecArray | null;
     VUE_KEBAB_RE.lastIndex = 0;
-    while ((m = VUE_KEBAB_RE.exec(tpl))) addEdge(resolve(kebabToPascal(m[1]!), COMPONENT_KINDS), { synthesizedBy: 'jsx-render', via: m[1] });
+    while ((m = VUE_KEBAB_RE.exec(tpl))) {
+      const tag = kebabToPascal(m[1]!);
+      addEdge(resolve(tag, COMPONENT_KINDS) ?? nuxtComponents.get(tag), { synthesizedBy: 'jsx-render', via: m[1] });
+    }
+    // PascalCase component tags. Try a direct name match first (flat components
+    // and explicit registrations), then the Nuxt dir-prefixed auto-import name
+    // (`<MediaCard>` → components/media/Card.vue). Built-ins match neither → no edge.
+    VUE_PASCAL_RE.lastIndex = 0;
+    while ((m = VUE_PASCAL_RE.exec(tpl))) {
+      const tag = m[1]!;
+      addEdge(resolve(tag, COMPONENT_KINDS) ?? nuxtComponents.get(tag), { synthesizedBy: 'jsx-render', via: tag });
+    }
     VUE_HANDLER_RE.lastIndex = 0;
     while ((m = VUE_HANDLER_RE.exec(tpl))) {
       const event = m[1]!;
@@ -780,6 +955,14 @@ const RN_SWIFT_SEND_RE = /\bsendEvent\s*\(\s*withName\s*:\s*"([^"]+)"/g;
 // JVM source files in the consumer so we don't re-process JS emits
 // (which `eventEmitterEdges` already handles).
 const RN_JVM_EMIT_RE = /\.emit\s*\(\s*"([^"]+)"\s*,/g;
+// Custom `sendEvent(reactContext, "X", body)` wrapper — extremely common
+// (react-native-device-info and many libs wrap `DeviceEventManagerModule…emit`
+// behind a helper whose `.emit(eventName, …)` uses a VARIABLE, so RN_JVM_EMIT_RE
+// misses it; the literal lives in the wrapper CALL instead). Captures the first
+// string literal inside a `sendEvent(...)` call. `[^;{}]*?` keeps it on one
+// statement and stops at a block boundary, so the wrapper DEFINITION (whose `(`
+// is followed by `… ) {`) never matches. Multi-line tolerant. (java/kotlin/swift)
+const RN_NATIVE_SENDEVENT_RE = /\bsendEvent\s*\([^;{}]*?"([^"]+)"/g;
 
 function rnEventEdges(ctx: ResolutionContext): Edge[] {
   // Native dispatchers (source = the native method whose body sends the
@@ -819,17 +1002,26 @@ function rnEventEdges(ctx: ResolutionContext): Edge[] {
       while ((m = RN_SWIFT_SEND_RE.exec(content))) {
         if (m[1]) addDispatcher(m[1], lineOf(m.index));
       }
+      RN_NATIVE_SENDEVENT_RE.lastIndex = 0;
+      while ((m = RN_NATIVE_SENDEVENT_RE.exec(content))) {
+        if (m[1]) addDispatcher(m[1], lineOf(m.index));
+      }
     }
 
-    // JVM side: `.emit("X", …)` in Java/Kotlin. (We pattern-match
-    // anywhere in the file; the JS in-language path uses a separate
-    // emitter object pattern and is already handled by eventEmitterEdges.)
+    // JVM side: `.emit("X", …)` in Java/Kotlin, plus the common
+    // `sendEvent(ctx, "X", body)` wrapper. (We pattern-match anywhere in the
+    // file; the JS in-language path uses a separate emitter object pattern and
+    // is already handled by eventEmitterEdges.)
     if (file.endsWith('.java') || file.endsWith('.kt')) {
-      RN_JVM_EMIT_RE.lastIndex = 0;
       let m: RegExpExecArray | null;
+      RN_JVM_EMIT_RE.lastIndex = 0;
       while ((m = RN_JVM_EMIT_RE.exec(content))) {
         if (m[1]) addDispatcher(m[1], lineOf(m.index));
       }
+      RN_NATIVE_SENDEVENT_RE.lastIndex = 0;
+      while ((m = RN_NATIVE_SENDEVENT_RE.exec(content))) {
+        if (m[1]) addDispatcher(m[1], lineOf(m.index));
+      }
     }
 
     // JS subscribers (.addListener("X", handler)). Restrict to JS-family
@@ -951,6 +1143,130 @@ function rnEventEdges(ctx: ResolutionContext): Edge[] {
  */
 const FABRIC_NATIVE_SUFFIXES = ['', 'View', 'ViewManager', 'ComponentView', 'Manager'];
 
+/**
+ * Expo Modules cross-platform pairing. An Expo Module exposes the SAME
+ * JS-visible method (`AsyncFunction("getBatteryLevelAsync")`) from BOTH an iOS
+ * (Swift) and an Android (Kotlin) implementation. A JS callsite name-resolves to
+ * only ONE of them, so the other platform's impl looked like nothing called it
+ * (and editing it showed no blast radius). Link the iOS and Android impls of the
+ * same `<module>.<method>` to each other (both directions), so a JS call that
+ * reaches one platform reaches the other, and editing either surfaces the JS
+ * caller. The Expo method nodes are id-prefixed `expo-module:` and qualified
+ * `<file>::<module>.<method>` by the framework extractor.
+ */
+function expoCrossPlatformEdges(queries: QueryBuilder): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const byKey = new Map<string, Node[]>();
+  for (const m of queries.getNodesByKind('method')) {
+    if (!m.id.startsWith('expo-module:')) continue;
+    const key = m.qualifiedName.split('::').pop(); // `<module>.<method>`
+    if (!key) continue;
+    const arr = byKey.get(key);
+    if (arr) arr.push(m);
+    else byKey.set(key, [m]);
+  }
+  for (const group of byKey.values()) {
+    if (group.length < 2) continue;
+    for (const a of group) {
+      for (const b of group) {
+        if (a.id === b.id || a.language === b.language) continue; // cross-platform only
+        const key = `${a.id}>${b.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: a.id,
+          target: b.id,
+          kind: 'calls',
+          line: a.startLine,
+          provenance: 'heuristic',
+          metadata: { synthesizedBy: 'expo-cross-platform', via: a.name },
+        });
+      }
+    }
+  }
+  return edges;
+}
+
+/**
+ * Classic React Native NativeModules cross-platform pairing. A native module
+ * method (`@ReactMethod` on Android, `RCT_EXPORT_METHOD` on iOS) is implemented
+ * on BOTH platforms, but a JS callsite name-resolves to only ONE — so the other
+ * platform's impl looked like nothing called it. A native method that HAS a JS
+ * caller is a confirmed bridge method; link it to the same-named native method
+ * in another language (the other platform's impl) so a JS call reaching one
+ * platform reaches the other, and editing either surfaces the JS caller.
+ *
+ * Names are normalized to the first selector keyword (`getFreeDiskStorage:` →
+ * `getFreeDiskStorage`) — that's the JS-visible name, and how the iOS selector
+ * lines up with the bare Android method name.
+ */
+function rnCrossPlatformEdges(queries: QueryBuilder): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const NATIVE = new Set(['java', 'kotlin', 'objc', 'cpp']);
+  const JS = new Set(['typescript', 'tsx', 'javascript', 'jsx']);
+  // RN module INFRASTRUCTURE methods exist on every native module (called by the
+  // RN runtime, not user JS), so pairing them by name would cross-link unrelated
+  // modules in a multi-module repo. Skip them — they aren't user-facing methods.
+  const RN_INFRA = new Set([
+    'addListener', 'removeListeners', 'getConstants', 'constantsToExport', 'getName',
+    'invalidate', 'initialize', 'getDefaultEventTypes', 'supportedEvents',
+    'requiresMainQueueSetup', 'methodQueue',
+  ]);
+  const norm = (name: string): string => {
+    const i = name.indexOf(':');
+    return i >= 0 ? name.slice(0, i) : name;
+  };
+
+  // Index native methods by their JS-visible (normalized) name. Only names with
+  // impls in ≥2 native languages can pair, so the per-method JS-caller check
+  // below only runs for genuine cross-platform candidates.
+  const byName = new Map<string, Node[]>();
+  for (const m of queries.iterateNodesByKind('method')) {
+    if (!NATIVE.has(m.language)) continue;
+    const key = norm(m.name);
+    const arr = byName.get(key);
+    if (arr) arr.push(m);
+    else byName.set(key, [m]);
+  }
+
+  for (const [groupName, group] of byName) {
+    if (RN_INFRA.has(groupName)) continue;
+    const langs = new Set(group.map((m) => m.language));
+    if (langs.size < 2) continue; // single-platform — nothing to pair
+    for (const m of group) {
+      // Is m a bridge method? (a JS-language `calls` edge points at it)
+      const incoming = queries.getIncomingEdges(m.id, ['calls']);
+      if (incoming.length === 0) continue;
+      const sources = queries.getNodesByIds(incoming.map((e) => e.source));
+      const isBridge = incoming.some((e) => {
+        const s = sources.get(e.source);
+        return !!s && JS.has(s.language);
+      });
+      if (!isBridge) continue;
+      // Link to the other-platform impls (both directions).
+      for (const sib of group) {
+        if (sib.id === m.id || sib.language === m.language) continue;
+        for (const [a, b] of [[m, sib], [sib, m]] as const) {
+          const key = `${a.id}>${b.id}`;
+          if (seen.has(key)) continue;
+          seen.add(key);
+          edges.push({
+            source: a.id,
+            target: b.id,
+            kind: 'calls',
+            line: a.startLine,
+            provenance: 'heuristic',
+            metadata: { synthesizedBy: 'rn-cross-platform', via: norm(m.name) },
+          });
+        }
+      }
+    }
+  }
+  return edges;
+}
+
 function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
   const edges: Edge[] = [];
   const seen = new Set<string>();
@@ -1183,25 +1499,116 @@ function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext):
   return edges;
 }
 
+/**
+ * Delphi form code-behind: a form unit `UFRMAbout.pas` owns its visual form
+ * definition `UFRMAbout.dfm` (VCL) / `.fmx` (FireMonkey) — paired by basename in
+ * the same directory, wired by the `{$R *.dfm}` directive rather than a `uses`
+ * clause. Link the unit → its form so a `.dfm`/`.fmx` used only as a form
+ * definition isn't orphaned, and editing the form surfaces its code-behind unit.
+ */
+function pascalFormEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const allFiles = new Set(ctx.getAllFiles());
+  for (const file of allFiles) {
+    if (!/\.(dfm|fmx)$/i.test(file)) continue;
+    const pasFile = file.replace(/\.(dfm|fmx)$/i, '.pas');
+    if (!allFiles.has(pasFile)) continue;
+    const formNode = ctx.getNodesInFile(file).find((n) => n.kind === 'file');
+    const unitNode = ctx.getNodesInFile(pasFile).find((n) => n.kind === 'file');
+    if (!formNode || !unitNode) continue;
+    edges.push({
+      source: unitNode.id,
+      target: formNode.id,
+      kind: 'references',
+      line: unitNode.startLine,
+      provenance: 'heuristic',
+      metadata: { synthesizedBy: 'pascal-form', registeredAt: pasFile },
+    });
+  }
+  return edges;
+}
+
+/**
+ * SvelteKit file-convention data flow. A route directory's `+page.svelte` (a
+ * `component` node) receives its `data` from the sibling `+page.server.{ts,js}`
+ * / `+page.{ts,js}` `load` function and posts forms to its `actions` — wired by
+ * the framework BY FILE PATH, with no static import between them. So editing a
+ * `load` shows no impact on the page it feeds, and the page looks like it has no
+ * server-side dependency. Link the page component to its sibling loader's
+ * `load` / `actions` (same for `+layout`). The pairing is path-deterministic
+ * (same directory, matching `+page`/`+layout` prefix), so it's precise — but
+ * it's a framework-convention edge, so provenance stays `heuristic`.
+ *
+ * Direction: page → load, so `getImpactRadius(load)` surfaces the page (editing
+ * a loader's data shows the page it feeds) and the page's dependencies include
+ * its loader.
+ */
+function svelteKitLoadEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const allFiles = new Set(ctx.getAllFiles());
+  const HOOKS = new Set(['load', 'actions']);
+  const HOOK_KINDS = new Set(['function', 'method', 'constant', 'variable']);
+  for (const file of allFiles) {
+    const m = file.match(/(.*\/)(\+(?:page|layout))\.svelte$/);
+    if (!m) continue;
+    const dir = m[1]!;
+    const prefix = m[2]!;
+    const page = ctx.getNodesInFile(file).find((n) => n.kind === 'component');
+    if (!page) continue;
+    for (const ext of ['.server.ts', '.server.js', '.ts', '.js']) {
+      const loaderFile = `${dir}${prefix}${ext}`;
+      if (!allFiles.has(loaderFile)) continue;
+      for (const hook of ctx.getNodesInFile(loaderFile)) {
+        if (!HOOK_KINDS.has(hook.kind) || !HOOKS.has(hook.name)) continue;
+        edges.push({
+          source: page.id,
+          target: hook.id,
+          kind: 'references',
+          line: page.startLine,
+          provenance: 'heuristic',
+          metadata: {
+            synthesizedBy: 'sveltekit-load',
+            via: hook.name,
+            registeredAt: `${loaderFile}:${hook.startLine ?? 0}`,
+          },
+        });
+      }
+    }
+  }
+  return edges;
+}
+
 /**
  * Synthesize dispatcher→callback edges (field observers + EventEmitters +
- * React re-render + JSX children + Vue templates + RN event channel +
- * Fabric native-impl + MyBatis Java↔XML + Gin middleware chain). Returns the
- * count added. Never throws into indexing — callers wrap in try/catch.
+ * React re-render + JSX children + Vue templates + SvelteKit load + RN event
+ * channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain).
+ * Returns the count added. Never throws into indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
+  // Go implicit `implements` edges must be synthesized AND persisted first: the
+  // interface-dispatch bridge below reads `implements` edges from the DB, and
+  // Go has none statically. (Other languages already have static implements
+  // edges from extraction, so they don't need this pre-pass.)
+  const goImpl = goImplementsEdges(queries);
+  if (goImpl.length > 0) queries.insertEdges(goImpl);
+
   const fieldEdges = fieldChannelEdges(queries, ctx);
   const closureCollEdges = closureCollectionEdges(queries, ctx);
   const emitterEdges = eventEmitterEdges(ctx);
   const renderEdges = reactRenderEdges(queries, ctx);
   const jsxEdges = reactJsxChildEdges(ctx);
   const vueEdges = vueTemplateEdges(ctx);
+  const svelteKitEdges = svelteKitLoadEdges(ctx);
+  const pascalEdges = pascalFormEdges(ctx);
   const flutterEdges = flutterBuildEdges(queries, ctx);
   const cppEdges = cppOverrideEdges(queries);
   const ifaceEdges = interfaceOverrideEdges(queries);
+  const kotlinExpectActual = kotlinExpectActualEdges(queries);
   const goGrpcEdges = goGrpcStubImplEdges(queries);
   const rnEventEdgesList = rnEventEdges(ctx);
   const fabricNativeEdges = fabricNativeImplEdges(ctx);
+  const expoXPlatEdges = expoCrossPlatformEdges(queries);
+  const rnXPlatEdges = rnCrossPlatformEdges(queries);
   const mybatisEdges = mybatisJavaXmlEdges(queries);
   const ginEdges = ginMiddlewareChainEdges(queries, ctx);
 
@@ -1214,12 +1621,17 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...renderEdges,
     ...jsxEdges,
     ...vueEdges,
+    ...svelteKitEdges,
+    ...pascalEdges,
     ...flutterEdges,
     ...cppEdges,
     ...ifaceEdges,
+    ...kotlinExpectActual,
     ...goGrpcEdges,
     ...rnEventEdgesList,
     ...fabricNativeEdges,
+    ...expoXPlatEdges,
+    ...rnXPlatEdges,
     ...mybatisEdges,
     ...ginEdges,
   ]) {
@@ -1229,5 +1641,5 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     merged.push(e);
   }
   if (merged.length > 0) queries.insertEdges(merged);
-  return merged.length;
+  return merged.length + goImpl.length;
 }

+ 6 - 1
src/resolution/frameworks/expo-modules.ts

@@ -54,9 +54,14 @@ import {
  * line as the keyword, which matches every real Expo Module declaration
  * style. Multi-line `AsyncFunction(\n"x"\n)` forms aren't a real shape in
  * the SDK; if any appear we'd extend the regex.
+ *
+ * The optional `<…>` covers Kotlin's GENERIC-typed declarations
+ * (`AsyncFunction<Float>("getBatteryLevelAsync")`, `AsyncFunction<Int, String>(…)`)
+ * — without it, every Android Expo Module method was silently dropped, so a JS
+ * callsite resolved only to the iOS Swift impl and never the Android one.
  */
 const EXPO_DECL_RE =
-  /\b(Function|AsyncFunction|Property|Constants)\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g;
+  /\b(Function|AsyncFunction|Property|Constants)\s*(?:<[^(]*>)?\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g;
 
 /**
  * Match the module name literal `Name("ExpoX")`. Used to enrich each emitted

+ 7 - 3
src/resolution/frameworks/python.ts

@@ -48,10 +48,14 @@ export const djangoResolver: FrameworkResolver = {
     return null;
   },
 
-  // Let the ORM dynamic-dispatch ref reach resolve() despite no symbol being
-  // named `_iterable_class` (it's a QuerySet attribute, not a declared method).
+  // Let two ref shapes past resolveOne's "no possible match" pre-filter so they
+  // reach resolution: the ORM dynamic-dispatch `_iterable_class` (a QuerySet
+  // attribute, not a declared symbol), and a Django `include('app.urls')` module
+  // path — a dotted module name with no symbol/import to match, which resolution
+  // (resolvePythonAbsoluteModule) then maps to its `urls.py` file so the included
+  // URLconf records a dependency on the root urlconf.
   claimsReference(name) {
-    return name === '_iterable_class';
+    return name === '_iterable_class' || name.endsWith('.urls');
   },
 
   extract(filePath, content) {

+ 52 - 5
src/resolution/frameworks/react-native.ts

@@ -99,8 +99,8 @@ function defaultObjcModuleName(className: string): string {
 function parseObjcRNExports(
   source: string,
   className: string | null
-): Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string }> {
-  const results: Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string }> = [];
+): Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string; line: number }> {
+  const results: Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string; line: number }> = [];
 
   // RCT_EXPORT_MODULE — one per file by convention. Capture the optional arg.
   const moduleMatch = source.match(/RCT_EXPORT_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)?\s*\)/);
@@ -111,6 +111,12 @@ function parseObjcRNExports(
     (className ? defaultObjcModuleName(className) : null);
   if (!moduleName) return results;
 
+  const lineOf = (idx: number): number => {
+    let line = 1;
+    for (let i = 0; i < idx && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
+    return line;
+  };
+
   // RCT_EXPORT_METHOD(selectorFirstKw:(args)…)
   // The first keyword (everything up to the first `:` or open paren) is the
   // JS-visible name. We don't try to parse full multi-keyword selectors —
@@ -119,7 +125,7 @@ function parseObjcRNExports(
   let m: RegExpExecArray | null;
   while ((m = exportRegex.exec(source)) !== null) {
     const kw = m[1];
-    if (kw) results.push({ moduleName, jsName: kw, nativeSelectorFirstKw: kw });
+    if (kw) results.push({ moduleName, jsName: kw, nativeSelectorFirstKw: kw, line: lineOf(m.index) });
   }
 
   // RCT_REMAP_METHOD(jsName, nativeSelectorFirstKw:(args)…)
@@ -129,7 +135,7 @@ function parseObjcRNExports(
     const jsName = m[1];
     const nativeKw = m[2];
     if (jsName && nativeKw) {
-      results.push({ moduleName, jsName, nativeSelectorFirstKw: nativeKw });
+      results.push({ moduleName, jsName, nativeSelectorFirstKw: nativeKw, line: lineOf(m.index) });
     }
   }
 
@@ -355,7 +361,48 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
 
 export const reactNativeBridgeResolver: FrameworkResolver = {
   name: 'react-native-bridge',
-  languages: ['javascript', 'typescript', 'tsx', 'jsx'],
+  // objc/mm included so `extract()` sees the native files — `resolve()` still
+  // only redirects JS callers (it returns null for native languages).
+  languages: ['javascript', 'typescript', 'tsx', 'jsx', 'objc'],
+
+  /**
+   * Extract `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` declarations as method
+   * nodes. These macros parse as a macro-expression (an ERROR node), NOT a
+   * `method_definition`, so the ObjC extractor never made a node for them — the
+   * iOS half of a native module was invisible, so a JS call couldn't resolve to
+   * it and the cross-platform pairing had nothing to pair. The node is named by
+   * the JS-visible name (the selector's first keyword, or the explicit
+   * `RCT_REMAP_METHOD` JS name) so it matches the Android `@ReactMethod` method.
+   */
+  extract(filePath, source) {
+    if (!filePath.endsWith('.m') && !filePath.endsWith('.mm')) return { nodes: [], references: [] };
+    if (!/RCT_EXPORT_MODULE\b/.test(source)) return { nodes: [], references: [] };
+    const exports = parseObjcRNExports(source, findObjcClassName(source));
+    const now = Date.now();
+    const nodes: Node[] = [];
+    const seen = new Set<string>();
+    for (const e of exports) {
+      if (seen.has(e.jsName)) continue;
+      seen.add(e.jsName);
+      nodes.push({
+        id: `rn-export:${filePath}:${e.moduleName}.${e.jsName}`,
+        kind: 'method',
+        name: e.jsName,
+        qualifiedName: `${filePath}::${e.moduleName}.${e.jsName}`,
+        filePath,
+        language: 'objc',
+        startLine: e.line,
+        endLine: e.line,
+        startColumn: 0,
+        endColumn: 0,
+        isExported: true,
+        docstring: `RCT_EXPORT_METHOD ${e.nativeSelectorFirstKw} (module ${e.moduleName})`,
+        signature: `RCT_EXPORT_METHOD(${e.nativeSelectorFirstKw}:…)`,
+        updatedAt: now,
+      });
+    }
+    return { nodes, references: [] };
+  },
 
   /**
    * Detect: package.json depends on `react-native`, OR any source file

+ 463 - 1
src/resolution/import-resolver.ts

@@ -213,6 +213,24 @@ function resolveRelativeImport(
   const projectRoot = context.getProjectRoot();
   const extensions = EXTENSION_RESOLUTION[language] || [];
 
+  // Python dotted-relative imports (`from .certs import x`, `from ..pkg.mod
+  // import y`): leading dots are PACKAGE levels (1 = current package), and the
+  // remainder is a dotted submodule path. `path.resolve(dir, '.certs')` would
+  // treat `.certs` as a literal hidden filename, so translate the Python form
+  // to a real filesystem-relative path before resolving.
+  if (language === 'python' && importPath.startsWith('.')) {
+    const dots = importPath.length - importPath.replace(/^\.+/, '').length;
+    const up = '../'.repeat(Math.max(0, dots - 1));    // 1 dot = current dir
+    const rest = importPath.slice(dots).replace(/\./g, '/'); // 'sub.mod' -> 'sub/mod'
+    const pyBase = path.resolve(fromDir, up + rest);
+    const pyRel = path.relative(projectRoot, pyBase).replace(/\\/g, '/');
+    for (const ext of extensions) {
+      if (context.fileExists(pyRel + ext)) return pyRel + ext;
+    }
+    if (pyRel && context.fileExists(pyRel)) return pyRel;
+    return null;
+  }
+
   // Try the path as-is first
   const basePath = path.resolve(fromDir, importPath);
   const relativePath = path.relative(projectRoot, basePath).replace(/\\/g, '/');
@@ -1012,14 +1030,53 @@ export function resolveJvmImport(
   const candidates = context.getNodesByQualifiedName(`${pkg}::${sym}`);
   if (candidates.length === 0) return null;
 
+  // Kotlin Multiplatform: an `expect` declaration and its `actual`s share one
+  // FQN across source sets (commonMain / androidMain / appleMain). Taking the
+  // first candidate let a single platform `actual` absorb every common-side
+  // import, so the `expect` (the canonical API a commonMain file imports)
+  // looked unused. Prefer the candidate CLOSEST to the importing file by
+  // directory proximity — a commonMain import resolves to the commonMain
+  // declaration — with the `expect` side as a tiebreak.
+  const best = candidates.length === 1 ? candidates[0]! : pickClosestJvmCandidate(candidates, ref.filePath);
   return {
     original: ref,
-    targetNodeId: candidates[0]!.id,
+    targetNodeId: best.id,
     confidence: 0.95,
     resolvedBy: 'import',
   };
 }
 
+/**
+ * Pick the same-FQN candidate closest to `fromPath` by shared directory
+ * prefix, preferring an `expect` declaration on a tie. Used to keep a Kotlin
+ * Multiplatform `expect`/`actual` import resolving within the importer's own
+ * source set instead of an arbitrary platform `actual`.
+ */
+function pickClosestJvmCandidate(candidates: Node[], fromPath: string): Node {
+  const fromDirs = fromPath.split('/').slice(0, -1);
+  const sharedPrefix = (p: string): number => {
+    const d = p.split('/').slice(0, -1);
+    let shared = 0;
+    for (let i = 0; i < Math.min(fromDirs.length, d.length); i++) {
+      if (fromDirs[i] === d[i]) shared++;
+      else break;
+    }
+    return shared;
+  };
+  const isExpect = (n: Node): boolean => Array.isArray(n.decorators) && n.decorators.includes('expect');
+  let best = candidates[0]!;
+  let bestProx = sharedPrefix(best.filePath);
+  for (let i = 1; i < candidates.length; i++) {
+    const c = candidates[i]!;
+    const prox = sharedPrefix(c.filePath);
+    if (prox > bestProx || (prox === bestProx && isExpect(c) && !isExpect(best))) {
+      best = c;
+      bestProx = prox;
+    }
+  }
+  return best;
+}
+
 export function resolveViaImport(
   ref: UnresolvedRef,
   context: ResolutionContext
@@ -1032,6 +1089,23 @@ export function resolveViaImport(
   // edge — resolveViaImport's symbol lookup below would search the
   // resolved file for a symbol named like the file extension and fail.
   if ((ref.language === 'c' || ref.language === 'cpp') && ref.referenceKind === 'imports') {
+    // C/C++ quoted includes (`#include "X.h"`) resolve relative to the
+    // INCLUDING file's own directory first (the C standard's quoted-include
+    // search order). Prefer a same-directory header over an -I directory or a
+    // same-named header on another platform (windows/code/RNCAsyncStorage.h vs
+    // apple/.../RNCAsyncStorage.h) — the include-dir heuristic below would
+    // otherwise pick an arbitrary same-named header, leaving the real local one
+    // with no dependents.
+    const slash = ref.filePath.lastIndexOf('/');
+    const fromDir = slash >= 0 ? ref.filePath.slice(0, slash) : '';
+    const siblingPath = path.posix.normalize(fromDir ? `${fromDir}/${ref.referenceName}` : ref.referenceName);
+    const siblingBase = siblingPath.split('/').pop()!;
+    const sibling = context
+      .getNodesByName(siblingBase)
+      .find((n) => n.kind === 'file' && n.filePath === siblingPath);
+    if (sibling) {
+      return { original: ref, targetNodeId: sibling.id, confidence: 0.92, resolvedBy: 'import' };
+    }
     const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
     if (!resolvedPath) return null;
     const basename = resolvedPath.split('/').pop()!;
@@ -1074,6 +1148,55 @@ export function resolveViaImport(
     if (javaResult) return javaResult;
   }
 
+  // Python qualified access through an imported MODULE: `certs.where()` after
+  // `from . import certs`, `mod.func()` after `import mod`. The receiver names a
+  // submodule (a file), not a symbol, so the generic symbol lookup below would
+  // search the *package* for `certs` instead of looking inside the module.
+  if (ref.language === 'python') {
+    const pyResult = resolvePythonModuleMember(ref, imports, context);
+    if (pyResult) return pyResult;
+    // Absolute dotted module import: `import conduit.apps.articles.signals`
+    // (the standard Django AppConfig.ready() signal-registration pattern, and
+    // any side-effect `import pkg.mod`). Map the dotted path to its file.
+    const pyModResult = resolvePythonAbsoluteModule(ref, context);
+    if (pyModResult) return pyModResult;
+  }
+
+  // Rust qualified path: resolve the module prefix of `crate::m::Item` /
+  // `self::sub::Item` / `super::m::func` to a file, then find the leaf symbol in
+  // it. Disambiguates common-name `pub use self::read::read` re-exports that
+  // name-matching would land on the wrong same-named symbol.
+  if (ref.language === 'rust' && ref.referenceName.includes('::')) {
+    const rustResult = resolveRustPathReference(ref, context);
+    if (rustResult) return rustResult;
+  }
+
+  // Lua / Luau `require(...)`: a dotted module path (`a.b.c` from
+  // `require("a.b.c")`) or an instance-path leaf (`Signal` from
+  // `require(script.Parent.Signal)`) — map it to a module file. There's no static
+  // import statement, so the generic path-matcher can't bridge the dot↔slash /
+  // leaf↔basename gap; resolve it explicitly to the module file.
+  if ((ref.language === 'lua' || ref.language === 'luau') && ref.referenceKind === 'imports') {
+    const luaResult = resolveLuaRequire(ref, context);
+    if (luaResult) return luaResult;
+  }
+
+  // Whole-module / namespace imports → link the importing file to the module
+  // file. Python `from . import certs` / `import mod`, and TS/JS `import * as ns
+  // from './x'` (so a namespace touched only via a value-member read still
+  // records the dependency). A named TS/JS import returns null here and falls
+  // through to symbol resolution below.
+  if (
+    ref.language === 'python' ||
+    ref.language === 'typescript' ||
+    ref.language === 'tsx' ||
+    ref.language === 'javascript' ||
+    ref.language === 'jsx'
+  ) {
+    const moduleFile = resolveModuleImportToFile(ref, imports, context);
+    if (moduleFile) return moduleFile;
+  }
+
   // Check if the reference name matches any import
   for (const imp of imports) {
     if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
@@ -1114,6 +1237,345 @@ export function resolveViaImport(
   return null;
 }
 
+/**
+ * Resolve a Python qualified reference whose receiver is an imported MODULE:
+ * `certs.where()` after `from . import certs`, `mod.func()` after `import mod`
+ * or `from pkg import mod`. The receiver names a submodule (a file), not a
+ * symbol, so the generic symbol lookup in `resolveViaImport` can't follow it —
+ * it would search the *package* for `certs`/`mod` instead of looking inside the
+ * module. This is the Python half of the cross-package qualified-call problem
+ * (cf. `resolveGoCrossPackageReference` for Go's `pkg.Func`, issue #388).
+ *
+ * Builds the module's dotted import path from the binding — `from . import
+ * certs` → `.certs`; `from pkg import mod` → `pkg.mod`; `import mod` → `mod` —
+ * resolves it to the module file, and finds the member defined there. Returns
+ * null when no module file exists at that path, so attribute access on an
+ * imported *value* (`helper.attr` where `helper` is a function) falls through
+ * to the other strategies untouched.
+ */
+function resolvePythonModuleMember(
+  ref: UnresolvedRef,
+  imports: ImportMapping[],
+  context: ResolutionContext
+): ResolvedRef | null {
+  const dotIdx = ref.referenceName.indexOf('.');
+  if (dotIdx <= 0) return null;
+  const receiver = ref.referenceName.substring(0, dotIdx);
+  // The immediate member of the module (first segment after the receiver).
+  const member = ref.referenceName.substring(dotIdx + 1).split('.')[0];
+  if (!member) return null;
+
+  for (const imp of imports) {
+    if (imp.localName !== receiver) continue;
+
+    // `import mod` / `import numpy as np` bind the module at `source` itself;
+    // `from . import certs` / `from pkg import mod` bind a SUBMODULE whose
+    // dotted path is the source joined with the imported name.
+    const modulePath = imp.isNamespace
+      ? imp.source
+      : imp.source.endsWith('.')
+        ? imp.source + imp.localName
+        : imp.source + '.' + imp.localName;
+
+    const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
+    if (!resolvedPath || resolvedPath === ref.filePath) continue;
+
+    // Find the member as a top-level definition in the module file. Exclude
+    // `method` so `mod.foo` never lands on a same-named class method.
+    const target = context.getNodesInFile(resolvedPath).find(
+      (n) =>
+        n.name === member &&
+        (n.kind === 'function' ||
+          n.kind === 'class' ||
+          n.kind === 'variable' ||
+          n.kind === 'constant')
+    );
+    if (target) {
+      return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' };
+    }
+  }
+  return null;
+}
+
+/**
+ * Resolve a whole-MODULE import to that module's file (a file→file dependency).
+ * The imported name is a module, not a symbol, so there's nothing to resolve to
+ * — but importing a module IS a dependency on it. Covers:
+ *   - Python submodule imports — `from . import certs`, `from pkg import sub`;
+ *   - namespace imports — Python `import mod` / `import numpy as np`, and
+ *     TS/JS `import * as ns from './x'`.
+ *
+ * It is also the robust backstop for {@link resolvePythonModuleMember} and for
+ * TS namespace usage: it records the dependency even when the used member is
+ * re-exported elsewhere (requests' `certs.where`, re-exported from `certifi`),
+ * the usage is module-level code that isn't extracted as a call, or a TS
+ * namespace is touched only via a value-member read (`ns.SOME_CONST`).
+ *
+ * Only fires for dot-free `imports`-kind refs whose module path resolves to a
+ * real file. A NAMED TS/JS import (`import { widget }`) is not a module, so it
+ * returns null and normal symbol resolution handles it.
+ */
+/**
+ * Resolve a Lua/Luau `require(...)` to its module file. The reference name is
+ * either a dotted module path (`telescope.config` → `telescope/config.lua`) or a
+ * Roblox instance-path leaf (`Signal` from `require(script.Parent.Signal)` →
+ * `Signal.luau`). We try `<path>.lua|.luau` and `<path>/init.lua|.luau`, matched
+ * by path suffix (the module root — `lua/`, `src/`, … — is project-specific).
+ * Among suffix matches, the one sharing the longest directory prefix with the
+ * requiring file wins (instance-path requires resolve within the same package).
+ */
+function resolveLuaRequire(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+  const name = ref.referenceName;
+  if (!name) return null;
+  const base = name.includes('.') ? name.replace(/\./g, '/') : name;
+  const suffixes = [`${base}.lua`, `${base}.luau`, `${base}/init.lua`, `${base}/init.luau`];
+  const files = context.getAllFiles();
+  const shared = (a: string, b: string): number => {
+    let i = 0;
+    while (i < a.length && i < b.length && a[i] === b[i]) i++;
+    return i;
+  };
+  for (const suffix of suffixes) {
+    const matches = files.filter((f) => f === suffix || f.endsWith('/' + suffix));
+    if (matches.length === 0) continue;
+    matches.sort((x, y) => shared(y, ref.filePath) - shared(x, ref.filePath));
+    const best = matches[0]!;
+    if (best === ref.filePath) continue;
+    const fileNode = context.getNodesInFile(best).find((n) => n.kind === 'file');
+    if (fileNode) {
+      // Confidence ≥ 0.9 so this deterministic path/suffix match wins over
+      // name-matching, which otherwise resolves the require to the import node
+      // itself (a same-name self-match).
+      return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
+    }
+  }
+  return null;
+}
+
+function resolveModuleImportToFile(
+  ref: UnresolvedRef,
+  imports: ImportMapping[],
+  context: ResolutionContext
+): ResolvedRef | null {
+  if (ref.referenceKind !== 'imports') return null;
+  if (ref.referenceName.includes('.')) return null;
+
+  for (const imp of imports) {
+    if (imp.localName !== ref.referenceName) continue;
+
+    let modulePath: string;
+    if (imp.isNamespace || imp.isDefault) {
+      // `import * as ns from './x'` (namespace) or `import x from './x'`
+      // (default) — the dependency is on the MODULE FILE. A default import binds
+      // a (possibly renamed) local to whatever the module's default export is
+      // (`import articlesController from './article.controller'` ← `export
+      // default router`), so the binding name can't be found as a symbol — link
+      // the file the import resolves to instead. External modules don't resolve
+      // (no file), so `import React from 'react'` creates no edge.
+      modulePath = imp.source;
+    } else if (ref.language === 'python') {
+      // `from . import certs` — the imported NAME is a submodule of the source.
+      modulePath = imp.source.endsWith('.')
+        ? imp.source + imp.localName
+        : imp.source + '.' + imp.localName;
+    } else {
+      // A named TS/JS import binds a symbol, not a module — leave it alone.
+      continue;
+    }
+
+    const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
+    if (resolvedPath && resolvedPath !== ref.filePath) {
+      const fileNode = context.getNodesInFile(resolvedPath).find((n) => n.kind === 'file');
+      if (fileNode) {
+        return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
+      }
+    }
+
+    // Python absolute `from a.b import submodule` (a FastAPI router aggregator's
+    // `from app.api.routes import authentication`): resolveImportPath only maps
+    // RELATIVE dotted paths to a file, so resolve the absolute dotted module
+    // directly to its file node.
+    if (ref.language === 'python') {
+      const modFile = findPythonModuleFile(modulePath, context, ref.filePath);
+      if (modFile) {
+        return { original: ref, targetNodeId: modFile.id, confidence: 0.9, resolvedBy: 'import' };
+      }
+    }
+  }
+  return null;
+}
+
+/**
+ * Find the file node for a Python dotted module path `a.b.c` — a module file
+ * ending in `a/b/c.py`, or a package `a/b/c/__init__.py` (suffix-matched, so a
+ * package rooted under `src/` etc. still resolves). Returns null for
+ * stdlib/external modules (no matching repo file node), so `import os` creates
+ * no edge. Shared by absolute `import a.b.c` and absolute `from a.b import c`
+ * (where `c` is a submodule) resolution.
+ */
+function findPythonModuleFile(
+  mod: string,
+  context: ResolutionContext,
+  excludeFilePath: string
+): Node | null {
+  if (!mod || mod.startsWith('.')) return null; // relative imports handled elsewhere
+  const rel = mod.replace(/\./g, '/');
+  const lastSeg = mod.split('.').pop()!;
+  const endsWith = (p: string, want: string): boolean => p === want || p.endsWith('/' + want);
+  const moduleFile = context
+    .getNodesByName(`${lastSeg}.py`)
+    .find((n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}.py`));
+  if (moduleFile) return moduleFile;
+  const pkgFile = context
+    .getNodesByName('__init__.py')
+    .find((n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}/__init__.py`));
+  return pkgFile ?? null;
+}
+
+/**
+ * Resolve a Python ABSOLUTE dotted module import (`import a.b.c`) to its file —
+ * the Django `AppConfig.ready(): import myapp.signals` pattern and any
+ * side-effect module import.
+ */
+function resolvePythonAbsoluteModule(
+  ref: UnresolvedRef,
+  context: ResolutionContext
+): ResolvedRef | null {
+  if (ref.referenceKind !== 'imports') return null;
+  // Only a DOTTED `import a.b.c` ref carries its full module path. A bare leaf
+  // (`from app.api.routes import authentication`) is ambiguous on its own — three
+  // `authentication.py` files may exist — so leave it to resolveModuleImportToFile,
+  // which uses the import's source (`app.api.routes`) to build the full path.
+  if (!ref.referenceName.includes('.')) return null;
+  const hit = findPythonModuleFile(ref.referenceName, context, ref.filePath);
+  return hit ? { original: ref, targetNodeId: hit.id, confidence: 0.9, resolvedBy: 'import' } : null;
+}
+
+/**
+ * Resolve a Rust qualified reference `A::B::C` by mapping the MODULE prefix
+ * (`A::B`) to a file and finding the leaf symbol (`C`) in it. This is the Rust
+ * analog of {@link resolvePythonModuleMember} / {@link resolveGoCrossPackageReference}
+ * and the precise answer to common-name re-exports (`pub use self::read::read`)
+ * that name-matching can't disambiguate. Returns null when the prefix isn't a
+ * real module path (e.g. `Widget::new` — `Widget` is a struct, not a module),
+ * so associated-function calls and enum-variant paths fall through untouched.
+ */
+function resolveRustPathReference(
+  ref: UnresolvedRef,
+  context: ResolutionContext
+): ResolvedRef | null {
+  const segments = ref.referenceName.split('::').filter((s) => s.length > 0);
+  if (segments.length < 2) return null;
+  const leaf = segments[segments.length - 1]!;
+  const modSegs = segments.slice(0, -1);
+
+  const file = resolveRustModuleFile(modSegs, ref.filePath, context);
+  if (!file || file === ref.filePath) return null;
+
+  const target = context.getNodesInFile(file).find(
+    (n) =>
+      n.name === leaf &&
+      (n.kind === 'function' ||
+        n.kind === 'struct' ||
+        n.kind === 'enum' ||
+        n.kind === 'trait' ||
+        n.kind === 'type_alias' ||
+        n.kind === 'constant' ||
+        n.kind === 'method' ||
+        n.kind === 'class' ||
+        n.kind === 'interface')
+  );
+  if (target) {
+    return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
+  }
+  return null;
+}
+
+/** The crate-root directory (holds `lib.rs`/`main.rs`), walking up from a file. */
+function rustCrateRootDir(fromFileAbs: string, context: ResolutionContext): string | null {
+  const projectRoot = context.getProjectRoot();
+  const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/');
+  let dir = path.dirname(fromFileAbs);
+  for (let i = 0; i < 64; i++) {
+    if (context.fileExists(toRel(path.join(dir, 'lib.rs'))) ||
+        context.fileExists(toRel(path.join(dir, 'main.rs')))) {
+      return dir;
+    }
+    const parent = path.dirname(dir);
+    if (parent === dir) return null;
+    dir = parent;
+  }
+  return null;
+}
+
+/** Directory under which the current file's module declares its SUBMODULES. */
+function rustSelfModuleDir(fromFileAbs: string): string {
+  const base = path.basename(fromFileAbs);
+  const dir = path.dirname(fromFileAbs);
+  // mod.rs / lib.rs / main.rs own their directory; `foo.rs`'s submodules live in `foo/`.
+  if (base === 'mod.rs' || base === 'lib.rs' || base === 'main.rs') return dir;
+  return path.join(dir, base.replace(/\.rs$/, ''));
+}
+
+/**
+ * Resolve a Rust module path (segments WITHOUT the leaf symbol) to the file of
+ * the last module segment — `crate::a::b` → `<crate>/a/b.rs` (or `.../b/mod.rs`).
+ * Anchors on `crate` / `self` / `super`; a bare path is tried crate-relative.
+ */
+function resolveRustModuleFile(
+  segments: string[],
+  fromFile: string,
+  context: ResolutionContext
+): string | null {
+  if (segments.length === 0) return null;
+  const projectRoot = context.getProjectRoot();
+  const fromAbs = path.join(projectRoot, fromFile);
+  const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/');
+
+  // Walk a sequence of module segments down from `startDir`, mapping each to a
+  // `<seg>.rs` or `<seg>/mod.rs` file. Returns the leaf module's file, or null
+  // if `startDir` is null or any segment has no file on disk.
+  const resolveUnder = (startDir: string | null, rest: string[]): string | null => {
+    if (!startDir) return null;
+    let dir = startDir;
+    let targetFile: string | null = null;
+    for (const seg of rest) {
+      if (seg === 'self' || seg === 'crate' || seg === 'super') continue;
+      const asFile = toRel(path.join(dir, seg + '.rs'));
+      const asMod = toRel(path.join(dir, seg, 'mod.rs'));
+      if (context.fileExists(asFile)) targetFile = asFile;
+      else if (context.fileExists(asMod)) targetFile = asMod;
+      else return null;
+      dir = path.join(dir, seg);
+    }
+    return targetFile;
+  };
+
+  const first = segments[0]!;
+  if (first === 'crate') {
+    return resolveUnder(rustCrateRootDir(fromAbs, context), segments.slice(1));
+  }
+  if (first === 'self') {
+    return resolveUnder(rustSelfModuleDir(fromAbs), segments.slice(1));
+  }
+  if (first === 'super') {
+    let supers = 0;
+    while (segments[supers] === 'super') supers++;
+    let dir: string | null = rustSelfModuleDir(fromAbs);
+    for (let s = 0; s < supers && dir; s++) dir = path.dirname(dir);
+    return resolveUnder(dir, segments.slice(supers));
+  }
+  // Bare path. In expression position (`submodule::item()` — the router-assembly
+  // and general cross-module-call pattern) the prefix is a SUBMODULE of the
+  // current module, i.e. 2018 `self::`-relative — so try self-relative FIRST.
+  // Fall back to crate-relative for 2015-edition / crate-root items. External
+  // crate paths (`serde::de::Error`) miss both and fall through to name-matching.
+  return (
+    resolveUnder(rustSelfModuleDir(fromAbs), segments) ??
+    resolveUnder(rustCrateRootDir(fromAbs, context), segments)
+  );
+}
+
 /**
  * Resolve a Java/Kotlin reference whose receiver is the simple name of
  * an imported FQN: `Foo.bar(...)` where `import com.example.Foo;`. The

+ 124 - 5
src/resolution/index.ts

@@ -16,7 +16,7 @@ import {
   FrameworkResolver,
   ImportMapping,
 } from './types';
-import { matchReference } from './name-matcher';
+import { matchReference, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
 import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs } from './import-resolver';
 import { detectFrameworks } from './frameworks';
 import { synthesizeCallbackEdges } from './callback-synthesizer';
@@ -185,6 +185,10 @@ export class ReferenceResolver {
   private queries: QueryBuilder;
   private context: ResolutionContext;
   private frameworks: FrameworkResolver[] = [];
+  // Per-`.razor`/`.cshtml`-file `@using` namespace set (own directives + folder
+  // `_Imports.razor`, cascading to the project root). Used to disambiguate a
+  // markup type ref to the right C# namespace.
+  private razorUsingsCache = new Map<string, string[]>();
   // All per-resolver caches are LRU-bounded. Previously these were
   // unbounded Maps that grew with every distinct lookup and OOM'd on
   // codebases with 20k+ files (see issue: unbounded cache growth).
@@ -560,6 +564,16 @@ export class ReferenceResolver {
       const receiver = name.substring(0, colonIdx);
       const member = name.substring(colonIdx + 2);
       if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true;
+      // Multi-segment path `a::b::c` (a Rust/C++ module call like
+      // `database::profiles::find`) — the only segment that names a symbol is
+      // the last (`c`); `member` above is `b::c`, which never matches a node
+      // name, so without this the pre-filter drops the ref before the Rust path
+      // resolver ever sees it. Mirror the dotted-name leaf check above.
+      const lastColon = name.lastIndexOf('::');
+      if (lastColon > colonIdx) {
+        const tail = name.substring(lastColon + 2);
+        if (tail && this.knownNames.has(tail)) return true;
+      }
     }
 
     // For path-like references (e.g., "snippets/drawer-menu.liquid"), check the filename
@@ -620,11 +634,25 @@ export class ReferenceResolver {
     const jvmImport = resolveJvmImport(ref, this.context);
     if (jvmImport) return jvmImport;
 
+    // Razor/Blazor: a markup or `@code` type ref resolves through the file's
+    // `@using` namespaces (incl. folder `_Imports.razor`). This precisely
+    // disambiguates a simple name that exists in several namespaces — e.g.
+    // `CatalogBrand` resolving to `BlazorShared.Models::CatalogBrand` (the DTO,
+    // which the `.razor` `@using`s) rather than the same-named domain entity.
+    if (ref.language === 'razor') {
+      const razorResult = this.resolveRazorUsing(ref);
+      if (razorResult) return razorResult;
+    }
+
     const candidates: ResolvedRef[] = [];
 
-    // Strategy 1: Try framework-specific resolution
+    // Strategy 1: Try framework-specific resolution. Cross-language bridges
+    // are deliberately preserved (Drupal `routing.yml` → PHP controller, RN
+    // JS → native `calls`) — `gateFrameworkLanguage` only drops a type/import
+    // edge between two KNOWN families (see its doc), never a `calls` bridge or
+    // a config↔code edge.
     for (const framework of this.frameworks) {
-      const result = framework.resolve(ref, this.context);
+      const result = this.gateFrameworkLanguage(framework.resolve(ref, this.context), ref);
       if (result) {
         if (result.confidence >= 0.9) return result; // High confidence, return immediately
         candidates.push(result);
@@ -632,14 +660,14 @@ export class ReferenceResolver {
     }
 
     // Strategy 2: Try import-based resolution
-    const importResult = resolveViaImport(ref, this.context);
+    const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref);
     if (importResult) {
       if (importResult.confidence >= 0.9) return importResult;
       candidates.push(importResult);
     }
 
     // Strategy 3: Try name matching
-    const nameResult = matchReference(ref, this.context);
+    const nameResult = this.gateLanguage(matchReference(ref, this.context), ref);
     if (nameResult) {
       candidates.push(nameResult);
     }
@@ -947,6 +975,97 @@ export class ReferenceResolver {
     const node = this.queries.getNodeById(nodeId);
     return node?.language || 'unknown';
   }
+
+  /**
+   * Drop an import/name-strategy resolution that crosses a language family.
+   * Two regimes (mirrors `applyLanguageGate`'s candidate filter):
+   *  - `references` (type usage): STRICT — a `Type.member` static read names a
+   *    same-family type, never a coincidentally same-named symbol in another
+   *    language. Drops any non-same-family target.
+   *  - `imports` (import binding / `#include`): both-known — a C++ `#include
+   *    "X.h"` must not resolve to a same-named ObjC header on another platform
+   *    (basename collision), but a singleton-family / SFC language (`vue` →
+   *    `.ts`) importing across is left alone.
+   * Applies to the import (strategy 2) + name-match (strategy 3) results.
+   */
+  /**
+   * Collect the `@using` namespaces in scope for a `.razor`/`.cshtml` file: its
+   * own `@using` directives plus every `_Imports.razor` from the file's folder up
+   * to the project root (Razor `_Imports` cascade). Cached per file.
+   */
+  private getRazorUsings(filePath: string): string[] {
+    const cached = this.razorUsingsCache.get(filePath);
+    if (cached) return cached;
+    const usings = new Set<string>();
+    const addFrom = (src: string | null): void => {
+      if (!src) return;
+      for (const m of src.matchAll(/^\s*@using\s+(?:static\s+)?([A-Za-z_][\w.]*)/gm)) usings.add(m[1]!);
+    };
+    addFrom(this.context.readFile(filePath));
+    let dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
+    // Walk up to the project root, reading each level's _Imports.razor.
+    for (;;) {
+      addFrom(this.context.readFile(dir ? `${dir}/_Imports.razor` : '_Imports.razor'));
+      if (!dir) break;
+      const slash = dir.lastIndexOf('/');
+      dir = slash >= 0 ? dir.slice(0, slash) : '';
+    }
+    const arr = [...usings];
+    this.razorUsingsCache.set(filePath, arr);
+    return arr;
+  }
+
+  /**
+   * Resolve a Razor/Blazor simple type ref through the file's `@using`
+   * namespaces: `CatalogBrand` + `@using BlazorShared.Models` → the node whose
+   * qualified name is `BlazorShared.Models::CatalogBrand`. Only resolves when the
+   * `@using` set yields exactly ONE type (otherwise it stays ambiguous and falls
+   * through to name-matching).
+   */
+  private resolveRazorUsing(ref: UnresolvedRef): ResolvedRef | null {
+    if (ref.referenceName.includes('.') || ref.referenceName.includes('::')) return null;
+    const usings = this.getRazorUsings(ref.filePath);
+    if (usings.length === 0) return null;
+    const found = new Map<string, Node>();
+    for (const ns of usings) {
+      for (const cand of this.context.getNodesByQualifiedName(`${ns}::${ref.referenceName}`)) {
+        found.set(cand.id, cand);
+      }
+    }
+    if (found.size !== 1) return null;
+    const target = found.values().next().value!;
+    return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
+  }
+
+  private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
+    if (!result) return result;
+    const tgt = this.getLanguageFromNodeId(result.targetNodeId);
+    if (!tgt || !ref.language) return result;
+    if (ref.referenceKind === 'references' && !sameLanguageFamily(tgt, ref.language)) return null;
+    if (ref.referenceKind === 'imports' && crossesKnownFamily(tgt, ref.language)) return null;
+    return result;
+  }
+
+  /**
+   * Drop a FRAMEWORK-strategy resolution that crosses two *known* language
+   * families for a type-usage (`references`) or import-binding (`imports`)
+   * edge. The framework strategy is intentionally ungated for cross-language
+   * bridges, but those legitimate bridges are either `calls` edges (RN/Expo
+   * JS → native) or config↔code edges whose config side (`yaml`/`blade`/…) is
+   * not a known programming-language family. A `references`/`imports` edge
+   * between two *known* families is always a coincidental name collision — the
+   * React/Svelte/Vue PascalCase component resolvers name-match `getNodesByName`
+   * without a language check, so a TS `<TestRunner>` ref happily matched a
+   * Kotlin `class TestRunner`. Gating only the both-known-cross-family case
+   * lets config bridges and `calls` bridges through untouched.
+   */
+  private gateFrameworkLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
+    if (!result) return result;
+    if (ref.referenceKind !== 'references' && ref.referenceKind !== 'imports') return result;
+    const tgt = this.getLanguageFromNodeId(result.targetNodeId);
+    if (tgt && ref.language && crossesKnownFamily(tgt, ref.language)) return null;
+    return result;
+  }
 }
 
 /**

+ 122 - 9
src/resolution/name-matcher.ts

@@ -15,7 +15,13 @@ export function matchByFilePath(
   ref: UnresolvedRef,
   context: ResolutionContext
 ): ResolvedRef | null {
-  if (!ref.referenceName.includes('/')) return null;
+  // Path-like (`a/b.liquid`) OR a bare filename ending in a short extension
+  // (`Foo.h` — an Objective-C `#import "Foo.h"`, resolved to the header by
+  // basename). A bare ref WITHOUT an extension is a symbol name, not a file, so
+  // leave it to the symbol-matching strategies.
+  if (!ref.referenceName.includes('/') && !/\.[A-Za-z][A-Za-z0-9]{0,3}$/.test(ref.referenceName)) {
+    return null;
+  }
 
   // Extract the filename from the path
   const fileName = ref.referenceName.split('/').pop();
@@ -38,12 +44,20 @@ export function matchByFilePath(
     };
   }
 
-  // Fall back to suffix match (e.g., ref="snippets/foo.liquid" matches "src/snippets/foo.liquid")
-  const suffixMatch = fileNodes.find(n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName));
-  if (suffixMatch) {
+  // Fall back to suffix match (e.g., ref="snippets/foo.liquid" matches
+  // "src/snippets/foo.liquid"). When several files share the basename — a
+  // `#include "RNCAsyncStorage.h"` with a same-named header on another platform
+  // (windows/code/ vs apple/) — prefer the one in the includer's own directory,
+  // then by directory proximity / same language family. A C/C++ include (and any
+  // bare-filename import) resolves relative to the including file, not to an
+  // arbitrary same-named header elsewhere in the tree.
+  const suffixMatches = fileNodes.filter(
+    n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName)
+  );
+  if (suffixMatches.length > 0) {
     return {
       original: ref,
-      targetNodeId: suffixMatch.id,
+      targetNodeId: pickClosestFileNode(suffixMatches, ref).id,
       confidence: 0.85,
       resolvedBy: 'file-path',
     };
@@ -62,6 +76,97 @@ export function matchByFilePath(
   return null;
 }
 
+/**
+ * Among several file nodes that all match a bare include/import by basename,
+ * pick the one closest to the referencing file: same directory first, then by
+ * directory-tree proximity, with the same language family as a tiebreak. A
+ * C/C++ `#include "X.h"` (and any bare-filename import) resolves relative to the
+ * including file — not to an arbitrary same-named header on another platform.
+ */
+function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node {
+  const dirOf = (p: string): string => {
+    const i = p.lastIndexOf('/');
+    return i >= 0 ? p.slice(0, i) : '';
+  };
+  const refDir = dirOf(ref.filePath);
+  const sameDir = candidates.filter((c) => dirOf(c.filePath) === refDir);
+  const pool = sameDir.length > 0 ? sameDir : candidates;
+  let best = pool[0]!;
+  let bestScore = -Infinity;
+  for (const c of pool) {
+    const score =
+      computePathProximity(ref.filePath, c.filePath) +
+      (sameLanguageFamily(c.language, ref.language) ? 5 : 0);
+    if (score > bestScore) {
+      bestScore = score;
+      best = c;
+    }
+  }
+  return best;
+}
+
+/**
+ * Language families that share a type system / runtime, so a same-language-only
+ * reference may still resolve across them (a Kotlin `Foo.BAR` can name a Java
+ * `Foo`). Anything not listed forms its own singleton family.
+ */
+const LANGUAGE_FAMILY: Record<string, string> = {
+  java: 'jvm', kotlin: 'jvm', scala: 'jvm',
+  swift: 'apple', objc: 'apple',
+  typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web',
+  c: 'c', cpp: 'c',
+  // Razor/Blazor markup names C# types — same family so `@model Foo` /
+  // `<MyComponent/>` resolve to their `.cs` class through the cross-family gate.
+  csharp: 'dotnet', razor: 'dotnet',
+};
+export function sameLanguageFamily(a: string, b: string): boolean {
+  if (a === b) return true;
+  const fa = LANGUAGE_FAMILY[a];
+  return fa !== undefined && fa === LANGUAGE_FAMILY[b];
+}
+/**
+ * True when `lang` belongs to a known multi-language family (jvm/apple/web/c).
+ * Languages not listed (php, python, go, ruby, rust, dart, …) and config
+ * formats (yaml/xml/blade) form their own singleton families and return
+ * `false` — used to leave config↔code framework bridges (whose config side is
+ * never a known programming-language family) out of the cross-family gate.
+ */
+export function isKnownLanguageFamily(lang: string): boolean {
+  return LANGUAGE_FAMILY[lang] !== undefined;
+}
+/**
+ * True when `a` and `b` are two DIFFERENT *known* language families — the
+ * signature of a coincidental cross-language name collision (a TS `import
+ * React` matching a Swift `import React`, a C++ `#include "X.h"` matching a
+ * same-named ObjC header on another platform). The both-*known* test is
+ * deliberately weaker than {@link sameLanguageFamily}'s negation: a
+ * single-file-component language that carries its own tag (`vue`/`svelte`)
+ * importing a `.ts` module, or any singleton-family language (php/go/ruby/…),
+ * returns `false` here and is left alone.
+ */
+export function crossesKnownFamily(a: string, b: string): boolean {
+  return isKnownLanguageFamily(a) && isKnownLanguageFamily(b) && !sameLanguageFamily(a, b);
+}
+/**
+ * Drop cross-language candidates from a name lookup. Two regimes:
+ *  - `references` (type-usage): a type named in language X resolves to a
+ *    SAME-family type, never a coincidentally same-named symbol in another
+ *    language (the Android `BatteryManager` system class vs a JS one). Strict
+ *    same-family filter — cross-language communication is `calls`, not refs.
+ *  - `imports` (import binding): an `import`/`#include` never crosses two
+ *    KNOWN families (TS `import React` ↮ Swift `import React`). Weaker
+ *    both-known filter so `.vue`/`.svelte` (own tag) importing `.ts` survives.
+ */
+function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] {
+  if (ref.referenceKind === 'references') {
+    return candidates.filter((c) => sameLanguageFamily(c.language, ref.language));
+  }
+  if (ref.referenceKind === 'imports') {
+    return candidates.filter((c) => !crossesKnownFamily(c.language, ref.language));
+  }
+  return candidates;
+}
+
 /**
  * Try to resolve a reference by exact name match
  */
@@ -69,7 +174,7 @@ export function matchByExactName(
   ref: UnresolvedRef,
   context: ResolutionContext
 ): ResolvedRef | null {
-  const candidates = context.getNodesByName(ref.referenceName);
+  const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref);
 
   if (candidates.length === 0) {
     return null;
@@ -357,8 +462,16 @@ export function matchMethodCall(
   ref: UnresolvedRef,
   context: ResolutionContext
 ): ResolvedRef | null {
-  // Parse method call patterns like "obj.method" or "Class::method"
-  const dotMatch = ref.referenceName.match(/^(\w+)\.(\w+)$/);
+  // Parse method call patterns like "obj.method" or "Class::method". The method
+  // part allows trailing `:` keywords so Objective-C selectors resolve
+  // (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other
+  // languages' method refs, so this is a no-op for them.
+  // The receiver allows dots (`builder.Services.AddCoreServices`) so a CHAINED
+  // call resolves by its last segment — Strategy 3 below name-matches the method
+  // (with its existing single-candidate / receiver-overlap guards). Without this
+  // a multi-dot extension-method call (C# DI `builder.Services.AddCoreServices()`,
+  // `Guard.Against.X()`) matched no pattern and never resolved.
+  const dotMatch = ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/);
   const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/);
 
   const match = dotMatch || colonMatch;
@@ -659,7 +772,7 @@ export function matchFuzzy(
 
   // Filter to callable kinds only (function, method, class)
   const callableKinds = new Set(['function', 'method', 'class']);
-  const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind));
+  const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref);
 
   // Prefer same-language matches
   const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);

+ 1 - 0
src/types.ts

@@ -75,6 +75,7 @@ export const LANGUAGES = [
   'c',
   'cpp',
   'csharp',
+  'razor',
   'php',
   'ruby',
   'swift',

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels