mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
fix(context): call paths label synthesized hops like explore, not as events (#2428)
Two surfaces label a synthesized (dynamic-dispatch) `calls` hop:
codegraph_explore's Flow and dynamic-dispatch links plus the
codegraph_node trail (`dynamic: interface → impl @file:line`), and
ContextBuilder's "## Call paths", which `codegraph context` prints
(`→[…]`). Each kept its own list. The call paths' list stopped at
callback, react-render, jsx-render, vue-handler, http-client, queue-job
and socket messages, so every other synthesizer fell through to
`event ${m.event ?? ''}`: an interface-impl hop read
`→[event @src/pdf-exporter.ts:5]`, and so did a C++ override, a redux
thunk, a gin middleware chain or a closure collection.
The wording now lives once, in src/graph/synthesized-hop.ts
(`describeSynthesizedHop`: a summary, the detail only the call paths
print, and the wiring site), as AGENTS.md asks of a derivation two
surfaces render. ToolHandler renders `dynamic: <summary> @site`, the
call paths `<summary><detail> @site`. Explore and the node trail print
exactly what they printed before; the call paths keep their wording for
the kinds they already labelled, including the queue name, a socket's
direction and the HTTP clause, and now name the rest the way explore
does. A bus event reads `bus event` there too, and the section's
footnote no longer says hops are marked `[callback/event …]`.
The helper also holds the wording #2414 gives a Go method a struct gets
by embedding (`metadata.promotedInto`), so that arm moves here too. The
unused long `label` form of ToolHandler.synthEdgeNote (last read by the
removed codegraph_trace) is dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+11
-24
@@ -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<string, string>();
|
||||
for (const e of subgraph.edges) {
|
||||
if (e.kind !== 'calls' || e.provenance !== 'heuristic') continue;
|
||||
const m = e.metadata as Record<string, unknown> | 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';
|
||||
|
||||
@@ -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<string, unknown> | 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, ' '));
|
||||
}
|
||||
}
|
||||
+15
-130
@@ -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<string, unknown> | 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<string>([node.id]);
|
||||
|
||||
Reference in New Issue
Block a user