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]);