fix(explore): every dynamic-dispatch link gets its own line, the ones the query names first (#2748)

codegraph_explore's "Dynamic-dispatch links among your symbols" section
deduped its lines by `source name > target name`. Every interface →
implementation link of one method reads the same way (`TrySet →
TrySet`), so an interface method with several implementations printed
one line, and which implementation it kept was edge order, not the
query. On gin, `tryToSetValue setter.TrySet multipartRequest.TrySet
setByForm` printed only `TrySet → TrySet @binding/header.go:35` and hid
formSource's link.

Lines are now deduped by the edge's endpoints (node ids), and the
6-line cap stays. When more links exist than fit:
- a link that reaches a test or example file through an end the query
  didn't name goes last, as explore's answer leaves those files out;
  the predicate is explore's own `isLowValue`, moved to module scope so
  both uses share it;
- otherwise links go by how pointedly the query named their two ends.
  A symbol a token picks out (at most three definitions, the same
  notion as the boundary scan's agent-specific symbols) or a named
  non-callable endpoint weighs 3, a candidate of an ambiguous token 1,
  an unnamed symbol 0. An implementation the query names always comes
  before one it doesn't. The weights also keep an ambiguous token's
  candidates from crowding out a precise one: prometheus's
  `Discovery.refresh` matches a `Discovery` in each discovery package,
  and in `Discovery.refresh fetchApps Decoder.Decode` their links to
  each other filled the cap and hid the one link into `fetchApps`;
- edge order breaks ties.

The section takes the rendered Flow chain itself. It leaves out an edge
between two of the chain's nodes, as before, and also a line that reads
the same as one of the chain's synthesized hops or as a line already
listed. gin declares its `Binding` and `StructValidator` interfaces
once per build tag (binding.go, binding_nomsgpack.go), so the twin
declaration's link printed exactly like the Flow's hop.

A same-name link names each end by its owner, `setter.TrySet →
formSource.TrySet`, the way an agent writes it in a query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 14:08:44 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e450ae3a63
commit 3cfd24a46d
3 changed files with 377 additions and 45 deletions
+91 -45
View File
@@ -58,6 +58,7 @@ import { isCopybookInclude, MAX_COPYBOOK_INCLUDES, type NamedCopybook } from '..
import {
findAllSymbols,
resolveNamedSymbolFlow,
type FlowStep,
} from '../graph/named-symbol-flow';
import { getUpdateNotice } from '../upgrade/update-check';
import { measurePendingChanges } from './index-freshness';
@@ -1236,14 +1237,38 @@ const TRIMMED_NAME_SKIP_KINDS = new Set([
* for a method, so an overloaded name resolves to the definition that was cut
* (django has 110 `as_sql`s; the note used to offer the bare one and agents
* then added a path and line to disambiguate it). The bare name otherwise.
* The dynamic-dispatch links name both ends of an `X → X` link the same way.
*/
function followUpName(e: ExploreWantedSpan): string {
function followUpName(e: Pick<ExploreWantedSpan, 'name' | 'kind' | 'qualifiedName'>): string {
if (e.kind !== 'method' || !e.qualifiedName) return e.name;
const segs = e.qualifiedName.split('::');
const owner = segs.length >= 2 ? segs[segs.length - 2]! : '';
return /^[A-Za-z_$][\w$]*$/.test(owner) ? `${owner}.${e.name}` : e.name;
}
/**
* Test/spec/icon/i18n file detector — used by explore's pre-floor hard filter,
* its rank penalty and comparator deprioritization, and the order of its
* dynamic-dispatch links.
*
* Tests are the engine's own reading (`isTestFile`), the one the gather
* already dampened them by: every ecosystem's test names and folders —
* a repo-ROOT `test/` (express's routing question once spent 59% of its
* envelope on three test files), LLVM's `unittests/`, Google's
* `foo_unittest.cc`, a C++ `FooTest.cpp`, `testing/`, `e2e/` — and the
* examples, samples, fixtures and benchmarks around a project. A list of
* its own here missed every one of those it hadn't been taught.
*/
function isLowValue(p: string): boolean {
const lp = p.toLowerCase();
return (
isTestFile(p) ||
/(?:^|\/)__test__\//.test(lp) ||
/\bicons?\b/.test(lp) ||
/\bi18n\b/.test(lp)
);
}
/**
* The shortest trailing slice of each path that no other path in `paths`
* ends with: `extHostExtensionService.ts` alone when it is the only one,
@@ -3369,33 +3394,76 @@ export class ToolHandler {
flow;
if (flow.tokens.length < 2) return EMPTY;
// Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING
// the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop
// already shown in the rendered main chain (a 2-node chain renders nothing, so a
// direct named→named synth hop still surfaces — #687).
const collectSynthLinks = (skipInChain: ((e: Edge) => boolean) | null): string[] => {
const synthLines: string[] = [];
const synthSeen = new Set<string>();
// the non-callable CONSTANT endpoints in `dynNamed`. `chain` is the rendered
// main chain, whose hops are not repeated here (a 2-node chain renders nothing,
// so a direct named→named synth hop still surfaces — #687).
//
// One line per LINK, keyed by its endpoints' node ids. Keyed by name pair, an
// interface method's implementations — `setter.TrySet` → gin's formSource,
// headerSource and multipartRequest — were one `TrySet → TrySet` line, and
// which one it showed was edge order. When more links exist than fit, links
// into tests and examples through an end the query didn't name go last, as
// explore's answer leaves those files out; before that, links go by how
// pointedly the query named their two ends, so the cap never drops the
// implementation the query names for one it doesn't. Edge order breaks ties.
const collectSynthLinks = (chain: readonly FlowStep[] | null): string[] => {
const MAX_SYNTH_LINKS = 6;
const onChain = new Set((chain ?? []).map((s) => s.node.id));
// A symbol a token picks out (at most 3 definitions), or a named non-callable
// endpoint, weighs 3. A candidate of an ambiguous token weighs 1: prometheus's
// `Discovery.refresh` matches a `Discovery` in each discovery package, and in
// `Discovery.refresh fetchApps Decoder.Decode` their links to each other filled
// the cap and hid the one link into `fetchApps`, which the query names exactly.
const weight = (id: string): number =>
uniqueNamedNodeIds.has(id) || dynNamed.has(id) ? 3 : named.has(id) ? 1 : 0;
const links: Array<{ edge: Edge; src: Node; tgt: Node; lowValue: boolean; weight: number }> = [];
const seen = new Set<string>();
for (const n of [...named.values(), ...dynNamed.values()]) {
if (synthLines.length >= 6) break;
// RAW edges for the same reason as hasHeuristicEdge above — a static
// edge over the same pair hides the synthesized one from getCallers.
const incident = [...cg.getIncomingEdges(n.id), ...cg.getOutgoingEdges(n.id)];
for (const edge of incident) {
if (synthLines.length >= 6) break;
// RAW edges for the same reason as `hasHeuristicEdge` in named-symbol-flow
// — a static edge over the same pair hides the synthesized one from getCallers.
for (const edge of [...cg.getIncomingEdges(n.id), ...cg.getOutgoingEdges(n.id)]) {
if (edge.provenance !== 'heuristic') continue;
const otherId = edge.source === n.id ? edge.target : edge.source;
if (otherId === n.id) continue;
const other = cg.getNode(otherId);
const key = `${edge.source}>${edge.target}`;
if (otherId === n.id || seen.has(key)) continue;
seen.add(key);
if (onChain.has(edge.source) && onChain.has(edge.target)) continue;
const other = named.get(otherId) ?? dynNamed.get(otherId) ?? cg.getNode(otherId);
if (!other) continue;
if (skipInChain && skipInChain(edge)) continue;
const src = edge.source === n.id ? n : other;
const tgt = edge.source === n.id ? other : n;
const key = `${src.name}>${tgt.name}`;
if (synthSeen.has(key)) continue;
synthSeen.add(key);
synthLines.push(`- ${src.name} → ${tgt.name} [${this.synthEdgeNote(edge) ?? edge.kind}]`);
links.push({
edge,
src: edge.source === n.id ? n : other,
tgt: edge.source === n.id ? other : n,
lowValue: weight(otherId) === 0 && isLowValue(other.filePath),
weight: weight(n.id) + weight(otherId),
});
}
}
links.sort((a, b) => Number(a.lowValue) - Number(b.lowValue) || b.weight - a.weight);
const linkLine = (edge: Edge, src: Node, tgt: Node): string => {
// `TrySet → TrySet` says neither which interface nor which implementation:
// a same-name link names each end by its owner, as the agent would write it.
const same = src.name === tgt.name;
return `- ${same ? followUpName(src) : src.name} → ${same ? followUpName(tgt) : tgt.name} [${this.synthEdgeNote(edge) ?? edge.kind}]`;
};
// Distinct links can still read the same, and one line says it: gin declares
// `Binding` once per build tag, so both declarations' `Bind` link to formBinding's,
// and when the Flow runs through one of them, the other's link reads as its hop.
const shown = new Set<string>();
for (let i = 1; i < (chain?.length ?? 0); i++) {
const prev = chain![i - 1]!.node;
const { node, edge } = chain![i]!;
if (edge?.provenance !== 'heuristic') continue;
shown.add(edge.source === prev.id ? linkLine(edge, prev, node) : linkLine(edge, node, prev));
}
const synthLines: string[] = [];
for (const { edge, src, tgt } of links) {
if (synthLines.length >= MAX_SYNTH_LINKS) break;
const line = linkLine(edge, src, tgt);
if (shown.has(line)) continue;
shown.add(line);
synthLines.push(line);
}
return synthLines;
};
/**
@@ -3515,9 +3583,7 @@ export class ToolHandler {
// wasn't named. The skip drops a hop already in the rendered main chain; a
// 2-node chain renders nothing (hasMain false) so a direct named→named synth
// hop still surfaces — too short for Flow, but #687-visible here.
const synthLines = collectSynthLinks(
hasMain ? (e: Edge) => pathIds.has(e.source) && pathIds.has(e.target) : null
);
const synthLines = collectSynthLinks(hasMain ? best : null);
if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return identityOnly();
const out: string[] = [];
@@ -4664,26 +4730,6 @@ export class ToolHandler {
// file's own path fragments must not count as "term hits" everywhere)
const queryTerms = matchQuery.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
// Test/spec/icon/i18n file detector — used by the pre-floor hard filter, the
// rank penalty, and the comparator deprioritization.
//
// Tests are the engine's own reading (`isTestFile`), the one the gather
// above already dampened them by: every ecosystem's test names and folders —
// a repo-ROOT `test/` (express's routing question once spent 59% of its
// envelope on three test files), LLVM's `unittests/`, Google's
// `foo_unittest.cc`, a C++ `FooTest.cpp`, `testing/`, `e2e/` — and the
// examples, samples, fixtures and benchmarks around a project. A list of
// its own here missed every one of those it hadn't been taught.
const isLowValue = (p: string) => {
const lp = p.toLowerCase();
return (
isTestFile(p) ||
/(?:^|\/)__test__\//.test(lp) ||
/\bicons?\b/.test(lp) ||
/\bi18n\b/.test(lp)
);
};
// One DB probe over every file the query touched, then O(1) per lookup.
// Unions the index-time content-banner flag (CG-5) with the filename
// convention, so a Go monorepo's generated CRUD (`payroll.go` carrying a