diff --git a/.claude/handoffs/codegraph-tool-surface-rethink-2026-05-27.md b/.claude/handoffs/codegraph-tool-surface-rethink-2026-05-27.md deleted file mode 100644 index 398e783d5..000000000 --- a/.claude/handoffs/codegraph-tool-surface-rethink-2026-05-27.md +++ /dev/null @@ -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//.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 "" 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. diff --git a/.claude/handoffs/cross-language-impact-coverage-2026-06-04.md b/.claude/handoffs/cross-language-impact-coverage-2026-06-04.md new file mode 100644 index 000000000..ba0f2ec3b --- /dev/null +++ b/.claude/handoffs/cross-language-impact-coverage-2026-06-04.md @@ -0,0 +1,316 @@ +--- +name: cross-language-impact-coverage-2026-06-04 +date: 2026-06-05 14:57 +project: codegraph +branch: feat/cross-language-impact-coverage +summary: Per-language coverage DONE (all 15 README langs + static-member pass + RN/Expo 95%+). Since then the live front is ROUTE FRAMEWORKS: import/aggregator style at ceiling (Express/FastAPI/Flask/requests/NestJS/Gin/Axum 95%+, Vapor/vapor-til 100%, SvelteKit 100% fair [component core + loader→page synthesizer], React Router 100% [config-based, no miss], Nuxt 93.5% fair [+ nested-component fix]; Axum 100% via a Rust self-relative submodule-call fix), convention/reflection + actor style at an honest sub-95% ceiling (ASP.NET 83.9%, Spring 83.3%, Drupal 78.9%, Django 74.1%, actix 65.4% [actor message dispatch] — all measured). **SWEEP COMPLETE — every README framework measured;** Rocket lifted 62.5%→**93.8%** via a new `routes![]`/`catchers![]` macro extractor (only the crate-root lib.rs remains). Six engine improvements en route (3 Rust: self-relative + multi-segment `a::b::c()` module calls + Rocket route-macro extraction; 1 Swift: Fluent `@Siblings(through: Pivot.self)` metatype refs; 1 SvelteKit: `+page.server.js` `load`→`+page.svelte` synthesizer; 1 Nuxt: nested auto-imported ``→`media/Card.vue` resolution). The static-member-pass-for-TS/JS/Python lever was **measured & rejected** (e7b86df — 0 coverage gain, the import edge already covers it, + graph noise). **Then the 4 niche README languages were knocked out**: Lua 31.6%→**84.2%** + Luau 12.5%→**92.2%** (new require resolver, 4155609); Liquid 39.1%→**73.8%** (Shopify OS 2.0 JSON-template section parsing, 2f57119); Pascal 73%→**75.7%** (.dfm/.fmx form↔code-behind pairing, 2f30a3b) — so **ALL 22 README "Full support" languages + all 14 README frameworks now have coverage validation (full README parity).** Branch 41 commits ahead of main, 3 behind, not merged. +--- + +# Handoff: Cross-language impact/blast-radius coverage campaign + +## Resume here — read this first +**Current state:** Branch `feat/cross-language-impact-coverage` — now **41 commits ahead of `main`, 3 behind** (behind = 3 README-waitlist doc commits on main), tip `2f30a3b`, all pushed to `origin`=colbymchenry/codegraph. NOT merged (review branch). **FULL README PARITY: all 22 "Full support" languages + all 14 frameworks now have coverage validation.** Two arcs are done: (1) **per-language** cross-file coverage for ALL 15 README "Full support" langs + the static-member pass; (2) **cross-language RN/Expo** at 95%+ FAIR (gate hole fixed 082353e; same-dir include + KMP import 529d822). **Since the 2026-06-04 save a 12-commit ROUTE-FRAMEWORK front landed** (`61a993a`→`a3f59fb`) — see the "Route-framework headroom map" below; that map is the live edge. Working tree: clean except one unrelated untracked file (`assets/generate-waitlist.py`, README-waitlist tooling, not this campaign). Full suite green (**1187 passed | 2 skipped**, 59 files). +**Immediate next step:** **THE README SWEEP IS COMPLETE — all 22 "Full support" languages + all 14 frameworks have coverage validation.** Per-language DONE, RN/Expo DONE, route-framework + component-node front DONE, the 4 niche langs (Lua/Luau/Liquid/Pascal) DONE; **10 engine improvements** shipped this run (3 Rust + 1 Swift + 1 SvelteKit + 1 Nuxt + 1 Lua/Luau require resolver + 1 Liquid Shopify-JSON + 1 Delphi form pairing; the static-member-for-TS/JS/Python lever was measured & rejected). **Recommended: (A) ship it** — open the PR to `main` for the 41 commits; everything actionable is done. Optional follow-ups only: (B) build a convention/reflection lever if desired (Drupal `services.yml` DI, ASP.NET/Spring markup/reflection modeling) — each a large per-framework feature. **The convention/reflection frameworks (ASP.NET 83.9%, Spring 83.3%, Drupal 78.9%, Django 74.1%, actix 65.4%) sit at a genuine sub-95% static-analysis ceiling — don't chase 95% there without large reflection/markup modeling (metric-gaming otherwise). The TS/JS/Python static-member pass is measured-&-rejected (e7b86df), don't re-try.** + +> Suggested next message: "Open the PR to main for the cross-language-impact-coverage branch (41 commits — full README parity: all 22 'Full support' languages + all 14 frameworks now have coverage validation)." + +## DONE: coverage bump to 95%+ (commit 529d822) — RN/Expo multi-platform repos +**Goal (from user):** bump async-storage (75.0%) and rn-device-info (72.4%) to 95%+. Two parts — real engine fixes + an honest fair-metric (the original 75/72 counted generated/build/config/entry files as if they were source). +**Engine fixes (real coverage, generalizable):** +1. **Same-dir C/C++ `#include`** — `#include "Foo.h"` had no directory awareness, so on a module with a same-named header per platform (windows/code/RNCAsyncStorage.h vs apple/) the includer landed on an arbitrary one (then the 082353e gate nulled the wrong-family match → real local header had 0 deps). Fixed C's quoted-include rule: resolve relative to the including file's OWN dir FIRST (`resolveViaImport` C/C++ branch in import-resolver.ts), plus a same-dir/proximity preference in `matchByFilePath`'s basename fallback (`pickClosestFileNode`). +2. **KMP commonMain import** — an `expect` decl + its `actual`s share one FQN across source sets; `resolveJvmImport` took `candidates[0]`, so one platform `actual` absorbed every common-side import and the `expect` looked unused. Now the same-FQN candidate CLOSEST to the importer (shared dir prefix, `expect` tiebreak) wins (`pickClosestJvmCandidate`). Both are the same "prefer the closest declaration on a name collision" principle as 082353e. +**Honest fair metric** (`/tmp/faircov.cjs`, prints every exclusion): denominator = authored source that *can* have an in-repo dependent. Excludes (per methodology, all auditable): structural (generated `.g.h`/codegen, `pch.*`, `*.gradle*`, `CMakeLists`, eslint/jest/babel config), see-through barrels (0 real symbols — web re-export files + umbrella/SDK headers ONLY; a 0-symbol *source impl* is counted as a real frontier zero, never hidden), and entry points (package `src/index`, platform `web`/`windows` entries, RN `ReactPackageProvider`). +**Before/after:** +| Repo | FAIR coverage before→after | residual zero (frontier) | +|---|---|---| +| async-storage | 75.0% → **97.4% (37/38)** | DatabaseFiles.kt (KMP expect-decl side, no in-repo caller) | +| rn-device-info | 72.4% → **95.2% (20/21)** | RNDeviceInfoCPP.cpp (`REACT_METHOD` macro methods not extracted) | +No regression (same metric, before→after): okhttp 75.9%→76.4%, kotlinx.coroutines 89.7% (neutral), leveldb 78.0% (neutral), redis 89.7%→89.9%, fmt 77.3% (neutral); cross-family false edges still 0 everywhere. 2 regression tests in `extraction.test.ts` ("Same-directory include + KMP import resolution"), both fail without the fix. Full suite 1169. + +## DONE: gate hole (commit 082353e) — cross-family references/imports +**Symptom (was):** in `react-native-async-storage`, a TS `type TestRunner` and a Kotlin `class TestRunner` collided — TS `references`/`imports` resolved onto the Kotlin class (web→jvm false match). Plus `import React`↔Swift `React` and a C++ `#include "RNCAsyncStorage.h"`↔iOS ObjC header (basename collision). +**Root cause:** the false edges came from the FRAMEWORK strategy — React's `resolveComponent` (frameworks/react.ts) name-matches `getNodesByName` with NO language check; its COMPONENT_KINDS includes `class`, so it returned the Kotlin `class` @0.8 (the TS `type_alias` filtered out), outranking the cross-lang-penalized (0.5) TS name-match. AND `imports` were never gated (only `references` was). NOTE: `this.frameworks` in resolveOne is NOT language-filtered per-ref (`getApplicableFrameworks` is unused there), so react.resolve runs for EVERY ref — its `languages` field is dead in that path. +**Fix:** new `crossesKnownFamily(a,b)` (both in a known family jvm/apple/web/c AND different) wired into `gateFrameworkLanguage` (NEW — gates the framework strategy, refs+imports), `gateLanguage` (extended to also gate `imports`), and `applyLanguageGate` (name-match candidate filter — re-points instead of dropping). KEY RULE (non-obvious): the `references` gate stays STRICT (`!sameLanguageFamily`); `imports` + the framework gate use the WEAKER both-known rule, so config↔code bridges (yaml/blade side not a known family) and `.vue`/`.svelte`→`.ts` imports survive. `calls` bridges are never gated. +**Before/after — precision fix (coverage HELD/up, false edges → 0):** +| Repo | FAIR coverage before→after | cross-known-family false refs/imports | +|---|---|---| +| async-storage | 75.0% (39/52) → **75.0% (39/52)** | **22 → 0** | +| rn-device-info (control) | 69.0% (20/29) → **72.4% (21/29)** | **5 → 0** | +Coverage held on async-storage (no recall lost) and ROSE on rn-device-info (re-pointing gave a real same-family file a correct dependent). Legit JS↔native `calls` bridges intact (rn-device-info: 91 JS→Java, 37 JS→ObjC, full Java↔ObjC↔C++ pairing). 2 regression tests in `extraction.test.ts` ("Cross-language type/import gate"), both fail without the fix. Full suite 1167. Measure: `/tmp/faircov.cjs ` (fair coverage + false-edge count) and `/tmp/xlang.cjs ` (cross-lang edges by src→tgt × kind). + +### Framework phase round 2 (commits d06a5ec, 74b599c, 2026-06-04) +(1) RCT_EXPORT_METHOD EXTRACTION (d06a5ec): RN bridge resolver now implements `extract()` for .m/.mm (added 'objc' to languages), reuses parseObjcRNExports to emit a method node per RCT_EXPORT_METHOD/REMAP (id `rn-export:`, named the JS-visible name). The macro parsed as ERROR before → iOS methods invisible. rn-device-info JS→objc 7→37, java↔objc pairs 22→29. (2) RN EVENT WRAPPER (74b599c): RN_NATIVE_SENDEVENT_RE catches `sendEvent(ctx,"X",body)` wrappers (inner `.emit` uses a variable) → native java/swift events now connect to JS hooks. Synth tag is `rn-event-channel`. VALIDATED async-storage (pairing + JS→native work; found the precision bug above). + +### Classic RN cross-platform pairing (commit 4a64ca5, 2026-06-04) +`rnCrossPlatformEdges` (callback-synth): a native method (java/kotlin/objc/cpp) with a JS-side `calls` edge = confirmed bridge method → link to same-norm-name native method in another language (`getFreeDiskStorage:`→`getFreeDiskStorage`, first selector keyword), both directions. Skip RN_INFRA names (addListener/getConstants/getName/…). rn-device-info: 152 pairs (Java↔ObjC↔C++). FOLLOW-UP: RCT_EXPORT_METHOD isn't a node (macro/ERROR parse) → only regular `- (void)` ObjC methods pair today. + +### Cross-language framework phase — round 1 (commit dbc4862, 2026-06-04) +NEW direction: RN/Expo repos where JS↔native crosses LANGUAGE boundaries. Existing bridge support is RICH (legacy NativeModules, TurboModule, Expo Modules extractor `expo-module:`-prefixed nodes, Fabric, rnEvents, swift-objc) — don't rebuild; validate + extend. Classic RN bridge WORKS (rn-device-info: 118 JS→Java + JS→ObjC calls). THREE Expo gaps fixed: (1) generic `AsyncFunction("x")` — regex didn't allow `<…>` so all Android Expo methods dropped; (2) cross-platform pairing — `expoCrossPlatformEdges` links Swift↔Kotlin impls of the same JS method (JS resolves to one platform only); (3) cross-lang type-ref precision — gated `references` edges to same language-family (name-matcher.ts `applyLanguageGate`/`sameLanguageFamily` + index.ts `gateLanguage`), so native `BatteryManager.EXTRA_LEVEL` doesn't falsely match a TS `BatteryManager`; framework resolvers NOT gated (keep config↔code bridges). Measure: `/tmp/xlang.cjs`. Detail in memory. + +### Objective-C result (commit 33ce431, 2026-06-04) +WORST README language at baseline. FOUR fixes (3 in tree-sitter.ts, 1 in name-matcher.ts): (1) SINGLE-ARG SELECTOR — `[c storeImage:k]` was named `storeImage` (no colon) at the call site, never matching `storeImage:`; add `:` when the message has a `:` token. (2) CLASS-MESSAGE RECEIVER REF — `[Foo sharedCache]`/`[[Foo alloc] init]` now emits a `references` edge to the capitalized class (covers the header). (3) #IMPORT BASENAME — `#import "Foo.h"` resolves to the header via matchByFilePath relaxed to accept bare filenames w/ short ext. (4) CLASS-METHOD COLON — `Foo.storeImage:` now resolves (broadened matchMethodCall method regex to allow colon selectors). AFNetworking 50%→**90%**, SDWebImage Core 33.8%→**91.6%**. GOTCHA: SDWebImage `include/SDWebImage/*.h` are SYMLINKS to `Core/` — measure Core/ only. Residual = public-API category methods called by app code (frontier). Detail in memory. + +### Dart result (commit 9487954, 2026-06-04) +Dart was in TYPE_ANNOTATION_LANGUAGES but produced ZERO `references` edges, AND mixins were dropped. (NOTE: dio raw 67.8% was example-dir pollution — real 86.4%.) Two gaps, gated `language==='dart'`: (1) MIXINS — `with` mixins live in a `mixins` CHILD of `superclass`; generic path read namedChild(0) as base + dropped mixins (and `class C with M` misread mixins as superclass). Dart branch in extractInheritance: `extends` base + `implements` per mixin. (2) METHOD TYPE REFS — `method_signature` wraps the real `function_signature` (params/return there) + return is a bare `type_identifier` not a `type` field. Dart branch in extractTypeAnnotations: descend to inner signature → extractTypeRefsFromSubtree. flutter/packages 88.8%→**92.4%**, dio 86.4%→**87.9%**. Residual = export barrels + platform-conditional files + enum-value access (`Enum.value` — value-read frontier; a Dart `Capitalized.member`→ref pass would be precise, the top follow-up). Detail in memory `impact-coverage-findings.md`. + +### Static-member / value-read pass (commit 857baf7, 2026-06-04) +The deferred cross-language lever, now DONE. A type used only via a static member / enum VALUE (`MediaKind.video`, `Colors.red`, `JsonScope.NAME`, `Foo::BAR`) recorded no edge (body walker only did CALLS + `new`). `extractStaticMemberRef` (tree-sitter.ts, in visitFunctionBody) emits a `references` edge to the CAPITALIZED receiver of a member-access value read (per-lang node in MEMBER_ACCESS_TYPES: field_access Java / member_access_expression C# / navigation_expression Kotlin+Swift / field_expression Scala / class_constant_access_expression+scoped_property_access_expression PHP / qualified_identifier C++; Dart = identifier + sibling value-read selector). Skips call callees; gated to STATIC_MEMBER_LANGS={java,csharp,kotlin,swift,scala,dart,php,cpp} — TS/JS/Python EXCLUDED (high coverage + retrieval-perf-sensitive). flutter/packages 92.4%→93.2%; additive elsewhere; nodes stable. Detail in memory. + +### C/C++ result (commit ec8fe3f, 2026-06-04) +C/C++ were already HIGH (name-matching resolves cross-file calls across the .h/.c split). NOT an import gap. The systematic gap was a C++ EXTRACTION BUG in languages/c-cpp.ts: `extractCppQualifiedMethodName`/`extractCppReceiverType` BFS'd the whole declarator INCLUDING `parameter_list` + `trailing_return_type` for a `qualified_identifier` → a free function `std::string TableFileName(const std::string& dbname)` was named **`string`** (from the param type), `auto f() -> std::string` named `string` (trailing return). Calls never resolved; defining file looked dependent-less. Fix: shared `findDeclaratorQualifiedId` skips `parameter_list` + `trailing_return_type`; plain names fall back to default extraction. leveldb 91.7%→**94.8%**, fmt 32 mis-named→1, redis (C, unaffected) 92.2% at ceiling. Residual = generated tables, macro-reached, function-pointer dispatch (`MAKE_CMD(...,sortCommand,...)` — deferred, broad/risky), C++ namespaces (deferred). Detail in memory `impact-coverage-findings.md`. + +### Ruby result (commits 44fb978 + 5bccab6, 2026-06-04) +TWO gaps. (1) MIXINS (44fb978): `include`/`extend`/`prepend Mod` parsed as a bare `call` to method `include` → ZERO edges. Fix in languages/ruby.ts visitNode: detect bare include/extend/prepend (guard `!receiver` so `arr.include?(x)` is safe) → emit `implements` edge class/module→module. (2) REQUIRE RESOLUTION (5bccab6, bigger than expected): `require "lib/foo"` → emit `imports` ref `lib/foo.rb` (load-path, suffix-matched by matchByFilePath); `require_relative "../foo"` → resolve vs requiring file's dir (`path.posix.normalize`); bare `require "json"` skipped. Resolves to the FILE node. **sidekiq 71%→76.8% (mixins)→100% (requires); activerecord 84.8%→93% (mixins)→96.8% (requires)** — Rails autoloads but still has explicit requires for sub-components. Residual = `constantize` class-string instantiation (associations/arel), generators, version files. Detail in memory `impact-coverage-findings.md`. + +### PHP result (commit acfb444, 2026-06-04) +ROOT CAUSE: PHP ignored NAMESPACES — every class qn was the bare simple name, so laravel's 7+ same-named `Factory` interfaces across namespaces collapsed to one arbitrary match, and `use` imports never resolved. Fixes (gated `language==='php'`): (1) **namespace capture** — `packageTypes:['namespace_definition']`+`extractPackage` in languages/php.ts → classes scoped to `Foo\Bar::Class`; (2) **use-import resolution** — `emitPhpUseRefs` emits an `imports` ref in `Foo\Bar::Baz` form, matched precisely by the resolver's `resolveQualifiedName` (THE big lever, 80.5%→94.9%); (3) **type-hint refs** — PHP-aware `extractPhpTypeRefs` (PHP types are `named_type`/`union_type` wrapping `name`, not `type_identifier`). guzzle 95.2%→**100%**, laravel 80.5%→**94.9%**. Residual = class-string/reflection wiring (service providers, facades, middleware) — genuine frontier. Detail in memory `impact-coverage-findings.md`. + +### Scala result (commit b5489d9, 2026-06-04) +Scala was the WORST starting point — extraction made nodes but almost NO edges for typeclass code (cats 1.66 edges/node). Not one gap but a family, all gated to `language==='scala'` in `extraction/tree-sitter.ts` (+ `languages/scala.ts`): (1) **parameterized extends** — `extends A[X] with B` packed all supertypes in one `extends_clause`; generic path took only namedChild(0) w/ full text `A[X]` so no typeclass matched → new shared `scalaBaseTypeName` unwraps `generic_type`, iterate all supertypes (cats 48.9%→77.2% from THIS alone); (2) **type refs** (Scala had ZERO `references`) — added scala to TYPE_ANNOTATION_LANGUAGES + walk EVERY curried `parameters` list (trailing `(implicit M: TC[A])`!) + `type_parameters` context bounds (`[A: Monoid]`) + val/var types from scala.ts (77.2%→89.2%); (3) **instantiation** `new T[...]` = `instance_expression`. cats 48.9%→**89.2% fair** (82.1% raw — scalafix/bench excluded), gatling 76.3%→**91.2%**. Residual = cross-build variants/laws/wildcard-barrels (frontiers). Detail in memory `impact-coverage-findings.md`. + +### Kotlin result (commit d8a2e91, 2026-06-04) +Systematic Kotlin gap = **Kotlin Multiplatform `expect`/`actual`** (the only Kotlin-unique construct). OkHttp (the README Kotlin benchmark) was ALREADY 96.2% out of the box; kotlinx.coroutines (KMP) was 76.8% → **93.5%**. Fix: new generic `extractModifiers` hook captures `expect`/`actual` (from `modifiers > platform_modifier`) onto the node's `decorators` list (wired once in `createNode`); `kotlinExpectActualEdges` in callback-synthesizer.ts links common decl → each platform `actual` as a heuristic `calls` edge (matched by qualified_name + the `actual` marker; decl side = non-`actual` same-qn node, which also gates out plain overloads; kind-widened so `expect class` ↔ `actual typealias` links). Node count stable. Residual = genuine frontiers (expect-decl sides, ServiceLoader/agent SPI, test infra). Full detail in memory `impact-coverage-findings.md`. + +## Goal +Make the engine's cross-file dependency graph complete for **every README "Full support" language**, so impact/`affected`/callers/callees/explore all see real dependencies. Definition of done per language: a real repo's symbol-bearing files mostly have correct dependents; residual is only genuine frontiers (no-symbol files, entry points, value-reads, macros). Each language: audit → fix → validate → commit to the branch. + +## Methodology (apply per language — this is the loop) +1. Clone 1 benchmark + 1 clean repo to `/tmp`. Index with `CodeGraph.initSync(repo,{config:{include:['**/*.'],exclude:[]}})` + `indexAll()` + `resolveReferences()` via a `node -e` against `dist/index.js`. +2. Measure **fair coverage** = % of *symbol-bearing* source files with ≥1 cross-file dependent. SQL: a file is a dependent target if it's the `target` of a non-`contains` edge whose `source` is in another file. **EXCLUDE from the denominator:** files with no non-`file` node (package-info.java, doc.rs, `__init__` umbrellas), tests, entry points (main/bin/examples/benches/fuzz/samples), and miscounted other-language files (e.g. `.kt` under a Java repo). +3. Audit the 0-dependent files → classify real-miss vs frontier. Controlled probe (2 tiny files) to isolate the exact gap. +4. Fix extraction/resolution. Re-measure. **Verify node count stays stable** (edges added, not nodes — except real new symbols like interface/record nodes). +5. `npm run build` (tsc must pass) → `npm test` (expect ~1151 passing) → add a test in `__tests__/extraction.test.ts` → CHANGELOG `[Unreleased] → Fixes` bullet. +6. `git add ` + commit (Co-Authored-By: Claude Opus 4.8 (1M context) ) + `git push origin feat/cross-language-impact-coverage`. Do NOT commit `.claude/handoffs/*`. + +## Key findings — the recurring gap shapes & where they're fixed +- **Foundation (16b5633):** `imports` edges are same-file (file→local import node), so the old `getFileDependents` returned 0 for every file. Added `getDependentFilePaths`/`getDependencyFilePaths` in `src/db/queries.ts` (indexed JOIN, all kinds except `contains`); `src/graph/queries.ts` delegates. +- **Import/binding linking** (per-lang emit* in `src/extraction/tree-sitter.ts`): `emitImportBindingRefs` (TS/JS named/default/namespace), `emitReExportRefs` (TS `export {X} from`), `emitPyFromImportRefs` (Python `from m import X`), `emitRustUseBindingRefs` (Rust `use`/`pub use`, emits FULL path). All gated by language in `extractImport`. +- **Module-path resolution** (`src/resolution/import-resolver.ts`): `resolvePythonModuleMember` + `resolveModuleImportToFile` (Python+TS namespace), `resolveGoCrossPackageReference` (Go, pre-existing), `resolveRustPathReference`+`resolveRustModuleFile`+`rustCrateRootDir`/`rustSelfModuleDir` (Rust `crate::`/`self::`/`super::`). Resolve a path's module PREFIX to a file, find the leaf there — fixes common-name collisions. +- **Instantiation:** `INSTANTIATION_KINDS` in tree-sitter.ts now includes `composite_literal` (Go) + `struct_expression` (Rust). `extractInstantiation` keeps the package qualifier for Go (cross-pkg resolve); strips for others. Also normalizes parenthesized type conversions `(*T)(x)`. +- **Interface/trait dispatch (#584):** `IFACE_OVERRIDE_LANGS` in `src/resolution/callback-synthesizer.ts` now includes `go` and `rust`. Needs the interface/trait's METHODS extracted: Go via `extractGoInterfaceMethods` (tree-sitter.ts), Rust via adding `function_signature_item` to rust.ts function/methodTypes. `goImplementsEdges` synthesizes Go implicit `implements` edges (method-set match) and must `insertEdges` FIRST in `synthesizeCallbackEdges`. +- **Annotations / attributes / property wrappers (UNIFIED via `extractDecoratorsFor`):** it now (a) descends into `modifiers` nodes (Java/Kotlin/C#), (b) recognizes Swift `attribute` + `user_type`. Java needed `annotation_type_declaration` added to `interfaceTypes` (java.ts). C# needed `record_declaration`/`record_struct_declaration` (csharp.ts). Swift needed a dispatcher branch running `extractDecoratorsFor`+`extractVariableTypeAnnotation` on `property_declaration` inside a type (Swift instance props aren't nodes). +- **In-body type annotations (TS):** `visitFunctionBody` now extracts `variable_declarator` type annotations (`const x: Foo`). + +## Per-language results — file-dependent coverage (% of symbol-bearing source files with ≥1 cross-file dependent) + +| Language | Repo | Before | After | Key fix | +|---|---|---|---|---| +| TypeScript/JS | codegraph (this repo) | 62.5% | **95.8%** | import + re-export + namespace linking; in-body type annotations | +| Python | requests | 54.1% | **100.0%** | `from x import` linking; `from . import sub` + `sub.f()` module-member resolution; relative-dot path fix | +| Python | flask (src) | 66.7% | **87.5%** (true ceiling — residual all correct-0) | (same) | +| Go | gin | 62.7% | **96.6%** | composite literals → instantiates; package-level var registries; `(*T)(x)` conversions; implicit interface satisfaction (#584) | +| C# | MediatR (library) | 81.5% | **85.2%** | `record` / `record struct` indexed (#237) | +| Rust | ripgrep | 63.4% | **86.7%** | struct literals; trait dispatch (trait methods + #584); `use`/`pub use` linking; module-path resolution for `pub use self::x::y` | +| Rust | tokio (src) | 70.0% | **81.9%** | (same — number is honest/precise; earlier leaf-only match had inflated it) | +| Java | gson | 78.2% | **85.1%** (raw) · **93.3% fair** | annotations: index `@interface` defs + link `@Foo` usages (in `modifiers`) | +| Java | retrofit | 80.5% (raw) | **94.9% fair** | (same) | +| Swift | Alamofire | 93.0% | **95.3%** | property wrappers / attributes (`@Argument`/`@Published`/`@objc`) | +| Swift | swift-argument-parser | 84.6% | **96.2%** | (same) | +| Kotlin | OkHttp | 96.2% | **96.2%** | already at ceiling (JVM, barely uses KMP) — no change needed | +| Kotlin | kotlinx.coroutines | 76.8% | **93.5%** | Kotlin Multiplatform `expect`/`actual` linking (incl. `actual typealias`) | +| Scala | typelevel/cats | 48.9% | **89.2% fair** (82.1% raw) | parameterized extends + type refs (implicit/context-bound) + `new` | +| Scala | gatling | 76.3% | **91.2%** | (same) | +| PHP | guzzle | 95.2% | **100.0%** | namespace capture + `use`-import resolution | +| PHP | laravel/framework | 80.5% | **94.9%** | namespace capture (disambiguates same-named contracts) + use-imports + type-hints | +| Ruby | rails/activerecord | 84.8% | **96.8%** | mixin edges (`include`/`extend`/`prepend`) + require resolution | +| Ruby | sidekiq | 71.0% | **100.0%** | mixins + `require`/`require_relative` → file resolution | +| C++ | google/leveldb | 91.7% | **94.8%** | fix free-function name extraction (was named after param/return type) | +| C | redis | 92.2% | **92.2%** | already at ceiling (C unaffected; residual = generated/macro/fn-ptr) | +| Dart | flutter/packages | 88.8% | **92.4%** | `with` mixins + method type references | +| Dart | dio | 86.4% | **87.9%** | (same; raw 67.8% was example-dir pollution) | +| Obj-C | AFNetworking | 50.0% | **90.0%** | single-arg selectors + class-receiver refs + #import + class-method resolution | +| Obj-C | SDWebImage (Core) | 33.8% | **91.6%** | (same; `include/` dirs are symlink dups — measure Core/) | +| Lua | nvim-telescope | 31.6% | **84.2%** | `require()` module resolution: dotted `a.b.c`→`a/b/c.lua` + instance-path leaf, path-suffix match w/ same-dir preference (4155609). Residual = telescope's dynamic `setmetatable` lazy-require pickers (frontier). | +| Luau | dphfox/Fusion | 12.5% | **92.2%** | (same require resolver — `require(script.Memory.deriveScope)`) | +| Liquid | Shopify/dawn | 39.1% | **73.8%** | Shopify OS 2.0 JSON-template section parsing: index `templates/*.json` + section-group `sections/*.json` (incl. nested `templates/customers/`), link each `"type"`→`sections/X.liquid` (2f57119). Snippet `{% render %}` already worked. Residual = preset/theme-editor sections (no static ref) + dynamic `{% render block %}`. | +| Pascal/Delphi | PascalCoin | 73.0% | **75.7%** | `uses`-resolution ALREADY worked (73%); + `.dfm`/`.fmx` form ↔ same-basename `.pas` code-behind pairing (2f30a3b). Residual = vendored third-party libs (`src/libraries/*` internal units, ≈node_modules) + dynamic form/RPC instantiation. | + +**"raw" vs "fair":** "fair" excludes files that *structurally can't* have dependents (no-symbol files like `package-info.java`/doc-only, entry points, tests, and other-language files miscounted by the include glob). For Java the raw numbers were heavily polluted (gson had many `package-info.java`; retrofit had `.kt` + samples), so the fair number is the real one (~93–95%). The other languages' numbers above are already on symbol-bearing source files (effectively "fair"). C# MediatR's 85.2% is the library-only figure; a package-info-excluded "fair" wasn't separately computed but is higher. + +## Per-framework results — cross-language file-dependent coverage (RN/Expo, multi-platform JS↔native) + +| Framework | Repo | Before | After | Key fix | +|---|---|---|---|---| +| React Native / Expo | react-native-async-storage | 75.0% | **97.4% fair** (37/38) | cross-family gate (082353e) + same-dir C/C++ `#include` + KMP commonMain import (529d822) | +| React Native | react-native-device-info | 72.4% | **95.2% fair** (20/21) | cross-family gate (082353e) + honest fair metric (its 529d822 engine-fix targets are excluded entry files) | + +**Metric note (read before trusting the "Before"):** the "Before" 75.0%/72.4% used an **under-exclusive** denominator — it counted generated codegen (`.g.h`), build scripts (`pch.*`, `*.gradle.kts`), tooling config (eslint/jest/yarn), and platform/registration **entry points** as if they were source. The "After" uses the **honest fair metric** the per-language table uses: excl. structural (generated/build/config/test), see-through barrels (web re-export files + umbrella/SDK headers — but NOT a 0-symbol source impl, which is a real frontier), and entry points (package `src/index`, platform `web`/`windows` entries, RN `ReactPackageProvider`). **Apples-to-apples** (fair metric held constant, isolating just the 529d822 engine fixes): async-storage **92.1% → 97.4%** (+RNCAsyncStorage.h via same-dir include, +Platform.kt via KMP import); rn-device-info **95.2% → 95.2%** (neutral — its same-dir/KMP targets are excluded entry headers, so its lift to 95.2% was the metric correction + the 082353e gate). Residual zeros (real frontiers): async-storage `DatabaseFiles.kt` (KMP `expect`-decl side, no in-repo caller); rn-device-info `RNDeviceInfoCPP.cpp` (`REACT_METHOD` macro methods not extracted). Measure with `/tmp/faircov.cjs --list`. No regression on controls: okhttp 75.9→76.4, kotlinx.coroutines 89.7 (neutral), leveldb 78.0 (neutral), redis 89.7→89.9, fmt 77.3 (neutral); cross-family false edges 0 everywhere. + +## Route-framework headroom map (canonical app per README framework, FAIR coverage) + +Measured 2026-06-04 (commit 61a993a) on a canonical real app for each README route framework. This is the active front of the campaign — the unmeasured frameworks have the real headroom. + +| Framework | App repo | FAIR coverage | Status / next | +|---|---|---|---| +| Express (TS) | express-realworld | 70.4% → **100%** ✅ | DONE (2a0b6e0): renamed default-import → module file (route controllers `export default router`). | +| FastAPI (Py) | fastapi-realworld | 78.6% → **98.0%** ✅ | DONE (2835623): source-aware `from pkg import submodule` (router aggregator). 1 residual = aliased sub-aggregator. | +| Flask (Py) | flask (lib) | **100.0%** ✅ | DONE (entries/barrels excluded) | +| requests (Py) | requests (lib) | **100.0%** ✅ | DONE | +| NestJS (TS) | nestjs-realworld | 93.8% → **96.8%** ✅ | DONE (main.ts entry excluded) | +| Gin (Go) | gin (lib) | **96.5%** ✅ | DONE (faircov Go `_test.go` exclusion) | +| Laravel (PHP) | laravel (lib) | 92.0% | done (per-language; app not separately measured) | +| Rails (Ruby) | rails (lib) | 89.6% | done (per-language) | +| Django (Py) | django-realworld | 45.9% → **74.1%** | PARTIAL (58dc463): abs-module-import + `include('app.urls')` done. Ceiling ~83% w/ entries excluded. FRONTIERS: signals via in-body `ready(): import myapp.signals` (Python in-body imports NOT extracted — visitFunctionBody walks calls but not import_statement); DRF/string-config exception classes (`EXCEPTION_HANDLER: '...'`). | +| ASP.NET (C#) | eShopOnWeb | 59.3% → **83.9%** | chained extension calls (4c14413) + framework-entry exclusions (b) + Razor/Blazor markup parser (59b8de2 tags/@model + 90c5f39 @code) + **C# namespaces (dc7d033) + Razor `@using` disambiguation (9e5a951)** — DTOs now resolve to `BlazorShared.Models::CatalogBrand` not the same-named entity. C# constructor DI / interface→impl ALREADY worked. Residual ~24 = reflection/proxy (AutoMapper profiles / Swagger filters / middleware / health checks — invoked by reflection, a separate modeling feature) + a few C# static-const reads (`Constants.X` — extend the static-member pass to C#). | +| Spring (Java) | spring-petclinic | 65.2% → **83.3%** ✅ | DONE — convention/reflection ceiling, on par with ASP.NET. 30 main java = 15 covered + 7 entry (1 `*Application` main + 6 `*Controller`) + 5 barrel (`package-info.java`) + 3 zero. 3 residual zeros all reflection-registered: `CacheConfiguration`/`WebConfiguration` (`@Configuration`, component-scanned — `WebConfiguration` is even `@SuppressWarnings("unused")`) + `PetClinicRuntimeHints` (`@ImportRuntimeHints(X.class)` class-literal = the documented `Foo.class`→`Class` frontier). faircov fix = barrel-exclude `package-info.java`/`module-info.java` shells. **No `samples`-dir exclusion ever existed in the script** — that handoff note was inaccurate. | +| Axum (Rust) | realworld-axum-sqlx | 72.7% → **100%** ✅ | DONE (a3f59fb) — import/aggregator style, confirmed in Rust. REAL engine miss found+fixed: bare Rust `submodule::fn()` calls (the `X::router()` router-assembly pattern) resolved crate-relative only → now self-relative FIRST (current module), then crate fallback. + `main.rs`/`src/bin/*.rs` faircov entry exclusion. Clean A/B (same faircov, engine ±fix): axum +2 → 100%, ripgrep neutral (66/79), tokio +3 (260/321); cross-family false edges 0. Regression test fails without the fix. | +| Rocket (Rust) | TatriX/realworld-rust-rocket | 62.5% → 68.8% → **93.8%** ✅ | DONE — TWO engine wins. (1) 7bb958b: a 3-segment `database::profiles::find()` call (inside `db.run(\|c\| …)`) was dropped by the resolver PRE-FILTER (leaf never checked) → +database/profiles.rs. (2) 6d214cd: NEW `routes![]`/`catchers![]` macro extractor — the handler paths live in a raw token-tree, so the 4 `routes/*.rs` handler modules (mounted at runtime) looked uncalled; now reconstructed from the macro + resolved via the Rust path resolver → all 4 covered. Only `lib.rs` (crate-root/launch hub, see-through) remains. No control regression; false edges 0; nodes unchanged (edges only). | +| actix-web (Rust) | fairingrey/actix-realworld-example-app | **65.4%** | DONE (genuine ceiling). actix **1.x ACTOR style**: routing resolves fine (`.route(web::post().to(users::register))` → handlers covered), but the DB layer is reached via actor message dispatch (`db.send(RegisterUser)` → `impl Handler for DbExecutor`) — a DYNAMIC runtime boundary, no static edge (the actix analog of the MediatR/reactive runtimes the engine deliberately leaves uncovered). 9 zeros = 5 `db/*.rs` (actor dispatch) + `models/mod`/`utils/mod`/`prelude` (glob `pub use self::x::*` re-export roots) + `utils/hasher` (reached only via that glob). Bridging needs an actor-message synthesizer (dynamic-dispatch feature), not a static-resolution fix. | +| Vapor (Swift) | vapor-til (canonical) | 85.7% → **100%** ✅ | DONE — import/aggregator confirmed in Swift. Controllers covered via `app.register(collection: X())`, models via controller queries + migrations. REAL miss found+fixed (bb7659e): the many-to-many pivot model was referenced ONLY via `@Siblings(through: Pivot.self)` — a metatype arg inside a property-wrapper attribute that wasn't walked → now routed through `extractStaticMemberRef` → pivot covered. + faircov excl `Package.swift` (SPM manifest) & `Public/` (static assets). A/B (same faircov): vapor-til +1 → 100%, Alamofire neutral (44/50); false edges 0. | +| Vapor (Swift) | penny-bot (real prod, 166 files) | **73.2%** | SECONDARY — a serverless/DI architecture, NOT the canonical route→controller→model shape. 37 zeros = AWS Lambda handlers (`*Handler`/`*Lambda`, invoked by the AWS runtime → entry points, no in-repo caller) + `exports.swift` re-export barrels (see-through, like Rust mod.rs roots) + Swift extension files (`+X.swift`) + DTOs/services reached via DI / JSON-decoding (genuine frontiers). Excluding the Lambda entries + exports barrels (faircov gaps) → ~80%; genuine residual is the DI/serialization architecture. NOT chased — vapor-til is the canonical result. | +| SvelteKit (Svelte) | sveltejs/realworld (official) | 35.9% raw → **100% fair** ✅ | DONE — characterization, NO engine miss (first framework that needed none). The `.svelte` import resolution ALREADY connects the whole component graph (page→component→lib: ArticlePreview←ArticleList, Nav←+layout, ListErrors←4 pages — imports+references+calls all resolve). Low RAW = the file-convention shape: 25/39 files are SvelteKit convention ENTRIES (`+page`/`+layout`/`+error`/`+server`/`hooks` — loaded by the framework BY PATH, no in-repo caller = route leaves, like route handlers). Excluding those (new faircov ENTRY patterns) → 14/14 = 100% of the coverable core. ONE framework-mediated gap: `+page.server.js` `load` → `+page.svelte` `data` is NOT a static link (runtime `data` prop) — was bridged with a SvelteKit convention synthesizer (sibling files by path, like Rocket routes!) — impact-granularity, not coverage (both files are entries). **BUILT (3ea03e5): `svelteKitLoadEdges`** links each page component → its OWN-dir loader's `load`/`actions` (same for `+layout`); path-deterministic (19 links / **0 cross-dir mislinks** on realworld), provenance heuristic, nodes unchanged. `getImpactRadius(load)` now surfaces its page. | +| React Router (React) | gothinkster realworld | **100%** ✅ | DONE — characterization, NO engine miss. The CONFIG-based component-node sub-shape: route components are NAMED in source (`` → App.js imports + references Login), so they resolve cleanly — verified Login/Editor/Profile all covered BY App.js's route config (imports+references). 29/29 (only `index.js` CRA entry + 8 anonymous-default-export reducers [0 named symbols → barrels] excluded). CONTRAST with SvelteKit (file-convention, needs entry-exclusion): the component-node category has TWO sub-shapes, both handled by the engine as-is. (App is old v4/v5 `component={X}`; modern v6/v7 `element={}` + data-router `{element, loader}` are also source-referenced → same conclusion.) No commit (nothing to fix). | +| Nuxt (Vue) | nuxt/movies (official demo) | 47.6% → **93.5% fair** ✅ | DONE — file-convention component framework (like SvelteKit) but with an AUTO-IMPORT twist that was a REAL miss (1dec765). Nuxt auto-imports a NESTED component by a DIR-PREFIXED name: `components/media/Card.vue` → ``, but the node is named `Card` → the PascalCase template usage never resolved (the synth only matched kebab tags; the extractor's PascalCase ref name-matched `MediaCard`→nothing). Fix in `vueTemplateEdges`: match PascalCase tags + a `nuxtComponentName` map (`media/Card.vue`→`MediaCard`, incl. Nuxt dir de-dup) → +9 nested components. + faircov Nuxt entry exclusions (pages/, app.vue, error.vue, layouts/, plugins/, middleware/, server/). 2 residual zeros = genuine frontiers (unused `` variant + unimported `constants/images.ts`). Composables auto-import (`useTmdb()`) already resolved by name-match. false edges 0, nodes unchanged. **vs SvelteKit:** SvelteKit needed NO component fix (svelte imports resolve); Nuxt DID (its auto-import dir-prefix naming is unique). | +| Drupal (PHP) | token (contrib module) | 57.1% raw → **78.9% fair** | DONE — the MOST reflection-heavy framework, a genuine sub-95% ceiling (≈ASP.NET 83.9%, Django 74.1%). Static parts WORK: `*.routing.yml`→controller (drupal.ts resolves it — `TokenTreeController` covered via the route ref), PHP class refs (namespaces/`use`/type-hints). 4 residual zeros all DI/reflection-wired: 2 services in `services.yml` (`TreeBuilder` = `@token.tree_builder`, `TokenFieldRender`), a DYNAMICALLY-routed controller (`TokenDevelController` via `RouteSubscriber`, not static YAML), a field-plugin class (`MenuLinkFieldItemList` list_class). + faircov excl JS libraries + convention entries (`.install`/`.module`, `src/Plugin`/`Element`, Drush, `*ServiceProvider`, `Routing`/`*Subscriber`). LEVERS (drupal.ts TODOs, large per-mechanism features, NOT chased): model `services.yml` DI (→ covers the 2 services), plugin annotations + modern `#[Hook]` attributes. No commit (no simple miss). | + +**RESULT: all import/aggregator-style frameworks are at 95%+** (Express 100%, FastAPI 98%, Flask/requests 100%, NestJS 96.8%, Gin 96.5%, Axum 100%). + +**Option (b) DONE (metric-only — framework-entry exclusions in `/tmp/faircov.cjs`):** added convention-entry patterns (`*Controller.cs/java`, `*.cshtml.cs`, `*Endpoint.cs`, EF `Data/Config/*.cs`, `Program/Startup.cs`, `*Application.java`, Django `admin.py`/`apps.py`). Result — convention frameworks rise but **still cap well below 95%**: ASP.NET 65.3% → **77.2%** (50 entries excluded), Spring petclinic 65.2% → **83.3%** (after the `package-info.java` shell-exclusion fix below), Django **74.1%**. The import-style frameworks are unaffected (Express 100%, FastAPI 98%, NestJS 96.8%, Gin 96.5% — the C#/Java/Django entry patterns don't touch them). + +**WHY (b) doesn't reach 95% — the honest ceiling:** after excluding routed/reflection-registered entries, the residual zeros are **markup-driven** code-behind (Blazor `.razor` / Razor `.cshtml` / Thymeleaf reference the `.cs`/`.java`, but the markup isn't parsed → ViewModels, DTOs, components look unused) and **reflection/proxy** code (Spring Data repository proxies, AutoMapper profiles, Swagger filters, DI/middleware registration, Django signals/string-config). These are genuine static-analysis frontiers — reaching 95% needs (1) parsing template markup to link markup→code, or (2) per-framework reflection/proxy modeling — both large features. **Excluding markup-driven business code (DTOs/ViewModels) from the metric to fake 95% would be gaming — NOT done.** Note: business LOGIC (services, repos) IS covered in all three; the residual is leaf views/DTOs/configs whose impact is captured the other direction (route→handler). + +**Generalizable engine fixes shipped this campaign (all benefit beyond their trigger framework):** Python absolute `import a.b.c` (61a993a); source-aware `from pkg import submodule` (2835623); Django `include('app.urls')` claim (58dc463); chained method calls `a.b.Method()` incl. C# extension methods (4c14413); renamed default-import → module file (2a0b6e0); Rust bare `submodule::fn()` calls resolved self-relative — current module first, then crate fallback (a3f59fb); multi-segment Rust `a::b::c()` module calls no longer dropped by the resolver pre-filter — the leaf name is now checked (7bb958b). + +**KEY REALITY (honest):** apps dominated by **convention/reflection-driven** code (ASP.NET MVC/Razor/Blazor, EF config, reflection DI; Django signals/DRF; any framework whose handlers are discovered by routing/DI container, not called by in-repo code) have files with NO static in-repo caller. Those are genuine static-analysis frontiers — **literal 95% is not reachable** on such apps without either (a) excluding all framework-entry conventions from the fair denominator (defensible per methodology but extensive + per-framework), or (b) modeling each framework's convention routing + DI container (large per-framework engine work). The DI-heavy/convention-heavy frameworks (ASP.NET, Spring, MVC) are this category; the import/aggregator-style ones (FastAPI, Flask, Express, Gin) reach 95%+ with tractable resolution fixes. + +**SWEEP COMPLETE — every README framework measured.** Route frameworks: Express 100%, FastAPI 98%, Flask/requests 100%, NestJS 96.8%, Gin 96.5%, Axum 100%, Rocket 93.8%, Vapor 100%, Laravel 92%, Rails 89.6% (import/aggregator) · ASP.NET 83.9%, Spring 83.3%, Drupal 78.9%, Django 74.1%, actix 65.4% (convention/reflection/actor ceiling). Component-node: React Router 100% (config-based) · SvelteKit 100% fair, Nuxt 93.5% fair (file-convention). **Component-node category fully characterized**: config-based (route components named in source → 100% raw) + file-convention (discovered by path → entry-exclusion; SvelteKit needed NO component fix, Nuxt needed the dir-prefix auto-import fix). Convention/reflection frameworks (ASP.NET/Spring/Drupal/Django/actix) are at a genuine sub-95% static-analysis ceiling — the levers are large per-mechanism features (markup/reflection/DI modeling), deliberately not chased. **faircov exclusions added this session:** language-aware test files (`_test.go`, `test_*.py`, `*Tests.cs`, `*_spec.rb`, …); generated migrations (Django/Alembic `migrations/`, EF `Migrations/*.cs`/`*.Designer.cs`/`*ModelSnapshot.cs`); Python entries (`__main__.py`, `setup.py`, `conf.py`, `docs/`) + `__init__.py` barrels. faircov now barrel-excludes `package-info.java`/`module-info.java` shells (package-declaration-only files — they carry a `namespace` node so `realSyms !== 0`, but nothing can import them, so they can never be a dependency target; the Java analog of `__init__.py`). This is what actually blocked an honest Spring number: petclinic's 5 `package-info.java` sat in the denominator as zeros, deflating 83.3% → 65.2%. **There was never a `samples`-dir exclusion in the script** — that earlier handoff note was inaccurate (the only similar rule is `example[s]?`, which can't match `samples`). Route-framework-front faircov ENTRY additions: Rust binary entries (`main.rs`, `src/bin/*.rs`); Swift `Package.swift` (SPM manifest) + `Public/` static assets; SvelteKit file-convention entries (`+page`/`+layout`/`+error`/`+server.*`, `hooks.*`, `service-worker.*` — framework-discovered route leaves, no in-repo caller); Nuxt file-convention entries (`pages/`, `app.vue`/`error.vue`, `layouts/`, `plugins/`, `middleware/`, `server/`, `proxy/routes/`); Drupal convention entries (`.install`/`.module`/`.profile`/`.theme` hook files, `src/Plugin`/`src/Element` annotation-plugins, `src/Drush`, `*ServiceProvider.php`, `src/Routing`/`*Subscriber.php`) + `js/` library assets. + +## How to push each language higher (remaining levers) + +**The one big cross-language lever: a static-member / const value-read pass.** Extract `Type.MEMBER` (capitalized/known-type receiver) as a `references` edge to `Type`. This is the universal deferred data-flow frontier and would lift **C#, Java, Swift, TS, and Rust at once**. Implement once in extraction with a heuristic (receiver resolves to / looks like a type → emit ref; skip lowercase `obj.field`). Trade-off = some instance-field-access noise; that's why it's been deferred. This is the highest-leverage single task remaining. + +Per language — what's left and the action to improve it: +- **C# — MediatR 85.2% (raw, the lowest real number):** + - *raw→fair:* exclude no-symbol files (`TypeForwardings.cs` = assembly attrs only, `package-info`-equivalents) + benchmark `main`s → ~92%+. **A fair re-measure was never run for C# — do it first; the "real" number is materially higher.** + - *to improve further:* static/const value reads (`BuildInfo.BuildDate`, enum `Edition?` where a same-named property shadows the type) → the static-member pass. +- **Java — gson 85.1% raw → 93.3% fair:** + - *raw→fair:* exclude `package-info.java` (no symbols) + `.kt`/samples (already done for the fair number). + - *to improve fair further:* static-field reads (`X.FACTORY`), `Foo.class` class literals (currently `Foo.class` references `Class`, not `Foo`), constant reads (`JsonScope.X`) → the static-member pass. +- **Rust — tokio 81.9% (lowest of the high group), ripgrep 86.7%:** + - residual = see-through `mod.rs`/`lib.rs` roots (correct-0), **macro-reached code** (`log!`, custom `macro_rules!`, derives — the big Rust frontier, hard), external-trait-only impls. + - *to improve:* macro handling (large, separate project) + static/const reads. Note tokio's 81.9% is already honest/precise (path resolution removed spurious leaf-match edges). +- **Go — gin 96.6%:** + - residual = `//go:build` alternates (appengine/jsoniter/go_json/sonic/nomsgpack) + external-API `version.go`. + - *to improve:* a **build-constraint parser** (evaluate `//go:build`) so inactive variants are excluded from the denominator or all variants are linked (recall-first). Only matters for build-tag-heavy repos; niche. +- **TS 95.8% · Python requests 100% / flask 87.5% · Swift 95.3% / 96.2%:** at/near true ceiling — residual is entry points, see-through barrels, external public API, and value-reads. The **only** lever left for these is the static-member/const pass. + +**Bottom line:** Python/TS/Swift/Go are effectively at ceiling. The two with real headroom are **C#** (mostly a fair-remeasure — do that first) and **Rust tokio** (macros — hard). The static-member/const pass is the one change that moves *everything* a few points; the rest is per-language frontier work. + +## Which tools benefit (asked + answered this session) +It's a GRAPH-WIDE update (one shared `edges` table). `getCallers`/`getCallees` follow `['calls','references','imports']`; `getImpactRadius` + `getFileDependents`/`affected` follow **all except `contains`**; `codegraph_explore` composes all of them. So `instantiates`/`implements`/`decorates` edges show in impact+explore but **not** callers/callees (a pre-existing edge-kind filter in `getCallers`/`getCallees` — could be broadened, deferred). + +## Gotchas +- **Include globs don't filter reliably** — tests/examples/benches/`.kt` leak into the index. Filter in the measurement SQL, not the config. +- **`/tmp` clones persist across turns** — `rm -rf /.codegraph` before re-indexing or `initSync` throws "already initialized" and you measure a STALE index (this bit me ~3×; a stale index massively under-reports). +- **Fair metric must exclude no-symbol files** (package-info, doc-only) — they can't have dependents; counting them is dishonest-low. Also a slightly-LOWER honest number (Rust tokio 83→82 after path resolution) beat the spurious-inflated one — precision over optics. +- **Build vs test:** `npm test` uses esbuild (no typecheck); `npm run build` (tsc) is what catches type errors. Always build before committing. Strict null on regex groups bit me — avoid `m[1]` indexed access. +- Node-version regex-group access (`m[1]`) is `string|undefined`; use guards. + +## How to test & validate +- `npm run build` → tsc clean (must pass before commit). +- `npm test` → **1178 passed | 2 skipped** (59 files, verified 2026-06-05). Per-language tests live in `__tests__/extraction.test.ts` (describe per language); route-framework/RN tests in `__tests__/{react-native-bridge,expo-modules,rn-event-channel}.test.ts`. +- Coverage probe recipe: clone repo → `node -e "...initSync...indexAll...resolveReferences..."` → the fair-coverage SQL (see Methodology #2). Node count stable = no explosion. +- Full per-language findings + exact fixes: memory file `~/.claude/projects/-Users-colby-Development-CodeGraph-codegraph/memory/impact-coverage-findings.md`. + +## Repo state +- branch `feat/cross-language-impact-coverage`, last commit `2f30a3b feat(impact): pair Delphi forms with their .pas code-behind (.dfm/.fmx ↔ .pas)`. +- **41 commits ahead of `main`, 3 behind** (behind = 3 README-waitlist doc commits on main). All pushed to origin. NOT merged — branch is for review. Niche-lang commits 39–41: 4155609 (Lua/Luau require resolver), 2f57119 (Liquid Shopify-JSON sections), 2f30a3b (Delphi .dfm form pairing). +- Commits 1–20 (per-language + RN/Expo, `16b5633`→`529d822`): 16b5633 (foundation+TS/Py/Go/C#), b538aee + 2ac7df5 (Rust), badb124 (Java), d111f26 (Swift), d8a2e91 (Kotlin), b5489d9 (Scala), acfb444 (PHP), 44fb978 + 5bccab6 (Ruby), ec8fe3f (C/C++), 9487954 (Dart), 857baf7 (static-member pass), 33ce431 (Objective-C), dbc4862 (Expo bridges), 4a64ca5 (classic RN pairing), d06a5ec (RCT_EXPORT_METHOD nodes), 74b599c (RN event wrapper), 082353e (cross-family gate), 529d822 (same-dir include + KMP import). +- Commits 21–31 (ROUTE-FRAMEWORK front, `61a993a`→`b653688`, added after the original save): 61a993a (Python absolute `import a.b.c`), 2835623 (source-aware `from pkg import submodule` — FastAPI), 58dc463 (Django `include('app.urls')`), 4c14413 (chained `a.b.Method()` + C# extension methods), 2a0b6e0 (renamed default-import → module file — Express), 59b8de2 (Razor/Blazor `.cshtml`/`.razor` markup parser), 90c5f39 (Blazor `@code`/Razor `@{}` → C# extractor), 4589cf9 (docs), dc7d033 (C# namespace extraction), 9e5a951 (Razor `@using` disambiguation), b653688 (handoff notes + docs). Commit 32 (`a3f59fb`): Rust self-relative `submodule::fn()` resolution — Axum realworld 72.7%→100%, controls neutral/+3, regression test added. Commit 33 (`7bb958b`): multi-segment Rust `a::b::c()` calls no longer dropped by the resolver pre-filter (leaf-name check in `hasAnyPossibleMatch`) — Rocket realworld 62.5%→68.8%, additive elsewhere, regression test added. Commit 34 (`6d214cd`): Rocket `routes![]`/`catchers![]` macro extractor (parse the handler paths out of the raw token-tree, emit references) — Rocket 68.8%→93.8%, nodes unchanged (edges only), no control regression, regression test added. Commit 35 (`bb7659e`): Swift property-wrapper attribute-arg type refs — route a property's attribute args through `extractStaticMemberRef` so a Fluent `@Siblings(through: Pivot.self)` links the model to the pivot — vapor-til 94.7%→100%, Alamofire neutral, regression test added. Commit 36 (`3ea03e5`): SvelteKit `load`→page synthesizer (`svelteKitLoadEdges` in callback-synthesizer.ts) — links each `+page.svelte`/`+layout.svelte` to its OWN-dir loader's `load`/`actions`; impact-granularity (coverage already 100%), 0 cross-dir mislinks, nodes unchanged, regression test added. Commit 37 (`1dec765`): Nuxt nested auto-imported component resolution — `vueTemplateEdges` now matches PascalCase tags + a `nuxtComponentName` map (``→`media/Card.vue`); nuxt/movies 47.6%→93.5%, +9 nested components, Vue-only (gated to .vue), nodes unchanged, regression test added. +- uncommitted: only untracked `assets/generate-waitlist.py` (unrelated — README-waitlist tooling; the `.claude/handoffs/*` files are committed on this branch). +- Touched source files (branch vs main): `src/db/queries.ts`, `src/graph/queries.ts`, `src/extraction/{tree-sitter,tree-sitter-types,grammars}.ts`, `src/extraction/razor-extractor.ts` (NEW — Razor/Blazor markup), `src/extraction/languages/{rust,java,csharp,kotlin,php,ruby,scala,c-cpp}.ts`, `src/resolution/{import-resolver,callback-synthesizer,index,name-matcher}.ts`, `src/resolution/frameworks/{python,react-native,expo-modules}.ts`, `src/types.ts`, `__tests__/{extraction,graph,expo-modules,react-native-bridge,rn-event-channel}.test.ts`, `CHANGELOG.md`. +- Measurement scripts (in /tmp, not committed): `faircov.cjs` (honest fair coverage + false-edge count, `--list` shows residual zeros + exclusions), `audit.cjs` (lists 0-dependent files by language), `xlang.cjs` (cross-lang edges by src→tgt × kind). + +## Open threads / TODO +- [x] **Kotlin DONE** (commit d8a2e91) — gap was KMP `expect`/`actual`; coroutines 76.8%→93.5%, OkHttp already 96.2%. See "Kotlin result" above. +- [x] **Scala DONE** (commit b5489d9) — gap was a whole family of missing edges (parameterized extends, type refs, implicit/context-bound params, `new`); cats 48.9%→89.2% fair, gatling 76.3%→91.2%. See "Scala result" above. +- [x] **PHP DONE** (commit acfb444) — gap was NAMESPACES (not #608/#660); guzzle 95.2%→100%, laravel 80.5%→94.9%. See "PHP result" above. +- [x] **Ruby DONE** (commits 44fb978 + 5bccab6) — gaps were MIXINS + REQUIRE resolution; activerecord 84.8%→96.8%, sidekiq 71%→**100%**. See "Ruby result" above. +- [x] **C/C++ DONE** (commit ec8fe3f) — gap was a C++ free-function name-extraction bug; leveldb 91.7%→94.8%, redis (C) 92.2% at ceiling. See "C/C++ result" above. +- [x] **Dart DONE** (commit 9487954) — gaps were mixins (`with`) + method type refs; flutter/packages 88.8%→92.4%, dio 86.4%→87.9%. See "Dart result" above. +- [ ] **Objective-C next** (last README language, already partial — `@interface`/`@implementation` split, `#import`, categories, protocols, `@property`). Niche after: Liquid, Pascal, Lua, Luau. +- [x] **Static-member/value-read pass DONE** (commit 857baf7) — `Enum.value`/`Type.CONST`/`Foo::BAR` → references; flutter 92.4%→93.2%, additive across Java/C#/Kotlin/Swift/Scala/PHP/C++. TS/JS/Python excluded. +- [x] **Objective-C DONE** (commit 33ce431) — selectors + class-receiver + #import + class-method resolution; AFNetworking 50%→90%, SDWebImage Core 33.8%→91.6%. ← LAST README language. +- [x] **Per-language + RN/Expo campaign COMPLETE** (`16b5633`→`529d822`): all 15 README langs + static-member pass + cross-language RN/Expo at 95%+. +- [x] **Route-framework front — import/aggregator style DONE** (`61a993a`→`2a0b6e0`): Express 100%, FastAPI 98%, Flask/requests 100%, NestJS 96.8%, Gin 96.5%. See the headroom map. +- [~] **Route-framework front — convention/reflection style PARTIAL** (`4c14413`,`59b8de2`,`90c5f39`,`dc7d033`,`9e5a951`): ASP.NET eShopOnWeb 59.3%→83.9% (Razor/Blazor parser + C# namespaces), Django realworld 45.9%→74.1%. At an honest sub-95% static-analysis ceiling (markup-driven code-behind + reflection/DI). Reaching 95% needs markup→code linking or per-framework reflection modeling (large features); faking it via metric exclusions = gaming, NOT done. +- [x] **Spring DONE** (83.3% fair, 15/18) — the real blocker was faircov NOT excluding `package-info.java` shells (5 in petclinic), not a `samples`-dir exclusion (which never existed in the script). Fixed → barrel-exclude `package-info.java`/`module-info.java`. 3 residual zeros = `@Configuration` beans + AOT `RuntimeHints`, all reflection-registered → the convention/reflection ceiling, on par with ASP.NET 83.9%. +- [x] **Axum (Rust) DONE** (72.7%→**100%**, a3f59fb) — import/aggregator thesis confirmed in a 3rd language family. Found+fixed a REAL engine miss (not a metric issue): bare Rust `submodule::fn()` calls (the `X::router()` router-assembly pattern) resolved crate-relative only → now self-relative FIRST (current module), then crate fallback. Clean A/B: no control regression (ripgrep neutral 66/79, tokio +3 → 260/321); cross-family false edges 0; regression test fails without the fix. NOTE: actix/Rocket NOW MEASURED (below). +- [x] **Rocket (Rust) DONE** (62.5%→68.8%→**93.8%**, 7bb958b + 6d214cd) — TWO engine wins: (1) multi-segment `a::b::c()` calls were dropped by the resolver pre-filter (leaf never checked) → fixed → +database/profiles.rs; (2) built the `routes![]`/`catchers![]` macro extractor — parses handler paths out of the raw token-tree + resolves them via the Rust path resolver → all 4 `routes/*.rs` handler modules covered. Only `lib.rs` (crate-root/launch hub, see-through) remains → effectively at ceiling. No control regression, false edges 0, nodes unchanged (edges only). +- [x] **actix-web (Rust) DONE** (**65.4%**, genuine ceiling) — actix 1.x ACTOR style: routing resolves (handlers covered), but the DB layer is reached via actor message dispatch (`db.send(Msg)` → `impl Handler for DbExecutor`) — a DYNAMIC runtime frontier with no static edge (deliberately uncovered, the actix analog of MediatR/reactive runtimes). + glob `pub use self::x::*` re-export roots. Bridging needs an actor-message synthesizer (dynamic-dispatch feature), not a static fix. +- [x] **Vapor (Swift) DONE** — vapor-til (canonical) 85.7%→**100%** (bb7659e), penny-bot (real prod, 166 files) 73.2% (serverless/DI, secondary). Import/aggregator confirmed in Swift: controllers via `app.register(collection: X())`, models via controller queries + migrations. REAL miss found+fixed: a many-to-many pivot model referenced ONLY via `@Siblings(through: Pivot.self)` (metatype arg in a property-wrapper attribute, not walked) → routed attribute args through `extractStaticMemberRef` → pivot covered. + faircov excl `Package.swift` + `Public/`. penny-bot's lower number = AWS Lambda entries + `exports.swift` barrels (faircov gaps, ~80% if excluded) + DI/serialization/extension frontiers (genuine); not chased. +- [x] **SvelteKit (Svelte) DONE** (35.9% raw → **100% fair**, NO commit — first framework needing NO engine fix) — sveltejs/realworld (official). The `.svelte` import resolution ALREADY connects the whole component graph (page→component→lib — verified: imports+references+calls all resolve, e.g. ListErrors←4 pages). Low raw = the file-convention shape: 25/39 files are SvelteKit convention ENTRIES (`+page`/`+layout`/`+error`/`+server`/`hooks` — framework-discovered route leaves, no in-repo caller) → excluded in faircov → 14/14 = 100% of the coverable core. ONE framework-mediated gap: `+page.server.js` `load` → `+page.svelte` `data` (runtime `data` prop, not static) — bridged with a SvelteKit convention synthesizer (sibling-by-path, like Rocket routes!) — impact-granularity not coverage (both files are entries). **BUILT (3ea03e5): `svelteKitLoadEdges`** (callback-synthesizer.ts) links each page → its own-dir loader's `load`/`actions` + `+layout`; 19 links / 0 cross-dir mislinks on realworld, nodes unchanged, regression test (incl. does-not-cross-routes guard). +- [x] **React Router (React) DONE** (**100%**, 29/29, NO commit — no engine miss) — gothinkster realworld. The CONFIG-based component-node sub-shape: route components are NAMED in source (``) → App.js imports+references each → all resolve (verified Login/Editor/Profile covered by App.js's route config). Only `index.js` (CRA entry) + 8 anonymous-default reducers (0 symbols → barrels) excluded. The component-node category is now fully characterized: config-based (React Router, source-referenced → 100% raw) vs file-convention (SvelteKit, path-discovered → fair-100% + synthesizer); the engine handles BOTH as-is. (Modern v6/v7 `element={}`/data-router `{element, loader}` also source-referenced → same.) +- [x] **Nuxt (Vue) DONE** (47.6% → **93.5% fair**, 1dec765) — nuxt/movies (official demo). File-convention component framework (like SvelteKit) but with an AUTO-IMPORT twist that WAS a real miss: Nuxt auto-imports a NESTED component by a DIR-PREFIXED name (`components/media/Card.vue` → ``), but the node is named `Card` → the PascalCase usage never resolved. Fixed `vueTemplateEdges` (match PascalCase tags, not just kebab + a `nuxtComponentName` map) → +9 nested components. + faircov Nuxt entry exclusions. composables (`useTmdb()`) already resolved by name-match. 2 residual zeros = genuine frontiers (unused `` + unimported `constants/images.ts`). **KEY contrast:** SvelteKit needed NO component fix, Nuxt DID — its dir-prefix auto-import naming is unique. The Vue PascalCase-tag change is gated to `.vue` files (suite green, false edges 0). +- [x] **Drupal (PHP) DONE** (57.1% raw → **78.9% fair**, NO commit — reflection ceiling, no simple miss) — token contrib module. The MOST reflection-heavy framework. Static parts WORK: `*.routing.yml`→controller (drupal.ts), PHP class refs. 4 residual zeros all DI/reflection: 2 `services.yml` services (`TreeBuilder`/`TokenFieldRender`), a dynamically-routed controller (`TokenDevelController` via `RouteSubscriber`), a field-plugin class. + faircov excl JS libraries + convention entries. Sits with ASP.NET 83.9% / Django 74.1% in the convention/reflection band. LEVERS = drupal.ts TODOs (model `services.yml` DI → +2 services; plugin annotations; modern `#[Hook]` attributes) — large per-mechanism features, NOT chased (same stance as ASP.NET/Spring/Django: don't fake 95% via reflection modeling). **← LAST framework; the sweep is complete.** +- [ ] **Frameworks not yet measured** (need a canonical app cloned): Drupal, React Router, Vue-Nuxt (SvelteKit DONE: 100% fair — component-node shape characterized, NO engine miss; Rust route DONE: Axum 100%, Rocket 93.8%, actix 65.4%; Vapor DONE: vapor-til 100%, penny-bot 73.2% serverless/DI) (component-node frameworks — coverage shape differs). The headroom map calls these the real remaining headroom. +- [x] **All 4 niche README languages DONE** (Lua 84.2%, Luau 92.2%, Liquid 73.8%, Pascal 75.7%) — see the per-language table. Full README parity (22 langs + 14 frameworks). +- [ ] **Open the PR to `main`** (41 commits) — everything actionable is done. Deferred polish (genuinely optional): C function-pointer dispatch + C++ namespace capture; PHP IFACE_OVERRIDE_LANGS + type-hint disambiguation; Scala wildcard imports; ObjC type refs; engine symlink-dedup (SDWebImage `include/` dup file nodes); a convention/reflection lever (Drupal services.yml DI / ASP.NET-Spring reflection modeling — large per-framework features). (Static-member pass for TS/JS/Python is NOT a lever — measured & rejected, e7b86df.) +- [x] **Static-member/const pass for TS/JS/Python MEASURED & REJECTED** (e7b86df) — the long-standing "one big cross-language lever." A/B (extend `extractStaticMemberRef` to ts/js/python): **0 coverage gain** on excalidraw (94.3%→94.3%) AND requests (100%→100%) — in import-based languages a `Type.MEMBER` read requires `import`ing the type, so the import edge already covers it (the static read is pure duplication) — while adding **+1813 edges / +2448 `references` on excalidraw** (the retrieval-perf benchmark), all pointing at already-covered types. Reverted; finding documented in the `STATIC_MEMBER_LANGS` comment so it isn't re-tried. The deferral was CORRECT — now proven with data. +- [ ] Cross-language **static-member / const value-read** pass (`X.FACTORY`, `JsonScope.X`, `Foo.class`) — the universal deferred data-flow frontier; would lift Java/C#/etc. ~5% more but risks noise. User aware; deferred. +- [ ] Optional: broaden `getCallers`/`getCallees` edge-kind list to include `instantiates`/`implements`/`decorates` (offered, deferred — changes what "caller" means). +- [ ] Branch is NOT merged — it's for review. Maintainer handles version bumps/releases. Don't merge or bump. + +## Recent transcript (oldest → newest) +### Turn — "Rust next" +- **Claude:** Cloned ripgrep+tokio. Found struct literals (`struct_expression`) not in INSTANTIATION_KINDS, `use` bindings not linked, trait methods (`function_signature_item`) not extracted. Fixed all + added rust to IFACE_OVERRIDE_LANGS. ripgrep 63%→81%, tokio→83%. → commits b538aee, then 2ac7df5 (module-path resolution for `pub use self::read::read` collisions → ripgrep 87%). +### Turn — "do fixes translate to explore/callers/callees or just impact?" +- **Claude:** Verified against tool code → graph-wide; mapped edge-kinds per tool (callers/callees = calls/references/imports; impact/explore = all-but-contains). instantiates/implements/decorates only in impact/explore. +### Turn — "Java next" → "is Java good or can we improve it?" +- **Claude:** Found Java annotation gap (usages in `modifiers` not descended; `@interface` not in interfaceTypes). Fixed both → gson 78%→85% (commit badb124). On re-push ask: pushed already. On "is it good": fair re-measure showed Java actually **93.3%/94.9%** (raw was polluted by package-info/.kt/samples); residual = universal value-read frontier. Concluded Java is good. +### Turn — "Swift next" +- **Claude:** Alamofire already 93%. argparser 84.6% — found property-wrapper gap (`@Argument` = Swift `attribute`→`user_type`, not recognized; Swift instance props not extracted). Extended `extractDecoratorsFor` + dispatcher branch. Alamofire→95.3%, argparser→96.2%. Commit d111f26. Completes README benchmarked 7. +### Turn — /handoff save (original, 2026-06-04) +- Saved at commit 529d822 (per-language + RN/Expo campaign). +### Session — ROUTE-FRAMEWORK front (2026-06-04→05, commits `61a993a`→`b653688`; reconstructed from commits) +- Measured a canonical real app per README route framework. Import/aggregator frameworks made reachable: Python absolute `import a.b.c` (61a993a), source-aware `from pkg import submodule` for FastAPI aggregators (2835623), Django `include('app.urls')` (58dc463), renamed default-import→module file for Express controllers (2a0b6e0) → Express 100%, FastAPI 98%, Flask/requests 100%, NestJS 96.8%, Gin 96.5%. +- Convention/reflection (ASP.NET/Blazor): built a Razor/Blazor `.cshtml`/`.razor` markup parser (59b8de2), delegated `@code`/`@{}` to the C# extractor (90c5f39), added C# namespace extraction (dc7d033) + Razor `@using` disambiguation (9e5a951), resolved chained `a.b.Method()` incl. C# extension methods (4c14413) → eShopOnWeb 59.3%→83.9%, Django→74.1%. Concluded these hit a genuine static-analysis ceiling (markup/reflection); did NOT game the metric to fake 95%. +### Session — this update (2026-06-05) +- **User:** loaded the wrong handoff first (explore-overhaul-bench), asked about per-language coverage %, then switched to this branch to work on impact coverage. +- **Claude:** sourced the coverage table from the `impact-coverage-findings` memory (corroborated by this handoff's own table); found this handoff committed on the branch + loaded it; flagged the branch had moved 11 commits past the handoff's stated tip; **refreshed this handoff to current state** (31 commits, route-framework front, fork of next steps; test count re-verified 1178). Then deleted the other 9 handoff files at the user's request, leaving this as the sole handoff. +- **Outcome:** handoff is current + the only one left; awaiting the user's pick of fork (ship / route front / static-member pass). +### Session — Spring + Axum (2026-06-05, commit a3f59fb) +- **User:** "unblock spring by fixing the faircov samples bug" → then "yes" to measuring an unmeasured framework. +- **Claude (Spring):** there was NO `samples`-dir exclusion in faircov (the handoff note was wrong); the real deflation was 5 `package-info.java` shells (namespace-only → realSyms≠0) sitting in the denominator as zeros. Fixed faircov to barrel-exclude `package-info.java`/`module-info.java`. Spring petclinic 65.2%→**83.3%** (15/18); 3 residual zeros all reflection/DI wiring (`@Configuration` + AOT hints — verified honest, not missed edges) → convention/reflection ceiling, ≈ASP.NET 83.9%. +- **Claude (Axum):** measured realworld-axum-sqlx → 72.7%, audited zeros → found a REAL engine miss (not a metric issue): bare Rust `submodule::fn()` calls (`X::router()` router-assembly) resolved crate-relative only. Fixed `resolveRustModuleFile` to try self-relative FIRST, then crate fallback; + `main.rs`/`src/bin/*.rs` faircov entry exclusion. Axum → **100%** (10/10). Clean A/B (same faircov, engine ±fix): axum +2, ripgrep neutral 66/79, tokio +3 → 260/321; cross-family false edges 0; regression test fails without the fix; full suite **1179** green. Committed+pushed `a3f59fb`. +- **Outcome:** Spring + Axum both measured; the import/aggregator → 95%+ thesis now holds in a 3rd language family (Rust) via a real engine fix, not a metric tweak. Remaining unmeasured: actix/Rocket (attribute-macro routing — likely convention/reflection shape), Vapor, React Router/SvelteKit/Vue-Nuxt, Drupal. +### Session — Rust attribute-macro frameworks (2026-06-05, commit 7bb958b) +- **User:** "rust frameworks to probe the ceiling" (actix + Rocket). +- **Claude:** measured Rocket (TatriX/realworld-rust-rocket) 62.5% + actix (fairingrey/actix-realworld) 65.4%. Audited zeros → TWO findings. (1) Rocket's 3-segment `database::profiles::find()` calls weren't becoming edges — isolated via micro-tests to a RESOLVER PRE-FILTER bug (`hasAnyPossibleMatch` checked first-seg + the `b::c` remainder, never the leaf), NOT closures or extraction (both fine). Fixed (leaf-name check) → Rocket 68.8%, additive elsewhere, no control regression, false edges 0. Committed 7bb958b + regression test. (2) Remaining ceilings are GENUINE: Rocket's `routes![]` macro-mounted handlers (bridgeable via a Rocket extractor — deferred, framework-specific) + actix's actor message dispatch (`db.send(Msg)`→`impl Handler`, a dynamic frontier like MediatR — not bridged). +- **Outcome:** both measured; the "attribute-macro = ceiling" hypothesis was PARTLY right (genuine ceilings exist) but probing surfaced a real generalizable resolver bug that benefits all Rust `a::b::c()` calls. All Rust route frameworks done. Next options: Vapor / component-node frameworks / Drupal, or build the Rocket `routes![]` extractor (→~94%). +### Session — Rocket routes![] extractor (2026-06-05, commit 6d214cd) +- **User:** "build the Rocket routes! extractor". +- **Claude:** probed the tree-sitter AST — `routes![a::b::h, …]` parses as `macro_invocation > token_tree` of FLAT raw tokens (`identifier` / `::` / `,`), so the handler paths were invisible to the call walker. Built `extractRustRouteMacro` (tree-sitter.ts, called from `visitFunctionBody`): for a `routes`/`catchers` macro, walk the token-tree, group `identifier` runs between commas into `::`-joined paths, emit a `references` edge each; the existing Rust path resolver (self-relative + multi-segment fixes already shipped) links them to the handler fns. Rocket 68.8%→**93.8%** (all 4 `routes/*.rs` covered; only lib.rs root left). No control regression, false edges 0, nodes unchanged (edges only), regression test fails without it. Committed 6d214cd. +- **Outcome:** Rocket effectively at ceiling. Handler names are explicit in source → precise static extraction, not a heuristic. Remaining route-front: Vapor (Swift), component-node frameworks (SvelteKit/React Router/Vue-Nuxt), Drupal — or ship the 34-commit branch. +### Session — Vapor (2026-06-06, commit bb7659e) +- **User:** "vapor next". +- **Claude:** measured vapor-til (canonical Vapor tutorial app, kodecocodes) 85.7% + penny-bot (real prod, 166 files) 73.2%. vapor-til zeros: Package.swift (SPM build manifest → faircov excl), Public/cookies.js (static client asset → faircov excl), and AcronymCategoryPivot.swift = a REAL miss. Audited: the pivot model is referenced ONLY via `@Siblings(through: AcronymCategoryPivot.self, …)` in Acronym/Category — a metatype arg inside a property-wrapper `attribute` whose ARGS were never walked (only the wrapper type `Siblings` + the property's declared type were captured). Fix: route the property's attribute-arg subtree through `extractStaticMemberRef` (already handles Swift `Type.self` navigation, self-filters to capitalized receivers, skips `\.$keypath`). vapor-til 94.7%→**100%**. Clean A/B: vapor-til +1, Alamofire neutral (44/50); false edges 0; suite 1182. Committed bb7659e + regression test. penny-bot 73.2% characterized (serverless/DI: Lambda entries + exports barrels + DI/serialization/extension frontiers) — NOT chased (not the canonical pattern). +- **Outcome:** Vapor DONE; import/aggregator confirmed in a 4th language family (Swift) via a real Fluent fix. Remaining route-front: component-node frameworks (SvelteKit/React Router/Vue-Nuxt), Drupal — or ship the 35-commit branch. +### Session — SvelteKit (2026-06-06, no commit — characterization) +- **User:** "component-node framework next". +- **Claude:** measured sveltejs/realworld (official SvelteKit app) → 35.9% raw (14/39). Audited: ALL 25 zeros are SvelteKit file-convention files (`+page`/`+layout`/`+error`/`+server`/`hooks`) — loaded by the framework BY PATH, no in-repo caller. Probed + CONFIRMED the engine already resolves every `.svelte` component import (ArticlePreview←ArticleList, Nav←+layout, ListErrors←4 pages; imports+references+calls all resolve) — NO component is a zero, so NO engine miss (first framework needing none). Added SvelteKit convention-entry patterns to faircov (methodology-aligned, like route handlers) → 14/14 = **100% fair** of the coverable core. Confirmed the one framework-mediated gap: `+page.server.js` `load` → `+page.svelte` `data` is NOT statically linked (runtime `data` prop). +- **Outcome:** the component-node shape is "file-convention entries (the majority) + a fully-covered component/lib core" — the engine handles it as-is. No commit (nothing to fix). OFFERED a SvelteKit convention synthesizer (sibling `+page.server.js`↔`+page.svelte` link, like Rocket routes!) for impact granularity — not built. Branch stays 35 commits. Next: React Router (config-based variant — likely MORE covered) / Nuxt (file-convention, SvelteKit-like) / Drupal, or ship. +### Session — SvelteKit load→page synthesizer (2026-06-06, commit 3ea03e5) +- **User:** "lets fix svelte with the synthesizer, then we can move onto react router". +- **Claude:** probed the graph: `+page.svelte`→`component` node, `+page.server.js`→`load`/`actions` function nodes, pages pair with sibling loaders in the SAME dir. Built `svelteKitLoadEdges` (callback-synthesizer.ts, wired into `synthesizeCallbackEdges` after `vueTemplateEdges`): for each `+page`/`+layout.svelte`, link its `component` → the `load`/`actions` in `/.server.{ts,js}` / `/.{ts,js}` (same dir only), direction page→load so `getImpactRadius(load)` surfaces the page; provenance heuristic. Verified on sveltejs/realworld: 19 links, **0 cross-dir mislinks**, false edges 0, nodes unchanged; `getImpactRadius(login load)` now returns login/+page.svelte. Committed 3ea03e5 + regression test (incl. a does-not-cross-routes guard). Suite 1183. +- **Outcome:** SvelteKit fully done (component coverage 100% + the loader→page impact link). Next per the user: React Router (the config-based component-node variant). +### Session — React Router (2026-06-06, no commit — characterization) +- **User:** "...then we can move onto react router" (continued from the svelte synthesizer). +- **Claude:** measured gothinkster react-redux-realworld (38 files, react-router v4/v5) → **100%** (29/29), false edges 0. The CONFIG-based component-node sub-shape: route components are NAMED in source (``), so App.js imports+references each → all resolve. Verified Login/Editor/Profile covered BY App.js's route config. Only `index.js` (CRA entry) + 8 anonymous-default reducers (0 named symbols → barrels) excluded. NO engine miss. (Modern v6/v7 `element={}` + data-router `{element, loader}` are also source-referenced → same conclusion.) +- **Outcome:** React Router DONE. Component-node category now fully characterized — two sub-shapes (config-based React Router = 100% raw; file-convention SvelteKit = fair-100% + synthesizer), both handled by the engine. No commit (nothing to fix). Remaining: Nuxt (Vue, file-convention — would mirror SvelteKit), Drupal (PHP), or ship the 36-commit branch. +### Session — Nuxt (2026-06-06, commit 1dec765) +- **User:** "nuxt next". +- **Claude:** measured nuxt/movies (official Nuxt 3 demo, 42 .vue + 37 ts) → 47.6% raw. Audited zeros: pages/app.vue/error.vue/server = file-convention entries (→ faircov Nuxt exclusions); NESTED components (`components/media/*`, `components/carousel/*`) = a REAL miss. Root cause: Nuxt auto-imports a nested component by DIR-PREFIX (`media/Card.vue` → ``), but the node is named `Card`; the synth `vueTemplateEdges` only matched KEBAB tags, and the extractor's PascalCase ref name-matched `MediaCard`→nothing. Fixed: added a PascalCase-tag regex to vueTemplateEdges + a `nuxtComponentName` map (`media/Card.vue`→`MediaCard`, Nuxt dir de-dup). nuxt/movies 47.6%→**93.5%** (+9 nested components). Vue-only (gated to .vue), false edges 0, nodes unchanged, suite 1183, regression test. Committed 1dec765. 2 residual zeros are genuine frontiers (unused ``, unimported constants). composables (`useTmdb()`) already worked. +- **Outcome:** Nuxt DONE. The two file-convention component frameworks DIFFER: SvelteKit's imports resolve as-is (no fix), Nuxt's dir-prefix auto-import needed a fix. Only **Drupal** (PHP) left unmeasured — or ship the 37-commit branch. +### Session — Drupal (2026-06-06, no commit — characterization, finishes the sweep) +- **User:** "drupal to finish the sweep". +- **Claude:** measured the `token` contrib module (58 PHP, modern Drupal 11) → 57.1% raw. Audited: `drupal.ts` ALREADY resolves `*.routing.yml`→controller (TokenTreeController covered via the route ref) and PHP class refs work; the zeros are ALL genuine reflection/convention — JS libraries, DI services (`TreeBuilder` = `@token.tree_builder` in services.yml, `TokenFieldRender`), a dynamically-routed controller (`TokenDevelController` via `RouteSubscriber`), plugins (`Element/`, `Plugin/Derivative/`), Drush commands, `*ServiceProvider`, `.install` hooks, field-plugin `MenuLinkFieldItemList`. NO simple fixable miss (verified TokenDevelController isn't in any routing.yml — genuinely dynamic). Added faircov Drupal convention-entry + JS-library exclusions → **78.9% fair** (4 residual zeros = DI services + dynamic route + field plugin). Token uses modern `#[Hook('...')]` attribute OOP hooks (a drupal.ts TODO). NO commit (the levers — services.yml DI / plugin annotations / `#[Hook]` — are large per-mechanism features, same don't-chase-95% stance as ASP.NET/Django/Spring). +- **Outcome:** **THE SWEEP IS COMPLETE.** Every README framework measured: import/aggregator + config-based-component + file-convention-component reach ceiling (95–100%) via 6 engine fixes; convention/reflection/actor frameworks (ASP.NET/Spring/Drupal/Django/actix) sit at a genuine sub-95% reflection ceiling. Branch 37 commits, ready to ship. Next: open the PR, or the static-member-pass TS/JS/Python lever. +### Session — static-member pass for TS/JS/Python: measured & rejected (2026-06-06, commit e7b86df) +- **User:** "extending the static-member/const value-read pass to TS/JS/Python." +- **Claude:** implemented (added `member_expression`/`attribute` to `MEMBER_ACCESS_TYPES` + ts/js/python to `STATIC_MEMBER_LANGS`), then ran a clean A/B (same faircov, engine ±change) on excalidraw (TS/React, the retrieval-perf canary) + requests (Python). Result: **0 coverage gain** on BOTH (excalidraw 94.3%→94.3%, requests 100%→100%) because the import edge already covers every `Type.MEMBER` read (in import-based languages you must import the type first — the static read is redundant), while adding **+1813 edges / +2448 `references` on excalidraw** (all pointing at already-covered types — pure retrieval-graph noise). REVERTED the functional change; enhanced the `STATIC_MEMBER_LANGS` comment with the measured finding so it isn't re-attempted. Committed e7b86df (doc-only). +- **Outcome:** the standing "one big cross-language lever" is CLOSED — the deferral was correct, now proven with data. The branch is 38 commits; everything actionable is done. **Recommended next: open the PR to main.** +### Session — the 4 niche README languages (2026-06-06, commits 4155609 / 2f57119 / 2f30a3b) +- **User:** confirmed only 4 README "Full support" languages lacked coverage validation (Lua/Luau/Liquid/Pascal); "knock out those 4 to close it out." +- **Claude:** measured + fixed each. **Lua** (telescope) 31.6% + **Luau** (Fusion) 12.5%: NO `require()` module resolver existed — added `resolveLuaRequire` (dotted `a.b.c`→file + instance-path leaf, path-suffix match w/ same-dir preference; confidence 0.9 so it beats the import-node self-match) → Lua **84.2%**, Luau **92.2%** (4155609). **Liquid** (Dawn) 39.1%: snippet `{% render %}` already worked, but Shopify OS 2.0 sections are referenced from JSON templates (`templates/*.json` `"type"`), which weren't even indexed — made Shopify JSON indexable (isSourceFile + detectLanguage) + a Liquid-extractor JSON branch emitting `sections/.liquid` refs (incl. nested `templates/customers/`) → **73.8%** (2f57119). **Pascal** (PascalCoin) 73.0%: `uses`-resolution ALREADY worked; added a `.dfm`/`.fmx`↔`.pas` form-code-behind synthesizer → **75.7%** (2f30a3b). Each: gated to its language, false edges 0, regression test fails without the fix, suite green (1187). +- **Outcome:** **FULL README PARITY** — all 22 "Full support" languages + all 14 frameworks now have coverage validation. Branch 41 commits, ready to ship. 3 new generalizable resolvers (Lua/Luau require, Shopify-JSON sections, Delphi form pairing). Only remaining work is optional reflection/DI levers + the PR. diff --git a/.claude/handoffs/explore-flow-tool-adoption.md b/.claude/handoffs/explore-flow-tool-adoption.md deleted file mode 100644 index b49938113..000000000 --- a/.claude/handoffs/explore-flow-tool-adoption.md +++ /dev/null @@ -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 ""` → look for the `## Flow` section. `probe-trace.mjs ` for trace. -- Synthesizer: `sqlite3 /.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 "" I ` (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). diff --git a/.claude/handoffs/framework-coverage-sweep-2026-05-23.md b/.claude/handoffs/framework-coverage-sweep-2026-05-23.md deleted file mode 100644 index 3ba99a5e5..000000000 --- a/.claude/handoffs/framework-coverage-sweep-2026-05-23.md +++ /dev/null @@ -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 (`` 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 "" 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/` (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 ` (shows Called-by/Calls trail) / `probe-trace.mjs `. -- 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 `` 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. diff --git a/.gitignore b/.gitignore index 7c949e1c2..da6c8ef6e 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,10 @@ nul release/ .antigravitycli/ + +# Local-only: browser-based tmux session launcher (see tmux-web/README.md) +tmux-web/ + +assets/__pycache__/ + +assets/generate-waitlist.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f0fcf6519..fbf4c5d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,134 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Security + +- Closed a path-traversal hole where a symbolic link inside an indexed project that pointed *outside* the project root could make CodeGraph serve that out-of-root file's contents (for example a file under your home directory) to the AI agent. CodeGraph now resolves symlinks when validating file access and refuses to read anything whose real location is outside the project, while still allowing symlinks that stay within it. Thanks @sulthonzh. (#527) +- CodeGraph now indexes Spring configuration files (`application.properties` / `application.yml`) by key only, and never includes their values in `codegraph_explore` or `codegraph_node` output. Previously a secret committed to one of these files — a database password, API key, or connection string with embedded credentials — could be surfaced to an AI agent that asked about nearby code, even though the agent never opened the file. The configuration keys are still indexed, so reference and impact analysis are unaffected; an agent that genuinely needs a value reads the file itself. Shopify Liquid `{% schema %}` blocks are likewise indexed by name only. (#383) + +### New Features + +- **Subagents and non-MCP agents can now reach CodeGraph.** Two new CLI commands — `codegraph explore ""` and `codegraph node ` — print exactly what the matching MCP tools return (relevant symbols' source + call paths; one symbol's source + callers; file reads with line numbers), so any agent with a shell can use the graph. And `codegraph install` now writes a small marker-fenced CodeGraph section into each agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) pointing at both surfaces — that file is what Task-tool subagents actually see, where the MCP server's own guidance only reaches the main agent. Measured on a delegated code-exploration task: subagents went from almost never using CodeGraph (~1 in 9 runs) to using it in every run, including runs with zero grep/file-reading fallback. The section is small, survives your own content, upgrades cleanly from the old long block, and `codegraph uninstall` removes it. Thanks @liuyao37511. (#704) +- **The MCP tool list is now a focused default of four** — `codegraph_explore`, `codegraph_node`, `codegraph_search`, and `codegraph_callers`. The other four (`codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) remain fully functional — the CLI and library API are unchanged, and `CODEGRAPH_MCP_TOOLS` re-enables any of them — but they're no longer listed to agents by default: measured agent behavior shows they're never or rarely picked, and the information they carry already arrives inline on the tools agents do use (explore's blast-radius section, node's dependents note, a symbol's own body as its callee list). A leaner list saves context tokens every session and steers agents to the right tool by presence alone. +- **CodeGraph now goes quiet instead of failing loudly in unindexed projects.** When an AI agent's session starts in a workspace that has no CodeGraph index, the MCP server now announces itself as inactive with a short note and lists no tools at all — instead of presenting the full toolset and erroring on every call, which taught agents to distrust CodeGraph even where it works. Querying another project that isn't indexed likewise returns clear guidance (use your regular tools for that codebase; the user can run `codegraph init` there to enable CodeGraph) instead of an error, and genuine internal errors now tell the agent to retry once rather than give up on CodeGraph entirely. Indexing stays your decision — agents are told not to run it themselves. (#769) +- **Astro projects are now indexed.** `.astro` files previously weren't parsed at all — on a typical Astro site that left most of the codebase invisible to search, impact, and `codegraph_explore`. CodeGraph now extracts the TypeScript frontmatter (functions, imports, `getStaticPaths`, …) and client-side `\n

Login {data.x}

\n`); + fs.writeFileSync(path.join(login, '+page.server.js'), `export function load() { return { x: 1 }; }\n`); + fs.writeFileSync(path.join(register, '+page.svelte'), `\n

Register

\n`); + fs.writeFileSync(path.join(register, '+page.server.js'), `export function load() { return { y: 2 }; }\n`); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + const loginLoad = cg + .getNodesByKind('function') + .find((n) => n.name === 'load' && n.filePath.endsWith('login/+page.server.js')); + expect(loginLoad, 'login load fn').toBeDefined(); + const impacted = [...cg.getImpactRadius(loginLoad!.id, 3).nodes.values()].map((n) => n.filePath ?? ''); + // editing login's load surfaces login's page (the framework-wired data flow)… + expect(impacted.some((p) => p.endsWith('login/+page.svelte')), 'load links to its own page').toBe(true); + // …but never register's page (same-directory only). + expect(impacted.some((p) => p.endsWith('register/+page.svelte')), 'does not cross routes').toBe(false); + }); +}); + +describe('Nuxt nested auto-imported component resolution', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + if (cg) cg.close(); + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('links a `` usage to components/media/Card.vue (Nuxt dir-prefixed auto-import)', async () => { + // Nuxt auto-imports a nested component by a DIRECTORY-PREFIXED name — + // components/media/Card.vue is used as , not — but the + // component node is named by basename (`Card`), so the PascalCase usage + // didn't resolve and the nested component looked unused. + const media = path.join(tempDir, 'components/media'); + fs.mkdirSync(media, { recursive: true }); + fs.writeFileSync(path.join(media, 'Card.vue'), `\n\n`); + fs.writeFileSync( + path.join(tempDir, 'components/Grid.vue'), + `\n\n` + ); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + const card = cg.getNodesByKind('component').find((n) => n.filePath.endsWith('media/Card.vue')); + expect(card, 'media/Card.vue component').toBeDefined(); + const deps = [...cg.getImpactRadius(card!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(deps.some((p) => p.endsWith('components/Grid.vue')), ' links Grid to media/Card.vue').toBe(true); + }); +}); + +describe('Swift property-wrapper attribute type references', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + if (cg) cg.close(); + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('a Fluent `@Siblings(through: Pivot.self)` links the model to the pivot type', async () => { + // A many-to-many pivot/join model is referenced ONLY through the relationship + // property wrapper's metatype argument (`Pivot.self`), never by a controller + // query. The wrapper type was captured but the argument expression wasn't + // walked, so the pivot model looked like nothing depended on it. + fs.writeFileSync(path.join(tempDir, 'Pivot.swift'), + `import Fluent\nfinal class AcronymCategoryPivot: Model {\n static let schema = "acronym-category"\n}\n`); + fs.writeFileSync(path.join(tempDir, 'Acronym.swift'), + `import Fluent\nfinal class Acronym: Model {\n` + + ` @Siblings(through: AcronymCategoryPivot.self, from: \\.$acronym, to: \\.$category)\n` + + ` var categories: [Category]\n}\n`); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + const pivot = cg.getNodesByKind('class').find((n) => n.name === 'AcronymCategoryPivot'); + expect(pivot, 'pivot model class').toBeDefined(); + const deps = [...cg.getImpactRadius(pivot!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(deps.some((p) => p.endsWith('Acronym.swift')), '@Siblings metatype arg links Acronym to the pivot').toBe(true); + }); +}); + +describe('Objective-C messages, class receivers, and #import', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + if (cg) cg.close(); + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('resolves single-arg selectors, class-message receivers, and #import headers', async () => { + fs.writeFileSync( + path.join(tempDir, 'SDImageCache.h'), + `#import +@interface SDImageCache : NSObject ++ (instancetype)sharedCache; ++ (void)storeImage:(NSString *)key; +@end +` + ); + fs.writeFileSync( + path.join(tempDir, 'SDImageCache.m'), + `#import "SDImageCache.h" +@implementation SDImageCache ++ (instancetype)sharedCache { return nil; } ++ (void)storeImage:(NSString *)key { } +@end +` + ); + fs.writeFileSync( + path.join(tempDir, 'SDManager.m'), + `#import "SDImageCache.h" +@interface SDManager : NSObject +@end +@implementation SDManager +- (void)run { + [SDImageCache sharedCache]; + [SDImageCache storeImage:@"k"]; +} +@end +` + ); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + // 1. The single-argument selector `[SDImageCache storeImage:@"k"]` resolves + // to the `storeImage:` method — named WITH its colon both at the call site + // and the definition (before the fix the call site dropped the colon). + const storeImage = cg.getNodesByKind('method').find((n) => n.name === 'storeImage:'); + expect(storeImage, 'storeImage: method').toBeDefined(); + const storeCallers = [...cg.getImpactRadius(storeImage!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(storeCallers.some((p) => p.endsWith('SDManager.m'))).toBe(true); + + // 2. The class-message receiver `[SDImageCache sharedCache]` references the + // SDImageCache class (whose @interface lives in the header). + const cache = cg.getNodesByKind('class').find((n) => n.name === 'SDImageCache'); + expect(cache, 'SDImageCache class').toBeDefined(); + const classDeps = [...cg.getImpactRadius(cache!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(classDeps.some((p) => p.endsWith('SDManager.m'))).toBe(true); + + // 3. `#import "SDImageCache.h"` resolves to the header FILE — editing it + // surfaces both importers. + const header = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('SDImageCache.h')); + expect(header, 'SDImageCache.h indexed').toBeDefined(); + const importers = [...cg.getImpactRadius(header!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(importers.some((p) => p.endsWith('SDManager.m'))).toBe(true); + }); +}); + +describe('Full Indexing', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should index a TypeScript file', async () => { + // Create test file + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync( + path.join(srcDir, 'utils.ts'), + ` +export function add(a: number, b: number): number { + return a + b; +} + +export function multiply(a: number, b: number): number { + return a * b; +} +` + ); + + // Initialize and index + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexAll(); + + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(1); + expect(result.nodesCreated).toBeGreaterThanOrEqual(2); + + // Check nodes were stored + const nodes = cg.getNodesInFile('src/utils.ts'); + expect(nodes.length).toBeGreaterThanOrEqual(2); + + const addFunc = nodes.find((n) => n.name === 'add'); + expect(addFunc).toBeDefined(); + expect(addFunc?.kind).toBe('function'); + + cg.close(); + }); + + it('should index multiple files', async () => { + // Create test files + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + + fs.writeFileSync( + path.join(srcDir, 'math.ts'), + `export function add(a: number, b: number) { return a + b; }` + ); + + fs.writeFileSync( + path.join(srcDir, 'string.ts'), + `export function capitalize(s: string) { return s.toUpperCase(); }` + ); + + // Initialize and index + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexAll(); + + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(2); + + const files = cg.getFiles(); + expect(files.length).toBe(2); + + cg.close(); + }); + + it('should track file hashes for incremental updates', async () => { + // Create initial file + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 1;`); + + // Initialize and index + const cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + + // Check file is tracked + const file = cg.getFile('src/main.ts'); + expect(file).toBeDefined(); + expect(file?.contentHash).toBeDefined(); + + // Modify file + fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 2;`); + + // Check for changes + const changes = cg.getChangedFiles(); + expect(changes.modified).toContain('src/main.ts'); + + cg.close(); + }); + + it('should sync and detect changes', async () => { + // Create initial file + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync( + path.join(srcDir, 'main.ts'), + `export function original() { return 1; }` + ); + + // Initialize and index + const cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + + const initialNodes = cg.getNodesInFile('src/main.ts'); + expect(initialNodes.some((n) => n.name === 'original')).toBe(true); + + // Modify file + fs.writeFileSync( + path.join(srcDir, 'main.ts'), + `export function updated() { return 2; }` + ); + + // Sync + const syncResult = await cg.sync(); + expect(syncResult.filesModified).toBe(1); + + // Check nodes were updated + const updatedNodes = cg.getNodesInFile('src/main.ts'); + expect(updatedNodes.some((n) => n.name === 'updated')).toBe(true); + expect(updatedNodes.some((n) => n.name === 'original')).toBe(false); + + cg.close(); + }); + + it('should count file-level tracked YAML files as indexed', async () => { + fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n'); + fs.writeFileSync(path.join(tempDir, 'routes.yml'), 'route: value\n'); + + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexAll(); + + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(2); + expect(result.filesSkipped).toBe(0); + expect(cg.getFiles().map((f) => f.path).sort()).toEqual(['app.yaml', 'routes.yml']); + + cg.close(); + }); + + it('should count file-level tracked YAML/Twig files as indexed in indexFiles()', async () => { + fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n'); + fs.writeFileSync(path.join(tempDir, 'view.twig'), '{{ title }}\n'); const cg = CodeGraph.initSync(tempDir); const result = await cg.indexFiles(['app.yaml', 'view.twig']); @@ -3515,6 +5352,54 @@ describe('Nested non-submodule git repos', () => { expect(files).toContain('sub_repo/src/real.ts'); expect(files).not.toContain('sub_repo/src/generated.ts'); }); + + // A .gitignore the `ignore` library can't compile to a regex must not abort + // the whole scan — the bad pattern is dropped, valid ones still apply (#682). + it('does not crash on a .gitignore with an uncompilable pattern (#682)', () => { + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'build'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'src', 'real.ts'), 'export const x = 1;'); + fs.writeFileSync(path.join(tempDir, 'build', 'out.ts'), 'export const y = 2;'); + // `\\[` makes the matcher build an unterminated character class — the throw + // is lazy (at match time), which is what escaped and killed sync. + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'build/\n\\\\[\n'); + + let files: string[] = []; + expect(() => { + files = scanDirectory(tempDir); + }).not.toThrow(); + expect(files).toContain('src/real.ts'); + // The still-valid `build/` rule is honored; only the bad line was dropped. + expect(files.some((f) => f.startsWith('build/'))).toBe(false); + }); + + // A .gitignore that isn't valid UTF-8 — e.g. encrypted in place by corporate + // DLP / endpoint software (UTF-16 header + ciphertext) — is skipped whole, + // not fed to the matcher as garbage patterns (#682). + it('does not crash on a non-UTF-8 (DLP-encrypted) .gitignore (#682)', () => { + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'src', 'real.ts'), 'export const x = 1;'); + const header = Buffer.concat([ + Buffer.from([0x00, 0x00]), + Buffer.from('[notice][user]', 'utf16le'), + ]); + const junk = Buffer.from([0x5b, 0x99, 0xc3, 0x28, 0x5c, 0x5b, 0xff, 0xfd]); + fs.writeFileSync(path.join(tempDir, '.gitignore'), Buffer.concat([header, junk])); + + let files: string[] = []; + expect(() => { + files = scanDirectory(tempDir); + }).not.toThrow(); + expect(files).toContain('src/real.ts'); + }); + + it('buildDefaultIgnore survives a bad .gitignore and still applies valid rules (#682)', () => { + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'dist/\n\\\\[\n'); + const ig = buildDefaultIgnore(tempDir); + expect(() => ig.ignores('src/app.ts')).not.toThrow(); + expect(ig.ignores('dist/')).toBe(true); // valid rule survives + expect(ig.ignores('src/app.ts')).toBe(false); + }); }); // ============================================================================= @@ -3918,6 +5803,32 @@ export default { expect(calls).toHaveLength(2); }); + it('should extract component usages from the Vue template (PascalCase + kebab, skipping built-ins) (#629)', () => { + const code = ` + + +`; + const result = extractFromSource('Host.vue', code); + const refs = result.unresolvedReferences + .filter((r) => r.referenceKind === 'references') + .map((r) => r.referenceName); + + expect(refs).toContain('UserCard'); // PascalCase tag + expect(refs).toContain('MyButton'); // kebab → MyButton + expect(refs).not.toContain('Transition'); // Vue built-in skipped + expect(refs).not.toContain('Div'); // native HTML element skipped + expect(refs).not.toContain('Span'); + }); + it('should extract from both + +`; + const result = extractFromSource('Guard.astro', code); + + const templateRefs = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'references' && r.referenceName === 'FakeComponent' + ); + expect(templateRefs).toHaveLength(0); + + // maybeCall/scriptCall come from the delegated TS extraction (once), + // not double-counted by the template scanner + const maybeCalls = result.unresolvedReferences.filter( + (r) => r.referenceName === 'maybeCall' && r.referenceKind === 'calls' + ); + expect(maybeCalls.length).toBeLessThanOrEqual(1); + }); + + it('should extract +`; + const result = extractFromSource('Tracker.astro', code); + + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'trackView'); + expect(fn).toBeDefined(); + expect(fn?.startLine).toBe(6); + expect(fn?.language).toBe('astro'); + }); + + it('should create component node for a frontmatter-less template-only file', () => { + const code = `
Static content
+`; + const result = extractFromSource('Static.astro', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('Static'); + expect(componentNode?.language).toBe('astro'); + }); + + it('should treat an unclosed frontmatter fence as no frontmatter', () => { + const code = `--- +const broken = true; +
never closed
+`; + const result = extractFromSource('Broken.astro', code); + + // No TS delegation happened (the fence never closes), but the component + // node still exists and nothing throws. + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(result.nodes.find((n) => n.name === 'broken')).toBeUndefined(); + }); + + it('should create containment edges from component to frontmatter nodes', () => { + const code = `--- +const value = 42; +--- +
{value}
+`; + const result = extractFromSource('Contained.astro', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + + const containEdges = result.edges.filter( + (e) => e.source === componentNode!.id && e.kind === 'contains' + ); + expect(containEdges.length).toBeGreaterThan(0); + }); +}); + describe('Instantiates + Decorates edge extraction', () => { it('emits an instantiates ref for `new Foo()`', () => { const code = ` @@ -4387,3 +6510,469 @@ void helperFunction(int count) { expect(getSupportedLanguages()).toContain('objc'); }); }); + +describe('Regression: issue-specific extraction fixes', () => { + it('indexes inner functions of an anonymous AMD/CommonJS module wrapper (#528)', () => { + const code = ` +define(['dep'], function (dep) { + function innerHelper(x) { return x + 1; } + function compute(y) { return innerHelper(y); } + return { compute: compute }; +}); +`; + const result = extractFromSource('amd-module.js', code); + const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fns).toContain('innerHelper'); + expect(fns).toContain('compute'); + }); + + it('attaches Go methods on generic receivers to their type (#583)', () => { + const code = ` +package main + +type Stack[T any] struct { items []T } + +func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) } +func (s Stack[T]) Len() int { return len(s.items) } +`; + const result = extractFromSource('stack.go', code); + const methods = result.nodes.filter((n) => n.kind === 'method'); + expect(methods.find((m) => m.name === 'Push')?.qualifiedName).toBe('Stack::Push'); + expect(methods.find((m) => m.name === 'Len')?.qualifiedName).toBe('Stack::Len'); + }); + + it('indexes new module extensions: .mts/.cts (TS) and .xsjs/.xsjslib (JS) (#366, #556)', () => { + expect(isSourceFile('mod.mts')).toBe(true); + expect(isSourceFile('mod.cts')).toBe(true); + expect(isSourceFile('service.xsjs')).toBe(true); + expect(isSourceFile('lib.xsjslib')).toBe(true); + expect(detectLanguage('mod.mts')).toBe('typescript'); + expect(detectLanguage('service.xsjs')).toBe('javascript'); + + // End-to-end: a .mts file is parsed as TS, a .xsjs file as JS. + const ts = extractFromSource('mod.mts', 'export function hello(): number { return 1; }'); + expect(ts.nodes.find((n) => n.name === 'hello' && n.kind === 'function')).toBeDefined(); + const js = extractFromSource('service.xsjs', 'function handleRequest() { return 1; }'); + expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined(); + }); +}); + +describe('Import / re-export dependency linking (blast-radius recall)', () => { + // An import IS a dependency, but extraction only emits references for calls, + // instantiations, type annotations, and inheritance — so a symbol imported and + // then merely re-exported, placed in a registry array, passed as an argument, + // or used in JSX produced no cross-file edge, leaving the providing file with a + // false "0 dependents". These tests pin the import/re-export binding linking. + it('emits an imports reference per named, aliased, and default import binding', () => { + const code = ` +import { widget, helper as h } from './foo'; +import Thing from './thing'; +import * as NS from './ns'; +export const registry = [widget]; +`; + const result = extractFromSource('bar.ts', code); + const names = result.unresolvedReferences + .filter((r) => r.referenceKind === 'imports') + .map((r) => r.referenceName); + expect(names).toContain('widget'); // named import → local name + expect(names).toContain('h'); // aliased import → local alias + expect(names).toContain('Thing'); // default import + expect(names).toContain('NS'); // namespace import → linked to the module file as a dependency + }); + + it('emits an imports reference per re-exported binding', () => { + const result = extractFromSource('barrel.ts', `export { alpha, beta as b } from './source';`); + const names = result.unresolvedReferences + .filter((r) => r.referenceKind === 'imports') + .map((r) => r.referenceName); + // Re-export links the SOURCE-side name, not the local alias. + expect(names).toContain('alpha'); + expect(names).toContain('beta'); + }); + + it('a value imported/re-exported but never called still makes the importer a dependent', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'foo.ts'), + `export const widget = { n: 1 };\nexport function helper(): void {}\n` + ); + // bar uses widget ONLY in an array and re-exports helper — neither is + // called/typed, so before import-linking bar had no edge to foo at all. + fs.writeFileSync( + path.join(dir, 'src', 'bar.ts'), + `import { widget } from './foo';\nexport { helper } from './foo';\nexport const registry = [widget];\n` + ); + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('a namespace import touched only via a value-member read still links the module file', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src', 'foo.ts'), `export const SOME_CONST = 42;\n`); + // `foo` is imported as a namespace and used ONLY via a value-member read + // (no call, no type) — `foo.helper()` would link on its own, but a bare + // `foo.SOME_CONST` would not, so the module-import backstop must link it. + fs.writeFileSync(path.join(dir, 'src', 'bar.ts'), `import * as foo from './foo';\nexport const x = foo.SOME_CONST;\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); +}); + +describe('Python import dependency linking (blast-radius recall)', () => { + // Same recall gap as TS: Python only linked called/instantiated imports, so a + // name brought in with `from module import X` and then merely stored, used as + // a decorator/argument, or re-exported through an `__init__.py` produced no + // cross-file edge — the providing module showed a false "0 dependents". + it('emits an imports reference per name in a `from module import ...` (incl. value/aliased)', () => { + const code = [ + 'from foo import helper, widget', + 'from foo import Thing as T', + 'from . import sibling', + 'from bar import *', + ].join('\n'); + const names = extractFromSource('mod.py', code) + .unresolvedReferences.filter((r) => r.referenceKind === 'imports') + .map((r) => r.referenceName); + expect(names).toContain('helper'); + expect(names).toContain('widget'); // value import + expect(names).toContain('T'); // aliased import → local name + expect(names).toContain('sibling'); // `from . import ` + expect(names).not.toContain('*'); // wildcard import has no names + }); + + it('a Python value imported but never called still makes the importer a dependent', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'pkg', 'foo.py'), `widget = {"n": 1}\ndef helper():\n return 1\n`); + // bar imports widget+helper but only stores widget in a list — nothing is + // called, so before import-linking bar had no edge to foo. + fs.writeFileSync(path.join(dir, 'pkg', 'bar.py'), `from foo import widget, helper\nregistry = [widget]\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('pkg/foo.py')).toContain('pkg/bar.py'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('resolves `from . import submodule` + `submodule.func()` to the submodule', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), ''); + fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `def where():\n return "/ca.pem"\n`); + // certs is an imported MODULE (a file), and certs.where() is a qualified + // call through it — the receiver isn't a symbol, so plain name-matching + // can't link it. Also exercises the Python relative-dot path fix (`.certs`). + fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\ndef go():\n return certs.where()\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('a module import is a dependency even when the used member is re-exported elsewhere', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), ''); + // `where` is NOT defined in certs.py (re-exported from a 3rd-party pkg), so + // member resolution can't find it — the module-import backstop must still + // record utils -> certs. (Mirrors requests' real `certs.where`.) + fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `from external_ca import where\n`); + fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\nCA = certs.where()\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); +}); + +describe('Go cross-package composite literals (blast-radius recall)', () => { + // Go function calls and type references across packages already resolved, but + // struct composite literals — `render.XML{...}` / `pkga.Widget{...}` — were not + // extracted at all, so a package whose types are only INSTANTIATED elsewhere + // (gin's render/binding implementations) showed 0 dependents. + it('links a cross-package struct composite literal to the defining package', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + fs.mkdirSync(path.join(dir, 'render'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct { Data any }\n`); + fs.writeFileSync(path.join(dir, 'app.go'), `package main\n\nimport "example.com/proj/render"\n\nfunc handle() any { return render.XML{} }\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('render/xml.go')).toContain('app.go'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('links a composite literal in a package-level var registry', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + fs.mkdirSync(path.join(dir, 'render'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct {}\nfunc (XML) Render() {}\n`); + // The implementation is registered only in a top-level `var registry = {...}` + // map literal — the body walker doesn't cover top-level declarations, so this + // exercises the var-initializer walking added for Go. + fs.writeFileSync(path.join(dir, 'reg.go'), `package main\n\nimport "example.com/proj/render"\n\ntype R interface { Render() }\n\nvar registry = map[string]R{ "xml": render.XML{} }\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('render/xml.go')).toContain('reg.go'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('attributes a call inside a top-level closure (cobra RunE) to the var, not the file (#693)', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + // Wire is called ONLY from the anonymous RunE closure inside a top-level + // `var rootCmd = &Cmd{...}` — previously the call leaked to the file node, + // so `callers(Wire)` surfaced a file (or read as "no caller"). It must now + // attribute to the enclosing var. + fs.writeFileSync(path.join(dir, 'factory.go'), `package main\n\nfunc Wire() error { return nil }\n`); + fs.writeFileSync( + path.join(dir, 'root.go'), + `package main\n\ntype Cmd struct{ RunE func() error }\n\nvar rootCmd = &Cmd{\n\tRunE: func() error { return Wire() },\n}\n` + ); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + + const wire = cg.getNodesByName('Wire').find((n) => n.kind === 'function'); + expect(wire).toBeDefined(); + const callers = cg.getCallers(wire!.id).map((c) => c.node); + expect(callers.some((n) => n.kind === 'variable' && n.name === 'rootCmd')).toBe(true); + expect(callers.some((n) => n.kind === 'file')).toBe(false); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('links a parenthesized pointer type conversion `(*T)(x)` to the type', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + fs.writeFileSync(path.join(dir, 'types.go'), `package main\n\ntype Wrapped struct { N int }\n`); + // `(*Wrapped)(x)` parses as a call whose callee is the parenthesized type + // `(*Wrapped)` — without normalization it dropped on the floor. + fs.writeFileSync(path.join(dir, 'use.go'), `package main\n\nfunc run(x *int) { _ = (*Wrapped)(x) }\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('types.go')).toContain('use.go'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('links an implementation reached only through a Go interface (implicit satisfaction, #584)', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + fs.mkdirSync(path.join(dir, 'codec'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'codec', 'api.go'), `package codec\n\ntype Core interface {\n\tMarshal(v any) ([]byte, error)\n}\n\nvar API Core\n`); + // jsonApi satisfies Core structurally (no `implements` keyword) and is + // reached ONLY through the interface (API.Marshal). Without implicit + // interface satisfaction + dispatch, json.go shows 0 dependents. + fs.writeFileSync(path.join(dir, 'codec', 'json.go'), `package codec\n\ntype jsonApi struct{}\n\nfunc (j jsonApi) Marshal(v any) ([]byte, error) { return nil, nil }\n\nfunc init() { API = jsonApi{} }\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('codec/json.go')).toContain('codec/api.go'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); +}); + +describe('C# records (blast-radius recall)', () => { + // Records are ubiquitous in modern C# (DTOs, value objects, CQRS messages), + // but `record` / `record struct` declarations weren't extracted as types — so + // every reference, generic-type-argument, and `new` of a record dropped on the + // floor and the defining file showed 0 dependents. (#237) + it('extracts a record as a graph node (record class + record struct)', () => { + const r = extractFromSource('r.cs', `namespace P;\npublic record Box(int N);\npublic record struct Pt(int X);\n`); + expect(r.nodes.find((n) => n.name === 'Box' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined(); + expect(r.nodes.find((n) => n.name === 'Pt' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined(); + }); + + it('resolves references / instantiations of a record across files', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'types.cs'), `namespace P;\npublic record Box(int N);\n`); + // Box is used as a generic type argument and instantiated — both require + // Box to be a node to resolve. + fs.writeFileSync( + path.join(dir, 'use.cs'), + `using System.Collections.Generic;\nnamespace P;\npublic class User {\n public IEnumerable Boxes { get; }\n public Box Make() => new Box(1);\n}\n` + ); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.cs'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('types.cs')).toContain('use.cs'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); +}); + +describe('Rust cross-module recall', () => { + function rustProject(files: Record): string { + const dir = createTempDir(); + fs.writeFileSync(path.join(dir, 'Cargo.toml'), '[package]\nname = "proj"\nversion = "0.1.0"\nedition = "2021"\n'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(dir, 'src', rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + return dir; + } + + it('extracts a struct literal `Foo { .. }` as an instantiation across modules', async () => { + const dir = rustProject({ + 'lib.rs': 'pub mod types;\npub mod consumer;\n', + 'types.rs': 'pub struct Widget { pub n: i32 }\n', + 'consumer.rs': 'use crate::types::Widget;\npub fn build() -> Widget { Widget { n: 1 } }\n', + }); + try { + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('src/types.rs')).toContain('src/consumer.rs'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); + + it('extracts trait method declarations and bridges trait dispatch to the impl', async () => { + const dir = rustProject({ + 'lib.rs': 'pub mod types;\npub mod consumer;\n', + 'types.rs': 'pub trait Render { fn render(&self) -> i32; }\n', + // Mine implements Render structurally; reached via &dyn Render dispatch. + 'consumer.rs': 'use crate::types::Render;\npub struct Mine { pub x: i32 }\nimpl Render for Mine { fn render(&self) -> i32 { self.x } }\n', + }); + try { + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + // implements edge (Mine -> Render) makes types.rs a dependent of consumer.rs's struct. + expect(cg.getFileDependents('src/types.rs')).toContain('src/consumer.rs'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); + + it('links `pub use` re-export hubs to the modules they re-export', async () => { + const dir = rustProject({ + 'lib.rs': 'pub mod api;\n', + 'api/mod.rs': 'mod widget;\npub use self::widget::Widget;\n', + 'api/widget.rs': 'pub struct Widget { pub n: i32 }\n', + }); + try { + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + // The re-export hub depends on the module it re-exports from. + expect(cg.getFileDependents('src/api/widget.rs')).toContain('src/api/mod.rs'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); + + it('resolves a qualified path to the correct module when the leaf name collides', async () => { + const dir = rustProject({ + 'lib.rs': 'pub mod fast;\npub mod slow;\npub mod hub;\n', + 'fast.rs': 'pub fn read() -> i32 { 1 }\n', + 'slow.rs': 'pub fn read() -> i32 { 2 }\n', + // `read` exists in BOTH fast.rs and slow.rs — module-path resolution must + // send this re-export to fast.rs specifically, not name-match either. + 'hub.rs': 'pub use crate::fast::read;\n', + }); + try { + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('src/fast.rs')).toContain('src/hub.rs'); + expect(cg.getFileDependents('src/slow.rs')).not.toContain('src/hub.rs'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); + +describe('Java annotations (blast-radius recall)', () => { + it('indexes @interface definitions and links @Annotation usages to them', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'p'), { recursive: true }); + // The annotation DEFINITION must be a node, and the @MyAnno usages (which + // live inside a `modifiers` node on the class/field/method) must extract. + fs.writeFileSync(path.join(dir, 'p', 'MyAnno.java'), `package p;\npublic @interface MyAnno { String value() default ""; }\n`); + fs.writeFileSync( + path.join(dir, 'p', 'User.java'), + `package p;\n@MyAnno("c")\npublic class User {\n @MyAnno("f") int field;\n @MyAnno("m") void go() {}\n}\n` + ); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.java'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('p/MyAnno.java')).toContain('p/User.java'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); + +describe('Swift property wrappers / attributes (blast-radius recall)', () => { + it('links a @propertyWrapper usage to the wrapper type', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'Sources', 'M'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'Sources', 'M', 'Wrap.swift'), `@propertyWrapper\npublic struct Argument { public var wrappedValue: T }\n`); + // `@Argument` is a Swift attribute on a stored property — it lives in the + // property's `modifiers` and Swift doesn't extract instance properties as + // their own nodes, so without the fix the wrapper type has no users. + fs.writeFileSync(path.join(dir, 'Sources', 'M', 'Cmd.swift'), `public struct MyCommand {\n @Argument var name: String\n @Argument var count: Int\n}\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['Sources/**/*.swift'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('Sources/M/Wrap.swift')).toContain('Sources/M/Cmd.swift'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 78ebfce4f..05fa79804 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; import { Node, Edge } from '../src/types'; -import { isInitialized, getCodeGraphDir, validateDirectory } from '../src/directory'; +import { isInitialized, getCodeGraphDir, validateDirectory, codeGraphDirName, isCodeGraphDataDir } from '../src/directory'; import { DatabaseConnection, getDatabasePath } from '../src/db'; // Create a temporary directory for each test @@ -54,7 +54,10 @@ describe('CodeGraph Foundation', () => { expect(fs.existsSync(gitignorePath)).toBe(true); const content = fs.readFileSync(gitignorePath, 'utf-8'); - expect(content).toContain('*.db'); + // Ignore everything in .codegraph/ except this file itself, so transient + // files (db, daemon.pid, sockets, logs) never show up in git. (#492, #484) + expect(content).toContain('*'); + expect(content).toContain('!.gitignore'); cg.close(); }); @@ -156,6 +159,46 @@ describe('CodeGraph Foundation', () => { expect(validation.valid).toBe(false); expect(validation.errors.length).toBeGreaterThan(0); }); + + it('upgrades a stale pre-wildcard .gitignore in place (issue #788)', () => { + const cg = CodeGraph.initSync(tempDir); + cg.close(); + + const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore'); + // A .gitignore written by an older version (<= 0.9.9): an explicit + // allowlist that never ignored daemon.pid, so the daemon's runtime + // pidfile got committed. + const staleV099 = + '# CodeGraph data files\n' + + '# These are local to each machine and should not be committed\n\n' + + '# Database\n*.db\n*.db-wal\n*.db-shm\n\n' + + '# Cache\ncache/\n\n# Logs\n*.log\n\n# Hook markers\n.dirty\n'; + fs.writeFileSync(gitignorePath, staleV099, 'utf-8'); + + // Opening the project runs validateDirectory, which self-heals. + const cg2 = CodeGraph.openSync(tempDir); + cg2.close(); + + const upgraded = fs.readFileSync(gitignorePath, 'utf-8'); + expect(upgraded).toContain('\n*\n'); // wildcard ignores everything… + expect(upgraded).toContain('!.gitignore'); // …except this file + expect(upgraded).not.toContain('.dirty'); // old explicit list is gone + }); + + it('leaves a user-customized .codegraph/.gitignore untouched', () => { + const cg = CodeGraph.initSync(tempDir); + cg.close(); + + const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore'); + // No CodeGraph header → user-authored → must not be rewritten. + const custom = '# my own rules\n*.db\n!keep-this.json\n'; + fs.writeFileSync(gitignorePath, custom, 'utf-8'); + + const cg2 = CodeGraph.openSync(tempDir); + cg2.close(); + + expect(fs.readFileSync(gitignorePath, 'utf-8')).toBe(custom); + }); }); describe('Uninitialize', () => { @@ -239,7 +282,7 @@ describe('Database Connection', () => { const version = db.getSchemaVersion(); expect(version).not.toBeNull(); - expect(version?.version).toBe(4); + expect(version?.version).toBe(5); db.close(); }); @@ -303,3 +346,93 @@ describe('Query Builder', () => { expect(files).toEqual([]); }); }); + +// Two environments that share one working tree (Windows-native + WSL) must not +// share one `.codegraph/`. CODEGRAPH_DIR overrides the data directory name so +// each side keeps its own index in the same tree (issue #636). +describe('CODEGRAPH_DIR override (#636)', () => { + const saved = process.env.CODEGRAPH_DIR; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-dirname-')); + }); + afterEach(() => { + if (saved === undefined) delete process.env.CODEGRAPH_DIR; + else process.env.CODEGRAPH_DIR = saved; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('codeGraphDirName()', () => { + it('defaults to .codegraph when unset', () => { + delete process.env.CODEGRAPH_DIR; + expect(codeGraphDirName()).toBe('.codegraph'); + }); + + it('honors a valid override', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + expect(codeGraphDirName()).toBe('.codegraph-win'); + }); + + // Anything that isn't a plain segment could escape the project root or + // clobber it, so it's ignored in favor of the default. + it.each(['foo/bar', 'a\\b', '..', '../x', '.', '/abs/path', ' ', ''])( + 'falls back to .codegraph for invalid value %j', + (bad) => { + process.env.CODEGRAPH_DIR = bad; + expect(codeGraphDirName()).toBe('.codegraph'); + } + ); + }); + + describe('isCodeGraphDataDir()', () => { + it('matches the default, the active override, and .codegraph-* siblings', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + expect(isCodeGraphDataDir('.codegraph')).toBe(true); // the other env's dir + expect(isCodeGraphDataDir('.codegraph-win')).toBe(true); // active override + expect(isCodeGraphDataDir('.codegraph-wsl')).toBe(true); // any sibling + }); + + it('does not match unrelated directories', () => { + delete process.env.CODEGRAPH_DIR; + for (const name of ['src', 'node_modules', '.git', 'codegraph', '.codegraphextra']) { + expect(isCodeGraphDataDir(name)).toBe(false); + } + }); + }); + + it('init writes the index under the overridden directory, not .codegraph', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + const cg = CodeGraph.initSync(tempDir); + try { + expect(fs.existsSync(path.join(tempDir, '.codegraph-win', 'codegraph.db'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, '.codegraph'))).toBe(false); + expect(getCodeGraphDir(tempDir)).toBe(path.join(tempDir, '.codegraph-win')); + expect(CodeGraph.isInitialized(tempDir)).toBe(true); + } finally { + cg.close(); + } + }); + + it('two index dirs coexist in one tree and the override side skips the sibling', async () => { + // WSL side: default `.codegraph`, with a source file. + delete process.env.CODEGRAPH_DIR; + fs.writeFileSync(path.join(tempDir, 'app.ts'), 'export function onlyReal() {}\n'); + const wsl = await CodeGraph.init(tempDir, { index: true }); + wsl.close(); + + // Windows side: override dir, same tree. Plant a decoy source file INSIDE + // the WSL data dir — the override-side index must not pick it up. + process.env.CODEGRAPH_DIR = '.codegraph-win'; + fs.writeFileSync(path.join(tempDir, '.codegraph', 'decoy.ts'), 'export function decoyLeak() {}\n'); + const win = await CodeGraph.init(tempDir, { index: true }); + try { + expect(fs.existsSync(path.join(tempDir, '.codegraph', 'codegraph.db'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, '.codegraph-win', 'codegraph.db'))).toBe(true); + expect(win.searchNodes('onlyReal').length).toBeGreaterThan(0); + expect(win.searchNodes('decoyLeak')).toEqual([]); // sibling data dir not indexed + } finally { + win.close(); + } + }); +}); diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index c0e874908..ff1abb57b 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -1373,6 +1373,7 @@ func boot(routes: RoutesBuilder) throws { import { reactResolver } from '../src/resolution/frameworks/react'; import { svelteResolver } from '../src/resolution/frameworks/svelte'; +import { astroResolver } from '../src/resolution/frameworks/astro'; describe('reactResolver.extract — React Router', () => { it('extracts a v6 }>', () => { @@ -1428,6 +1429,77 @@ describe('svelteResolver.extract (smoke)', () => { }); }); +describe('astroResolver.extract — src/pages file-based routing', () => { + const routeNames = (filePath: string): string[] => + astroResolver.extract!(filePath, '').nodes.filter((n) => n.kind === 'route').map((n) => n.name); + + it('maps index.astro to /', () => { + expect(routeNames('src/pages/index.astro')).toEqual(['/']); + }); + + it('maps nested index and plain pages', () => { + expect(routeNames('src/pages/blog/index.astro')).toEqual(['/blog']); + expect(routeNames('src/pages/about.astro')).toEqual(['/about']); + }); + + it('converts [param] and [...rest] syntax', () => { + expect(routeNames('src/pages/blog/[slug].astro')).toEqual(['/blog/:slug']); + expect(routeNames('src/pages/[...path].astro')).toEqual(['/*path']); + }); + + it('maps .ts endpoints under src/pages to routes', () => { + expect(routeNames('src/pages/api/posts.ts')).toEqual(['/api/posts']); + expect(routeNames('src/pages/rss.xml.js')).toEqual(['/rss.xml']); + }); + + it('excludes underscore-prefixed segments and config files', () => { + expect(routeNames('src/pages/_partial.astro')).toEqual([]); + expect(routeNames('src/pages/blog/_components/Card.astro')).toEqual([]); + expect(routeNames('src/pages/vite.config.ts')).toEqual([]); + }); + + it('ignores .astro files outside src/pages', () => { + expect(routeNames('src/components/Button.astro')).toEqual([]); + expect(routeNames('docs/pages/guide.astro')).toEqual([]); + }); +}); + +describe('astroResolver.resolve — Astro global and virtual modules', () => { + const ctx = {} as never; + const baseRef = { + fromNodeId: 'component:a', + line: 1, + column: 0, + filePath: 'src/pages/index.astro', + language: 'astro', + }; + + it('claims Astro.* global references as framework-provided', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'Astro.props', referenceKind: 'references' } as never, + ctx + ); + expect(res?.resolvedBy).toBe('framework'); + expect(res?.confidence).toBe(1.0); + }); + + it('claims astro:content virtual module imports', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'astro:content', referenceKind: 'imports' } as never, + ctx + ); + expect(res?.resolvedBy).toBe('framework'); + }); + + it('leaves ordinary names alone', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'astrolabe', referenceKind: 'calls' } as never, + { getNodesByName: () => [] } as never + ); + expect(res).toBeNull(); + }); +}); + // Regression tests: commented-out and docstring route examples must NOT // surface as phantom route nodes. These would have failed before the // strip-comments wiring (the regex would happily scan comments/docstrings). diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts new file mode 100644 index 000000000..993b68614 --- /dev/null +++ b/__tests__/function-ref.test.ts @@ -0,0 +1,790 @@ +/** + * Function-as-value capture tests (#756) — registration-linking for callbacks. + * + * A function name used as a VALUE (passed as an argument, assigned to a + * field/function pointer, placed in a struct/object initializer or function + * table) must produce a `references` edge from the registration site to the + * function, so `callers`/`impact` surface where a callback is wired up. + * + * Safety properties verified here, per the dynamic-dispatch discipline + * ("a wrong edge is worse than none"): + * - decoy: an ambiguous cross-file name (no import, ≥2 definitions) → NO edge + * - same-file priority: a same-file definition beats a same-named decoy + * - kind filter: a class/variable passed as a value never gets a + * function-ref edge + * - self: a function passing itself → no self-loop + * - drain: all resolvable function_ref rows leave unresolved_refs (no + * batched-resolver runaway), and re-index is idempotent + */ + +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import type { Edge } from '../src/types'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +/** Incoming edges to `name`'s node that came from function-as-value capture. */ +function fnRefEdgesInto(cg: CodeGraph, name: string): Edge[] { + const targets = cg.getNodesByName(name); + const edges: Edge[] = []; + for (const t of targets) { + for (const e of cg.getIncomingEdges(t.id)) { + if (e.kind === 'references' && e.metadata?.fnRef === true) { + edges.push(e); + } + } + } + return edges; +} + +/** Names of the source nodes of the given edges, sorted. */ +function sourceNames(cg: CodeGraph, edges: Edge[]): string[] { + const names: string[] = []; + for (const e of edges) { + const n = cg.getNode(e.source); + if (n) names.push(n.name); + } + return names.sort(); +} + +describe('Function-as-value capture (#756)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('C: registration sites produce references edges (the #756 scenario)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-c-')); + fs.writeFileSync( + path.join(tmpDir, 'driver.c'), + [ + 'struct ops { void (*recv_cb)(int); void (*send_cb)(int); };', + 'typedef void (*cb_t)(int);', + '', + 'static void my_recv_cb(int x) { (void)x; }', + 'static void my_send_cb(int x) { (void)x; }', + '', + 'void register_handler(void (*cb)(int)) { cb(1); }', + '', + 'void direct_caller(void) { my_recv_cb(5); }', + '', + 'void arg_registrar(void) { register_handler(my_recv_cb); }', + 'void addr_registrar(void) { register_handler(&my_recv_cb); }', + 'void assign_registrar(struct ops *o) { o->recv_cb = my_recv_cb; }', + '', + 'static struct ops global_ops = { .recv_cb = my_recv_cb, .send_cb = my_send_cb };', + 'static cb_t cb_table[] = { my_recv_cb, my_send_cb };', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const intoRecv = fnRefEdgesInto(cg, 'my_recv_cb'); + expect(sourceNames(cg, intoRecv)).toEqual([ + 'addr_registrar', + 'arg_registrar', + 'assign_registrar', + 'driver.c', // file-scope: designated init + positional table (deduped per source) + ]); + + // The direct call is still a `calls` edge — unchanged by this feature. + const recv = cg.getNodesByName('my_recv_cb')[0]!; + const callEdges = cg + .getIncomingEdges(recv.id) + .filter((e) => e.kind === 'calls'); + expect(sourceNames(cg, callEdges)).toEqual(['direct_caller']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('TypeScript: arg / object / array / member / assignment forms', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ts-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'export function targetCb(x: number): void { console.log(x); }', + 'function registerHandler(cb: (x: number) => void): void { cb(1); }', + '', + 'export function argRegistrar(): void { registerHandler(targetCb); }', + 'export function timerRegistrar(): void { setTimeout(targetCb, 100); }', + 'export function objRegistrar(): unknown { return { recv: targetCb }; }', + 'export function arrRegistrar(): unknown { return [targetCb]; }', + '', + 'class Emitter { cb: ((x: number) => void) | null = null; }', + 'export function assignRegistrar(e: Emitter): void { e.cb = targetCb; }', + '', + 'interface Btn { on(ev: string, cb: () => void): void; }', + 'export class Comp {', + ' handleClick(): void {}', + ' wire(btn: Btn): void { btn.on("click", this.handleClick); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'targetCb'))).toEqual([ + 'argRegistrar', + 'arrRegistrar', + 'assignRegistrar', + 'objRegistrar', + 'timerRegistrar', + ]); + // `this.handleClick` resolves class-scoped (#808): the target must be a + // method of the ENCLOSING class, in the same file. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'handleClick'))).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('resolves an imported callback across files via its import', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-import-')); + fs.writeFileSync( + path.join(tmpDir, 'handlers.ts'), + 'export function onMessage(x: number): void { console.log(x); }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'wiring.ts'), + [ + "import { onMessage } from './handlers';", + 'export function wire(bus: { on(cb: (x: number) => void): void }): void {', + ' bus.on(onMessage);', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'onMessage'); + expect(sourceNames(cg, edges)).toContain('wire'); + // The edge must target the handlers.ts definition. + const target = cg.getNode(edges[0]!.target); + expect(target?.filePath.endsWith('handlers.ts')).toBe(true); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('DECOY: ambiguous cross-file name without an import resolves to NO edge', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-decoy-')); + // Two same-named functions in different files… + fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export function process(x: number): void {}\n'); + fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'export function process(x: number): void {}\n'); + // …and a registrar that names `process` WITHOUT importing it. The name + // still passes the extraction gate only if imported/defined here — it is + // neither, so this asserts the gate; even if it leaked through, the + // ambiguity rule (unique-only cross-file) must yield no edge. + fs.writeFileSync( + path.join(tmpDir, 'c.ts'), + 'export function wire(bus: { on(cb: unknown): void }, process: unknown): void { bus.on(process); }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'process'); + expect(sourceNames(cg, edges)).not.toContain('wire'); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SAME-FILE PRIORITY: a same-file definition beats a same-named decoy elsewhere', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-samefile-')); + fs.writeFileSync(path.join(tmpDir, 'decoy.c'), 'void my_cb(int x) { (void)x; }\n'); + fs.writeFileSync( + path.join(tmpDir, 'real.c'), + [ + 'static void my_cb(int x) { (void)x; }', + 'void register_handler(void (*cb)(int)) { cb(1); }', + 'void wire(void) { register_handler(my_cb); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const wires = fnRefEdgesInto(cg, 'my_cb').filter((e) => { + const src = cg.getNode(e.source); + return src?.name === 'wire'; + }); + expect(wires).toHaveLength(1); + const target = cg.getNode(wires[0]!.target); + expect(target?.filePath.endsWith('real.c')).toBe(true); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('KIND FILTER: a class passed as a value gets no function-ref edge', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-kind-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'export class Strategy { run(): void {} }', + 'export function consume(x: unknown): void { void x; }', + 'export function wire(): void { consume(Strategy); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const strategy = cg.getNodesByName('Strategy').find((n) => n.kind === 'class')!; + const fnRef = cg + .getIncomingEdges(strategy.id) + .filter((e) => e.metadata?.fnRef === true); + expect(fnRef).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SELF: a function registering itself produces no self-loop', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-self-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'declare function schedule(cb: () => void): void;', + 'export function retry(): void { schedule(retry); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const retry = cg.getNodesByName('retry')[0]!; + const selfLoops = cg + .getIncomingEdges(retry.id) + .filter((e) => e.source === retry.id && e.metadata?.fnRef === true); + expect(selfLoops).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('C++: &Cls::method member pointers resolve scoped; bare ids are free-function-only', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-cpp-')); + fs.writeFileSync( + path.join(tmpDir, 'widget.cpp'), + [ + 'struct Widget {', + ' void on_click(int x);', + '};', + 'void Widget::on_click(int x) { (void)x; }', + 'struct Decoy {', + ' void on_click(int x);', + '};', + 'void Decoy::on_click(int x) { (void)x; }', + 'void free_cb(int x) { (void)x; }', + 'void bare_fn(int x) { (void)x; }', + 'void reg(void* p) { (void)p; }', + 'void wire() {', + ' auto p = &Widget::on_click;', // qualified — must hit Widget, not Decoy + ' reg(p);', + ' reg(&free_cb);', // explicit address-of — captured + ' reg(bare_fn);', // bare id in args — NOT captured for C++ (addressOfOnly) + '}', + // A method named like a local: passing the LOCAL must not resolve to + // the method (cpp args accept only explicit & forms). + 'struct Buf { char* out(); };', + 'void copy_to(void* out_) { (void)out_; }', + 'void caller(char* out) { copy_to(out); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + // Qualified member pointer resolves to Widget::on_click specifically. + const onClicks = cg.getNodesByName('on_click'); + const widgetOnClick = onClicks.find((n) => n.qualifiedName.includes('Widget'))!; + const decoyOnClick = onClicks.find((n) => n.qualifiedName.includes('Decoy'))!; + const intoWidget = cg + .getIncomingEdges(widgetOnClick.id) + .filter((e) => e.metadata?.fnRef === true); + expect(intoWidget).toHaveLength(1); + expect(cg.getNode(intoWidget[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(decoyOnClick.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + + // Explicit &fn resolves; bare identifier in C++ args does NOT (the + // generic-name collision class: fmt's `begin`/`out`/`size` params). + expect(sourceNames(cg, fnRefEdgesInto(cg, 'free_cb'))).toContain('wire'); + expect(fnRefEdgesInto(cg, 'bare_fn')).toHaveLength(0); + + // The local `out` param must NOT produce an edge to Buf::out. + const outMethod = cg.getNodesByName('out').find((n) => n.kind === 'method'); + if (outMethod) { + expect( + cg.getIncomingEdges(outMethod.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('Pascal: := event wiring, @addr and bare args', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pas-')); + fs.writeFileSync( + path.join(tmpDir, 'main.pas'), + [ + 'unit Main;', + 'interface', + 'type', + ' TCallback = procedure(X: Integer);', + ' THolder = class', + ' public', + ' OnFire: TCallback;', + ' procedure Wire;', + ' end;', + 'procedure TargetCb(X: Integer);', + 'procedure RegisterHandler(Cb: TCallback);', + 'procedure ArgRegistrar;', + 'procedure AddrRegistrar;', + 'implementation', + 'procedure TargetCb(X: Integer);', + 'begin', + ' WriteLn(X);', + 'end;', + 'procedure RegisterHandler(Cb: TCallback);', + 'begin', + ' Cb(1);', + 'end;', + 'procedure ArgRegistrar;', + 'begin', + ' RegisterHandler(TargetCb);', + 'end;', + 'procedure AddrRegistrar;', + 'begin', + ' RegisterHandler(@TargetCb);', + 'end;', + 'procedure THolder.Wire;', + 'begin', + ' OnFire := TargetCb;', + 'end;', + 'end.', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'TargetCb'))).toEqual([ + 'AddrRegistrar', + 'ArgRegistrar', + 'Wire', + ]); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('THIS-MEMBER SCOPING: this.X resolves only to the enclosing class, never elsewhere', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-thisx-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'declare const bus: { on(ev: string, cb: () => void): void };', + // Decoy: a same-named method on an UNRELATED class. + 'export class Decoy { refresh(): void {} }', + 'export class Panel {', + ' views: number[] = [];', // property (post-#808), shares no name + ' refresh(): void {}', + ' wire(): void {', + ' bus.on("update", this.refresh);', // → Panel::refresh, not Decoy::refresh + ' bus.on("data", this.views as never);', // property → NO edge + ' bus.on("gone", this.missing as never);', // unknown member → NO edge + ' }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const refreshes = cg.getNodesByName('refresh'); + const panelRefresh = refreshes.find((n) => n.qualifiedName.includes('Panel'))!; + const decoyRefresh = refreshes.find((n) => n.qualifiedName.includes('Decoy'))!; + + const intoPanel = cg + .getIncomingEdges(panelRefresh.id) + .filter((e) => e.metadata?.fnRef === true); + expect(intoPanel).toHaveLength(1); + expect(cg.getNode(intoPanel[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(decoyRefresh.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + + // The property and the unknown member produce nothing. + const views = cg.getNodesByName('views').find((n) => n.kind === 'property'); + if (views) { + expect( + cg.getIncomingEdges(views.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('INHERITED this.X: resolves on a supertype via the second pass, never on unrelated classes', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-inherit-')); + fs.writeFileSync( + path.join(tmpDir, 'base.ts'), + 'export class FormBase { handleSubmit(): void {} }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'unrelated.ts'), + 'export class Unrelated { handleSubmit(): void {} }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'login.ts'), + [ + "import { FormBase } from './base';", + 'declare const bus: { on(ev: string, cb: () => void): void };', + 'export class LoginForm extends FormBase {', + ' wire(): void { bus.on("submit", this.handleSubmit); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const handleSubmits = cg.getNodesByName('handleSubmit'); + const baseM = handleSubmits.find((n) => n.qualifiedName.includes('FormBase'))!; + const unrelatedM = handleSubmits.find((n) => n.qualifiedName.includes('Unrelated'))!; + + const intoBase = cg.getIncomingEdges(baseM.id).filter((e) => e.metadata?.fnRef === true); + expect(intoBase).toHaveLength(1); + expect(cg.getNode(intoBase[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(unrelatedM.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('JAVA: Type::method cross-file, this::/super:: scoped, variable:: yields nothing', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-java-')); + fs.writeFileSync( + path.join(tmpDir, 'Handlers.java'), + [ + 'package com.example;', + 'public class Handlers {', + ' public static void onMessage(int x) { System.out.println(x); }', + '}', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'BaseForm.java'), + ['package com.example;', 'public class BaseForm {', ' void baseHandler(int x) {}', '}'].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'Main.java'), + [ + 'package com.example;', + 'import com.example.Handlers;', + 'import java.util.function.IntConsumer;', + 'public class Main extends BaseForm {', + ' static void registerHandler(IntConsumer cb) { cb.accept(1); }', + ' void run0() {}', + ' void crossFile() { registerHandler(Handlers::onMessage); }', + ' void thisRef() { registerHandler(this::run0); }', + ' void superRef() { registerHandler(super::baseHandler); }', + ' void varRef(Main m) { registerHandler(m::run0); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'onMessage'))).toEqual(['crossFile']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'baseHandler'))).toEqual(['superRef']); + // this::run0 resolves class-scoped; m::run0 (variable receiver) must NOT + // add a second edge — exactly one source. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'run0'))).toEqual(['thisRef']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('KOTLIN: companion-object refs resolve cross-file without imports; decoy companion untouched', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ktcomp-')); + // Same package, no imports — the Java/Kotlin reality the name gate can't + // see, which is why qualified `Type::member` candidates skip it. + fs.writeFileSync( + path.join(tmpDir, 'Handlers.kt'), + [ + 'class KtHandlers {', + ' companion object {', + ' fun handle(x: Int) {}', + ' }', + '}', + 'class Decoy {', + ' companion object {', + ' fun handle(x: Int) {}', + ' }', + '}', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'Wirer.kt'), + [ + 'fun register(cb: Any) {}', + 'class Wirer {', + ' fun wire() { register(KtHandlers::handle) }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const handles = cg.getNodesByName('handle'); + const target = handles.find((n) => n.qualifiedName.includes('KtHandlers'))!; + const decoy = handles.find((n) => n.qualifiedName.includes('Decoy'))!; + const into = cg.getIncomingEdges(target.id).filter((e) => e.metadata?.fnRef === true); + expect(into).toHaveLength(1); + expect(cg.getNode(into[0]!.source)?.name).toBe('wire'); + expect(cg.getIncomingEdges(decoy.id).filter((e) => e.metadata?.fnRef === true)).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SWIFT SCOPING: bare ids hit only the enclosing type’s methods; top-level bare hits functions only', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-swiftscope-')); + fs.writeFileSync( + path.join(tmpDir, 'main.swift'), + [ + 'func register(_ cb: (Int) -> Void) { cb(1) }', + 'class Monitor {', + ' func report(_ x: Int) {}', + ' func wire() { register(report) }', // implicit self → Monitor::report + '}', + 'class Other {', + // `report` here is a PARAMETER; Monitor::report must not win. + ' func use(report: (Int) -> Void) { register(report) }', + '}', + 'func topLevel() { register(report) }', // no implicit self → no method target + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'report'); + expect(sourceNames(cg, edges)).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-')); + // Handler defined in its own file… + fs.writeFileSync(path.join(tmpDir, 't_string.c'), 'void getCommand(int c) { (void)c; }\n'); + // …and registered in a table in ANOTHER file, with no import mechanism (C). + fs.writeFileSync( + path.join(tmpDir, 'server.c'), + [ + 'struct cmd { const char *name; void (*proc)(int); };', + 'static struct cmd commandTable[] = {', + ' { "get", getCommand },', + '};', + ].join('\n') + ); + // Ambiguity safety: two files define dupCmd; a third table references it → + // NO edge (unique-or-drop). + fs.writeFileSync(path.join(tmpDir, 'dup_a.c'), 'void dupCmd(int c) { (void)c; }\n'); + fs.writeFileSync(path.join(tmpDir, 'dup_b.c'), 'void dupCmd(int c) { (void)c; }\n'); + fs.writeFileSync( + path.join(tmpDir, 'other.c'), + [ + 'struct cmd2 { void (*proc)(int); };', + 'static struct cmd2 otherTable[] = { { dupCmd } };', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + // Cross-file unique handler resolves from the table's file. + const intoGet = fnRefEdgesInto(cg, 'getCommand'); + expect(sourceNames(cg, intoGet)).toEqual(['server.c']); + const target = cg.getNode(intoGet[0]!.target); + expect(target?.filePath.endsWith('t_string.c')).toBe(true); + + // Ambiguous handler resolves to NOTHING — silent beats wrong. + expect(fnRefEdgesInto(cg, 'dupCmd')).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('PHP: HOF string callables, [$this,…] and [Cls::class,…] arrays; non-HOF strings ignored', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-php-')); + fs.writeFileSync( + path.join(tmpDir, 'handlers.php'), + " $b; }\n" + ); + fs.writeFileSync( + path.join(tmpDir, 'main.php'), + [ + ' { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-rubyhooks-')); + fs.writeFileSync( + path.join(tmpDir, 'posts_controller.rb'), + [ + 'class ApplicationController', + ' def authenticate; end', + 'end', + '', + 'class PostsController < ApplicationController', + ' before_action :authenticate', // inherited → ApplicationController + ' after_save :reindex', + ' validates :title, presence: true', // attributes, NOT methods → no edge + ' rescue_from StandardError, with: :render_500', + '', + ' def reindex; end', + ' def render_500; end', + ' def title; end', + 'end', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const auth = fnRefEdgesInto(cg, 'authenticate'); + expect(auth).toHaveLength(1); + expect(cg.getNode(auth[0]!.target)?.qualifiedName).toContain('ApplicationController'); + + expect(fnRefEdgesInto(cg, 'reindex')).toHaveLength(1); + expect(fnRefEdgesInto(cg, 'render_500')).toHaveLength(1); + // `validates :title` names an attribute — the same-named METHOD must + // get no registration edge. + expect(fnRefEdgesInto(cg, 'title')).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-')); + fs.writeFileSync( + path.join(tmpDir, 'main.c'), + [ + 'static void cb_a(int x) { (void)x; }', + 'void reg(void (*cb)(int)) { cb(1); }', + 'void wire(void) { reg(cb_a); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const stats1 = cg.getStats(); + + // No function_ref rows may linger for resolvable names — the batched + // resolver must have drained them (delete keyed on the ORIGINAL stored + // ref; the #760 runaway came from violating that). + const db = (cg as unknown as { db: { prepare(sql: string): { all(): unknown[] } } }).db; + let leftover: unknown[] = []; + try { + leftover = db + .prepare("SELECT * FROM unresolved_refs WHERE reference_kind = 'function_ref'") + .all(); + } catch { + // If internals aren't reachable this guard is covered by the edge + // assertions below. + } + expect(leftover).toHaveLength(0); + + // Re-index: identical node/edge counts (idempotent, no accumulation). + await cg.indexAll(); + const stats2 = cg.getStats(); + expect(stats2.totalNodes).toBe(stats1.totalNodes); + expect(stats2.totalEdges).toBe(stats1.totalEdges); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'cb_a'))).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); +}); diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts index 7c771af0b..bc25942ac 100644 --- a/__tests__/graph.test.ts +++ b/__tests__/graph.test.ts @@ -293,6 +293,25 @@ export { main }; expect(Array.isArray(callees)).toBe(true); }); + + it('treats class instantiation as a caller/callee of the class (#774)', () => { + // main() does `new DerivedClass(10, 'test')`. Constructing a class is + // calling its constructor, so main is a caller of DerivedClass and + // DerivedClass is a callee of main. Before #774 the `instantiates` edge + // was excluded from the caller/callee traversal, so `callers ` + // returned the importing file (or nothing) and missed every + // construction site. + const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass'); + const main = cg.getNodesByKind('function').find((n) => n.name === 'main'); + expect(derived).toBeDefined(); + expect(main).toBeDefined(); + + const callerNames = cg.getCallers(derived!.id).map((c) => c.node.name); + expect(callerNames).toContain('main'); + + const calleeNames = cg.getCallees(main!.id).map((c) => c.node.name); + expect(calleeNames).toContain('DerivedClass'); + }); }); describe('getImpactRadius()', () => { @@ -309,6 +328,19 @@ export { main }; expect(impact.nodes.size).toBeGreaterThan(0); expect(impact.nodes.has(formatValue.id)).toBe(true); }); + + it('does not drag in sibling members via the structural contains edge (#536)', () => { + const getName = cg.getNodesByKind('method').find((n) => n.name === 'getName'); + const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass'); + expect(getName).toBeDefined(); + expect(derived).toBeDefined(); + + const impact = cg.getImpactRadius(getName!.id, 3); + // The containing class must NOT be pulled into impact just because it + // *contains* getName — climbing that contains edge would re-expand every + // sibling method and explode impact for a leaf symbol. (#536) + expect(impact.nodes.has(derived!.id)).toBe(false); + }); }); describe('findPath()', () => { @@ -375,16 +407,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); + } }); }); diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 27fcbd6e8..eed76515e 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -192,20 +192,23 @@ describe('Installer targets — partial-state idempotency', () => { fs.rmSync(tmpCwd, { recursive: true, force: true }); }); - it('codex: install writes config.toml but never an AGENTS.md instructions file (#529)', () => { + it('codex: install writes config.toml AND the AGENTS.md codegraph block (#704)', () => { const codex = getTarget('codex')!; const first = codex.install('global', { autoAllow: false }); const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md'); - // No instructions file is created, and no file action references it. - expect(fs.existsSync(agentsMd)).toBe(false); - expect(first.files.some((f) => f.path.endsWith('AGENTS.md'))).toBe(false); expect(first.files.some((f) => f.path.endsWith('config.toml'))).toBe(true); - // Re-install is fully unchanged (config.toml only, nothing to strip). + // The short instructions block IS written (subagents / non-MCP + // harnesses read AGENTS.md but never the MCP initialize instructions). + expect(fs.existsSync(agentsMd)).toBe(true); + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('## CodeGraph'); + expect(body).toContain('codegraph explore'); + // Re-install is fully unchanged (byte-equal block → idempotent). const second = codex.install('global', { autoAllow: false }); for (const f of second.files) expect(f.action).toBe('unchanged'); }); - it('codex: install strips a legacy AGENTS.md codegraph block, keeping user content (#529)', () => { + it('codex: install replaces a legacy AGENTS.md codegraph block with the current one, keeping user content', () => { const codex = getTarget('codex')!; const dir = path.join(tmpHome, '.codex'); fs.mkdirSync(dir, { recursive: true }); @@ -217,10 +220,11 @@ describe('Installer targets — partial-state idempotency', () => { const body = fs.readFileSync(agentsMd, 'utf-8'); expect(body).toContain('# My codex notes'); expect(body).toContain('Be terse.'); - expect(body).not.toContain('CODEGRAPH_START'); - // The strip is reported as a 'removed' action on AGENTS.md. + // Self-heal: the stale pre-#529 body is gone, the current block is in. + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); const mdEntry = result.files.find((f) => f.path.endsWith('AGENTS.md')); - expect(mdEntry?.action).toBe('removed'); + expect(mdEntry?.action).toBe('updated'); }); it('opencode: prefers .jsonc when both .json and .jsonc exist', () => { @@ -290,15 +294,16 @@ describe('Installer targets — partial-state idempotency', () => { expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall); }); - it('opencode: install does NOT write an AGENTS.md instructions file (#529)', () => { + it('opencode: install writes the AGENTS.md codegraph block (#704)', () => { const opencode = getTarget('opencode')!; const result = opencode.install('global', { autoAllow: true }); const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md'); - expect(fs.existsSync(agentsMd)).toBe(false); - expect(result.files.some((f) => f.path.endsWith('AGENTS.md'))).toBe(false); + expect(fs.existsSync(agentsMd)).toBe(true); + expect(fs.readFileSync(agentsMd, 'utf-8')).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('created'); }); - it('opencode: install strips a legacy AGENTS.md codegraph block, preserving user content (#529)', () => { + it('opencode: install replaces a legacy AGENTS.md codegraph block, preserving user content', () => { const opencode = getTarget('opencode')!; const dir = path.join(tmpHome, '.config', 'opencode'); fs.mkdirSync(dir, { recursive: true }); @@ -310,8 +315,9 @@ describe('Installer targets — partial-state idempotency', () => { const body = fs.readFileSync(agentsMd, 'utf-8'); expect(body).toContain('# My personal opencode instructions'); expect(body).toContain('Always respond in pirate.'); - expect(body).not.toContain('CODEGRAPH_START'); - expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('removed'); + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('updated'); }); it('opencode: uninstall strips a leftover codegraph block from AGENTS.md, keeping user content', () => { @@ -329,24 +335,25 @@ describe('Installer targets — partial-state idempotency', () => { expect(body).not.toContain('CODEGRAPH_START'); }); - it('opencode: local install writes ./opencode.jsonc and never an ./AGENTS.md (#529)', () => { + it('opencode: local install writes ./opencode.jsonc and the ./AGENTS.md block (#704)', () => { const opencode = getTarget('opencode')!; const result = opencode.install('local', { autoAllow: true }); const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); // macOS realpath shenanigans (/var vs /private/var) — suffix match. expect(paths.some((p) => p.endsWith('/opencode.jsonc'))).toBe(true); - expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(false); - expect(fs.existsSync(path.join(process.cwd(), 'AGENTS.md'))).toBe(false); + expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), 'AGENTS.md'))).toBe(true); }); - it('gemini: install writes settings.json (mcpServers.codegraph) and no GEMINI.md (#529)', () => { + it('gemini: install writes settings.json (mcpServers.codegraph) and the GEMINI.md block (#704)', () => { const gemini = getTarget('gemini')!; const result = gemini.install('global', { autoAllow: true }); const settings = path.join(tmpHome, '.gemini', 'settings.json'); const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md'); expect(result.files.some((f) => f.path === settings)).toBe(true); - expect(result.files.some((f) => f.path === geminiMd)).toBe(false); - expect(fs.existsSync(geminiMd)).toBe(false); + expect(result.files.some((f) => f.path === geminiMd)).toBe(true); + expect(fs.existsSync(geminiMd)).toBe(true); + expect(fs.readFileSync(geminiMd, 'utf-8')).toContain('codegraph explore'); const cfg = JSON.parse(fs.readFileSync(settings, 'utf-8')); expect(cfg.mcpServers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); @@ -383,13 +390,13 @@ describe('Installer targets — partial-state idempotency', () => { expect(after.mcpServers).toBeUndefined(); }); - it('gemini: local install writes ./.gemini/settings.json and never a ./GEMINI.md (#529)', () => { + it('gemini: local install writes ./.gemini/settings.json and the project-root ./GEMINI.md block (#704)', () => { const gemini = getTarget('gemini')!; const result = gemini.install('local', { autoAllow: true }); const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); expect(paths.some((p) => p.endsWith('/.gemini/settings.json'))).toBe(true); - expect(paths.some((p) => p.endsWith('/GEMINI.md'))).toBe(false); - expect(fs.existsSync(path.join(process.cwd(), 'GEMINI.md'))).toBe(false); + expect(paths.some((p) => p.endsWith('/GEMINI.md'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), 'GEMINI.md'))).toBe(true); }); it('gemini: uninstall strips a leftover GEMINI.md codegraph block, keeping user content', () => { @@ -880,15 +887,18 @@ describe('Installer targets — partial-state idempotency', () => { expect(cfg.mcpServers.codegraph).toBeDefined(); }); - it('claude: install does NOT create a CLAUDE.md instructions file (#529)', () => { + it('claude: install creates the CLAUDE.md codegraph block (#704)', () => { const claude = getTarget('claude')!; const result = claude.install('local', { autoAllow: false }); const claudeMd = path.join(tmpCwd, '.claude', 'CLAUDE.md'); - expect(fs.existsSync(claudeMd)).toBe(false); - expect(result.files.some((f) => f.path.endsWith('CLAUDE.md'))).toBe(false); + expect(fs.existsSync(claudeMd)).toBe(true); + const body = fs.readFileSync(claudeMd, 'utf-8'); + expect(body).toContain('## CodeGraph'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('created'); }); - it('claude: install strips a legacy CLAUDE.md codegraph block, keeping user content (#529)', () => { + it('claude: install replaces a legacy CLAUDE.md codegraph block, keeping user content', () => { const claude = getTarget('claude')!; const claudeMd = path.join(tmpCwd, '.claude', 'CLAUDE.md'); fs.mkdirSync(path.dirname(claudeMd), { recursive: true }); @@ -899,8 +909,9 @@ describe('Installer targets — partial-state idempotency', () => { const body = fs.readFileSync(claudeMd, 'utf-8'); expect(body).toContain('# My project rules'); expect(body).toContain('Use tabs.'); - expect(body).not.toContain('CODEGRAPH_START'); - expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('removed'); + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('updated'); }); it('claude: global install targets ~/.claude.json (user scope)', () => { diff --git a/__tests__/integration/mcp-input-limits.test.ts b/__tests__/integration/mcp-input-limits.test.ts index 495d49339..7471f8289 100644 --- a/__tests__/integration/mcp-input-limits.test.ts +++ b/__tests__/integration/mcp-input-limits.test.ts @@ -53,9 +53,9 @@ describe('MCP input size limits', () => { expect(result.content[0]!.text).toMatch(/maximum length/i); }); - it('rejects an oversize task on codegraph_context', async () => { + it('rejects an oversize query on codegraph_explore', async () => { const huge = 'b'.repeat(50_000); - const result = await handler.execute('codegraph_context', { task: huge }); + const result = await handler.execute('codegraph_explore', { query: huge }); expect(result.isError).toBe(true); expect(result.content[0]!.text).toMatch(/maximum length/i); }); diff --git a/__tests__/iterate-nodes-by-kind.test.ts b/__tests__/iterate-nodes-by-kind.test.ts new file mode 100644 index 000000000..19ee05f67 --- /dev/null +++ b/__tests__/iterate-nodes-by-kind.test.ts @@ -0,0 +1,62 @@ +/** + * `QueryBuilder.iterateNodesByKind` — the streaming scan that fixes the #610 + * OOM. The dynamic-edge synthesizers used to `getNodesByKind('function')` / + * `('method')`, materializing every symbol into one array (gigabytes on a + * symbol-dense project → JS-heap OOM). They now iterate. These tests pin the + * two properties that refactor relies on: the streamed set equals the eager + * set, and an open iterator cursor coexists with other queries on the same + * connection. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; + +describe('iterateNodesByKind (#610 streaming)', () => { + let dir: string; + let cg: CodeGraph; + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-iter-')); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync( + path.join(dir, 'src', 'a.ts'), + 'export function foo() { return 1; }\n' + + 'export function bar() { return 2; }\n' + + 'export class C { m() { return 3; } n() { return 4; } }\n' + ); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + }); + + afterEach(() => { + try { cg.close(); } catch { /* ignore */ } + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('yields exactly the same nodes as the eager getNodesByKind', () => { + const q = (cg as unknown as { queries: any }).queries; + for (const kind of ['function', 'method', 'class'] as const) { + const eager = q.getNodesByKind(kind).map((n: any) => n.id).sort(); + const streamed = [...q.iterateNodesByKind(kind)].map((n: any) => n.id).sort(); + expect(streamed).toEqual(eager); + } + // sanity: the fixture actually produced functions + methods to stream + expect([...q.iterateNodesByKind('function')].length).toBeGreaterThan(0); + expect([...q.iterateNodesByKind('method')].length).toBeGreaterThan(0); + }); + + it('keeps the cursor valid while other queries run mid-iteration', () => { + const q = (cg as unknown as { queries: any }).queries; + let seen = 0; + for (const n of q.iterateNodesByKind('function')) { + // A different prepared statement stepped on the same connection while the + // iterator's cursor is open must not corrupt it. + const again = q.getNodeById(n.id); + expect(again?.id).toBe(n.id); + seen++; + } + expect(seen).toBe(q.getNodesByKind('function').length); + }); +}); diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index 1711d673d..c00d528f6 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -52,7 +52,10 @@ function spawnServer(cwd: string, env: NodeJS.ProcessEnv = {}): SpawnedServer { const child = spawn(process.execPath, [BIN, 'serve', '--mcp'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, ...env }, + // #618: the daemon-attach log line is now off by default; opt the test + // harness into it (CODEGRAPH_MCP_LOG_ATTACH=1) so the attach assertions + // below can still observe a successful attach. A per-test env still wins. + env: { CODEGRAPH_MCP_LOG_ATTACH: '1', ...process.env, ...env }, }) as ChildProcessWithoutNullStreams; // Swallow spawn/EPIPE errors so killing a child mid-write can't surface as an // unhandled error that crashes the vitest worker. @@ -359,6 +362,30 @@ describe('Shared MCP daemon (issue #411)', () => { } }, 30000); + // The over-the-wire client-hello → record → sweep path is covered by the + // deterministic `Daemon.reapDeadClients` unit test in daemon-client-liveness + // (a raw-socket variant here was flaky under heavy parallel load), plus the + // client-hello round-trip exercised by every test above (the real proxy now + // sends it). What stays here is the lifecycle behavior that needs real procs. + it('exits on the inactivity backstop even while a client stays connected (#692)', async () => { + // Backstop short, idle timeout long: with a client connected the idle timer + // never arms, so only the inactivity backstop can take the daemon down. + const env = { CODEGRAPH_DAEMON_MAX_IDLE_MS: '1500', CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '60000' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 10000); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); + const daemonPid = readLockPid(realRoot)!; + expect(isAlive(daemonPid)).toBe(true); + + // Send nothing further — the client stays connected but idle. The backstop + // should fire and the daemon should exit and clean up its lockfile. + expect(await waitProcessExit(daemonPid, 12000)).toBe(true); + expect(readDaemonLog(realRoot)).toContain('inactivity backstop'); + expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid'))).toBe(false); + }, 30000); + it('daemon idle-times-out after the last client disconnects', async () => { const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '800', CODEGRAPH_PPID_POLL_MS: '200' }; const server = spawnServer(tempDir, env); @@ -375,4 +402,34 @@ describe('Shared MCP daemon (issue #411)', () => { expect(await waitProcessExit(daemonPid, 10000)).toBe(true); expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid'))).toBe(false); }, 30000); + + it('proxy survives the daemon dying mid-session and keeps serving (#662)', async () => { + // The #662 scenario: an MCP host SIGTERM's the shared daemon while a session + // is live. The proxy must NOT exit (losing CodeGraph for that session) — it + // falls back to an in-process engine and keeps answering. + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_PPID_POLL_MS: '5000' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 10000); + await waitFor(() => server.stderr.some((l) => l.includes('Attached to shared daemon')), 8000); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); + const daemonPid = readLockPid(realRoot)!; + + // A warm call goes through the daemon. + sendMessage(server.child, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); + await waitFor(() => findResponse(server.stdout, 2), 10000); + + // Kill the daemon out from under the live proxy. + process.kill(daemonPid, 'SIGTERM'); + expect(await waitProcessExit(daemonPid, 8000)).toBe(true); + + // The proxy must still be alive and still answer — served in-process now. + expect(isAlive(server.child.pid!)).toBe(true); + await waitFor(() => server.stderr.some((l) => l.includes('serving this session in-process')), 8000); + sendMessage(server.child, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); + const resp = await waitFor(() => findResponse(server.stdout, 3), 15000); + expect(resp.result !== undefined || resp.error !== undefined).toBe(true); + expect(isAlive(server.child.pid!)).toBe(true); + }, 45000); }); diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 31899aa7c..0a320773d 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -154,4 +154,30 @@ describe('MCP initialize handshake (issue #172)', () => { expect(json.id).toBe(0); expect(json.result.serverInfo.name).toBe('codegraph'); }, 20000); + + it('answers resources/list and prompts/list with empty lists, not -32601 (issue #621)', async () => { + child = spawnServer(tempDir); + const events = tagStreams(child); + sendInitialize(child, tempDir); + await waitFor(events, (e) => e.stream === 'stdout', 5000); // initialize reply + + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'resources/list', params: {} }) + '\n'); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'prompts/list', params: {} }) + '\n'); + + const replyFor = async (id: number) => { + const ev = await waitFor(events, (e) => { + if (e.stream !== 'stdout') return false; + try { return JSON.parse(e.text).id === id; } catch { return false; } + }, 5000); + return JSON.parse(ev.text); + }; + + const resources = await replyFor(1); + expect(resources.error).toBeUndefined(); + expect(resources.result.resources).toEqual([]); + + const prompts = await replyFor(2); + expect(prompts.error).toBeUndefined(); + expect(prompts.result.prompts).toEqual([]); + }, 15000); }); diff --git a/__tests__/mcp-staleness-banner.test.ts b/__tests__/mcp-staleness-banner.test.ts index fade28971..5ea5a0103 100644 --- a/__tests__/mcp-staleness-banner.test.ts +++ b/__tests__/mcp-staleness-banner.test.ts @@ -11,27 +11,22 @@ * decides whether to Read the specific stale file. These tests exercise * the full real path: real CodeGraph index + real ToolHandler.execute(). * - * **chokidar is mocked** (see __helpers__/chokidar-mock.ts): the real - * FSEvents/inotify event delivery is non-deterministic under parallel - * vitest execution and produced a consistent ~30% failure rate on these - * tests when run inside the full suite. The mock replaces chokidar with - * a controllable EventEmitter so the tests synthesize file events - * deterministically via `triggerFileEvent(...)` instead of waiting on - * the OS-level watcher to deliver. The watcher's actual debounce timer - * (real setTimeout) is left untouched. + * **Event delivery uses a synthetic seam** (`__emitWatchEventForTests`): the + * real native fs.watch (FSEvents/inotify) delivery is non-deterministic under + * parallel vitest execution and produced a consistent ~30% failure rate on + * these tests when run inside the full suite. The seam drives the watcher's + * pending-set pipeline directly so the tests synthesize file events + * deterministically. The watcher's actual debounce timer (real setTimeout) is + * left untouched. */ -import { vi } from 'vitest'; -// Hoisted: chokidar is replaced by the controllable mock for this file. -vi.mock('chokidar', async () => (await import('./__helpers__/chokidar-mock')).chokidarMockModule); - import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import CodeGraph from '../src/index'; import { ToolHandler } from '../src/mcp/tools'; -import { triggerFileEvent } from './__helpers__/chokidar-mock'; +import { __emitWatchEventForTests } from '../src/sync/watcher'; function waitFor(condition: () => boolean, timeoutMs = 2000, intervalMs = 25): Promise { return new Promise((resolve, reject) => { @@ -83,7 +78,7 @@ describe('MCP staleness banner', () => { it('prepends a stale banner when the response references a pending file', async () => { // Long debounce so the edit lingers in pendingFiles while we query. - cg.watch({ debounceMs: 4000 }); + cg.watch({ debounceMs: 4000, inertForTests: true }); await cg.waitUntilWatcherReady(); // Real disk write so a later sync (if it fires) sees the new content, @@ -93,7 +88,7 @@ describe('MCP staleness banner', () => { path.join(testDir, 'src', 'alpha-only.ts'), 'export function alphaOnly() { return 99; }\n', ); - triggerFileEvent(testDir, 'change', 'src/alpha-only.ts'); + __emitWatchEventForTests(testDir, 'src/alpha-only.ts'); // With mocked chokidar this is synchronous — keep the wait just to // exercise the realistic shape (the watcher's `chokidarReady` gate @@ -114,7 +109,7 @@ describe('MCP staleness banner', () => { }); it('uses the footer (not the banner) when pending files are not referenced', async () => { - cg.watch({ debounceMs: 4000 }); + cg.watch({ debounceMs: 4000, inertForTests: true }); await cg.waitUntilWatcherReady(); // Edit bravo-only.ts but search for the alphaOnly symbol, whose hit is @@ -124,7 +119,7 @@ describe('MCP staleness banner', () => { path.join(testDir, 'src', 'bravo-only.ts'), 'export function bravoOnly() { return 22; }\n', ); - triggerFileEvent(testDir, 'change', 'src/bravo-only.ts'); + __emitWatchEventForTests(testDir, 'src/bravo-only.ts'); await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/bravo-only.ts')); const res = await handler.execute('codegraph_search', { query: 'alphaOnly' }); @@ -136,14 +131,14 @@ describe('MCP staleness banner', () => { }); it('drops the banner once the sync completes and clears the pending entry', async () => { - cg.watch({ debounceMs: 200 }); + cg.watch({ debounceMs: 200, inertForTests: true }); await cg.waitUntilWatcherReady(); fs.writeFileSync( path.join(testDir, 'src', 'alpha-only.ts'), 'export function alphaOnly() { return 7; }\n', ); - triggerFileEvent(testDir, 'change', 'src/alpha-only.ts'); + __emitWatchEventForTests(testDir, 'src/alpha-only.ts'); // Wait through debounce (200ms) + sync; pendingFiles drains back to empty. await waitFor(() => cg.getPendingFiles().length === 0, 3000); @@ -154,14 +149,14 @@ describe('MCP staleness banner', () => { }); it('lists pending files under "Pending sync" in codegraph_status', async () => { - cg.watch({ debounceMs: 4000 }); + cg.watch({ debounceMs: 4000, inertForTests: true }); await cg.waitUntilWatcherReady(); fs.writeFileSync( path.join(testDir, 'src', 'charlie-only.ts'), 'export function charlieOnly() { return 33; }\n', ); - triggerFileEvent(testDir, 'change', 'src/charlie-only.ts'); + __emitWatchEventForTests(testDir, 'src/charlie-only.ts'); await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/charlie-only.ts')); const res = await handler.execute('codegraph_status', {}); diff --git a/__tests__/mcp-tool-allowlist.test.ts b/__tests__/mcp-tool-allowlist.test.ts index 6f29616dc..08067c918 100644 --- a/__tests__/mcp-tool-allowlist.test.ts +++ b/__tests__/mcp-tool-allowlist.test.ts @@ -17,32 +17,43 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => { const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort(); - it('exposes the full tool surface when unset', () => { + it('exposes the default 4-tool surface when unset', () => { delete process.env[ENV]; - const all = listed(); - expect(all).toContain('codegraph_explore'); - expect(all).toContain('codegraph_context'); - expect(all).toContain('codegraph_trace'); - expect(all.length).toBeGreaterThanOrEqual(10); + // The default set (see DEFAULT_MCP_TOOLS): explore + node are the + // validated workhorses, search the cheap lookup, callers the one + // irreplaceable enumerator. callees/impact/files/status stay defined + // and executable but unlisted — impact appeared in ZERO recorded runs. + expect(listed()).toEqual([ + 'codegraph_callers', + 'codegraph_explore', + 'codegraph_node', + 'codegraph_search', + ]); + }); + + it('re-enables an unlisted tool via the allowlist (impact)', () => { + process.env[ENV] = 'explore,impact'; + expect(listed()).toEqual(['codegraph_explore', 'codegraph_impact']); }); it('filters ListTools to the allowlisted short names', () => { - process.env[ENV] = 'trace,search,node'; - expect(listed()).toEqual(['codegraph_node', 'codegraph_search', 'codegraph_trace']); + process.env[ENV] = 'explore,search,node'; + expect(listed()).toEqual(['codegraph_explore', 'codegraph_node', 'codegraph_search']); }); it('accepts fully-qualified codegraph_ names and ignores whitespace', () => { - process.env[ENV] = ' codegraph_trace , search '; - expect(listed()).toEqual(['codegraph_search', 'codegraph_trace']); + process.env[ENV] = ' codegraph_explore , search '; + expect(listed()).toEqual(['codegraph_explore', 'codegraph_search']); }); - it('treats an empty/whitespace value as unset (full surface)', () => { + it('treats an empty/whitespace value as unset (default surface)', () => { process.env[ENV] = ' '; - expect(listed().length).toBeGreaterThanOrEqual(10); + expect(listed()).toHaveLength(4); + expect(listed()).toContain('codegraph_explore'); }); it('rejects a disabled tool on execute (defense in depth)', async () => { - process.env[ENV] = 'trace'; + process.env[ENV] = 'node'; const res = await new ToolHandler(null).execute('codegraph_explore', {}); expect(res.isError).toBe(true); expect(res.content[0].text).toMatch(/disabled via CODEGRAPH_MCP_TOOLS/); diff --git a/__tests__/mcp-unindexed.test.ts b/__tests__/mcp-unindexed.test.ts new file mode 100644 index 000000000..2b0019d6d --- /dev/null +++ b/__tests__/mcp-unindexed.test.ts @@ -0,0 +1,225 @@ +/** + * Unindexed-workspace session policy tests. + * + * An MCP session attached to a workspace with no .codegraph/ must go quiet + * rather than fail loudly: `initialize` returns the short "inactive" + * instructions variant (not the full playbook), `tools/list` returns an + * EMPTY list, and a tool call that still arrives (cross-project + * `projectPath`, or a host that skips tools/list) answers with a + * SUCCESS-shaped guidance message — never `isError: true`. One or two early + * isError responses teach an agent to abandon codegraph for the whole + * session; that observed failure mode is what this suite guards. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { ToolHandler } from '../src/mcp/tools'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnServer(cwd: string): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + // Direct (in-process) mode — the unindexed path never has a daemon + // anyway (the daemon socket lives in .codegraph/), and this keeps the + // suite from leaking a detached daemon in the indexed test. + // CODEGRAPH_WASM_RELAUNCHED skips the --liftoff-only re-exec: without + // it the server runs as a GRANDCHILD that survives child.kill() on + // Windows and holds the temp cwd/SQLite handles, failing teardown with + // EPERM no matter how long rmSync retries (the class documented for + // the mcp-initialize/mcp-roots suites). + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + }) as ChildProcessWithoutNullStreams; +} + +/** Send a JSON-RPC request and resolve with the response matching its id. */ +function request( + child: ChildProcessWithoutNullStreams, + msg: { id: number; method: string; params?: unknown }, + timeoutMs = 15000 +): Promise> { + return new Promise((resolve, reject) => { + let buf = ''; + const timer = setTimeout(() => { + child.stdout.off('data', onData); + reject(new Error(`timeout waiting for response id=${msg.id}`)); + }, timeoutMs); + const onData = (chunk: Buffer) => { + buf += chunk.toString(); + let idx: number; + while ((idx = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + try { + const parsed = JSON.parse(line) as Record; + if (parsed.id === msg.id) { + clearTimeout(timer); + child.stdout.off('data', onData); + resolve(parsed); + return; + } + } catch { + // non-JSON noise on stdout — ignore + } + } + }; + child.stdout.on('data', onData); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n'); + }); +} + +function initializeParams(projectPath: string) { + return { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${projectPath}`, + }; +} + +describe('Unindexed-workspace session policy', () => { + let tempDir: string; + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unindexed-')); + }); + + afterEach(async () => { + if (child) { + // Wait for the child to actually exit before removing its cwd — on + // Windows a just-killed process briefly holds the directory/SQLite + // handles, and an immediate rmSync fails the teardown with EPERM + // (the documented file-locking class that fails the sibling + // mcp-initialize/mcp-roots suites). kill + await exit + retried + // removal keeps this suite green on Windows. + const exited = new Promise((resolve) => child!.once('exit', () => resolve())); + child.kill('SIGKILL'); + await Promise.race([exited, new Promise((r) => setTimeout(r, 3000))]); + child = null; + } + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + }); + + it('initialize returns the short "inactive" instructions, not the playbook', async () => { + fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const x = 1;\n'); + child = spawnServer(tempDir); + + const res = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + const instructions = (res.result as { instructions: string }).instructions; + + expect(instructions).toMatch(/inactive/i); + expect(instructions).toMatch(/codegraph init/); + // The full playbook must NOT be sent into a session where every call fails + expect(instructions).not.toMatch(/Tool selection by intent/); + expect(instructions).not.toMatch(/codegraph_explore/); + }); + + it('tools/list returns an EMPTY list when the workspace has no index', async () => { + child = spawnServer(tempDir); + await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + + const res = await request(child, { id: 1, method: 'tools/list' }); + expect((res.result as { tools: unknown[] }).tools).toEqual([]); + }); + + it('an INDEXED workspace still gets the full playbook and all tools', async () => { + fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export function hello(): string { return "hi"; }\n'); + const cg = await CodeGraph.init(tempDir, { index: true }); + cg.close(); + + child = spawnServer(tempDir); + const init = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + const instructions = (init.result as { instructions: string }).instructions; + expect(instructions).toMatch(/Tool selection by intent/); + expect(instructions).not.toMatch(/inactive/i); + + const list = await request(child, { id: 1, method: 'tools/list' }); + const tools = (list.result as { tools: Array<{ name: string }> }).tools; + // A 1-file project triggers the pre-existing tiny-repo tool gating (a + // reduced core set) — the contract under test is "indexed → tools are + // PRESENT", in contrast to the unindexed empty list above. + expect(tools.length).toBeGreaterThanOrEqual(3); + expect(tools.map((t) => t.name)).toContain('codegraph_explore'); + }); +}); + +describe('No-error policy on expected conditions', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-noerror-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('cross-project query to an unindexed path is SUCCESS-shaped guidance, not isError', async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { + query: 'anything', + projectPath: tempDir, + }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/isn't indexed/); + expect(res.content[0]!.text).toMatch(/codegraph init/); + expect(res.content[0]!.text).toMatch(/built-in tools/); + }); + + it('no-default-project (working-directory detection miss) is SUCCESS-shaped guidance', async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { query: 'anything' }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/No CodeGraph project is loaded/); + expect(res.content[0]!.text).toMatch(/projectPath/); + }); + + it.runIf(process.platform !== 'win32')( + 'sensitive-path refusal stays a hard error (no retry encouragement)', + async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { + query: 'anything', + projectPath: '/etc', + }); + + expect(res.isError).toBe(true); + expect(res.content[0]!.text).not.toMatch(/retry the call once/); + } + ); +}); + +describe('search kind filter', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-kind-')); + fs.writeFileSync( + path.join(tempDir, 'types.ts'), + 'export type PaymentMethod = { id: string };\nexport function pay(): void {}\n' + ); + cg = await CodeGraph.init(tempDir, { index: true }); + }); + + afterEach(() => { + cg.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("kind: 'type' (the advertised enum value) finds type aliases", async () => { + const res = await new ToolHandler(cg).execute('codegraph_search', { + query: 'PaymentMethod', + kind: 'type', + }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/PaymentMethod/); + expect(res.content[0]!.text).not.toMatch(/No results found/); + }); +}); diff --git a/__tests__/node-file-view.test.ts b/__tests__/node-file-view.test.ts new file mode 100644 index 000000000..7d2a5703c --- /dev/null +++ b/__tests__/node-file-view.test.ts @@ -0,0 +1,118 @@ +/** + * codegraph_node FILE READ mode: a `file` with no `symbol` reads that file like + * the Read tool — current source with `\t` numbering (byte-for-byte + * Read's shape), narrowable with offset/limit — plus a one-line blast-radius + * header. `symbolsOnly` returns the structural map instead. Config/data files + * are summarized by key, never dumped (#383). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; + +describe('codegraph_node file-view (Read replacement)', () => { + let dir: string; + let cg: CodeGraph; + let h: ToolHandler; + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fileview-')); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync( + path.join(dir, 'src', 'a.ts'), + 'export function helper(x: number) {\n return x + 1;\n}\nexport class Widget {\n build() { return helper(1); }\n}\n', + ); + fs.writeFileSync( + path.join(dir, 'src', 'b.ts'), + "import { helper } from './a';\n\n// a comment between symbols\nconst SETTING = 7;\nexport function useHelper() { return helper(2) + SETTING; }\n", + ); + // A config/data file (#383): its values may be secrets and must never be + // dumped verbatim by the file-view. + fs.writeFileSync( + path.join(dir, 'src', 'application.properties'), + 'spring.datasource.password=SUPERSECRET123\nserver.port=8080\n', + ); + // A large file: exceeds the file-view line budget, so it must be windowed + // honestly (not silently truncated). + fs.writeFileSync( + path.join(dir, 'src', 'big.ts'), + 'export function big() {\n' + + Array.from({ length: 2000 }, (_, i) => ` const v${i} = ${i};`).join('\n') + + '\n return 0;\n}\n', + ); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts', '**/*.properties'], exclude: [] } }); + await cg.indexAll(); + h = new ToolHandler(cg); + }); + + afterEach(() => { + if (cg) cg.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const text = async (args: Record): Promise => + (await h.execute('codegraph_node', args)).content.map((c) => c.text).join('\n'); + + it('reads a whole file like Read by default — `\\t` lines (no pad), imports + gaps included', async () => { + const out = await text({ file: 'b.ts' }); // no includeCode needed — content is the default + // Byte-for-byte Read shape: line 1 is "1import …", NOT space-padded. + expect(out).toMatch(/^1\timport \{ helper \} from '\.\/a';$/m); + expect(out).toContain('// a comment between symbols'); // inter-symbol gap (Read has it; old reconstruction dropped it) + expect(out).toContain('const SETTING = 7'); // top-level statement + expect(out).toContain('useHelper'); // the symbol body too + expect(out).not.toContain('```'); // Read has no code fence; neither do we + }); + + it('leads with a one-line blast-radius header (the value-add over Read)', async () => { + const out = await text({ file: 'a.ts' }); + expect(out).toMatch(/used by 1 file: src\/b\.ts/); // a.ts is imported by b.ts + expect(out).toContain('return x + 1'); // still returns the source + }); + + it('offset/limit narrow the window exactly like Read', async () => { + const out = await text({ file: 'big.ts', offset: 1000, limit: 3 }); + // Window starts at the requested line, numbered exactly: "1000 const v998 = 998;" + expect(out).toMatch(/^1000\t {2}const v998 = 998;$/m); + expect(out).not.toMatch(/^1\t/m); // line 1 is NOT shown + expect(out).toMatch(/lines 1000[–-]1002 of \d+/); // honest pagination note + }); + + it('an offset past EOF is reported, not a crash', async () => { + const out = await text({ file: 'a.ts', offset: 9999 }); + expect(out).toMatch(/past the end/i); + }); + + it('paginates a large file honestly by default — "lines 1–N of TOTAL", never a silent truncate', async () => { + const out = await text({ file: 'big.ts' }); + expect(out).toMatch(/lines 1[–-]\d+ of \d+/); // explicit window note + expect(out).not.toContain('(output truncated)'); // not the generic 15k chop + expect(out).toMatch(/^1\texport function big/m); // the head of the window is real source + }); + + it('does NOT dump a config/data file (yaml/properties) — #383 secret safety', async () => { + const out = await text({ file: 'application.properties' }); + expect(out).not.toContain('SUPERSECRET123'); // the value never reaches the agent + expect(out.toLowerCase()).toMatch(/config|values withheld/); + }); + + it('symbolsOnly returns the structural map, not the source', async () => { + const out = await text({ file: 'a.ts', symbolsOnly: true }); + expect(out).toContain('### Symbols'); + expect(out).toContain('helper'); + expect(out).toContain('Widget'); + expect(out).not.toContain('return x + 1'); // bodies are NOT included in the map + }); + + it('still works as a normal symbol lookup (no regression)', async () => { + const out = await text({ symbol: 'helper', includeCode: true }); + expect(out).toContain('helper'); + expect(out).toContain('return x + 1'); + }); + + it('a miss returns a helpful message, not a crash', async () => { + const out = await text({ file: 'does-not-exist.ts' }); + expect(out).toMatch(/no indexed file matches/i); + }); +}); diff --git a/__tests__/object-literal-methods.test.ts b/__tests__/object-literal-methods.test.ts new file mode 100644 index 000000000..1722ad2d0 --- /dev/null +++ b/__tests__/object-literal-methods.test.ts @@ -0,0 +1,176 @@ +/** + * Object-literal method extraction (general AST rule). + * + * The extractor pulls function-valued properties out of an object literal that + * is the value of an exported const — either DIRECTLY + * (`export const actions = { foo: () => {} }`) or RETURNED by an initializer + * call (`export const useStore = create((set, get) => ({ foo: () => {} }))`, + * incl. middleware wrappers). This makes store actions (Zustand/Redux/Pinia/ + * MobX/handler maps) real nodes, so `codegraph_node`/`callers` on them resolve + * instead of returning "not found" and forcing the agent to Read the store. + * + * Keyed purely on AST shape — no library names in the implementation — so any + * same-shaped store is covered. Resolution then falls out of the existing + * exact-name matcher: every call form (`const {foo}=useStore.getState(); foo()`, + * `useStore.getState().foo()`, in-store `get().foo()`) reduces to a bare `foo` + * call that resolves to the action node once it exists. + */ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('object-literal method extraction', () => { + it('extracts Zustand store actions (object returned by create()) as function nodes', () => { + const code = ` + import { create } from 'zustand' + interface Store { + count: number + fetchUser(): Promise + switchOrganization(id: string): Promise + reset(): void + } + export const useStore = create((set, get) => ({ + count: 0, + fetchUser: async () => { await get().reset() }, + switchOrganization: async (id: string) => { set({ count: 1 }) }, + reset: () => set({ count: 0 }), + })) + `; + const result = extractFromSource('store.ts', code); + const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fnNames).toContain('fetchUser'); + expect(fnNames).toContain('switchOrganization'); + expect(fnNames).toContain('reset'); + + // Each action's body was walked: fetchUser references its sibling `reset`, + // so an in-store calls edge will resolve once the pipeline runs. + const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!; + const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id); + expect(fetchUserRefs.map((r) => r.referenceName)).toContain('reset'); + + // The action's body wasn't mis-attributed to the file scope (the reason we + // skip the generic body-visit for the store-factory call). + const fileNode = result.nodes.find((n) => n.kind === 'file')!; + const fileRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fileNode.id); + expect(fileRefs.map((r) => r.referenceName)).not.toContain('reset'); + }); + + it('extracts actions through a middleware wrapper (create(persist(...)))', () => { + const code = ` + import { create } from 'zustand' + import { persist } from 'zustand/middleware' + export const useCounter = create( + persist( + (set, get) => ({ + value: 0, + increment: () => set({ value: get().value + 1 }), + }), + { name: 'counter' } + ) + ) + `; + const result = extractFromSource('counter.ts', code); + const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fnNames).toContain('increment'); + }); + + it('extracts actions when the initializer returns via a block (=> { return {...} })', () => { + const code = ` + import { create } from 'zustand' + export const useThing = create((set) => { + const initial = 0 + return { + value: initial, + bump: () => set({ value: 1 }), + } + }) + `; + const result = extractFromSource('thing.ts', code); + const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fnNames).toContain('bump'); + }); + + it('does NOT extract methods from a non-exported call-wrapped object (noise gate)', () => { + const code = ` + function wrap(f: any) { return f } + const local = wrap(() => ({ shouldNotExtract: () => {} })) + `; + const result = extractFromSource('inline.ts', code); + const names = result.nodes.map((n) => n.name); + expect(names).not.toContain('shouldNotExtract'); + }); + + it('still extracts the existing direct-object shape (export const actions = {...})', () => { + const code = ` + export const actions = { + load: async () => { helper() }, + } + function helper() {} + `; + const result = extractFromSource('actions.ts', code); + const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fnNames).toContain('load'); + }); +}); + +describe('object-literal method resolution (end-to-end)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('resolves callers of store actions across files (destructured + chained getState())', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-store-')); + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"t","dependencies":{"zustand":"^4"}}\n'); + fs.writeFileSync( + path.join(tmpDir, 'store.ts'), + `import { create } from 'zustand'\n` + + `interface S { fetchUser(): Promise; reset(): void }\n` + + `export const useStore = create((set, get) => ({\n` + + ` fetchUser: async () => { get().reset() },\n` + + ` reset: () => set({}),\n` + + `}))\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'caller.ts'), + `import { useStore } from './store'\n` + + `export async function loginFlow() {\n` + + ` const { fetchUser } = useStore.getState()\n` + + ` await fetchUser()\n` + + `}\n` + + `export function hardReset() {\n` + + ` useStore.getState().reset()\n` + + `}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const fns = cg.getNodesByKind('function'); + const fetchUser = fns.find((n) => n.name === 'fetchUser' && n.filePath.endsWith('store.ts')); + const reset = fns.find((n) => n.name === 'reset' && n.filePath.endsWith('store.ts')); + expect(fetchUser).toBeDefined(); + expect(reset).toBeDefined(); + + // Destructured-then-bare call: loginFlow -> fetchUser + const fetchUserCallers = cg.getCallers(fetchUser!.id).map((c) => c.node.name); + expect(fetchUserCallers).toContain('loginFlow'); + + // Chained getState() call: hardReset -> reset, AND in-store sibling: fetchUser -> reset + const resetCallers = cg.getCallers(reset!.id).map((c) => c.node.name); + expect(resetCallers).toContain('hardReset'); + expect(resetCallers).toContain('fetchUser'); + + cg.close(); + }); +}); diff --git a/__tests__/ppid-watchdog.test.ts b/__tests__/ppid-watchdog.test.ts new file mode 100644 index 000000000..ba270fa7d --- /dev/null +++ b/__tests__/ppid-watchdog.test.ts @@ -0,0 +1,138 @@ +/** + * Unit coverage for the PPID-watchdog decision logic (#277, #692). + * + * The live watchdog timers in `proxy.ts` / `index.ts` are integration-tested on + * POSIX in `mcp-ppid-watchdog.test.ts`, but that test is skipped on Windows + * (`process.kill(pid, 'SIGKILL')` and reparenting are POSIX-specific). That gap + * is exactly how the Windows leak (#692) shipped: on Windows `process.ppid` + * never changes when the parent dies, so the old change-only check could never + * fire. These pure-function tests exercise the Windows branch on any OS by + * stubbing `isAlive` and `platform`. + */ +import { describe, it, expect } from 'vitest'; +import { supervisionLostReason } from '../src/mcp/ppid-watchdog'; + +const alive = () => true; +const dead = () => false; +/** Alive for everyone except the listed pids. */ +const deadOnly = (...pids: number[]) => (pid: number) => !pids.includes(pid); + +describe('supervisionLostReason', () => { + describe('POSIX (parent death reparents → ppid changes)', () => { + it('returns null while the parent is unchanged', () => { + expect( + supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, + hostPpid: null, + isAlive: alive, + platform: 'linux', + }), + ).toBeNull(); + }); + + it('detects a reparent (ppid divergence) as the death signal', () => { + const reason = supervisionLostReason({ + originalPpid: 100, + currentPpid: 1, // reparented to init + hostPpid: null, + isAlive: alive, + platform: 'linux', + }); + expect(reason).toBe('ppid 100 -> 1'); + }); + + it('does NOT use liveness on POSIX — a dead original ppid is not orphaning', () => { + // A double-forked grandparent can die while we stay correctly parented. + // POSIX must rely on the change-check only, or it would false-positive. + expect( + supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, + hostPpid: null, + isAlive: dead, + platform: 'linux', + }), + ).toBeNull(); + }); + }); + + describe('Windows (ppid is stable across parent death → poll liveness)', () => { + it('returns null while the original parent is still alive', () => { + expect( + supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, + hostPpid: null, + isAlive: alive, + platform: 'win32', + }), + ).toBeNull(); + }); + + it('detects parent death by liveness even though ppid is unchanged (the #692 fix)', () => { + const reason = supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, // Windows never reparents + hostPpid: null, + isAlive: deadOnly(100), + platform: 'win32', + }); + expect(reason).toBe('parent pid 100 exited'); + }); + + it('ignores pid 0/1 — never a real Windows parent, must not trigger shutdown', () => { + for (const ppid of [0, 1]) { + expect( + supervisionLostReason({ + originalPpid: ppid, + currentPpid: ppid, + hostPpid: null, + isAlive: dead, + platform: 'win32', + }), + ).toBeNull(); + } + }); + }); + + describe('threaded host pid (reached past an intermediate launcher shim)', () => { + it('shuts down when the host pid is gone, on either platform', () => { + for (const platform of ['linux', 'win32'] as const) { + const reason = supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, + hostPpid: 42, + isAlive: deadOnly(42), // shim 100 alive, host 42 dead + platform, + }); + expect(reason).toBe('host pid 42 exited'); + } + }); + + it('stays supervised while the host pid is alive', () => { + expect( + supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, + hostPpid: 42, + isAlive: alive, + platform: 'linux', + }), + ).toBeNull(); + }); + }); + + describe('signal precedence', () => { + it('reports the ppid change ahead of a host-gone reason', () => { + const reason = supervisionLostReason({ + originalPpid: 100, + currentPpid: 1, + hostPpid: 42, + isAlive: dead, + platform: 'linux', + }); + expect(reason).toBe('ppid 100 -> 1'); + }); + }); +}); diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts index 6741e905e..8e8ca8177 100644 --- a/__tests__/pr19-improvements.test.ts +++ b/__tests__/pr19-improvements.test.ts @@ -299,7 +299,7 @@ describe('Best-Candidate Resolution', () => { describe('Schema v2 Migration', () => { it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => { const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations'); - expect(CURRENT_SCHEMA_VERSION).toBe(4); + expect(CURRENT_SCHEMA_VERSION).toBe(5); }); it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => { @@ -501,10 +501,10 @@ describe('MCP Tool Improvements', () => { expect(typeof ToolHandler).toBe('function'); }); - it.skipIf(!HAS_SQLITE)('should have findSymbol and truncateOutput as private methods', async () => { + it.skipIf(!HAS_SQLITE)('should have findSymbolMatches and truncateOutput as private methods', async () => { const { ToolHandler } = await import('../src/mcp/tools'); const proto = ToolHandler.prototype; - expect(typeof (proto as any).findSymbol).toBe('function'); + expect(typeof (proto as any).findSymbolMatches).toBe('function'); expect(typeof (proto as any).truncateOutput).toBe('function'); }); @@ -567,20 +567,19 @@ export function getValueFromCache(): number { return 2; } await cg.indexAll(); const handler = new ToolHandler(cg); - const findSymbol = (handler as any).findSymbol.bind(handler); + const findSymbolMatches = (handler as any).findSymbolMatches.bind(handler); - const match = findSymbol(cg, 'getValue'); - expect(match).not.toBeNull(); - expect(match.node.name).toBe('getValue'); - // Should not have a disambiguation note for single exact match - expect(match.note).toBe(''); + const matches = findSymbolMatches(cg, 'getValue'); + // Exact-name match wins — a single result, not the partial getValueFromCache. + expect(matches.length).toBe(1); + expect(matches[0].name).toBe('getValue'); handler.closeAll(); cg.destroy(); cleanupTempDir(tmpDir); }); - it.skipIf(!HAS_SQLITE)('should note when multiple symbols share the same name', async () => { + it.skipIf(!HAS_SQLITE)('should return all definitions when multiple symbols share the same name', async () => { const { ToolHandler } = await import('../src/mcp/tools'); const CodeGraph = (await import('../src/index')).default; @@ -602,20 +601,21 @@ export function handle(): void {} await cg.indexAll(); const handler = new ToolHandler(cg); - const findSymbol = (handler as any).findSymbol.bind(handler); + const findSymbolMatches = (handler as any).findSymbolMatches.bind(handler); - const match = findSymbol(cg, 'handle'); - expect(match).not.toBeNull(); - expect(match.node.name).toBe('handle'); - // Should have a disambiguation note - expect(match.note).toContain('2 symbols named "handle"'); + // Both same-named definitions are returned (no longer one + a dead-end + // note) so codegraph_node can hand back every overload and the agent never + // Reads to find the one it wanted. + const matches = findSymbolMatches(cg, 'handle'); + expect(matches.length).toBe(2); + expect(matches.every((n: any) => n.name === 'handle')).toBe(true); handler.closeAll(); cg.destroy(); cleanupTempDir(tmpDir); }); - it.skipIf(!HAS_SQLITE)('should return null when symbol is not found', async () => { + it.skipIf(!HAS_SQLITE)('should return no matches when symbol is not found', async () => { const { ToolHandler } = await import('../src/mcp/tools'); const CodeGraph = (await import('../src/index')).default; @@ -630,10 +630,10 @@ export function handle(): void {} await cg.indexAll(); const handler = new ToolHandler(cg); - const findSymbol = (handler as any).findSymbol.bind(handler); + const findSymbolMatches = (handler as any).findSymbolMatches.bind(handler); - const match = findSymbol(cg, 'nonExistentSymbol'); - expect(match).toBeNull(); + const matches = findSymbolMatches(cg, 'nonExistentSymbol'); + expect(matches.length).toBe(0); handler.closeAll(); cg.destroy(); diff --git a/__tests__/react-native-bridge.test.ts b/__tests__/react-native-bridge.test.ts index e443ab337..dec3ce5cb 100644 --- a/__tests__/react-native-bridge.test.ts +++ b/__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 + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 03b8ea6ab..3059392d4 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -12,7 +12,7 @@ import { CodeGraph } from '../src'; import { Node, UnresolvedReference } from '../src/types'; import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution'; import { matchReference } from '../src/resolution/name-matcher'; -import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache } from '../src/resolution/import-resolver'; +import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache, isPhpIncludePathRef } from '../src/resolution/import-resolver'; import type { UnresolvedRef } from '../src/resolution/types'; import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks'; import { QueryBuilder } from '../src/db/queries'; @@ -581,12 +581,23 @@ from ..services import auth_service line: 10, column: 5, filePath: 'src/App.tsx', - language: 'typescript' as const, + // Refs extracted from .tsx files carry language 'tsx' — component + // resolution is gated to JSX-capable refs (#764: PascalCase TYPE refs + // from plain .ts files were resolving to arbitrary same-named classes). + language: 'tsx' as const, }; const result = reactResolver!.resolve(ref, context); expect(result).not.toBeNull(); expect(result?.targetNodeId).toBe('component:src/Button.tsx:Button:5'); + + // The same PascalCase name referenced from a plain .ts file is a TYPE + // reference, not a component usage — component resolution must decline + // and leave it to proximity-aware name matching (#764: a .ts GraphQL + // types file's own `Account` alias was losing to an arbitrary same-named + // class in another monorepo package). + const tsRef = { ...ref, filePath: 'src/models.ts', language: 'typescript' as const }; + expect(reactResolver!.resolve(tsRef, context)).toBeNull(); }); it('should resolve custom hook references', () => { @@ -853,6 +864,114 @@ func UseAliased() { expect(target?.filePath.replace(/\\/g, '/')).toBe('pkgb/lib.go'); }); + it('resolves Python module-attribute calls after `from pkg import module` (#578)', async () => { + // Pre-#578, a `module.func()` call where `module` was bound via + // `from pkg import module` dropped its `calls` edge. The file→file import + // edge resolved (resolveModuleImportToFile falls back to a dotted-module + // file lookup for absolute package paths), but resolvePythonModuleMember + // had no such fallback — resolveImportPath returns null for an absolute + // package path like `pkg.module`, so the member never resolved and + // callers/callees/impact on the target came back empty. Same root-cause + // class as the Go cross-package qualified call (#388). + fs.mkdirSync(path.join(tempDir, 'pkg')); + fs.writeFileSync(path.join(tempDir, 'pkg', '__init__.py'), ''); + fs.writeFileSync( + path.join(tempDir, 'pkg', 'module.py'), + 'def func():\n return 1\n' + ); + fs.writeFileSync( + path.join(tempDir, 'main.py'), + `from pkg import module +import os + + +def caller(): + return module.func() + + +def external_caller(): + return os.getcwd() +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const caller = cg.getNodesByKind('function').filter((n) => n.name === 'caller')[0]; + expect(caller).toBeDefined(); + const calls = cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls'); + // module.func() must resolve to the real function in the submodule file. + expect(calls).toHaveLength(1); + const target = cg.getNode(calls[0]!.target); + expect(target?.name).toBe('func'); + expect(target?.filePath.replace(/\\/g, '/')).toBe('pkg/module.py'); + + // The flip side of the fix: an attribute call through a *stdlib* module + // (`os.getcwd()`) must still create no edge — the fallback only matches + // real in-repo module files. + const externalCaller = cg.getNodesByKind('function').filter((n) => n.name === 'external_caller')[0]; + expect(externalCaller).toBeDefined(); + const externalCalls = cg.getOutgoingEdges(externalCaller!.id).filter((e) => e.kind === 'calls'); + expect(externalCalls).toHaveLength(0); + }); + + it('attaches Go methods to their receiver type across files (#583, cross-file half)', async () => { + // In Go a type's methods are commonly declared in a different file from the + // `type` declaration (`type Box` in box.go, `func (b *Box) Get()` in + // box_methods.go). Extraction only attaches the struct→method `contains` + // edge when the type is in the SAME file (the owner lookup is file-scoped), + // so a cross-file method was orphaned from its struct — breaking member + // outlines and any callers/callees/impact traversal through `contains`. A + // resolution-phase pass now links them within the package (= directory). + fs.writeFileSync( + path.join(tempDir, 'box.go'), + 'package main\n\ntype Box struct{ v int }\n' + ); + fs.writeFileSync( + path.join(tempDir, 'box_methods.go'), + 'package main\n\nfunc (b *Box) Get() int { return b.v }\nfunc (b *Box) Set(x int) { b.v = x }\n' + ); + // Generic receiver declared cross-file too — exercises #583 half A + // (generic `*Stack[T]` receiver parsing) and half B (cross-file) together. + fs.writeFileSync( + path.join(tempDir, 'stack.go'), + 'package main\n\ntype Stack[T any] struct {\n\titems []T\n}\n' + ); + fs.writeFileSync( + path.join(tempDir, 'stack_push.go'), + 'package main\n\nfunc (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }\n' + ); + // A same-named type in another package must NOT capture this package's + // methods — the link is scoped to the receiver type's own directory. + fs.mkdirSync(path.join(tempDir, 'other')); + fs.writeFileSync( + path.join(tempDir, 'other', 'box.go'), + 'package other\n\ntype Box struct{ w int }\n' + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const methodsOf = (typeName: string, file: string): string[] => { + const node = cg + .getNodesByKind('struct') + .find((n) => n.name === typeName && n.filePath.replace(/\\/g, '/') === file); + expect(node, `${typeName} @ ${file}`).toBeDefined(); + return cg + .getOutgoingEdges(node!.id) + .filter((e) => e.kind === 'contains') + .map((e) => cg.getNode(e.target)) + .filter((n) => !!n && n.kind === 'method') + .map((n) => n!.name) + .sort(); + }; + + // Cross-file (non-generic) methods now attach to their struct. + expect(methodsOf('Box', 'box.go')).toEqual(['Get', 'Set']); + // Generic + cross-file. + expect(methodsOf('Stack', 'stack.go')).toEqual(['Push']); + // Cross-package isolation: other/Box defines no methods of its own. + expect(methodsOf('Box', 'other/box.go')).toEqual([]); + }); + it('TS type_alias object-shape members resolve method calls (#359)', async () => { // Pre-#359, `recorder.stop()` (recorder: RecorderHandle) attached // to `StdioMcpClient.stop` in a sibling directory via path-proximity @@ -1024,6 +1143,41 @@ public class DataExporter expect(userIncoming.length).toBeGreaterThanOrEqual(3); }); + it('C# primary-constructor parameters record their type dependencies (#237)', async () => { + // C# 12 primary constructors declare a type's injected dependencies inline + // (`class Svc(IRepo repo, [FromKeyedServices("k")] ICache cache)`). Each + // ctor parameter's type is recorded as a `references` edge from the class, + // so a DI-registered contract reached only through a primary ctor is no + // longer reported as having no dependents. + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src', 'Contracts.cs'), + `namespace App; +public interface IRepo { } +public class ICache { } +` + ); + fs.writeFileSync( + path.join(tempDir, 'src', 'OrderService.cs'), + `namespace App; +public sealed class OrderService(IRepo repo, [FromKeyedServices("primary")] ICache cache) +{ + public void Run() { } +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const svc = cg.getNodesByKind('class').find((n) => n.name === 'OrderService'); + expect(svc).toBeDefined(); + // The class itself must index (it used to vanish under the old grammar). + const out = cg.getOutgoingEdges(svc!.id).filter((e) => e.kind === 'references'); + const depNames = out.map((e) => cg.getNode(e.target)?.name); + expect(depNames).toContain('IRepo'); + expect(depNames).toContain('ICache'); // the keyed-DI ([FromKeyedServices]) dependency + }); + it('Go: leaves stdlib calls (fmt.Println, etc.) external', async () => { fs.writeFileSync( path.join(tempDir, 'go.mod'), @@ -1248,6 +1402,219 @@ func main() { const callers = cg.getCallers(signInNode!.id); expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); }); + + it('follows a default re-export of a .svelte component (export { default as Foo } from ./RealButton.svelte) (#629)', async () => { + // The ubiquitous Svelte/React component-barrel form. The leaf is a + // .svelte component (extracted as kind 'component', the default + // export). The re-export ALIAS (`Foo`) deliberately differs from the + // component's real name (`RealButton`) so the name-matcher fallback + // can't coincidentally connect them — the only path to the edge is + // the import-chase, which must match a `component` (not just + // function/class) for the default export. Otherwise the + // consumer↔component edge is never created and `callers` returns a + // false 0. + fs.mkdirSync(path.join(tempDir, 'src/lib'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/lib/RealButton.svelte'), + `\n\n\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/lib/index.ts'), + `export { default as Foo } from './RealButton.svelte';\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/Bar.svelte'), + `\n\n\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const fooNode = cg + .getNodesByKind('component') + .find((n) => n.name === 'RealButton' && n.filePath === 'src/lib/RealButton.svelte'); + expect(fooNode).toBeDefined(); + const callers = cg.getCallers(fooNode!.id); + expect(callers.some((c) => c.node.filePath === 'src/Bar.svelte')).toBe(true); + }); + + it('links an .astro page to the component and TS util it uses (#768)', async () => { + // The canonical Astro shape: a page imports a layout/component in + // frontmatter and uses it as a template tag; the component's template + // calls an imported .ts util. Both hops must produce graph edges or + // an Astro project is invisible to callers/impact. + fs.mkdirSync(path.join(tempDir, 'src/components'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'src/utils'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'src/pages'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/utils/format.ts'), + `export function formatDate(d: Date): string { return d.toISOString(); }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/components/PostCard.astro'), + `---\nimport { formatDate } from '../utils/format';\nconst { date } = Astro.props;\n---\n\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/pages/index.astro'), + `---\nimport PostCard from '../components/PostCard.astro';\n---\n\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + // Hop 1: page → component (template tag through the frontmatter import) + const cardNode = cg + .getNodesByKind('component') + .find((n) => n.name === 'PostCard' && n.filePath === 'src/components/PostCard.astro'); + expect(cardNode).toBeDefined(); + const cardCallers = cg.getCallers(cardNode!.id); + expect(cardCallers.some((c) => c.node.filePath === 'src/pages/index.astro')).toBe(true); + + // Hop 2: component template call → .ts util + const fmtNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'formatDate' && n.filePath === 'src/utils/format.ts'); + expect(fmtNode).toBeDefined(); + const fmtCallers = cg.getCallers(fmtNode!.id); + expect(fmtCallers.some((c) => c.node.filePath === 'src/components/PostCard.astro')).toBe(true); + }); + + it('resolves a bare directory import (import { x } from "." / "./") to index.ts (#629)', async () => { + // `import { helper } from '.'` (or './') must map to the + // directory's index.ts before the re-export chase can run. The + // barrel renames `realHelper` → `helper` so the name-matcher can't + // mask a path-resolution failure: only the bare-dir resolution + + // rename chase can connect the edge. + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/util.ts'), + `export function realHelper(): void {}\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/index.ts'), + `export { realHelper as helper } from './util';\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main.ts'), + `import { helper } from '.';\nexport function go(): void { helper(); }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main2.ts'), + `import { helper } from './';\nexport function go2(): void { helper(); }\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const helperNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'realHelper' && n.filePath === 'src/util.ts'); + expect(helperNode).toBeDefined(); + const callers = cg.getCallers(helperNode!.id); + expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); + expect(callers.some((c) => c.node.filePath === 'src/main2.ts')).toBe(true); + }); + + it('resolves a workspace package-subpath barrel (@scope/pkg/sub) to its index (#629)', async () => { + // bun/npm/pnpm workspace: `@scope/ui/widgets` → the `ui` package's + // `widgets/` subdir index, which re-exports a .svelte component. + // Alias `Thing` ≠ component `Widget` defeats the name-matcher, so + // only workspace-package resolution can connect the edge. + fs.mkdirSync(path.join(tempDir, 'packages/ui/widgets'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*'] }, null, 2) + ); + fs.writeFileSync( + path.join(tempDir, 'packages/ui/package.json'), + JSON.stringify({ name: '@scope/ui', version: '1.0.0' }, null, 2) + ); + fs.writeFileSync( + path.join(tempDir, 'packages/ui/widgets/Widget.svelte'), + `\n\n\n` + ); + fs.writeFileSync( + path.join(tempDir, 'packages/ui/widgets/index.ts'), + `export { default as Thing } from './Widget.svelte';\n` + ); + fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'app/App.svelte'), + `\n\n\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const buttonNode = cg + .getNodesByKind('component') + .find((n) => n.name === 'Widget' && n.filePath === 'packages/ui/widgets/Widget.svelte'); + expect(buttonNode).toBeDefined(); + const callers = cg.getCallers(buttonNode!.id); + expect(callers.some((c) => c.node.filePath === 'app/App.svelte')).toBe(true); + }); + + it('resolves a barrel import from a Vue SFC \n\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const runNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'realRun' && n.filePath === 'src/util.ts'); + expect(runNode).toBeDefined(); + const callers = cg.getCallers(runNode!.id); + expect(callers.some((c) => c.node.filePath === 'src/App.vue')).toBe(true); + }); + + it('follows a Vue component used in a