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/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/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 2cfedac4f..9d1606a75 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -1,98 +1,427 @@ { "_comment": "Test corpus for /agent-eval. Add entries freely. size: Small (<~150 files), Medium (~150-1500), Large (>~1500). 'question' is a representative architectural question that exercises cross-file understanding.", "TypeScript": [ - { "name": "ky", "repo": "https://github.com/sindresorhus/ky", "size": "Small", "files": "~25", "question": "How does ky implement request retries and timeouts?" }, - { "name": "excalidraw", "repo": "https://github.com/excalidraw/excalidraw", "size": "Medium", "files": "~600", "question": "How does Excalidraw render and update canvas elements?" }, - { "name": "vscode", "repo": "https://github.com/microsoft/vscode", "size": "Large", "files": "~10000", "question": "How does the extension host communicate with the main process?" } + { + "name": "ky", + "repo": "https://github.com/sindresorhus/ky", + "size": "Small", + "files": "~25", + "question": "How does ky implement request retries and timeouts?" + }, + { + "name": "excalidraw", + "repo": "https://github.com/excalidraw/excalidraw", + "size": "Medium", + "files": "~600", + "question": "How does Excalidraw render and update canvas elements?" + }, + { + "name": "vscode", + "repo": "https://github.com/microsoft/vscode", + "size": "Large", + "files": "~10000", + "question": "How does the extension host communicate with the main process?" + } ], "JavaScript": [ - { "name": "express", "repo": "https://github.com/expressjs/express", "size": "Small", "files": "~50", "question": "How does Express route a request through its middleware stack?" } + { + "name": "express", + "repo": "https://github.com/expressjs/express", + "size": "Small", + "files": "~50", + "question": "How does Express route a request through its middleware stack?" + } ], "Go": [ - { "name": "cobra", "repo": "https://github.com/spf13/cobra", "size": "Small", "files": "~50", "question": "How does cobra parse commands and flags?" }, - { "name": "gin", "repo": "https://github.com/gin-gonic/gin", "size": "Medium", "files": "~150", "question": "How does gin route requests through its middleware chain?" }, - { "name": "terraform", "repo": "https://github.com/hashicorp/terraform", "size": "Large", "files": "~4000", "question": "How does Terraform build and walk the resource dependency graph?" }, - { "name": "cosmos-sdk", "repo": "https://github.com/cosmos/cosmos-sdk", "size": "Large", "files": "~5000", "question": "How does a bank module MsgSend message reach the account balance update? Trace the cross-module call path from the bank keeper's Send handler through to the account/balance store update." } + { + "name": "cobra", + "repo": "https://github.com/spf13/cobra", + "size": "Small", + "files": "~50", + "question": "How does cobra parse commands and flags?" + }, + { + "name": "gin", + "repo": "https://github.com/gin-gonic/gin", + "size": "Medium", + "files": "~150", + "question": "How does gin route requests through its middleware chain?" + }, + { + "name": "terraform", + "repo": "https://github.com/hashicorp/terraform", + "size": "Large", + "files": "~4000", + "question": "How does Terraform build and walk the resource dependency graph?" + }, + { + "name": "cosmos-sdk", + "repo": "https://github.com/cosmos/cosmos-sdk", + "size": "Large", + "files": "~5000", + "question": "How does a bank module MsgSend message reach the account balance update? Trace the cross-module call path from the bank keeper's Send handler through to the account/balance store update." + } ], "Python": [ - { "name": "click", "repo": "https://github.com/pallets/click", "size": "Small", "files": "~60", "question": "How does click parse command-line arguments into commands?" }, - { "name": "flask", "repo": "https://github.com/pallets/flask", "size": "Medium", "files": "~90", "question": "How does Flask dispatch a request to a view function?" }, - { "name": "django", "repo": "https://github.com/django/django", "size": "Large", "files": "~2700", "question": "How does Django's ORM build and execute a query from a QuerySet?" } + { + "name": "click", + "repo": "https://github.com/pallets/click", + "size": "Small", + "files": "~60", + "question": "How does click parse command-line arguments into commands?" + }, + { + "name": "flask", + "repo": "https://github.com/pallets/flask", + "size": "Medium", + "files": "~90", + "question": "How does Flask dispatch a request to a view function?" + }, + { + "name": "django", + "repo": "https://github.com/django/django", + "size": "Large", + "files": "~2700", + "question": "How does Django's ORM build and execute a query from a QuerySet?" + } ], "Rust": [ - { "name": "clap", "repo": "https://github.com/clap-rs/clap", "size": "Medium", "files": "~200", "question": "How does clap parse arguments against a derived command definition?" }, - { "name": "tokio", "repo": "https://github.com/tokio-rs/tokio", "size": "Large", "files": "~700", "question": "How does tokio schedule and run async tasks on its runtime?" }, - { "name": "deno", "repo": "https://github.com/denoland/deno", "size": "Large", "files": "~1500", "question": "How does Deno load and execute a TypeScript module?" } + { + "name": "clap", + "repo": "https://github.com/clap-rs/clap", + "size": "Medium", + "files": "~200", + "question": "How does clap parse arguments against a derived command definition?" + }, + { + "name": "tokio", + "repo": "https://github.com/tokio-rs/tokio", + "size": "Large", + "files": "~700", + "question": "How does tokio schedule and run async tasks on its runtime?" + }, + { + "name": "deno", + "repo": "https://github.com/denoland/deno", + "size": "Large", + "files": "~1500", + "question": "How does Deno load and execute a TypeScript module?" + } ], "Java": [ - { "name": "gson", "repo": "https://github.com/google/gson", "size": "Medium", "files": "~200", "question": "How does Gson serialize an object to JSON?" }, - { "name": "okhttp", "repo": "https://github.com/square/okhttp", "size": "Medium", "files": "~640", "question": "How does OkHttp process a request through its interceptor chain?" }, - { "name": "guava", "repo": "https://github.com/google/guava", "size": "Large", "files": "~3000", "question": "How does Guava's CacheBuilder build and configure a cache?" } + { + "name": "gson", + "repo": "https://github.com/google/gson", + "size": "Medium", + "files": "~200", + "question": "How does Gson serialize an object to JSON?" + }, + { + "name": "okhttp", + "repo": "https://github.com/square/okhttp", + "size": "Medium", + "files": "~640", + "question": "How does OkHttp process a request through its interceptor chain?" + }, + { + "name": "guava", + "repo": "https://github.com/google/guava", + "size": "Large", + "files": "~3000", + "question": "How does Guava's CacheBuilder build and configure a cache?" + } ], "Kotlin": [ - { "name": "koin", "repo": "https://github.com/InsertKoinIO/koin", "size": "Medium", "files": "~300", "question": "How does Koin resolve and inject dependencies?" }, - { "name": "leakcanary", "repo": "https://github.com/square/leakcanary", "size": "Medium", "files": "~250", "question": "How does LeakCanary detect and analyze a memory leak?" } + { + "name": "koin", + "repo": "https://github.com/InsertKoinIO/koin", + "size": "Medium", + "files": "~300", + "question": "How does Koin resolve and inject dependencies?" + }, + { + "name": "leakcanary", + "repo": "https://github.com/square/leakcanary", + "size": "Medium", + "files": "~250", + "question": "How does LeakCanary detect and analyze a memory leak?" + } ], "Swift": [ - { "name": "alamofire", "repo": "https://github.com/Alamofire/Alamofire", "size": "Small", "files": "~100", "question": "How does Alamofire build, send, and validate a request?" } + { + "name": "alamofire", + "repo": "https://github.com/Alamofire/Alamofire", + "size": "Small", + "files": "~100", + "question": "How does Alamofire build, send, and validate a request?" + } ], "C#": [ - { "name": "serilog", "repo": "https://github.com/serilog/serilog", "size": "Medium", "files": "~250", "question": "How does Serilog route a log event to its sinks?" }, - { "name": "jellyfin", "repo": "https://github.com/jellyfin/jellyfin", "size": "Large", "files": "~2500", "question": "How does Jellyfin scan and identify items in a media library?" } + { + "name": "serilog", + "repo": "https://github.com/serilog/serilog", + "size": "Medium", + "files": "~250", + "question": "How does Serilog route a log event to its sinks?" + }, + { + "name": "jellyfin", + "repo": "https://github.com/jellyfin/jellyfin", + "size": "Large", + "files": "~2500", + "question": "How does Jellyfin scan and identify items in a media library?" + } ], "Ruby": [ - { "name": "sinatra", "repo": "https://github.com/sinatra/sinatra", "size": "Small", "files": "~60", "question": "How does Sinatra match a request to a route handler?" }, - { "name": "discourse", "repo": "https://github.com/discourse/discourse", "size": "Large", "files": "~3000", "question": "How does Discourse create and render a new post?" } + { + "name": "sinatra", + "repo": "https://github.com/sinatra/sinatra", + "size": "Small", + "files": "~60", + "question": "How does Sinatra match a request to a route handler?" + }, + { + "name": "discourse", + "repo": "https://github.com/discourse/discourse", + "size": "Large", + "files": "~3000", + "question": "How does Discourse create and render a new post?" + } ], "PHP": [ - { "name": "slim", "repo": "https://github.com/slimphp/Slim", "size": "Small", "files": "~80", "question": "How does Slim handle a request through its middleware?" }, - { "name": "laravel", "repo": "https://github.com/laravel/framework", "size": "Large", "files": "~3000", "question": "How does Laravel resolve and dispatch a route to a controller?" } + { + "name": "slim", + "repo": "https://github.com/slimphp/Slim", + "size": "Small", + "files": "~80", + "question": "How does Slim handle a request through its middleware?" + }, + { + "name": "laravel", + "repo": "https://github.com/laravel/framework", + "size": "Large", + "files": "~3000", + "question": "How does Laravel resolve and dispatch a route to a controller?" + } ], "C": [ - { "name": "redis", "repo": "https://github.com/redis/redis", "size": "Large", "files": "~600", "question": "How does Redis parse and dispatch a client command?" } + { + "name": "redis", + "repo": "https://github.com/redis/redis", + "size": "Large", + "files": "~600", + "question": "How does Redis parse and dispatch a client command?" + } ], "C++": [ - { "name": "json", "repo": "https://github.com/nlohmann/json", "size": "Small", "files": "~100", "question": "How does nlohmann::json parse a JSON string into a value?" }, - { "name": "grpc", "repo": "https://github.com/grpc/grpc", "size": "Large", "files": "~3000", "question": "How does gRPC dispatch an incoming RPC to its handler?" } + { + "name": "json", + "repo": "https://github.com/nlohmann/json", + "size": "Small", + "files": "~100", + "question": "How does nlohmann::json parse a JSON string into a value?" + }, + { + "name": "grpc", + "repo": "https://github.com/grpc/grpc", + "size": "Large", + "files": "~3000", + "question": "How does gRPC dispatch an incoming RPC to its handler?" + } ], "Dart": [ - { "name": "flutter", "repo": "https://github.com/flutter/flutter", "size": "Large", "files": "~6000", "question": "How does Flutter build and lay out a widget tree?" } + { + "name": "flutter", + "repo": "https://github.com/flutter/flutter", + "size": "Large", + "files": "~6000", + "question": "How does Flutter build and lay out a widget tree?" + } ], "Svelte": [ - { "name": "shadcn-svelte", "repo": "https://github.com/huntabyte/shadcn-svelte", "size": "Medium", "files": "~600", "question": "How do shadcn-svelte components compose and apply their styling?" } + { + "name": "shadcn-svelte", + "repo": "https://github.com/huntabyte/shadcn-svelte", + "size": "Medium", + "files": "~600", + "question": "How do shadcn-svelte components compose and apply their styling?" + } ], "Lua": [ - { "name": "lualine.nvim", "repo": "https://github.com/nvim-lualine/lualine.nvim", "size": "Small", "files": "~120", "question": "How does lualine assemble and render its statusline sections and components?" }, - { "name": "telescope.nvim", "repo": "https://github.com/nvim-telescope/telescope.nvim", "size": "Medium", "files": "~80", "question": "How does Telescope wire a picker to its finder, sorter, and previewer?" }, - { "name": "kong", "repo": "https://github.com/Kong/kong", "size": "Large", "files": "~1330", "question": "How does Kong execute plugins across a request's lifecycle phases?" } + { + "name": "lualine.nvim", + "repo": "https://github.com/nvim-lualine/lualine.nvim", + "size": "Small", + "files": "~120", + "question": "How does lualine assemble and render its statusline sections and components?" + }, + { + "name": "telescope.nvim", + "repo": "https://github.com/nvim-telescope/telescope.nvim", + "size": "Medium", + "files": "~80", + "question": "How does Telescope wire a picker to its finder, sorter, and previewer?" + }, + { + "name": "kong", + "repo": "https://github.com/Kong/kong", + "size": "Large", + "files": "~1330", + "question": "How does Kong execute plugins across a request's lifecycle phases?" + } ], "Luau": [ - { "name": "Knit", "repo": "https://github.com/Sleitnick/Knit", "size": "Small", "files": "~10", "question": "How does Knit register services and expose them to clients?" }, - { "name": "vide", "repo": "https://github.com/centau/vide", "size": "Small", "files": "~40", "question": "How does vide track reactive sources and re-run effects when state changes?" }, - { "name": "Fusion", "repo": "https://github.com/dphfox/Fusion", "size": "Medium", "files": "~115", "question": "How does Fusion build and update its reactive UI graph from state objects?" } + { + "name": "Knit", + "repo": "https://github.com/Sleitnick/Knit", + "size": "Small", + "files": "~10", + "question": "How does Knit register services and expose them to clients?" + }, + { + "name": "vide", + "repo": "https://github.com/centau/vide", + "size": "Small", + "files": "~40", + "question": "How does vide track reactive sources and re-run effects when state changes?" + }, + { + "name": "Fusion", + "repo": "https://github.com/dphfox/Fusion", + "size": "Medium", + "files": "~115", + "question": "How does Fusion build and update its reactive UI graph from state objects?" + } ], "Objective-C": [ - { "name": "Masonry", "repo": "https://github.com/SnapKit/Masonry", "size": "Small", "files": "~50", "question": "How does Masonry build and activate Auto Layout constraints from its block DSL?" }, - { "name": "FMDB", "repo": "https://github.com/ccgus/fmdb", "size": "Medium", "files": "~80", "question": "How does FMDB execute a prepared SQL statement and bind parameters?" }, - { "name": "SDWebImage", "repo": "https://github.com/SDWebImage/SDWebImage", "size": "Large", "files": "~400", "question": "How does SDWebImage download, cache, and decode an image for a UIImageView?" } + { + "name": "Masonry", + "repo": "https://github.com/SnapKit/Masonry", + "size": "Small", + "files": "~50", + "question": "How does Masonry build and activate Auto Layout constraints from its block DSL?" + }, + { + "name": "FMDB", + "repo": "https://github.com/ccgus/fmdb", + "size": "Medium", + "files": "~80", + "question": "How does FMDB execute a prepared SQL statement and bind parameters?" + }, + { + "name": "SDWebImage", + "repo": "https://github.com/SDWebImage/SDWebImage", + "size": "Large", + "files": "~400", + "question": "How does SDWebImage download, cache, and decode an image for a UIImageView?" + } ], "Mixed iOS (Swift+ObjC)": [ - { "name": "Charts", "repo": "https://github.com/danielgindi/Charts", "size": "Small", "files": "~270", "question": "How does the ChartsDemo ObjC demo controller drive the Swift Charts library to animate and notify a data update?" }, - { "name": "realm-swift", "repo": "https://github.com/realm/realm-swift", "size": "Medium", "files": "~370", "question": "How does a Swift `Realm.write { realm.add(obj) }` reach the Objective-C persistence layer?" }, - { "name": "wikipedia-ios", "repo": "https://github.com/wikimedia/wikipedia-ios", "size": "Large", "files": "~1700", "question": "How does tapping a search result reach the article-fetch network call across the Swift / ObjC boundary?" } + { + "name": "Charts", + "repo": "https://github.com/danielgindi/Charts", + "size": "Small", + "files": "~270", + "question": "How does the ChartsDemo ObjC demo controller drive the Swift Charts library to animate and notify a data update?" + }, + { + "name": "realm-swift", + "repo": "https://github.com/realm/realm-swift", + "size": "Medium", + "files": "~370", + "question": "How does a Swift `Realm.write { realm.add(obj) }` reach the Objective-C persistence layer?" + }, + { + "name": "wikipedia-ios", + "repo": "https://github.com/wikimedia/wikipedia-ios", + "size": "Large", + "files": "~1700", + "question": "How does tapping a search result reach the article-fetch network call across the Swift / ObjC boundary?" + } ], "React Native (legacy bridge + TurboModule)": [ - { "name": "@react-native-async-storage", "repo": "https://github.com/react-native-async-storage/async-storage", "size": "Small", "files": "~60", "question": "How does `setItem` in JS reach the native `legacy_multiSet` implementation?" }, - { "name": "react-native-svg", "repo": "https://github.com/software-mansion/react-native-svg", "size": "Medium", "files": "~700", "question": "How does a JS `Svg.getTotalLength(...)` reach the iOS / Android native implementation via TurboModule?" }, - { "name": "react-native-firebase", "repo": "https://github.com/invertase/react-native-firebase", "size": "Large", "files": "~1100", "question": "How does a native iOS push notification reach the JS `messaging().onMessage(...)` listener?" } + { + "name": "@react-native-async-storage", + "repo": "https://github.com/react-native-async-storage/async-storage", + "size": "Small", + "files": "~60", + "question": "How does `setItem` in JS reach the native `legacy_multiSet` implementation?" + }, + { + "name": "react-native-svg", + "repo": "https://github.com/software-mansion/react-native-svg", + "size": "Medium", + "files": "~700", + "question": "How does a JS `Svg.getTotalLength(...)` reach the iOS / Android native implementation via TurboModule?" + }, + { + "name": "react-native-firebase", + "repo": "https://github.com/invertase/react-native-firebase", + "size": "Large", + "files": "~1100", + "question": "How does a native iOS push notification reach the JS `messaging().onMessage(...)` listener?" + } ], "Expo Modules": [ - { "name": "expo-haptics", "repo": "https://github.com/expo/expo/tree/main/packages/expo-haptics", "size": "Small", "files": "~15", "question": "How does `Haptics.notificationAsync(...)` in JS reach `UINotificationFeedbackGenerator` in the Swift Module?" }, - { "name": "expo-camera", "repo": "https://github.com/expo/expo/tree/main/packages/expo-camera", "size": "Medium", "files": "~70", "question": "How does a JS `CameraView.takePictureAsync(options)` reach the native AVCaptureSession / CameraDevice call?" } + { + "name": "expo-haptics", + "repo": "https://github.com/expo/expo/tree/main/packages/expo-haptics", + "size": "Small", + "files": "~15", + "question": "How does `Haptics.notificationAsync(...)` in JS reach `UINotificationFeedbackGenerator` in the Swift Module?" + }, + { + "name": "expo-camera", + "repo": "https://github.com/expo/expo/tree/main/packages/expo-camera", + "size": "Medium", + "files": "~70", + "question": "How does a JS `CameraView.takePictureAsync(options)` reach the native AVCaptureSession / CameraDevice call?" + } ], "React Native Fabric (view components)": [ - { "name": "react-native-segmented-control", "repo": "https://github.com/react-native-segmented-control/segmented-control", "size": "Small", "files": "~25", "question": "How does JSX `` reach the native onChange handler on iOS/Android?" }, - { "name": "react-native-screens", "repo": "https://github.com/software-mansion/react-native-screens", "size": "Medium", "files": "~1200", "question": "How does JSX `` reach the native RNSScreenStackView component?" }, - { "name": "react-native-skia", "repo": "https://github.com/Shopify/react-native-skia", "size": "Large", "files": "~1000", "question": "How does a `` JSX usage reach the iOS / Android native renderer?" } + { + "name": "react-native-segmented-control", + "repo": "https://github.com/react-native-segmented-control/segmented-control", + "size": "Small", + "files": "~25", + "question": "How does JSX `` reach the native onChange handler on iOS/Android?" + }, + { + "name": "react-native-screens", + "repo": "https://github.com/software-mansion/react-native-screens", + "size": "Medium", + "files": "~1200", + "question": "How does JSX `` reach the native RNSScreenStackView component?" + }, + { + "name": "react-native-skia", + "repo": "https://github.com/Shopify/react-native-skia", + "size": "Large", + "files": "~1000", + "question": "How does a `` JSX usage reach the iOS / Android native renderer?" + } + ], + "R": [ + { + "name": "AnomalyDetection", + "repo": "https://github.com/twitter/AnomalyDetection", + "size": "Small", + "files": "~24", + "question": "How does AnomalyDetectionTs go from the exported entry function to the underlying S-H-ESD statistical test? Name the functions on the path in order." + }, + { + "name": "dplyr", + "repo": "https://github.com/tidyverse/dplyr", + "size": "Medium", + "files": "~450", + "question": "When mutate() is called on a grouped data frame, which functions handle the grouping and expression evaluation, in order, from mutate() down?" + }, + { + "name": "ggplot2", + "repo": "https://github.com/tidyverse/ggplot2", + "size": "Large", + "files": "~1150", + "question": "When a ggplot object is printed, how does the plot actually get built and drawn \u2014 trace the path from print/plot to where geoms render. Name the key functions in order." + } ] -} +} \ No newline at end of file diff --git a/.cursor/rules/codegraph.mdc b/.cursor/rules/codegraph.mdc index 00a3f8152..17d144a60 100644 --- a/.cursor/rules/codegraph.mdc +++ b/.cursor/rules/codegraph.mdc @@ -1,37 +1,22 @@ --- -description: CodeGraph MCP usage guide — when to use which tool +description: CodeGraph MCP usage guide — one tool, codegraph_explore alwaysApply: true --- ## CodeGraph -This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot. +This project has a CodeGraph MCP server configured, exposing a single tool: `codegraph_explore`. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot. -### When to prefer codegraph over native search +### Use codegraph_explore instead of reading files -Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open. - -| Question | Tool | -|---|---| -| "Where is X defined?" / "Find symbol named X" | `codegraph_search` | -| "What calls function Y?" | `codegraph_callers` | -| "What does Y call?" | `codegraph_callees` | -| "How does X reach/become Y? / trace the flow from X to Y" | `codegraph_trace` (one call = the whole path, incl. callback/React/JSX dynamic hops) | -| "What would break if I changed Z?" | `codegraph_impact` | -| "Show me Y's signature / source / docstring" | `codegraph_node` | -| "Give me focused context for a task/area" | `codegraph_context` | -| "See several related symbols' source at once" | `codegraph_explore` | -| "What files exist under path/" | `codegraph_files` | -| "Is the index healthy?" | `codegraph_status` | +Reach for `codegraph_explore` before grep/find or Read for any **structural** question — how does X work, how does X reach Y, what calls what, where is X defined, or surveying an area. It takes a natural-language question or a bag of symbol/file names and returns the relevant symbols' **verbatim, line-numbered source** grouped by file (the same `\t` shape Read gives you, safe to Edit from), plus the call paths between them — including dynamic-dispatch hops (callbacks, React re-render, JSX children) grep can't follow — and a blast-radius summary of what depends on them. Name a file or symbol in the query to read its current source. ### Rules of thumb -- **Answer directly — don't delegate exploration.** For "how does X work" / architecture questions, answer with 2-3 codegraph calls: `codegraph_context` first, then ONE `codegraph_explore` for the source of the symbols it surfaces. For a specific **flow** ("how does X reach Y") start with `codegraph_trace` from→to — one call returns the whole path with dynamic hops bridged — then ONE `codegraph_explore` for the bodies; don't rebuild the path with `codegraph_search` + `codegraph_callers`. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer. +- **Answer directly — don't delegate exploration.** ONE `codegraph_explore` usually answers the whole question; follow up with another `codegraph_explore` naming more specific symbols if you need more. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer. - **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context. -- **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call. -- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call. -- **Don't loop `codegraph_node` over many symbols** — one `codegraph_explore` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more. -- **Index lag — check the staleness banner, don't guess a wait.** When a codegraph response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Files NOT in that banner are fresh and codegraph is authoritative for them. `codegraph_status` also lists pending files under "Pending sync". +- **Don't grep or Read first** to find or understand indexed code — one `codegraph_explore` returns the relevant source in a single round-trip. Reach for raw Read/Grep only to confirm a specific detail codegraph didn't cover, or for what it doesn't index (configs, docs). +- **Index lag — check the staleness banner, don't guess a wait.** When a codegraph response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Files NOT in that banner are fresh and codegraph is authoritative for them. ### If `.codegraph/` doesn't exist diff --git a/.gitignore b/.gitignore index 7c949e1c2..4963a4b52 100644 --- a/.gitignore +++ b/.gitignore @@ -40,10 +40,15 @@ npm-debug.log* # Local Claude settings .claude/settings.local.json .claude/scheduled_tasks.lock +.claude/handoffs/ # Parallels Windows VM SSH/connection config (local machine, see CLAUDE.md) .parallels +# Confidential business / product / strategy docs — must NOT land in the +# public engine repo (see the IP boundary in CLAUDE.md) +docs/business/ + # CodeGraph data directories (in test projects) .codegraph/ @@ -56,3 +61,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..e62e6d53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,206 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- **Claude Code:** an optional front-load hook makes your agent reach for CodeGraph automatically. When you ask a structural question — "how does X work", "what calls Y", "trace the flow from A to B" — CodeGraph injects the relevant source and call paths into the prompt up front, so the agent answers from the graph instead of grepping around to rebuild it. You're asked during `codegraph install` (default yes; Claude Code only, since it's the agent with prompt hooks), it's removed by `codegraph uninstall`, and `codegraph upgrade` turns it on for existing Claude setups. It's strictly additive and degradable — non-structural prompts and un-indexed projects are left alone — and you can switch it off any time without uninstalling by setting `CODEGRAPH_NO_PROMPT_HOOK=1`. +- Vue store actions, mutations, and getters are now indexed as symbols you can find and read. Whether your store is **Vuex** (`mutations` / `actions` objects in a module) or **Pinia** — both the options form (`defineStore({ actions: { … } })`) and the setup form (`defineStore('id', () => { … })`, where actions are local functions) — each action, mutation, and getter is now a real node. So `codegraph search` finds `login` or `getSessionList`, and `codegraph_explore` / `codegraph_node` show its body and what it calls, instead of "not found" because the function only existed as an object-literal property. +- `codegraph_explore` now connects a Vue component to the **Pinia** store action it calls. When code does `const store = useUserStore()` and then `store.fetchUser()`, that call now links through to the `fetchUser` action in the store module — so "what happens when this view loads its data?" traces from the component into the action's body instead of stopping at the `store.fetchUser()` line. Works for both Pinia store styles (options and setup), and stays precise (a built-in like `store.$patch()` or an unrelated same-named method isn't mislinked). +- `codegraph_explore` now follows **Vuex** string dispatch. A `dispatch('user/login')` or `commit('SET_TOKEN')` call — namespaced `'module/action'` keys included — now links to the action or mutation it names, resolved to the correct store module even when several modules share an action name (and without being fooled by a same-named `api/` helper). So "what runs when this dispatches?" traces from the call into the store handler and on to the mutations it commits. Vuex's canonical `export default { namespaced, actions, mutations }` module shape is now indexed too, so those handlers are findable symbols. +- `codegraph_explore` now connects React data-fetching flows built on **RTK Query** (Redux Toolkit's `createApi`). An endpoint defined inside `createApi({ endpoints })` and the `useGetXQuery` / `useUpdateYMutation` hook it generates were both invisible to analysis — so "what does this component fetch?" or "where does `useGetThingQuery` get its data?" dead-ended, because the hook, the endpoint, and the component had nothing linking them. CodeGraph now indexes each endpoint and each generated hook as real symbols and wires the path `component → useGetXQuery → getX → queryFn`, so the flow resolves in one explore call instead of reading the API slice by hand. Both the arrow (`endpoints: build => ({ … })`) and method (`endpoints(builder) { return { … } }`) styles are recognized, along with the `useLazyGetXQuery` variant; hand-written hooks of a similar name are left untouched. +- `codegraph_explore` now follows **Celery** task dispatch in Python. A `send_email.delay(...)` or `send_email.apply_async(...)` call now links to the `@shared_task` / `@app.task` function it runs — typically defined in a different module (`tasks.py`) from where it's triggered (a view or service) — so "what actually happens when this is dispatched?" traces from the call site straight into the task body instead of stopping at the `.delay()` line. Both decorator dialects are recognized (bare `@shared_task` and the arg'd `@app.task(bind=True, …)` form), including the module-qualified `tasks.invalidate_cache.apply_async()` call style. It stays precise: a `.delay()` on something that isn't a Celery task is never mislinked, so a project that doesn't use Celery is unaffected. +- `codegraph_explore` now follows **Spring application events** in Java. A `publishEvent(new OrderShippedEvent(...))` call now links to every `@EventListener` that handles that event — usually in a different class — so "what reacts when this is published?" traces from the publisher straight into each listener method instead of dead-ending at `publishEvent(...)`. The link is by event type, and all the common listener styles are recognized: a `@EventListener` typed on its parameter, the `@EventListener(SomeEvent.class)` form, `@TransactionalEventListener`, and the older `implements ApplicationListener`. One event fans out to all its listeners, and a plain Spring app with no event bus is unaffected. +- `codegraph_explore` now follows **MediatR** request and notification dispatch in C#/.NET. A `_mediator.Send(command)` or `_mediator.Publish(notification)` call now links to the `Handle` method of the matching `IRequestHandler<>` / `INotificationHandler<>` — usually in a different file in a Clean Architecture layout — so "what handles this command?" traces from the controller straight into the handler instead of stopping at the mediator call. The sent type is recognized whether it's constructed inline (`Send(new GetFooQuery())`), built into a local first (`var cmd = new …; Send(cmd)`), or passed in as a parameter, and it's matched by type — so a `MessagingCenter.Send(...)` or a same-named DTO that isn't a request is never mislinked, and a project without MediatR is unaffected. +- `codegraph_explore` now follows **Sidekiq** background-job dispatch in Ruby. A `DestroyUserWorker.perform_async(id)` (or `.perform_in` / `.perform_at`) call now links to that worker's `perform` method — usually in `app/workers/` away from the controller or model that enqueues it — so "what runs in the background here?" traces from the enqueue straight into the job body. Both the modern `include Sidekiq::Job` and the older `Sidekiq::Worker` are recognized, namespaced workers resolve to the right class even when several share a name (e.g. `Comments::NotifyWorker` vs `Articles::NotifyWorker`), and Rails ActiveJob's `perform_later` — a different mechanism — is intentionally left alone. +- `codegraph_explore` now follows **Laravel events** in PHP. An `event(new OrderShipped($order))` call now links to every listener that handles it — each listener's `handle()` method, usually a separate `app/Listeners/` class — so "what reacts to this event?" traces from the dispatch straight into the listener bodies. Listeners are found both ways Laravel registers them: by a typed `handle(OrderShipped $event)` (auto-discovery, including a `handle(A|B $event)` union that listens for two events) and by the `protected $listen` map in your `EventServiceProvider` (which also catches a listener whose `handle()` has no type-hint). One event fans out to all its listeners, and queued jobs — dispatched via `::dispatch()` rather than `event()` — are correctly left out. +- CodeGraph now understands **Lombok**-generated methods in Java. `@Getter`, `@Setter`, `@Data`, `@Value`, and `@Builder` generate getters, setters, `builder()`, `equals`/`hashCode`/`toString`, and the `@Slf4j` `log` field at compile time, so those methods never appear in the source — and a `user.getName()`, `User.builder()`, or `log.info(...)` call used to resolve to nothing, silently breaking call-chain analysis (the agent would conclude the method didn't exist and reconstruct it by hand). Those members are now indexed from the annotations and fields, so they appear in `codegraph search` and `codegraph_explore`/`codegraph_node`, and callers trace through them like any hand-written method. They're marked as Lombok-generated so they read as generated, not hand-written; a method you write yourself is never overridden, static fields get no accessor, and a class without Lombok is unaffected. Thanks @git87663849. (#912) +- `codegraph_explore` now follows **C and C++ function-pointer dispatch**. C does polymorphism with function pointers: a struct carries a function-pointer field, concrete functions are registered into it through a table (`static struct cmd commands[] = {{"add", cmd_add}, …}`), a designated initializer (`.handler = on_open`), or an assignment, and the code dispatches indirectly (`p->fn(argv)`). None of that was visible to analysis — the indirect call resolved to nothing, so `git`'s command runner looked like it called nothing and a vtable's implementations had no callers. CodeGraph now links the dispatch site to the registered handlers, keyed by the struct field, so "what runs when this dispatches?" traces from `p->fn(...)` into every function registered for that field. This covers the command-table idiom (git, redis) and the ops-struct/vtable idiom (curl's content-encoders, protocol handlers), including the case where a generic hook slot is reassigned from a registry (`h->func = found->fn`). It stays precise — distinct function-pointer fields don't cross-link, a plain data field is never treated as a dispatch, and a project without function-pointer dispatch is unaffected. (#932) +- `codegraph_explore` now follows **GoFrame** route bindings in Go. GoFrame's standard router wires routes reflectively: the path and method live in a `g.Meta` struct tag on a request type (`` g.Meta `path:"/user/sign-in" method:"post"` ``), the controller method that serves it is matched by that request type, and the two are joined at runtime by `group.Bind(...)` — so there was no path string and no edge from a route to its handler, and "where is `/user/sign-in` handled?" or "where are the routes bound to controllers?" could only be answered by reading. CodeGraph now indexes each `g.Meta` route as a real route node and links it to the controller method whose signature takes that request type, so a route resolves to its handler structurally in one `codegraph_explore` call. The link is by request type, not method name — so it's correct even when the two differ (a `DeptSearchReq` served by a `List` method); it tells apart the many identical request types a large app defines one-per-module (`cash.ListReq` vs `order.ListReq`) by package, including cloned addon modules; and a route whose handler isn't present is left unlinked rather than guessed. (#747) + +- `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking. +- Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off. +- C file-scope constants and globals — `static const` scalars, pointer/array lookup tables, and shared mutable globals — are now recognized as symbols in their own right. They previously weren't extracted at all, so they never appeared in search or carried any dependents; now they show up in `codegraph search` and participate in impact analysis (see above), so changing a C lookup table surfaces the same-file functions that read it. +- Java `static final` constants, C# `const` / `static readonly` constants, Scala `object` vals, and Kotlin top-level / `object` / `companion object` `val`s are now classified as constants rather than generic fields, so they participate in the constant-reader impact analysis above — change a `public static final` table, a `const string`, a Scala `object Config { val Timeout = … }`, or a Kotlin `companion object { const val … }` and the methods that read it now show up as affected. (Per-object Java `final` / C# `readonly` / Scala & Kotlin `class` instance properties are unchanged.) Kotlin constants were previously not indexed as their own symbols at all, so they now also appear in `codegraph search`. +- Swift top-level `let`s and `static let` constants (including those namespaced in an `enum`/`struct`, the common Swift pattern) are now indexed as constants and participate in the constant-reader impact analysis above — change a `static let defaultRetryLimit` or an `enum Constants { static let … }` and the same-file code that reads it shows up as affected. Computed properties and per-instance `let`s are not treated as constants. +- Dart top-level `const`/`final` and class `static const`/`static final` constants are now indexed as constants and participate in the constant-reader impact analysis above. Instance fields, `var`s, and locals are not treated as constants. (Generated Dart code with the standard `.g.dart`/`.freezed.dart`/`.pb.dart` suffixes is already skipped.) +- You can now teach CodeGraph about custom file extensions. Drop a `codegraph.json` at your repo root with an `extensions` map — `{ "extensions": { ".dota_lua": "lua", ".tpl": "php" } }` — and files with those extensions get indexed under the language you name, instead of being silently skipped because the extension wasn't one of the built-in defaults. It's opt-in and committed alongside your code so the whole team shares it, your mappings layer on top of the built-ins and win on conflict (you can even re-point a built-in, e.g. `.h` → `cpp`), and a typo'd language or a malformed config is warned about and skipped rather than breaking indexing. Projects without a `codegraph.json` behave exactly as before. (#906) + +### Fixes + +- `codegraph index` and `codegraph init` no longer crawl during the "Resolving refs" phase on large projects — most painfully ones that mix a big front-end and back-end, where the phase could stretch to many minutes. A package or module imported across hundreds or thousands of files (`react`, a shared UI package, Python `logging` / `typing`) was being treated as if every one of those import statements might be its definition, so the resolver compared each import against all the others — work that grows with the *square* of how widely a package is imported, which is why it blew up only on big, import-heavy repos. Imports now resolve straight to the definitions they actually point at, so those redundant comparisons are gone (reference resolution is dramatically faster on large repos), and the graph no longer accumulates the meaningless import-to-import links the old fallback created. (#915) +- MCP tool results no longer show up as oversized headings in Markdown-rendering clients (such as the Claude Code VSCode extension). Results used Markdown headings (`##`/`###`) for things like the status summary, each search hit, and every file section in an exploration, so a normal query filled the transcript with large-font lines — worst with `codegraph_search` and `codegraph_explore`, where the noise grew with the number of results. Section headers are now bold labels, which render at normal text size while keeping the same structure. Terminal/CLI output is unchanged. (#778) +- An MCP server pointed at a very large repository (tens of thousands of files) no longer hangs on the first tool call after a fresh start. On startup CodeGraph reconciles its index against the current files on disk, and on a huge repo that reconcile could run for minutes while blocking the very first request — long enough that the background server was sometimes force-restarted mid-scan, so the first query never came back at all. The reconcile now yields as it runs (keeping the server responsive instead of pinning it), and the first tool call waits only briefly for it before answering and letting the rest finish in the background — so you get a fast first response and the index still catches up. Set `CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS` to tune how long that first call waits (default 3000ms), or `=0` to always wait for the full reconcile. (#905) +- `codegraph install` now wires up your agents and stops there — it no longer indexes the current directory. Building a project's graph is always the explicit `codegraph init` (or `codegraph index`), so you decide what gets indexed and when, and the steps are the same whether you installed globally or just for one project. This clears up the confusion where a project-local install silently indexed but a global one didn't, and where the docs and the tool disagreed about whether you still had to run `init`. (#826) +- React components declared with `forwardRef`, `memo`, or styled-components / emotion (`const Button = forwardRef(...)`, `const Card = memo(...)`, `const Box = styled.button\`…\``) are now recognized as components, so finding where they're used works. Before, they were indexed as plain constants, so `codegraph callers` and impact analysis reported "no callers found" even when the component was rendered across dozens of files — a dangerous false "safe to change" right before refactoring a shared component. Now every `; +} +` + ); + const db = await index(); + + // The render edge exists and is the synthesized jsx-render kind. + const edgeRows = db + .prepare( + `SELECT s.name caller 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') = 'jsx-render' + AND t.kind = 'component' AND t.name = 'Button'` + ) + .all(); + expect(edgeRows.map((r: any) => r.caller)).toContain('Page'); + + // ...and it surfaces through the public callers API (the issue's symptom: + // "No callers found" before the fix). + const buttonId = db + .prepare("SELECT id FROM nodes WHERE name='Button' AND kind='component'") + .get().id as string; + const callers = cg.getCallers(buttonId).map((c: any) => c.node.name); + expect(callers).toContain('Page'); + }); + + it('captures the inner render-fn body callees under the component', async () => { + fs.writeFileSync( + path.join(dir, 'widget.tsx'), + `import * as React from 'react'; +function useThing() { return 1; } +export const Widget = React.forwardRef((props, ref) => { + const v = useThing(); + return
{v}
; +}); +` + ); + const db = await index(); + const rows = db + .prepare( + `SELECT t.name FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = 'Widget' AND s.kind = 'component' + AND e.kind = 'calls' AND t.name = 'useThing'` + ) + .all(); + expect(rows.length).toBeGreaterThanOrEqual(1); + }); + + it('does not misclassify non-component PascalCase consts (precision)', async () => { + fs.writeFileSync( + path.join(dir, 'controls.tsx'), + `import * as React from 'react'; +const cache = memo(expensiveFn); +export const Config = loadConfig(); +export const Client = new ApiClient(); +export const Styles = styledHelper(); +export const Total = [1, 2].reduce((a, b) => a + b, 0); +export const Theme = { color: 'red' }; +` + ); + const db = await index(); + for (const name of ['Config', 'Client', 'Styles', 'Total', 'Theme']) { + expect(kindsOf(db, name), `${name} must stay a constant`).toContain('constant'); + expect(kindsOf(db, name), `${name} must not be a component`).not.toContain('component'); + } + // A lowercase-named memo() result is a memoization util, not a component. + expect(kindsOf(db, 'cache')).not.toContain('component'); + }); +}); 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__/redux-thunk-synthesizer.test.ts b/__tests__/redux-thunk-synthesizer.test.ts new file mode 100644 index 000000000..bde12e241 --- /dev/null +++ b/__tests__/redux-thunk-synthesizer.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +/** + * End-to-end test for the redux-thunk dispatch-chain synthesizer. + * + * `createAsyncThunk(prefix, async (a, api) => {...})` passes the async body as an argument, so + * tree-sitter never makes it its own function node — the thunk `constant`'s body calls (incl. + * `dispatch(nextThunk(...))`) are orphaned and `callees(thunk)` is empty. Verify the synthesizer + * body-scans each thunk constant and links it → each dispatched thunk, so the chain + * `outer → inner → deep` connects end-to-end; and that a non-thunk constant is skipped. + */ +describe('redux-thunk synthesizer', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redux-thunk-fixture-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('links each thunk constant to the thunks it dispatches, and skips non-thunks', async () => { + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } }) + ); + fs.writeFileSync( + path.join(dir, 'thunks.ts'), + `import { createAsyncThunk } from '@reduxjs/toolkit'; + +export const deepThunk = createAsyncThunk('app/deep', async (n: number) => { + return n * 2; +}); + +export const innerThunk = createAsyncThunk('app/inner', async (n: number, { dispatch }) => { + return dispatch(deepThunk(n)); +}); + +export const outerThunk = createAsyncThunk('app/outer', async (n: number, { dispatch }) => { + await dispatch(innerThunk(n)); +}); + +// Non-thunk constant that only MENTIONS dispatch in a string — must be skipped. +export const notAThunk = 'dispatch(innerThunk())'; +` + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT s.name source_name, s.kind source_kind, t.name target_name, + json_extract(e.metadata,'$.via') via, + json_extract(e.metadata,'$.registeredAt') registeredAt + 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') = 'redux-thunk'` + ) + .all(); + cg.close?.(); + + // The dispatch chain connects: outer → inner → deep. + const pairs = new Set(rows.map((r: any) => `${r.source_name}>${r.target_name}`)); + expect(pairs.has('outerThunk>innerThunk')).toBe(true); + expect(pairs.has('innerThunk>deepThunk')).toBe(true); + + // Sources are thunk constants; the non-thunk string constant is never a source. + expect(rows.every((r: any) => r.source_kind === 'constant')).toBe(true); + expect(rows.some((r: any) => r.source_name === 'notAThunk')).toBe(false); + + // Edges are 'calls' with the wiring site surfaced for the agent. + const outer = rows.find((r: any) => r.source_name === 'outerThunk'); + expect(outer.via).toBe('innerThunk'); + expect(outer.registeredAt).toMatch(/thunks\.ts:\d+/); + }); + + it('on a name collision, a dispatch resolves to the THUNK, not a same-named service function', async () => { + // Regression for the octo-call case: `leaveCall` exists as BOTH a `createAsyncThunk` + // const and an unrelated service function. `dispatch(leaveCall())` targets the thunk, + // but the old first-match resolver could pick the function. The resolver now prefers a + // thunk-signature const > other const > same-file > first. + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } }) + ); + // A plain service function that shares the name `leaveCall` with the thunk below. + fs.writeFileSync(path.join(dir, 'service.ts'), `export function leaveCall(id: string) { return id; }\n`); + fs.writeFileSync( + path.join(dir, 'thunks.ts'), + `import { createAsyncThunk } from '@reduxjs/toolkit'; + +export const leaveCall = createAsyncThunk('call/leave', async () => { + return 1; +}); + +export const logout = createAsyncThunk('user/logout', async (_: void, { dispatch }) => { + dispatch(leaveCall()); +}); +` + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + + const db = (cg as any).db.db; + const row = db + .prepare( + `SELECT t.kind target_kind, t.file_path target_file + 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') = 'redux-thunk' + AND s.name = 'logout' AND t.name = 'leaveCall'` + ) + .get(); + cg.close?.(); + + expect(row).toBeTruthy(); + // Resolved to the createAsyncThunk constant in thunks.ts, NOT service.ts's function. + expect(row.target_kind).toBe('constant'); + expect(row.target_file).toMatch(/thunks\.ts$/); + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 03b8ea6ab..caf41c7df 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', () => { @@ -757,6 +768,46 @@ def bootstrap(): expect(callsToUserService).toHaveLength(0); }); + it('resolves a cross-file static method call to the method, not the class (#825)', async () => { + // `Foo.bar()` where `Foo` is an imported class must link to the static + // method `Foo::bar`, NOT to the class `Foo`. Previously the import + // resolver dropped the `.bar` member and resolved to `Foo`, which the + // calls→instantiates promotion then turned into `run instantiates Foo`, + // leaving the static method with zero callers and a hollow impact radius. + fs.writeFileSync( + path.join(tempDir, 'helpers.ts'), + `export class Foo {\n static bar(x: number) { return x + 1; }\n}\n` + ); + fs.writeFileSync( + path.join(tempDir, 'caller.ts'), + `import { Foo } from './helpers';\nexport function run() { return Foo.bar(41); }\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const bar = cg.getNodesByKind('method').find((n) => n.name === 'bar'); + const foo = cg.getNodesByKind('class').find((n) => n.name === 'Foo'); + const run = cg.getNodesByKind('function').find((n) => n.name === 'run'); + expect(bar).toBeDefined(); + expect(foo).toBeDefined(); + expect(run).toBeDefined(); + + // `run` is reported as a caller of the static method `Foo.bar`. + const barCallers = cg.getCallers(bar!.id).map((c) => c.node.name); + expect(barCallers).toContain('run'); + + // And the call is NOT mis-promoted to `run instantiates Foo`. + const outgoing = cg.getOutgoingEdges(run!.id); + expect( + outgoing.filter((e) => e.kind === 'instantiates' && e.target === foo!.id) + ).toHaveLength(0); + // The real edge is a `calls` edge to the method. + expect( + outgoing.some((e) => e.kind === 'calls' && e.target === bar!.id) + ).toBe(true); + }); + it('resolves Go cross-package qualified calls via go.mod module path (#388)', async () => { // Pre-#388, every `pkga.FuncX(...)` call in a Go monorepo was flagged // external (isExternalImport returned true for any non-`/internal/` @@ -853,6 +904,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 +1183,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 +1442,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