diff --git a/CHANGELOG.md b/CHANGELOG.md index 660d92dd2..361f7efe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,6 +197,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a value the file imports from your own project and passes along now links only to what it imports: a constant like `isCloudHosted`, a class like a `Star` model, a whole module (`import * as Slack from '../slack'`), or a stylesheet, image or GraphQL document (`import TCO21 from './themes/TCO21.scss'`) no longer links to a function elsewhere that only shares its name. Before, when the project had exactly one function of that name, such a value linked to it: outline's settings screens showed up among the callers of the server's `isCloudHosted()` check, and its stores among those of the `` icon, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A component that wraps the function declared beside it, like `export default styled(Disclosure)`, and a page loaded lazily, like `lazy(() => import('../pages/Home'))`, still lead to that function. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript, an import of a TypeScript module written without an extension, like `import AudioModal from './audio_modal'` in a `.jsx` file next to `audio_modal.tsx`, now links to that module, as it already did from a TypeScript file. Before, a `.js` or `.jsx` file's imports of TypeScript modules were never followed, so in a codebase moving to TypeScript they were matched by name instead: an import under another name linked nothing, and a name that repeats could link to the wrong file, so mastodon's `` and `` tags rendered other components of those names than the ones each file imports. A JavaScript module beside a TypeScript one of the same name still comes first. Re-index JavaScript projects after upgrading. - `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named. +- `codegraph_explore`'s "Dynamic-dispatch links among your symbols" list now shows every link on its own line. Before, the links from an interface method to its implementations all read alike, like `TrySet → TrySet`, so only one was listed, chosen by the order of the index rather than by your query: asking about gin's `tryToSetValue setter.TrySet multipartRequest.TrySet setByForm` showed `headerSource`'s implementation and hid `formSource`'s. Both ends of such a link are now named with their type, like `setter.TrySet → formSource.TrySet`. When there are more links than the list shows, the implementations your query names come first and those in tests or examples last, and a hop the Flow already shows is not repeated. - In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of ``. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading. - In Objective-C, a struct, union or enum defined in a variable declaration, like `static struct { Class isa; } placeholder;` or `enum { stateType, stateParamName } state;`, is now indexed with its enum values, next to the variable, as in C and C++. Before, only the variable was, so the type was missing from search and `codegraph_explore`. The same went for a `typedef enum { … } Name;` or `typedef struct … Name;` written after a line holding only a macro, like `OF_ASSUME_NONNULL_BEGIN` or `RCT_EXTERN_C_BEGIN`: ObjFW's `OFZIPArchiveEntryCompressionMethod` and every value in it were missing, and so was React Native's `RCTMethodInfo`. A struct, union or enum with no name of its own takes the name of the variable it declares, so ObjFW's class-cluster placeholders are found as `placeholder`, one declared inside a function or method is named after its variable instead of ``, and the comment above the declaration becomes the type's description. As in C, a type that a `.m` file defines links only from that file, so a call elsewhere that just shares its name, like ObjFW's calls of AmigaOS's `Lock(path, mode)`, doesn't link to the runtime's private `struct Lock`. Re-index Objective-C projects after upgrading. - In Objective-C, a macro written alone on its line, like `NS_ASSUME_NONNULL_BEGIN`, `OF_SUBCLASSING_RESTRICTED` or React Native's `RCT_EXTERN_C_BEGIN`, no longer hides the declaration below it. Before, the parser read such a macro as the start of a declaration, so an `@interface` under it could come out as a function and a `typedef` under it as two variables: many of ObjFW's classes, with their properties and methods and the classes they extend and protocols they adopt, were missing, and so were classes and protocols in React Native and KSCrash headers. A message to `super` from a class whose `@interface` follows an attribute macro like `NS_SWIFT_NAME(…)` still reaches the superclass. Re-index Objective-C projects after upgrading. diff --git a/__tests__/explore-synth-link-endpoints.test.ts b/__tests__/explore-synth-link-endpoints.test.ts new file mode 100644 index 000000000..5df27ee6b --- /dev/null +++ b/__tests__/explore-synth-link-endpoints.test.ts @@ -0,0 +1,285 @@ +/** + * Regression: codegraph_explore's "Dynamic-dispatch links among your symbols" + * section lists each synthesized LINK, not each pair of names. + * + * It used to dedupe its lines by `source name > target name`. Every + * `interface → impl` link of one interface method reads `area → area`, so an + * interface method with several implementations printed ONE line, and which + * implementation survived was decided by edge order, not by the query: naming + * `Square.area` showed Triangle's link and hid Square's (gin's + * `setter.TrySet`, prometheus's `Queryable.Querier`). Links are now keyed by + * their endpoints, and a hop already on the rendered Flow chain is still left + * out. When more links exist than the 6-line cap holds, links into test files + * through an end the query didn't name go last, and the rest go by how + * pointedly the query named their ends, so the cap never drops an + * implementation the query names for one it doesn't. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; + +// Assertions read RAW codegraph_explore output; managed offload would replace it. +let prevOffloadDisable: string | undefined; +beforeAll(() => { + prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; + process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; +}); +afterAll(() => { + if (prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE; + else process.env.CODEGRAPH_OFFLOAD_DISABLE = prevOffloadDisable; +}); + +const SECTION = '**Dynamic-dispatch links among your symbols**'; + +/** The `- a → b [...]` lines of the dynamic-dispatch links section. */ +function linkLines(text: string): string[] { + const at = text.indexOf(SECTION); + if (at < 0) return []; + const out: string[] = []; + for (const line of text.slice(at + SECTION.length).split(/\r?\n/)) { + if (line.startsWith('- ')) out.push(line); + else if (out.length > 0) break; + } + return out; +} + +/** The rendered Flow section, up to the next section header. */ +function flowSection(text: string): string { + const at = text.indexOf('**Flow (call path'); + if (at < 0) return ''; + const end = text.indexOf(SECTION, at); + return text.slice(at, end < 0 ? undefined : end); +} + +describe('codegraph_explore — dynamic-dispatch links are listed per link', () => { + let dir = ''; + let cg: CodeGraph | undefined; + + afterEach(() => { + cg?.destroy(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = ''; + }); + + /** + * `Shape.area` with one implementation per name. `Circle.area` calls + * `scale`, so `report → Shape.area → Circle.area → scale` is the longest + * chain among the query's symbols and the Flow section runs through Circle, + * never through the implementation the query names. `testDoubles` go in + * `fakes/.test.ts`. + */ + async function indexShapes(implementations: string[], testDoubles: string[] = []): Promise { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'explore-synth-links-')); + const write = (rel: string, content: string) => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + }; + write('shape.ts', 'export interface Shape {\n area(): number;\n}\n'); + write('scale.ts', 'export function scale(n: number): number {\n return n * 2;\n}\n'); + write( + 'report.ts', + "import type { Shape } from './shape';\n\nexport function report(shape: Shape): number {\n return shape.area();\n}\n" + ); + write( + 'circle.ts', + "import type { Shape } from './shape';\nimport { scale } from './scale';\n\nexport class Circle implements Shape {\n area(): number {\n return scale(3);\n }\n}\n" + ); + for (const name of implementations) { + write( + `${name.toLowerCase()}.ts`, + `import type { Shape } from './shape';\n\nexport class ${name} implements Shape {\n area(): number {\n return 1;\n }\n}\n` + ); + } + for (const name of testDoubles) { + write( + `fakes/${name.toLowerCase()}.test.ts`, + `import type { Shape } from '../shape';\n\nexport class ${name} implements Shape {\n area(): number {\n return 0;\n }\n}\n` + ); + } + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + return new ToolHandler(cg); + } + + /** `Shape.area`'s interface → impl links, in the order the graph returns them. */ + function implementationsInEdgeOrder(): string[] { + const iface = cg!.getNodesByName('area').find((n) => n.qualifiedName === 'Shape::area'); + expect(iface).toBeDefined(); + return cg! + .getOutgoingEdges(iface!.id) + .filter((e) => e.provenance === 'heuristic' && e.metadata?.synthesizedBy === 'interface-impl') + .map((e) => cg!.getNode(e.target)?.qualifiedName.split('::')[0] ?? ''); + } + + it('lists the named implementation and every other off-chain one, the named one first', async () => { + const handler = await indexShapes(['Square', 'Triangle']); + expect(implementationsInEdgeOrder().sort()).toEqual(['Circle', 'Square', 'Triangle']); + + const res = await handler.execute('codegraph_explore', { query: 'report Shape.area Square.area scale' }); + const text = res.content[0].text as string; + + // Precondition: the Flow runs through Circle, so Square's link is not on it. + const flow = flowSection(text); + expect(flow).toMatch(/\d\. area \(circle\.ts:\d+\)/); + expect(flow).not.toContain('square.ts'); + + const links = linkLines(text); + const square = links.findIndex((l) => l.includes('@square.ts:')); + const triangle = links.findIndex((l) => l.includes('@triangle.ts:')); + // Distinct implementations are distinct links — the name pair `area → area` + // no longer folds them into one line. + expect(square).toBeGreaterThanOrEqual(0); + expect(triangle).toBeGreaterThanOrEqual(0); + // The implementation the query named leads. + expect(square).toBeLessThan(triangle); + // `area → area` names neither side; each end is named by its owner. + expect(links[square]).toMatch(/^- Shape\.area → Square\.area\s+\[dynamic: interface → impl @square\.ts:4\]$/); + expect(links[triangle]).toMatch(/^- Shape\.area → Triangle\.area\s+\[dynamic: interface → impl @triangle\.ts:4\]$/); + // The chain's own hop is rendered in the Flow, not repeated here. + expect(links.some((l) => l.includes('circle.ts'))).toBe(false); + expect(links).toHaveLength(2); + }, 120_000); + + it('keeps the 6-line cap and spends its first line on the named implementation', async () => { + const others = ['Square', 'Triangle', 'Pentagon', 'Hexagon', 'Heptagon', 'Octagon', 'Nonagon', 'Decagon']; + const handler = await indexShapes(others); + // Name the implementation edge order would list LAST, so only the + // named-first rule can bring it under the cap. + const order = implementationsInEdgeOrder().filter((owner) => owner !== 'Circle'); + expect([...order].sort()).toEqual([...others].sort()); + const last = order[order.length - 1]!; + + const res = await handler.execute('codegraph_explore', { query: `report Shape.area ${last}.area scale` }); + const text = res.content[0].text as string; + expect(flowSection(text)).toMatch(/\d\. area \(circle\.ts:\d+\)/); + + const links = linkLines(text); + expect(links).toHaveLength(6); + expect(links[0]).toMatch(new RegExp(`^- Shape\\.area → ${last}\\.area\\s+\\[dynamic: interface → impl @${last.toLowerCase()}\\.ts:4\\]$`)); + // The other five are five distinct implementations, none of them the chain's. + const rest = links.slice(1).map((l) => /→ (\w+)\.area/.exec(l)?.[1]); + expect(new Set(rest).size).toBe(5); + for (const owner of rest) expect(order).toContain(owner); + expect(rest).not.toContain(last); + expect(links.some((l) => l.includes('circle.ts'))).toBe(false); + }, 120_000); + + it('lists links into test files after the rest when the cap forces a choice', async () => { + // prometheus's `Queryable.Querier` has about as many test doubles as real + // implementations. Explore's answer leaves test files out, and here they only + // get the lines the real implementations leave. + const production = ['Square', 'Triangle', 'Pentagon', 'Hexagon', 'Heptagon', 'Octagon']; + const doubles = ['FakeA', 'FakeB', 'FakeC', 'FakeD', 'FakeE', 'FakeF']; + const handler = await indexShapes(production, doubles); + // Precondition: in edge order, a test double would take one of the six lines. + const order = implementationsInEdgeOrder().filter((owner) => owner !== 'Circle'); + expect([...order].sort()).toEqual([...production, ...doubles].sort()); + expect(order.slice(0, 6).some((owner) => doubles.includes(owner))).toBe(true); + + const res = await handler.execute('codegraph_explore', { query: 'report Shape.area scale' }); + const text = res.content[0].text as string; + expect(flowSection(text)).toMatch(/\d\. area \(circle\.ts:\d+\)/); + const links = linkLines(text); + expect(links).toHaveLength(6); + expect(links.map((l) => /→ (\w+)\.area/.exec(l)?.[1]).sort()).toEqual([...production].sort()); + }, 120_000); + + it("puts the links of an interface the query picks out before links among an ambiguous name's candidates", async () => { + // prometheus's `Discovery.refresh` matches a `Discovery` type in each discovery + // package. 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. + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'explore-synth-links-ambiguous-')); + const write = (rel: string, content: string) => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + }; + write('iface.ts', 'export interface Discovery {\n refresh(): number;\n}\n'); + for (let i = 1; i <= 5; i++) { + write( + `pkg${i}/discovery.ts`, + `import type { Discovery as Base } from '../iface';\n\nexport class Discovery implements Base {\n refresh(): number {\n return ${i};\n }\n}\n` + ); + } + write('refresher.ts', 'export interface Refresher {\n refresh(): number;\n}\n'); + for (const name of ['Hcloud', 'Robot']) { + write( + `${name.toLowerCase()}.ts`, + `import type { Refresher } from './refresher';\n\nexport class ${name} implements Refresher {\n refresh(): number {\n return 0;\n }\n}\n` + ); + } + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + // Precondition: the interface `Discovery` links to all five classes of its name. + const discoveryLinks = cg + .getNodesByName('refresh') + .filter((n) => n.qualifiedName === 'Discovery::refresh') + .flatMap((n) => cg!.getOutgoingEdges(n.id).filter((e) => e.metadata?.synthesizedBy === 'interface-impl')); + expect(discoveryLinks).toHaveLength(5); + + const res = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'Discovery.refresh Refresher.refresh Hcloud.refresh', + }); + const links = linkLines(res.content[0].text as string); + expect(links).toHaveLength(6); + expect(links[0]).toBe('- Refresher.refresh → Hcloud.refresh [dynamic: interface → impl @hcloud.ts:4]'); + expect(links[1]).toBe('- Refresher.refresh → Robot.refresh [dynamic: interface → impl @robot.ts:4]'); + for (const l of links.slice(2)) { + expect(l).toMatch(/^- Discovery\.refresh → Discovery\.refresh {3}\[dynamic: interface → impl @pkg\d\/discovery\.ts:4\]$/); + } + }, 120_000); + + it('prints two links that read the same once, and none that reads like a Flow hop', async () => { + // gin declares `Binding` once per build tag (binding.go, binding_nomsgpack.go), + // so formBinding.Bind is linked from two `Binding.Bind` nodes and both links + // print as the same line. + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'explore-synth-links-go-')); + const write = (rel: string, content: string) => fs.writeFileSync(path.join(dir, rel), content); + write('go.mod', 'module example.com/binding\n\ngo 1.22\n'); + const iface = 'package binding\n\ntype Binding interface {\n\tBind(req string) error\n}\n'; + write('binding.go', `//go:build !nomsgpack\n\n${iface}`); + write('binding_nomsgpack.go', `//go:build nomsgpack\n\n${iface}`); + write( + 'form.go', + 'package binding\n\ntype formBinding struct{}\n\nfunc (b formBinding) Bind(req string) error {\n\treturn mapForm(req)\n}\n\nfunc mapForm(req string) error {\n\treturn nil\n}\n' + ); + write( + 'query.go', + 'package binding\n\ntype queryBinding struct{}\n\nfunc (b queryBinding) Bind(req string) error {\n\treturn mapForm(req)\n}\n' + ); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + + // Precondition: each declaration links to both implementations. + const declarations = cg.getNodesByName('Bind').filter((n) => n.qualifiedName === 'Binding::Bind'); + expect(declarations).toHaveLength(2); + for (const d of declarations) { + const targets = cg + .getOutgoingEdges(d.id) + .filter((e) => e.metadata?.synthesizedBy === 'interface-impl') + .map((e) => cg!.getNode(e.target)?.qualifiedName); + expect(targets.sort()).toEqual(['formBinding::Bind', 'queryBinding::Bind']); + } + + const handler = new ToolHandler(cg); + // Two symbols make no Flow: each implementation's link, once. + const pair = await handler.execute('codegraph_explore', { query: 'Binding.Bind formBinding.Bind' }); + expect(linkLines(pair.content[0].text as string)).toEqual([ + '- Binding.Bind → formBinding.Bind [dynamic: interface → impl @form.go:5]', + '- Binding.Bind → queryBinding.Bind [dynamic: interface → impl @query.go:5]', + ]); + + // The Flow runs through one declaration's link to formBinding.Bind. The other + // declaration's link reads exactly like that hop, so it is not listed under it. + const res = await handler.execute('codegraph_explore', { query: 'Binding.Bind formBinding.Bind mapForm' }); + const text = res.content[0].text as string; + expect(flowSection(text)).toMatch(/\d\. Bind \(binding(_nomsgpack)?\.go:\d+\)\n\s+↓ dynamic: interface → impl @form\.go:5\n\d\. Bind \(form\.go:5\)/); + expect(linkLines(text)).toEqual([ + '- Binding.Bind → queryBinding.Bind [dynamic: interface → impl @query.go:5]', + ]); + }, 120_000); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 86084e841..02d7ebb45 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -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): 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(); + // 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(); 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(); + 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