diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b562cf..1070b44d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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. - `codegraph_explore`, `codegraph_node`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact` and the `codegraph callers`, `callees` and `impact` commands now find a symbol written with its package, the way Go code names it: `refresh.Discovery.refresh`, `storage.Queryable.Querier`, or `gin.Context.Next` in gin's root package. Before, such a name found nothing, so a question written that way lost the symbol and explore's Flow had nothing to connect. A name the search ranked below other results, like gin's `binding.Default`, is found as well, and a Python or Rust module followed by a class and method works the same way. +- When `codegraph_explore` finds more dynamic-dispatch links than its list shows, the symbols you named now take turns, so each one's links make the list: asking about prometheus's `refresh.Discovery.refresh hetzner.refresher.refresh hcloudDiscovery.refresh` now lists hetzner's `robotDiscovery` beside the links into other discovery packages that used to fill the list. - 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 index 5df27ee6..96f7e91b 100644 --- a/__tests__/explore-synth-link-endpoints.test.ts +++ b/__tests__/explore-synth-link-endpoints.test.ts @@ -233,6 +233,34 @@ describe('codegraph_explore — dynamic-dispatch links are listed per link', () } }, 120_000); + it('lets the named symbols take turns, so one long fan-out does not fill the list', async () => { + // prometheus's `refresh.Discovery.refresh` hands its func field to 28 + // discovery packages, and in a query that also names hetzner's + // `refresher.refresh` those links filled the list before robotDiscovery's. + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'explore-synth-links-turns-')); + const write = (rel: string, content: string) => fs.writeFileSync(path.join(dir, rel), content); + const family = (iface: string, classes: string[]) => { + write(`${iface.toLowerCase()}.ts`, `export interface ${iface} {\n run(): number;\n}\n`); + for (const name of classes) { + write( + `${name.toLowerCase()}.ts`, + `import type { ${iface} } from './${iface.toLowerCase()}';\n\nexport class ${name} implements ${iface} {\n run(): number {\n return 1;\n }\n}\n` + ); + } + }; + family('Wide', ['WideA', 'WideB', 'WideC', 'WideD', 'WideE', 'WideF', 'WideG', 'WideH']); + family('Narrow', ['NarrowA', 'NarrowB']); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + + const res = await new ToolHandler(cg).execute('codegraph_explore', { query: 'Wide.run Narrow.run' }); + const links = linkLines(res.content[0].text as string); + expect(links).toHaveLength(6); + // Both of Narrow's links make the list, taking turns with Wide's. + expect(links.map((l) => /^- (\w+)\.run →/.exec(l)?.[1])).toEqual(['Wide', 'Narrow', 'Wide', 'Narrow', 'Wide', 'Wide']); + expect(links.filter((l) => l.startsWith('- Narrow.run → ')).map((l) => /→ (\w+)\.run/.exec(l)?.[1]).sort()).toEqual(['NarrowA', 'NarrowB']); + }, 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 diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 5266f7c0..e952492a 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -3407,7 +3407,9 @@ export class ToolHandler { // 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. + // implementation the query names for one it doesn't. Links that rank the same + // take turns by the named symbol they hang from, so one symbol's long fan-out + // can't fill the list before another's first link; 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)); @@ -3418,8 +3420,14 @@ export class ToolHandler { // 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 links: Array<{ edge: Edge; src: Node; tgt: Node; lowValue: boolean; weight: number; turn: number }> = []; const seen = new Set(); + // A link's turn: how many links of its rank the same named symbol already has. + // prometheus's `refresh.Discovery.refresh` hands its func field to 28 + // discovery packages; in `refresh.Discovery.refresh hetzner.refresher.refresh + // hcloudDiscovery.refresh` those filled the list before `refresher.refresh` → + // robotDiscovery's, at the same rank. + const turns = new Map(); for (const n of [...named.values(), ...dynNamed.values()]) { // 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. @@ -3432,16 +3440,22 @@ export class ToolHandler { if (onChain.has(edge.source) && onChain.has(edge.target)) continue; const other = named.get(otherId) ?? dynNamed.get(otherId) ?? cg.getNode(otherId); if (!other) continue; + const lowValue = weight(otherId) === 0 && isLowValue(other.filePath); + const linkWeight = weight(n.id) + weight(otherId); + const rank = `${n.id}|${lowValue}|${linkWeight}`; + const turn = turns.get(rank) ?? 0; + turns.set(rank, turn + 1); 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), + lowValue, + weight: linkWeight, + turn, }); } } - links.sort((a, b) => Number(a.lowValue) - Number(b.lowValue) || b.weight - a.weight); + links.sort((a, b) => Number(a.lowValue) - Number(b.lowValue) || b.weight - a.weight || a.turn - b.turn); 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.