diff --git a/AGENTS.md b/AGENTS.md
index fd5daee8e..d46e3cc67 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,7 +80,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the
- `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread.
- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`).
- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws),
- `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
+ `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed), `synthesized-hop.ts` (how a synthesized hop reads, in explore's Flow, the `codegraph_node` trail and ContextBuilder's call paths — a new synthesizer's wording goes here). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
- `src/context/` — `ContextBuilder` + formatter for markdown/JSON output.
- `src/search/` — full-text query parser and helpers for FTS5.
- `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c748563f..3ff4b07c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -43,6 +43,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Indexing a project that includes large bundled JavaScript files, such as a copy of pdf.js or d3, is fast again: since 1.6.2, resolving the calls in a JavaScript or TypeScript file re-read the file's text above each call, so a single bundled library could add many seconds to an index. The graph it builds is unchanged. Thanks @bompus for the report. (#2334)
- Indexing a project that ships a large minified JavaScript file, such as the GraphiQL bundle inside go-ethereum, no longer stalls at "Resolving refs" when CodeGraph runs on Node 22, and linking such a file is faster on any Node version. The graph it builds is unchanged.
- The time `codegraph init` and `codegraph index` print beside the node and edge counts now covers the whole run, resolving references and linking included, and `codegraph sync` reports its whole run the same way. Before, it counted only reading and parsing the files, which can be a small part of an index, so a slow index looked fast. Thanks @bompus for the report. (#2334)
+- The call paths `codegraph context` prints now say what bridges each dynamic-dispatch hop, in the same words `codegraph_explore` uses: a call through an interface reads `interface → impl`, and a C++ override, a Redux thunk or a Gin middleware chain is named as one. Before, every hop of a kind it didn't know was labeled an "event", so a call through an interface looked like an event being emitted.
- In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328)
- Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326)
- In JavaScript and TypeScript, the functions written inside a named object literal are now symbols of their own even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE or a function, and a namespace hung on the page or on another object, like `window.App = { init() {…} }`, `App.utils = {…}` or `dw_page = {…}`. Calls such as `api.load()`, `window.App.init()`, `App.utils.pad()`, a sibling's `this.render()`, and `App.init()` from another script on the page now reach them, the calls made inside a member belong to that member instead of the object, and a bare `init()` is no longer taken for `App.init`, so script-tag apps no longer lose most of their code from `callers` and impact. A member's qualified name now carries its object, exported or not (`api::load`, `window.App::init`), so asking for `App.init` finds it. Re-index JavaScript and TypeScript projects after upgrading. Thanks @tkhoaaa for the report and @danusha2345. (#2300)
diff --git a/__tests__/synthesized-hop.test.ts b/__tests__/synthesized-hop.test.ts
new file mode 100644
index 000000000..22a17ed8b
--- /dev/null
+++ b/__tests__/synthesized-hop.test.ts
@@ -0,0 +1,119 @@
+/**
+ * One wording for a synthesized (dynamic-dispatch) hop, on both surfaces that
+ * print one: codegraph_explore's Flow and the codegraph_node trail
+ * (`dynamic: …`), and ContextBuilder's "## Call paths" (`→[…]`, what
+ * `buildContext` and `codegraph context` print). The call paths used to keep
+ * their own list, and every synthesizer missing from it — interface dispatch,
+ * a C++ override, a redux thunk — read as an "event".
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+import { describeSynthesizedHop } from '../src/graph/synthesized-hop';
+import type { Edge } from '../src/types';
+
+describe('an interface-impl hop on both surfaces', () => {
+ const query = 'runExport exportReport renderPdf';
+ let dir: string;
+ let cg: CodeGraph;
+ // Assertions read RAW codegraph_explore output; managed offload would replace it.
+ let prevOffloadDisable: string | undefined;
+
+ beforeAll(async () => {
+ prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE;
+ process.env.CODEGRAPH_OFFLOAD_DISABLE = '1';
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-synth-hop-'));
+ // One file each: ContextBuilder keeps only a few nodes per file, and with
+ // everything in one file the interface method is the node it drops.
+ const files: Record = {
+ 'src/exporter.ts': `export interface Exporter {
+ exportReport(data: string): string;
+}
+`,
+ 'src/pdf-exporter.ts': `import type { Exporter } from './exporter';
+import { renderPdf } from './render';
+
+export class PdfExporter implements Exporter {
+ exportReport(data: string): string {
+ return renderPdf(data);
+ }
+}
+`,
+ 'src/render.ts': `export function renderPdf(data: string): string {
+ return '%PDF ' + data;
+}
+`,
+ 'src/run.ts': `import type { Exporter } from './exporter';
+
+export function runExport(exporter: Exporter): string {
+ return exporter.exportReport('q3');
+}
+`,
+ };
+ for (const [rel, body] of Object.entries(files)) {
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
+ fs.writeFileSync(path.join(dir, rel), body);
+ }
+ cg = CodeGraph.initSync(dir);
+ await cg.indexAll();
+ });
+
+ afterAll(() => {
+ cg?.close();
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
+ if (prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE;
+ else process.env.CODEGRAPH_OFFLOAD_DISABLE = prevOffloadDisable;
+ });
+
+ it('ContextBuilder call paths label it interface dispatch, not "event"', async () => {
+ // Precondition: the hop is a real synthesized edge, not a static call.
+ const [iface] = cg.getNodesByName('exportReport').filter((n) => n.qualifiedName === 'Exporter::exportReport');
+ expect(cg.getOutgoingEdges(iface!.id).map((e) => e.metadata?.synthesizedBy)).toContain('interface-impl');
+
+ const md = String(await cg.buildContext(query, { format: 'markdown' }));
+ expect(md).toContain('## Call paths');
+ const callPaths = md.slice(md.indexOf('## Call paths'));
+ expect(callPaths).toContain(
+ '- runExport → exportReport →[interface → impl @src/pdf-exporter.ts:5] exportReport → renderPdf'
+ );
+ expect(callPaths).not.toContain('→[event');
+ });
+
+ it('codegraph_explore tags the same hop with the same words', async () => {
+ const res = await new ToolHandler(cg).execute('codegraph_explore', { query });
+ expect(res.content?.[0]?.text).toContain('↓ dynamic: interface → impl @src/pdf-exporter.ts:5');
+ });
+});
+
+describe('describeSynthesizedHop', () => {
+ const at = 'src/wire.ts:7';
+ const edge = (metadata: Record): Edge =>
+ ({ source: 'a', target: 'b', kind: 'calls', provenance: 'heuristic', metadata: { registeredAt: at, ...metadata } });
+
+ it.each([
+ // Synthesizers the call paths used to call an "event".
+ [{ synthesizedBy: 'interface-impl', via: 'handle' }, 'interface → impl', ''],
+ [{ synthesizedBy: 'interface-impl', via: 'Report', promotedInto: 'scrapeLoop' }, 'interface → method promoted into scrapeLoop', ''],
+ [{ synthesizedBy: 'closure-collection', field: 'validators' }, 'runs `validators` handlers', ''],
+ [{ synthesizedBy: 'cpp-override', via: 'Run' }, 'cpp override', ''],
+ [{ synthesizedBy: 'redux-thunk', via: 'fetchUser' }, 'redux thunk', ''],
+ [{ synthesizedBy: 'gin-middleware-chain', via: 'Auth' }, 'gin middleware chain', ''],
+ [{ synthesizedBy: 'event-bus', channel: 'event', event: 'user.created' }, 'bus event `user.created`', ''],
+ // The detail only the call paths print.
+ [{ synthesizedBy: 'queue-job', event: 'send-email', queue: 'emails' }, 'queue job `send-email`', ' on `emails`'],
+ [{ synthesizedBy: 'event-bus', channel: 'socket', event: 'chat', tier: 'client→server' }, 'socket message `chat`', ' → server'],
+ [{ synthesizedBy: 'http-client', method: 'POST', href: '/api/users' }, 'HTTP POST /api/users', " — the client's call onto its own route"],
+ ])('%o reads "%s"', (metadata, summary, detail) => {
+ expect(describeSynthesizedHop(edge(metadata))).toEqual({ summary, detail, registeredAt: at });
+ });
+
+ it('is null for a static edge and for a heuristic edge no synthesizer made', () => {
+ expect(describeSynthesizedHop({ source: 'a', target: 'b', kind: 'calls', provenance: 'tree-sitter' })).toBeNull();
+ expect(describeSynthesizedHop({ ...edge({ synthesizedBy: 'callback', via: 'on' }), provenance: undefined })).toBeNull();
+ expect(describeSynthesizedHop(edge({ confidence: 0.5 }))).toBeNull();
+ expect(describeSynthesizedHop(null)).toBeNull();
+ });
+});
diff --git a/src/context/index.ts b/src/context/index.ts
index acf1cf5c3..4f924eb40 100644
--- a/src/context/index.ts
+++ b/src/context/index.ts
@@ -28,6 +28,7 @@ import { validatePathWithinRoot, isConfigLeafNode } from '../utils';
import { isTestFile, extractSearchTerms, scorePathRelevance, getStemVariants, isDistinctiveIdentifier } from '../search/query-utils';
import { LOW_CONFIDENCE_MARKER } from './markers';
import { findNamedCopybooks, isCopybookInclude } from '../graph/cobol-copybooks';
+import { describeSynthesizedHop } from '../graph/synthesized-hop';
/**
* Most top-level declarations of one copybook that become entry points — its
@@ -403,31 +404,17 @@ export class ContextBuilder {
const name = (id: string): string => subgraph.nodes.get(id)?.name ?? id;
// Synthesized (dynamic-dispatch) hops are real `calls` edges but invisible to
- // static parsing — mark them inline so the agent sees WHERE the callback was
- // wired up (`registered @file:line`) instead of grepping for it. Keyed by
- // "source>target".
+ // static parsing — mark them inline so the agent sees WHAT bridged the hop
+ // and WHERE it was wired up (`@file:line`) instead of grepping for it. The
+ // wording is `graph/synthesized-hop.ts`'s, the same explore's Flow uses, plus
+ // the detail this roomier line has space for. Keyed by "source>target".
const synthByPair = new Map();
for (const e of subgraph.edges) {
- if (e.kind !== 'calls' || e.provenance !== 'heuristic') continue;
- const m = e.metadata as Record | undefined;
- if (!m?.synthesizedBy) continue;
- const at = typeof m.registeredAt === 'string' ? ` @${m.registeredAt}` : '';
- const label = m.synthesizedBy === 'callback'
- ? `callback via ${m.via ? `\`${String(m.via)}\`` : 'registrar'}${at}`
- : m.synthesizedBy === 'react-render'
- ? `React re-render via setState${at}`
- : m.synthesizedBy === 'jsx-render'
- ? `renders <${String(m.via || 'child')}>`
- : m.synthesizedBy === 'vue-handler'
- ? `Vue @${String(m.event || 'event')} handler`
- : m.synthesizedBy === 'http-client'
- ? `HTTP ${String(m.method || 'GET')} ${String(m.href || '')} — the client's call onto its own route${at}`
- : m.synthesizedBy === 'queue-job'
- ? `queue job ${m.event ? `\`${String(m.event)}\`` : ''}${m.queue ? ` on \`${String(m.queue)}\`` : ''}${at}`
- : m.synthesizedBy === 'event-bus' && m.channel === 'socket'
- ? `socket message ${m.event ? `\`${String(m.event)}\`` : ''}${m.tier === 'client→server' ? ' → server' : m.tier === 'server→client' ? ' → client' : ''}${at}`
- : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
- synthByPair.set(`${e.source}>${e.target}`, label);
+ if (e.kind !== 'calls') continue;
+ const hop = describeSynthesizedHop(e);
+ if (!hop) continue;
+ const at = hop.registeredAt ? ` @${hop.registeredAt}` : '';
+ synthByPair.set(`${e.source}>${e.target}`, `${hop.summary}${hop.detail}${at}`);
}
const renderChain = (c: string[]): string => {
let s = name(c[0]!);
@@ -447,7 +434,7 @@ export class ContextBuilder {
...kept.map((c) => `- ${renderChain(c)}`),
'',
hasSynth
- ? '_Hops marked `[callback/event …]` are dynamic dispatch bridged by codegraph (with the registration site); the rest are direct calls. codegraph_node any symbol for its body._'
+ ? '_Hops marked `[…]` are dynamic dispatch bridged by codegraph (with the registration site); the rest are direct calls. codegraph_node any symbol for its body._'
: '_codegraph_node any symbol above for its source + its own callers/callees._',
];
return '\n' + lines.join('\n') + '\n';
diff --git a/src/graph/synthesized-hop.ts b/src/graph/synthesized-hop.ts
new file mode 100644
index 000000000..741749c7a
--- /dev/null
+++ b/src/graph/synthesized-hop.ts
@@ -0,0 +1,82 @@
+/**
+ * How a synthesized hop reads — what bridged the call, and where it was wired.
+ *
+ * Static parsing cannot see a handler registered in one place and run in
+ * another, so the resolver synthesizes the `calls` edge across it
+ * (`provenance: 'heuristic'`, `metadata.synthesizedBy`, and usually a
+ * `metadata.registeredAt` wiring site). Whatever prints that hop has to say what
+ * bridged it, or the hop reads as a direct call — or as the wrong mechanism.
+ *
+ * Two surfaces print one. `codegraph_explore` (its Flow section and its
+ * dynamic-dispatch links) and the `codegraph_node` trail tag the hop
+ * `dynamic: callback via \`subscribe\` @src/store.ts:4`; ContextBuilder's
+ * "## Call paths" (`buildContext`, `codegraph context`) writes it inside the
+ * arrow, `→[callback via \`subscribe\` @src/store.ts:4]`. Each used to keep its
+ * own list, and the shorter one called every synthesizer it did not know —
+ * interface dispatch, a C++ override, a redux thunk — an "event". So the
+ * wording lives here once, and each surface renders it.
+ */
+
+import type { Edge } from '../types';
+
+export interface SynthesizedHop {
+ /** What bridged the hop: "callback via `subscribe`", "interface → impl", "redux thunk". */
+ summary: string;
+ /**
+ * What the call-paths rendering adds after the summary — the queue a job runs
+ * on, a socket message's direction, what an HTTP hop is; '' when nothing.
+ */
+ detail: string;
+ /** Where the hop was wired up (`file:line`), when the synthesizer recorded it. */
+ registeredAt?: string;
+}
+
+/**
+ * Describe a synthesized (dynamic-dispatch) edge; null for an ordinary static
+ * one. A synthesizer not named below still reads as dynamic dispatch, by its own
+ * name and with its wiring site — never as a bare call or a borrowed label.
+ */
+export function describeSynthesizedHop(edge: Edge | null | undefined): SynthesizedHop | null {
+ const m = edge?.provenance === 'heuristic' ? (edge.metadata as Record | undefined) : undefined;
+ const by = m?.synthesizedBy;
+ if (!m || typeof by !== 'string') return null;
+ const registeredAt = typeof m.registeredAt === 'string' ? m.registeredAt : undefined;
+ const hop = (summary: string, detail = ''): SynthesizedHop => ({ summary, detail, registeredAt });
+ const quoted = (value: unknown, otherwise: string): string => (value ? `\`${String(value)}\`` : otherwise);
+ switch (by) {
+ case 'callback':
+ return hop(`callback via ${quoted(m.via, 'a registrar')}`);
+ case 'http-client': {
+ const request = `${String(m.method ?? 'GET')} ${String(m.href ?? '')}`.trim();
+ return hop(`HTTP ${request}`, " — the client's call onto its own route");
+ }
+ case 'queue-job':
+ return hop(`queue job ${quoted(m.event, 'a job')}`, m.queue ? ` on \`${String(m.queue)}\`` : '');
+ case 'event-bus': {
+ const what = m.channel === 'socket' ? 'socket message' : 'bus event';
+ const direction = m.tier === 'client→server' ? ' → server' : m.tier === 'server→client' ? ' → client' : '';
+ return hop(`${what} ${quoted(m.event, 'an event')}`, direction);
+ }
+ case 'event-emitter':
+ return hop(`event ${quoted(m.event, 'an event')}`);
+ case 'react-render':
+ return hop('React re-render via setState');
+ case 'jsx-render':
+ return hop(`renders ${m.via ? `<${String(m.via)}>` : 'a child component'}`);
+ case 'vue-handler':
+ return hop(`Vue ${m.event ? `@${String(m.event)}` : 'a template event'} handler`);
+ case 'interface-impl':
+ // Go: the implementing struct gets the method from a type it embeds.
+ if (typeof m.promotedInto === 'string') return hop(`interface → method promoted into ${m.promotedInto}`);
+ return hop('interface → impl');
+ case 'closure-collection':
+ return hop(`runs ${quoted(m.field, 'a collection')} handlers`);
+ case 'fn-pointer-dispatch':
+ return hop(`fn-pointer ${m.via ? String(m.via) : ''}`);
+ case 'goframe-route':
+ return hop(`GoFrame route ${m.route ? String(m.route) : ''}`);
+ default:
+ // redux-thunk, cpp-override, gin-middleware-chain, flutter-build, …
+ return hop(by.replace(/-/g, ' '));
+ }
+}
diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts
index fa8f35425..8aebac958 100644
--- a/src/mcp/tools.ts
+++ b/src/mcp/tools.ts
@@ -53,6 +53,7 @@ import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, C
import { guardLabel, guardsForFileSync, siteKey, supportsBranchGuards, warmBranchGuardGrammars } from '../graph/branch-guards';
import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report';
import { countImplementers } from '../graph/type-hierarchy';
+import { describeSynthesizedHop } from '../graph/synthesized-hop';
import { isCopybookInclude, MAX_COPYBOOK_INCLUDES, type NamedCopybook } from '../graph/cobol-copybooks';
import {
findAllSymbols,
@@ -3304,12 +3305,6 @@ export class ToolHandler {
return this.answerResult(cg, this.truncateOutput(sections.join('\n') + filterNote), answerPaths);
}
- /**
- * Describe a synthesized (dynamic-dispatch) edge for human output: how the
- * callback was wired up — the bridge static parsing can't see. Returns null
- * for ordinary static edges. Used by trace + the node trail so a synthesized
- * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
- */
/**
* The branch conditions a flow hop's call site runs under, read from the
* caller's source now (`graph/branch-guards.ts`); '' when unconditional,
@@ -3333,125 +3328,17 @@ export class ToolHandler {
}
}
- private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
- if (!edge || edge.provenance !== 'heuristic') return null;
- const m = edge.metadata as Record | undefined;
- const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
- const at = registeredAt ? ` @${registeredAt}` : '';
- if (m?.synthesizedBy === 'callback') {
- const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
- const field = m.field ? ` on .${String(m.field)}` : '';
- return {
- label: `callback — registered via ${via}${field} (dynamic dispatch)`,
- compact: `dynamic: callback via ${via}${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'http-client') {
- const req = `${String(m.method ?? 'GET')} ${String(m.href ?? '')}`.trim();
- return {
- label: `HTTP request \`${req}\` — the client's call onto its own route (cross-tier)`,
- compact: `dynamic: HTTP ${req}${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'queue-job') {
- const job = m.event ? `\`${String(m.event)}\`` : 'a job';
- const queue = m.queue ? ` on queue \`${String(m.queue)}\`` : '';
- return {
- label: `queue job ${job}${queue} — producer → consumer (cross-tier)`,
- compact: `dynamic: queue job ${job}${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'event-bus') {
- const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
- const what = m.channel === 'socket' ? 'socket message' : 'bus event';
- const dir = m.tier === 'client→server' ? ', client → server' : m.tier === 'server→client' ? ', server → client' : '';
- return {
- label: `${what} ${ev} — emit → handler${dir} (dynamic dispatch)`,
- compact: `dynamic: ${what} ${ev}${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'event-emitter') {
- const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
- return {
- label: `event ${ev} — emit → handler (dynamic dispatch)`,
- compact: `dynamic: event ${ev}${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'react-render') {
- return {
- label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
- compact: `dynamic: React re-render via setState${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'jsx-render') {
- const child = m.via ? `<${String(m.via)}>` : 'a child component';
- return {
- label: `renders ${child} (JSX child — dynamic dispatch)`,
- compact: `dynamic: renders ${child}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'vue-handler') {
- const ev = m.event ? `@${String(m.event)}` : 'a template event';
- return {
- label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
- compact: `dynamic: Vue ${ev} handler`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'interface-impl' && typeof m.promotedInto === 'string') {
- // Go: the implementing struct gets this method from a type it embeds.
- return {
- label: `interface dispatch — runs the method \`${m.promotedInto}\` gets by embedding (dynamic dispatch)`,
- compact: `dynamic: interface → method promoted into ${m.promotedInto}${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'interface-impl') {
- return {
- label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
- compact: `dynamic: interface → impl${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'closure-collection') {
- const field = m.field ? `\`${String(m.field)}\`` : 'a collection';
- return {
- label: `closure collection — runs handlers appended to ${field} (dynamic dispatch)`,
- compact: `dynamic: runs ${field} handlers${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'fn-pointer-dispatch') {
- const via = m.via ? `\`${String(m.via)}\`` : 'a function pointer';
- return {
- label: `function-pointer dispatch via ${via} (dynamic dispatch)`,
- compact: `dynamic: fn-pointer ${m.via ? String(m.via) : ''}${at}`,
- registeredAt,
- };
- }
- if (m?.synthesizedBy === 'goframe-route') {
- const route = m.route ? `\`${String(m.route)}\`` : 'a route';
- return {
- label: `GoFrame route ${route} — reflective Bind → controller method (dynamic dispatch)`,
- compact: `dynamic: GoFrame route ${m.route ? String(m.route) : ''}${at}`,
- 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;
+ /**
+ * The tag a synthesized (dynamic-dispatch) hop carries in explore's Flow and
+ * dynamic-dispatch links and in the codegraph_node trail, so it reads as
+ * "dynamic: callback via `onUpdate` @App.tsx:3148", not a bare arrow. Null
+ * for an ordinary static edge. The wording is `graph/synthesized-hop.ts`'s,
+ * shared with ContextBuilder's call paths.
+ */
+ private synthEdgeNote(edge: Edge | null): string | null {
+ const hop = describeSynthesizedHop(edge);
+ if (!hop) return null;
+ return `dynamic: ${hop.summary}${hop.registeredAt ? ` @${hop.registeredAt}` : ''}`;
}
/**
@@ -3506,8 +3393,7 @@ export class ToolHandler {
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}]`);
+ synthLines.push(`- ${src.name} → ${tgt.name} [${this.synthEdgeNote(edge) ?? edge.kind}]`);
}
}
return synthLines;
@@ -3640,9 +3526,8 @@ export class ToolHandler {
for (let i = 0; i < best!.length; i++) {
const step = best![i]!;
if (step.edge) {
- const sy = this.synthEdgeNote(step.edge);
const when = i > 0 ? this.whenLabel(cg, best![i - 1]!.node, step.edge) : '';
- out.push(` ↓ ${sy ? sy.compact : step.edge.kind}${when ? ` (when ${when})` : ''}`);
+ out.push(` ↓ ${this.synthEdgeNote(step.edge) ?? step.edge.kind}${when ? ` (when ${when})` : ''}`);
}
out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
}
@@ -8042,7 +7927,7 @@ export class ToolHandler {
const fmt = (e: { node: Node; edge: Edge }) => {
const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
const synth = this.synthEdgeNote(e.edge);
- return synth ? `${base} [${synth.compact}]` : base;
+ return synth ? `${base} [${synth}]` : base;
};
const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => {
const seen = new Set([node.id]);