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$/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user