From 270e50655a84f163d1879c3313d61cf5c5067add Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sat, 20 Jun 2026 14:45:51 -0500 Subject: [PATCH] fix(explore): surface synth constant-endpoint edges + precise redux-thunk dispatch resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes hardening the redux-thunk dynamic-dispatch synthesizer, found by validating it on real RTK repos beyond its trezor origin (uwave-web, session-desktop, octo-call): - Surfacing: buildFlowFromNamedSymbols filtered its named set to CALLABLE kinds, so synthesized edges between `constant` nodes (RTK thunks are `const X = createAsyncThunk(...)`) never entered the Flow / Dynamic-dispatch links scan — invisible at every tier, while the kind-agnostic Relationships section is off below 500 files. Add a `dynNamed` set (named constant/variable/ field nodes with a heuristic edge) feeding a shared collectSynthLinks into the "## Dynamic-dispatch links" section, threaded through the named.size<2 early-out (both-endpoints-constant hit return EMPTY first) and the main path. Main call-chain stays callable-only; the <500 budget tiers are untouched. No-op for callable flows. Plus a generic synthEdgeNote fallback so any synth hop reads "dynamic: @site", not a bare "[calls]". - Precision: reduxThunkEdges resolved a dispatched name by first-match-by-kind, so a thunk name colliding with a same-named service function linked to the wrong node (octo-call `leaveCall`). Prefer thunk-signature const > other const > same-file callable > first match. Tests: new explore-synth-constant-endpoints.test.ts (surfacing on a small repo) + a collision case in redux-thunk-synthesizer.test.ts. Full suite green (1605). Rationale + coverage backlog in docs/design/dispatch-synthesizer-backlog.md. Co-Authored-By: Claude Opus 4.8 --- .../explore-synth-constant-endpoints.test.ts | 86 +++++++++++ __tests__/redux-thunk-synthesizer.test.ts | 47 ++++++ docs/design/dispatch-synthesizer-backlog.md | 145 ++++++++++++++++++ .../dynamic-dispatch-coverage-playbook.md | 3 + src/mcp/tools.ts | 121 ++++++++++----- src/resolution/callback-synthesizer.ts | 15 +- 6 files changed, 374 insertions(+), 43 deletions(-) create mode 100644 __tests__/explore-synth-constant-endpoints.test.ts create mode 100644 docs/design/dispatch-synthesizer-backlog.md diff --git a/__tests__/explore-synth-constant-endpoints.test.ts b/__tests__/explore-synth-constant-endpoints.test.ts new file mode 100644 index 0000000..50c8a35 --- /dev/null +++ b/__tests__/explore-synth-constant-endpoints.test.ts @@ -0,0 +1,86 @@ +/** + * Regression: codegraph_explore must SURFACE a synthesized edge whose endpoints are + * `constant` nodes (RTK thunk→thunk), on a SMALL repo. + * + * `buildFlowFromNamedSymbols` historically filtered its "named" set to CALLABLE kinds + * (method/function/component/constructor), excluding `constant`. RTK thunks are + * `export const X = createAsyncThunk(...)`, so a thunk→thunk hop is constant→constant — + * it never entered the flow scan and surfaced nowhere on the Flow path. The kind-agnostic + * "### Relationships" section would have caught it, but that is disabled below 500 files. + * Net: on a small RTK app the synthesized edge existed in the graph yet was invisible to + * the agent. The fix feeds a `dynNamed` set (named non-callable endpoints that participate + * in a heuristic edge) to the tier-independent "## Dynamic-dispatch links" scan. This test + * pins it on a deliberately tiny (<150-file) fixture so the Relationships gate is OFF and + * the dynamic-dispatch-links path is the ONLY thing that can surface the hop. + */ +import { describe, it, expect, beforeAll, afterAll, 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/index'; +import { ToolHandler } from '../src/mcp/tools'; + +// Assertions read RAW codegraph_explore output; managed offload would replace it. Disable +// it for this file so the suite is hermetic regardless of dev-machine config, then restore. +let _prevOffloadDisable: string | undefined; +beforeAll(() => { _prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; }); +afterAll(() => { + if (_prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE; + else process.env.CODEGRAPH_OFFLOAD_DISABLE = _prevOffloadDisable; +}); + +describe('codegraph_explore — synthesized constant→constant edges surface on small repos', () => { + let dir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + afterEach(() => { + if (cg) cg.destroy(); + if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('surfaces an RTK thunk→thunk hop (both `constant`) in the Dynamic-dispatch links section', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'explore-thunk-surface-')); + 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) => 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)); +}); +` + ); + + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + + // Precondition: the endpoints really are `constant` nodes — the exact kind the old + // CALLABLE-only flow scan dropped (if extraction ever classed them as functions the + // test would pass vacuously, so assert the case we actually fixed). + const db = (cg as any).db.db; + const outerKind = db.prepare(`SELECT kind FROM nodes WHERE name = 'outerThunk' LIMIT 1`).get()?.kind; + expect(outerKind).toBe('constant'); + + handler = new ToolHandler(cg); + const res = await handler.execute('codegraph_explore', { query: 'outerThunk innerThunk' }); + const text = res.content[0].text as string; + + // The synthesized hop now surfaces (was invisible: both endpoints `constant` AND the + // small-repo Relationships section is off). + expect(text).toContain('## Dynamic-dispatch links among your symbols'); + expect(text).toMatch(/outerThunk\s+→\s+innerThunk/); + // It reads as a dynamic-dispatch bridge with its wiring site, not a bare `calls`. + expect(text).toMatch(/dynamic: redux thunk @/); + expect(text).not.toMatch(/outerThunk\s+→\s+innerThunk\s+\[calls\]/); + }); +}); diff --git a/__tests__/redux-thunk-synthesizer.test.ts b/__tests__/redux-thunk-synthesizer.test.ts index 4494193..bde12e2 100644 --- a/__tests__/redux-thunk-synthesizer.test.ts +++ b/__tests__/redux-thunk-synthesizer.test.ts @@ -79,4 +79,51 @@ export const notAThunk = 'dispatch(innerThunk())'; 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/docs/design/dispatch-synthesizer-backlog.md b/docs/design/dispatch-synthesizer-backlog.md new file mode 100644 index 0000000..ebb6e40 --- /dev/null +++ b/docs/design/dispatch-synthesizer-backlog.md @@ -0,0 +1,145 @@ +# Dispatch-Synthesizer Backlog — the "dispatch-through-indirection" family + +**Audience:** a Claude agent continuing the coverage mission. +**Relationship to the playbook:** this is a *cross-cutting* companion to +[`dynamic-dispatch-coverage-playbook.md`](./dynamic-dispatch-coverage-playbook.md). +The playbook's §6 matrix is organized by **language × framework**. This doc is +organized by **dispatch *shape*** — because a single framework can contain several +distinct indirection shapes (Redux alone is ≥2: hand-written thunks vs RTK Query), +and several shapes recur identically across many frameworks/languages (a name→class +registry is the same problem in trezor `connect`, n8n nodes, and a VS Code command +palette). Redux-thunk (`synthesizedBy:'redux-thunk'`) was the first member shipped; +this is the queue behind it. + +Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but under-validated +· 🔬 hole identified · ⬜ not started · ⛔ deliberately not built (silent beats wrong). + +--- + +## The discipline (lessons already paid for — read before building any of these) + +1. **Build against ≥2 real repos that *contain the pattern*, from the start.** + redux-thunk was tuned on **trezor-suite alone (n=1)**. The obvious second repo, + **shapeshift/web**, fires **0** redux-thunk edges — and that 0 is *correct*: + shapeshift has **zero** `createAsyncThunk`/`createThunk` (it's an **RTK Query** + codebase, 14 `createApi` files). So shapeshift could neither confirm nor refute + generalization — it doesn't contain the shape. **A synthesizer validated on one + repo is unvalidated.** Pick the validation repos *by grepping for the pattern + first*, not by reputation. + +2. **"One framework" ≠ "one shape."** The trezor→shapeshift split is the proof: + - `createAsyncThunk` + thunk→thunk `dispatch(Y())` chains → **redux-thunk** ✅ (trezor) + - `createApi` + `builder.query/mutation` endpoints → hooks/components → **RTK Query** 🔬 (shapeshift) — a *different, unbuilt* synthesizer + - plain `dispatch(action)` → matching `reducer`/slice `case` → **slice-dispatch** ⬜ + Don't let "we did Redux" hide two-thirds of Redux. + +3. **Precision is free recall's price.** redux-thunk's 0-on-shapeshift is the *good* + kind of zero (no false edges on a non-thunk repo — same bar as the playbook's + "0 on every non-pattern control"). Every synthesizer below must show **0 on a + control that lacks the shape** *and* **non-zero + precise on ≥2 that have it**. + +4. **Two-part master lever still governs.** An edge only helps if a *realistic + symbol-named explore seeds a path it lies on*. A synthesizer whose far endpoint + no normal query names buys nothing (the trezor "11 explores" tail). Prefer shapes + where both endpoints are names an agent would actually type. + +5. **Partial coverage is worse than none** (playbook §7). Close each flow + *end-to-end* and re-measure; never ship a half-bridged flow. + +--- + +## The backlog (prioritized by frequency × static-resolvability × query-seedability) + +### Tier A — high traffic, cleanly static, build next + +| Shape | Ecosystem | The static anchor that bridges it | Mechanism | Status | +|---|---|---|---|---| +| **Name→class registry / command bus** | any (TS, .NET, Java, Go…) | a registry `{key: Class}` (object literal *or* module-namespace export) + a call naming `key`; bridge `key → Class.run/handle` by literal-key match, else camel↔Pascal convention | S (fan-out + name-match) | 🔬 **the most generalizable one.** trezor `getMethod` (`methods[method]→new MethodConstructor`), n8n node registry, VS Code commands, webpack loaders. **Hard sub-case:** trezor resolves via *dynamic import of a computed path* + runtime-string index + **case transform** (`'signTransaction'`→`SignTransaction`) — a fan-out (dispatcher→all registered classes) gated by name-match, like `gin-middleware-chain`. | +| **RTK Query** | TS / Redux Toolkit | `createApi({ endpoints: b => ({ getX: b.query(...) }) })` → generated `useGetXQuery` hook → component; endpoint name ↔ hook name (`getX`↔`useGetXQuery`) is convention | X (extract endpoints) + S (endpoint→hook) | 🔬 **found on shapeshift** (14 `createApi` files, currently invisible). The modern RTK default — likely higher traffic than hand thunks now. | +| **Vuex / Pinia** | Vue | `store.dispatch('ns/action')` / `commit('mutation')` → action/mutation by string key (namespaced) | S (string-keyed, like `event-emitter`) | ⬜ | +| **NgRx effects** | Angular | `createEffect(() => actions.pipe(ofType(LoginAction), …))` → effect handler; `Store.dispatch(new LoginAction())` → effect by action type/class | S (type/class-keyed) | ⬜ | + +### Tier B — backend command/event/message buses (each needs its own canonical flow + ≥2 repos) + +| Shape | Ecosystem | Anchor | Mechanism | Status | +|---|---|---|---|---| +| **MediatR / CQRS** | .NET | `IRequest` → `IRequestHandler` by the generic request type; `_mediator.Send(new GetFooQuery())` → handler | S (generic-type-keyed) | 🔬 named a frontier in CLAUDE.md, but it's statically keyable via the generic — worth a real attempt | +| **Celery / Sidekiq** | Python / Ruby | `@task`/`@shared_task` + `.delay()`/`.apply_async()`; `Worker.perform_async` → `perform` | R/X (decorator + name) | ⬜ | +| **Laravel / Spring events** | PHP / Java | `event(OrderShipped::class)` → `EventServiceProvider` listener map; `@EventListener onX(EventT)` → publisher by event type | R (mapped) | ⬜ | + +### Tier C — frontier, ⛔ do **not** build (no static anchor; would add noise) + +| Shape | Why not | +|---|---| +| **RxJS subscribe** | observable→observer is predominantly *anonymous* closures; no name to seed (playbook ⬜, deferred) | +| **MobX / Vue-reactivity / Solid signals** | Proxy reactive runtime — the edge doesn't exist statically at all; silent beats wrong (matches vue-core deferral) | +| **Redux-Saga** | generator `yield put()` / `takeEvery(ACTION, saga*)` — generator-body dispatch, materially harder; revisit only if a real repo demands it | + +### Already shipped (for context) + +| Shape | `synthesizedBy` | Validated on | +|---|---|---| +| Redux thunk | `redux-thunk` | ✅ **generalizes (2026-06-20)** — precise on uwave-web (small, 5 edges), session-desktop (medium, 2), trezor (large, 211); control shapeshift (RTK Query, no thunks) = 0. Receiver-agnostic (`api.dispatch`/`thunkApi.dispatch`/`window.…dispatch` all matched). **⚠️ 2 follow-ups below.** | +| (see playbook §6 / `callback-synthesizer.ts` for the other ~20 channels) | | | + +### redux-thunk follow-ups (found by the n>1 validation — this is exactly what it's for) + +1. **Precision: name-collision target resolution — ✅ FIXED (2026-06-20).** `reduxThunkEdges` + resolved the dispatched name via `getNodesByName(name).find(kind ∈ {constant,function, + method})` — first match wins, no preference for the thunk. On **octo-call**, `leaveCall` + collides (a `createAsyncThunk` const at `state/call.ts:201` *and* a service `function` + at `services/firestore-signaling.ts:253`); **both** edges mis-resolved to the *service + function*. trezor's long unique thunk names hid this. **Fix:** resolution now prefers a + thunk-signature const > other const > same-file callable > first match (single-candidate + unaffected). Verified: octo-call's 2 edges now target the thunk (`call.ts:201`); uwave's 5 + unchanged; regression test in `__tests__/redux-thunk-synthesizer.test.ts`. +2. **Surfacing: synth edges between non-callable nodes were invisible — ✅ ROOT-CAUSED + FIXED + (2026-06-20).** redux-thunk connects `constant` nodes (thunks are `const X=createAsyncThunk`), + but explore's flow machinery assumed callables, so the hop fell through both surfacing + paths: **(a)** `buildFlowFromNamedSymbols` filtered its named set to + `CALLABLE={method,function,component,constructor}` (tools.ts:1554) → constants never entered + the Flow scan / #687 Dynamic-dispatch-links loop, at any tier; **(b)** the kind-agnostic + `### Relationships` section (which *does* render constant→constant) is + `includeRelationships:false` below 500 files. Net: redux-thunk edges surfaced ONLY via + Relationships, ONLY on repos ≥500 files (uwave/octo-call showed nothing). **Fix (surgical, + tier-independent):** a `dynNamed` set of named CONSTANT/VARIABLE/FIELD nodes that participate + in a heuristic edge feeds the `## Dynamic-dispatch links` scan (main call-chain stays + callable-only); plus a generic `synthEdgeNote` fallback so any synth hop reads + `dynamic: @wiring-site`, not a bare `[calls]`. Verified: uwave `shufflePlaylist→ + loadPlaylist` and `register→login→initState` now surface; trezor unchanged; full suite + + new `__tests__/explore-synth-constant-endpoints.test.ts` pass. **No-op for callable flows** + (dynNamed stays empty) — so it generalizes: any future constant/variable/field-connecting + synth (RTK Query, Vuex) surfaces for free. + +--- + +## Per-synthesizer validation protocol (condensed from the playbook) + +For each shape, before marking ✅: +1. **Grep ≥3 real repos for the pattern**; keep the **2+ that contain it** (small/medium) + + **1 control that lacks it**. (Graph-level precision/recall validation does **not** + need not-trained-on repos — that constraint is only for *agent A/B baselines*.) +2. **Measure the hole**: `select count(*) from edges where synthesizedBy='X'` → + non-zero + node count stable (no explosion) on the pattern repos; **0 on the control**. +3. **Precision spot-check**: sample ~12 edges; source & target must both be real and the + indirection must actually exist in the source body. +4. **Seed a flow**: `scripts/agent-eval/probe-explore.mjs` with the shape's endpoint + symbol names → the Flow section shows the path through the synthesized hop. +5. **Agent A/B** (only for the headline repo, not every control): `--model sonnet + --effort high`, n≥2/arm, record Read/Grep/duration. + +--- + +## Immediate next actions + +- [ ] **Validate redux-thunk for real (workstream 1):** clone a small + medium + `createAsyncThunk`-using app (grep-confirmed), re-index, repeat the protocol. + Promote `redux-thunk` 🟡→✅ or fix the overfit. *(None of the 4 already-cloned + eval repos contain `createAsyncThunk`.)* +- [ ] **Decide trezor end (workstream 3):** the **name→class registry** synthesizer is + the valuable, generalizable Tier-A item (closes trezor's `getMethod` end *and* + n8n/VS-Code-class registries). The **facade** (`connect-common/factory.ts`) is + **low-value** — it collapses every method to a single `call` fan-in with no + per-method disambiguation; bridging it buys ~nothing. Build the registry, skip the facade. +- [ ] **RTK Query (workstream 2 spillover):** shapeshift handed us a free, real, + already-indexed target. Strong candidate right after redux-thunk is de-risked. diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index 213942a..8a5bb72 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -7,6 +7,9 @@ each one the same way, so cross-symbol *flows* exist in the graph everywhere. > This is the top-level playbook. The deep design for one mechanism (the callback > synthesizer) is in [`callback-edge-synthesis.md`](./callback-edge-synthesis.md). +> The cross-cutting **dispatch-shape** queue (Redux/RTK Query/NgRx/MediatR/registries — +> organized by indirection shape, not language×framework) is in +> [`dispatch-synthesizer-backlog.md`](./dispatch-synthesizer-backlog.md). > Full investigation context + findings: auto-memory `project_codegraph_read_displacement`. > **Update (2026-06-01):** the `codegraph_trace` and `codegraph_context` MCP tools were diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index c11ecd8..4421772 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1528,6 +1528,13 @@ export class ToolHandler { registeredAt, }; } + // Generic fallback for any other synthesizer (redux-thunk, gin-middleware-chain, + // flutter-build, …): a synthesized hop must never read as a bare static `calls`. + // It's a dynamic-dispatch bridge — label it as one and keep its wiring site. + if (typeof m?.synthesizedBy === 'string') { + const kind = m.synthesizedBy.replace(/-/g, ' '); + return { label: `${kind} (dynamic dispatch)`, compact: `dynamic: ${kind}${at}`, registeredAt }; + } return null; } @@ -1583,8 +1590,20 @@ export class ToolHandler { // A LARGE family that fails to connect on the chain is a polymorphic // interface/registry dispatch — surfaced by buildPolymorphicBoundaries below. const tokenFamily = new Map(); + // Non-callable endpoints (CONSTANT/VARIABLE/FIELD) connected by a SYNTHESIZED + // edge. RTK thunks are `const X = createAsyncThunk(...)`, so a thunk→thunk hop + // is constant→constant — the CALLABLE-only `named` set can't hold it, and + // without this the hop is invisible to the Flow path at every tier (the + // Relationships section catches it only on repos ≥500 files). Kept SEPARATE + // from `named` (which drives the call-chain + source sizing, callable-only); + // fed only to the dynamic-dispatch-links scan below. + const dynNamed = new Map(); + const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']); + const hasHeuristicEdge = (id: string): boolean => + [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic'); for (const t of tokens) { - const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind)); + const hits = this.findAllSymbols(cg, t).nodes; + const cands = hits.filter((n) => CALLABLE.has(n.kind)); tokenFamily.set(t, cands); // A qualified or otherwise-specific name (<=3 hits) keeps all; an // ambiguous simple name keeps only candidates whose container is named. @@ -1602,18 +1621,58 @@ export class ToolHandler { named.set(n.id, n); if (specific) uniqueNamedNodeIds.add(n.id); } + // Same token, non-callable synth endpoints (capped, precision-gated on an + // actual heuristic edge so plain config constants never qualify). + if (dynNamed.size < 12) { + for (const n of hits) { + if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id)) continue; + if (hasHeuristicEdge(n.id)) dynNamed.set(n.id, n); + if (dynNamed.size >= 12) break; + } + } if (named.size > 40) break; } + // Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING + // the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop + // already shown in the rendered main chain (a 2-node chain renders nothing, so a + // direct named→named synth hop still surfaces — #687). + const collectSynthLinks = (skipInChain: ((e: Edge) => boolean) | null): string[] => { + const synthLines: string[] = []; + const synthSeen = new Set(); + for (const n of [...named.values(), ...dynNamed.values()]) { + if (synthLines.length >= 6) break; + for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) { + if (synthLines.length >= 6) break; + if (edge.provenance !== 'heuristic' || other.id === n.id) continue; + if (skipInChain && skipInChain(edge)) continue; + const src = edge.source === n.id ? n : other; + const tgt = edge.source === n.id ? other : n; + const key = `${src.name}>${tgt.name}`; + if (synthSeen.has(key)) continue; + synthSeen.add(key); + const note = this.synthEdgeNote(edge); + synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`); + } + } + return synthLines; + }; if (named.size < 2) { - // The agent named a flow but only one side resolved (the other end is - // anonymous / runtime-registered / not extracted). The resolved side's - // body may still hold the dynamic-dispatch site that EXPLAINS the gap — - // surface that instead of silently returning nothing. - if (named.size === 0) return EMPTY; - const boundaries = this.buildDynamicBoundaries(cg, [...named.values()], named); - if (!boundaries) return EMPTY; - const text = boundaries + '> Full source for these symbols is below.\n'; - return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds, spineCallSites: new Map() }; + // <2 CALLABLES resolved. Two recoveries before giving up: (1) synthesized + // edges among named CONSTANT/VARIABLE endpoints — RTK thunk→thunk is + // constant→constant, so `named` can be empty while `dynNamed` holds the + // whole chain; (2) the one resolved callable's body may hold the + // dynamic-dispatch site that EXPLAINS a half-connected flow. + const synthLines = collectSynthLinks(null); + const boundaries = named.size === 0 ? '' : (this.buildDynamicBoundaries(cg, [...named.values()], named) || ''); + if (synthLines.length === 0 && !boundaries) return EMPTY; + const out: string[] = []; + if (synthLines.length) out.push( + '## Dynamic-dispatch links among your symbols', + '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', + '', ...synthLines, ''); + if (boundaries) out.push(boundaries); + out.push('> Full source for these symbols is below.\n'); + return { text: out.join('\n'), pathNodeIds: new Set(), namedNodeIds: new Set([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map() }; } const MAX_HOPS = 7; let best: Array<{ node: Node; edge: Edge | null }> | null = null; @@ -1709,36 +1768,16 @@ export class ToolHandler { if (polyCands.length) polyText = this.buildPolymorphicBoundaries(cg, polyCands, named); } - // Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED - // symbol — the indirect hops an agent would otherwise grep/Read to - // reconstruct ("where do the appended `validators` actually run?"). The - // synth edge IS that answer, so surface it even when the OTHER end wasn't - // named (e.g. the agent names `validate` but not the `didCompleteTask` - // that drains the collection). On-topic by construction: only heuristic - // edges touching a symbol the agent named; skipped when the hop already - // shows in the main chain. - const synthLines: string[] = []; - const synthSeen = new Set(); - for (const n of named.values()) { - if (synthLines.length >= 6) break; - for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) { - if (synthLines.length >= 6) break; - if (edge.provenance !== 'heuristic' || other.id === n.id) continue; - // "Already in the main chain" only applies when a chain RENDERS - // (hasMain). A 2-node chain populates pathIds but renders nothing, - // so a direct synthesized hop between two named symbols (custom - // EventBus emit→handler, #687) was invisible — too short for Flow, - // skipped here as in-chain. Surface it. - if (hasMain && pathIds.has(edge.source) && pathIds.has(edge.target)) continue; - const src = edge.source === n.id ? n : other; - const tgt = edge.source === n.id ? other : n; - const key = `${src.name}>${tgt.name}`; - if (synthSeen.has(key)) continue; - synthSeen.add(key); - const note = this.synthEdgeNote(edge); - synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`); - } - } + // Supplementary: dynamic-dispatch (synthesized) edges incident to a named + // symbol (incl. the non-callable CONSTANT endpoints in `dynNamed`) — the + // indirect hops an agent would otherwise grep/Read to reconstruct ("where do + // the appended `validators` actually run?"). Surfaced even when the OTHER end + // wasn't named. The skip drops a hop already in the rendered main chain; a + // 2-node chain renders nothing (hasMain false) so a direct named→named synth + // hop still surfaces — too short for Flow, but #687-visible here. + const synthLines = collectSynthLinks( + hasMain ? (e: Edge) => pathIds.has(e.source) && pathIds.has(e.target) : null + ); if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return EMPTY; const out: string[] = []; @@ -1768,7 +1807,7 @@ export class ToolHandler { // must keep full source even if it's an off-spine polymorphic sibling — the // agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql` // as the mechanism, not as an interchangeable leaf. See the skeleton gate. - return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds, spineCallSites }; + return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites }; } catch { return EMPTY; } diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index c82d8e9..81b57ed 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1681,9 +1681,20 @@ function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] while ((m = THUNK_DISPATCH_RE.exec(safe)) && added < THUNK_FANOUT_CAP) { const name = m[1]!; if (name === node.name) continue; // self-dispatch (recursive thunk) — skip - const target = ctx + // Resolve the dispatched name, PREFERRING the thunk/action-creator over a same-named + // service function. `dispatch(X(...))` dispatches a thunk or an action-creator (both + // `constant`s) — never an unrelated helper that merely shares the name. On octo-call, + // `leaveCall` is BOTH a `createAsyncThunk` const AND a service function, and the bare + // `.find()` picked the function (wrong). Order: thunk const > other const > same-file + // callable > first match. A single candidate (no collision) is unaffected. + const cands = ctx .getNodesByName(name) - .find((n) => n.kind === 'constant' || n.kind === 'function' || n.kind === 'method'); + .filter((n) => n.kind === 'constant' || n.kind === 'function' || n.kind === 'method'); + const target = + cands.find((n) => !!n.signature && THUNK_DECL_RE.test(n.signature)) ?? + cands.find((n) => n.kind === 'constant') ?? + cands.find((n) => n.filePath === node.filePath) ?? + cands[0]; if (!target || target.id === node.id) continue; const key = `${node.id}>${target.id}`; if (seen.has(key)) continue;