fix(explore): surface a named method's buried signature type (#1064) (#1069)

* fix(explore): surface a named method's buried signature type (change surface) (#1064)

#1064: a natural-language query like "what do I need to change if I add a new
parameter to NewClient" dropped the file the answer lives in (grpc-go's
dialoptions.go, which defines NewClient's DialOption) and surfaced lexical
namesakes instead, so the agent fell back to grep.

Root cause (instrumented on grpc-go): the answer file is lexically dissimilar
to the query and reachable only structurally, so it scores ~0 on every text
and centrality signal and never renders.

Two-part fix, both bounded and validated to not perturb flow queries:

1. Change surface — read each named method's signature-type edges from the full
   graph and, ONLY when the type's file is genuinely BURIED (≈0 graph mass AND
   no term hits), inject + rank + gate-keep + tier it. A well-connected type
   file is left to rank on its own merit, so this never displaces a flow file.

2. Tier de-noise by centrality — still seed every <=3-def name (RWR/flow ranking
   unchanged), but the named-first tier admits only the most-substantive def
   plus co-named defs of comparable centrality (>=25% of the top def's caller
   count). This keeps real overloads/wrappers (excalidraw's `mutateElement` in
   three files, callers 74/58/40) while dropping vastly-less-central namesakes
   (Go's `NewClient`: real 492 callers vs xds-pool 11, test-fake 3) that would
   otherwise crowd the answer file out of the tier.

Validation:
- Deterministic probe: dialoptions.go goes from dropped to surfaced (with the
  full option field set via defaultDialOptions).
- Broader-repo regression check (Alamofire, excalidraw, axios): 5/6 control
  flow queries byte-identical to baseline; the 1 shift (Alamofire "how a request
  gets validated": Validation.swift -> DataRequest.swift) is lateral —
  DataRequest defines validate() and is a directly-relevant answer.
- grpc-go agent A/B (n=2, sonnet): grep fallback eliminated (0 vs baseline 4,2).
- 1845 unit tests pass.

* docs(changelog): note explore signature-type surfacing (#1064)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-29 20:29:30 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 575d960bb4
commit 2b256b93e5
2 changed files with 85 additions and 1 deletions
+81 -1
View File
@@ -2532,11 +2532,19 @@ export class ToolHandler {
// trace endpoint picker uses) and inject it as an entry, so every symbol the
// agent explicitly named is in the subgraph and its file is scored.
const namedSeedIds = new Set<string>();
// The subset of named seeds that earns the named-FIRST sort tier. We still
// SEED every ≤3-def name (so RWR / flow ranking is unchanged), but only the
// most-substantive def is tiered — a bare name's unrelated namesakes (Go's
// `NewClient` = real client + test fake + xds pool) must not fill the tier
// and crowd out the real answer file (grpc's `dialoptions.go`). Corroborated
// overloads (the query also named the type) all earn it. (#1064)
const tierSeedIds = new Set<string>();
{
const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro)$/i;
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
const isTestPath = (p: string) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
const bodyLines = (n: Node) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
const callerCount = (n: Node) => { try { return cg.getCallers(n.id).length; } catch { return 0; } };
const tokens = [...new Set(
query.split(/[\s,()[\]]+/)
.map((t) => t.replace(FILE_EXT, '').trim())
@@ -2577,11 +2585,22 @@ export class ToolHandler {
// capped; else fall back to the single most-substantive def. This is the
// explore-side mirror of codegraph_node's overload disambiguation.
let picks: Node[];
let tierPicks: Node[]; // subset that earns the named-first tier (#1064)
if (cands.length <= 3) {
picks = cands;
// Centrality de-noise: tier the most-substantive def PLUS any co-named
// def of comparable centrality (a real overload/wrapper — excalidraw's
// `mutateElement` lives in mutateElement.ts, App.tsx AND Scene.ts, all
// within ~2x callers). EXCLUDE a vastly-less-central namesake (Go's
// `NewClient`: real client 492 callers vs xds-pool 11, test-fake 3 →
// ratio <0.025) so it doesn't fill the tier and crowd out the answer.
const counts = new Map(cands.map((c) => [c.id, callerCount(c)]));
const maxCallers = Math.max(1, ...counts.values());
tierPicks = cands.filter((c, i) => i === 0 || (counts.get(c.id) ?? 0) >= maxCallers * 0.25);
} else {
const ctx = cands.filter(inNamedContext);
picks = ctx.length > 0 ? ctx.slice(0, 4) : cands.slice(0, 1);
tierPicks = picks; // corroborated overloads (or the single fallback) all earn it
}
for (const n of picks) {
if (!subgraph.nodes.has(n.id)) subgraph.nodes.set(n.id, n);
@@ -2592,6 +2611,7 @@ export class ToolHandler {
// so a named symbol FTS already gathered never sorted to the top.)
namedSeedIds.add(n.id);
}
for (const n of tierPicks) tierSeedIds.add(n.id);
}
}
@@ -2606,6 +2626,38 @@ export class ToolHandler {
if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
}
// CHANGE SURFACE (#1064): a named method's signature types — its parameter
// and return types — are part of what you'd edit to "add a parameter to X",
// yet they can be lexically dissimilar to the query ("add a parameter to
// NewClient" shares no words with `dialoptions.go`, which defines NewClient's
// `DialOption`) and sit a hop away. COLLECT them here from each named-seed
// callable's outgoing signature edges (full graph — the type is often not in
// the subgraph); the decision to surface one is DEFERRED to the buried-rescue
// pass below, which fires only when the type's file would otherwise be
// dropped — so a well-connected type (excalidraw's element types, Alamofire's
// `DataRequest` on a flow query) is left to rank on its own and never
// displaces a flow-central file. Bounded: only the few named seeds, only the
// types in their signatures.
const CALLABLE_KINDS = new Set(['method', 'function', 'component', 'constructor']);
const TYPE_KINDS = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'type_alias']);
const SIG_EDGE = new Set(['references', 'type_of', 'returns']);
const changeSurfaceCandidates: Node[] = [];
const seenChangeSurface = new Set<string>();
for (const seedId of tierSeedIds) {
const seedNode = subgraph.nodes.get(seedId);
if (!seedNode || !CALLABLE_KINDS.has(seedNode.kind)) continue;
let outs: Edge[] = [];
try { outs = cg.getOutgoingEdges(seedId); } catch { continue; }
for (const e of outs) {
if (!SIG_EDGE.has(e.kind)) continue;
const tgt = cg.getNode(e.target);
if (!tgt || !TYPE_KINDS.has(tgt.kind) || namedSeedIds.has(tgt.id)) continue;
if (seenChangeSurface.has(tgt.id)) continue;
seenChangeSurface.add(tgt.id);
changeSurfaceCandidates.push(tgt);
}
}
for (const node of subgraph.nodes.values()) {
// Skip import/export nodes — they add noise without information
if (node.kind === 'import' || node.kind === 'export') continue;
@@ -2735,6 +2787,29 @@ export class ToolHandler {
const n = subgraph.nodes.get(id);
if (n) entryFiles.add(n.filePath);
}
// Buried-rescue pass (#1064): surface a named method's signature type ONLY
// when its file is genuinely buried — near-zero graph mass AND not lexically
// matched. That is the invisible case (grpc's `DialOption` → `dialoptions.go`,
// g≈0, 0 term hits): reachable but ranked nowhere, so the agent greps. A
// well-connected type file (excalidraw element types, Alamofire `DataRequest`)
// is NOT buried and is left alone — rescuing it would displace a flow-central
// file (App.tsx, Validation.swift). Buried is judged on the PRE-rescue graph,
// so injecting the type below can't make it look connected. A rescued file is
// injected (so it renders), force-kept (gate + relevantFiles), and tiered.
const changeSurfaceFiles = new Set<string>();
for (const t of changeSurfaceCandidates) {
const fp = t.filePath;
const buried = (fileGraphScore.get(fp) ?? 0) < maxGraph * 0.06
&& (fileTermHits.get(fp) ?? 0) < 2;
if (!buried) continue;
changeSurfaceFiles.add(fp);
if (!subgraph.nodes.has(t.id)) subgraph.nodes.set(t.id, t);
let group = fileGroups.get(fp);
if (!group) { group = { nodes: [], score: 0 }; fileGroups.set(fp, group); }
if (!group.nodes.some((n) => n.id === t.id)) group.nodes.push(t);
group.score = Math.max(group.score, 45);
if (!relevantFiles.some(([f]) => f === fp)) relevantFiles.push([fp, group]);
}
// Relevance gate (so the generous budget is a CEILING, not a target): keep a
// file only if it is STRUCTURALLY relevant by ANY of:
@@ -2753,6 +2828,7 @@ export class ToolHandler {
(fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
|| centralFiles.has(fp)
|| entryFiles.has(fp)
|| changeSurfaceFiles.has(fp)
|| (fileTermHits.get(fp) ?? 0) >= 2,
);
if (gated.length >= 2) relevantFiles = gated;
@@ -2768,10 +2844,14 @@ export class ToolHandler {
// in other files (`Validation.swift`), falls outside the budget, and the
// agent Reads it. The named file is the answer — rank it at the top.
const namedSeedFiles = new Set<string>();
for (const id of namedSeedIds) {
for (const id of tierSeedIds) {
const n = subgraph.nodes.get(id);
if (n) namedSeedFiles.add(n.filePath);
}
// A rescued change-surface file (only the genuinely-buried ones — see the
// buried-rescue pass) is the lexically-dissimilar answer; give it the named
// tier so it isn't buried under files that merely share surface words (#1064).
for (const fp of changeSurfaceFiles) namedSeedFiles.add(fp);
// Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
// (a search root, named seed, or graph-central hub — i.e. structurally part of