Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<anonymous>`. 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 `<anonymous>`, 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.
Expand Down
28 changes: 28 additions & 0 deletions __tests__/explore-synth-link-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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<string>();
// 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<string, number>();
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.
Expand All @@ -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.
Expand Down