fix(explore): surface synth constant-endpoint edges + precise redux-thunk dispatch resolution
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: <kind> @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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e5897d0334
commit
270e50655a
@@ -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\]/);
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T>` → `IRequestHandler<TReq,T>` 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: <kind> @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.
|
||||
@@ -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
|
||||
|
||||
+80
-41
@@ -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<string, Node[]>();
|
||||
// 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<string, Node>();
|
||||
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<string>();
|
||||
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<string, number>() };
|
||||
// <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<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map<string, number>() };
|
||||
}
|
||||
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<string>();
|
||||
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<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites };
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user