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:
Colby McHenry
2026-06-20 14:45:51 -05:00
co-authored by Claude Opus 4.8
parent e5897d0334
commit 270e50655a
6 changed files with 374 additions and 43 deletions
+47
View File
@@ -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$/);
});
});