From e3409feae52b9362f909a00e42001d071d5cc1e7 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 6 Oct 2026 23:02:12 -0500 Subject: [PATCH] fix(go): a call through an interface reaches a method the struct gets by embedding Since goImplementsEdges counts promoted methods, a struct can satisfy an interface with methods its embedded types declare. The interface-dispatch bridge linked each interface method only to a method the struct declares itself, so a call through such a method reached nothing when the embedded type does not implement the interface on its own: prometheus's scraper.Report never reached Target.Report through targetScraper. For a Go struct's synthesized implements edge, each interface method the struct does not declare now links to the method Go's selector picks: the shallowest embedded type that has it, nothing when two occurrences tie at that depth (one type reached along two paths included), and nothing when an embedded interface provides it, which is a dynamic call again. These links come after the per-implementer loop, from what the struct's cap has left, so every existing edge is unchanged. They keep interface-impl metadata plus promotedInto, and registeredAt names the struct's embedding; codegraph_explore labels the hop as a promoted method. The embedding test is shared with goImplementsEdges. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/go-promoted-dispatch.test.ts | 379 +++++++++++++++++++++++++ src/mcp/tools.ts | 8 + src/resolution/callback-synthesizer.ts | 186 +++++++++++- 4 files changed, 568 insertions(+), 6 deletions(-) create mode 100644 __tests__/go-promoted-dispatch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe1144fe4..61397af89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading. - In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading. +- In Go, a call through an interface now reaches a method that the implementing struct gets from a type it embeds: prometheus's `targetScraper` gets `Report` from the `*Target` it embeds, so a call through the `scraper` interface now reaches `Target.Report`, and etcd's `Hasher.MemberID` reaches `EtcdServer.MemberID` through `hasherAdapter`. Before, the call reached only structs that declare the method themselves, often just a test double, so `codegraph_explore`'s call path stopped at the interface. The method is picked the way Go picks it, from the nearest embedded type, and none is linked when two embedded types at the same depth both have it or it comes from an embedded interface. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. diff --git a/__tests__/go-promoted-dispatch.test.ts b/__tests__/go-promoted-dispatch.test.ts new file mode 100644 index 000000000..76b5d6243 --- /dev/null +++ b/__tests__/go-promoted-dispatch.test.ts @@ -0,0 +1,379 @@ +/** + * A Go struct can satisfy an interface with methods it gets by embedding: + * + * type blockBaseSeriesSet struct{ … } // prometheus's tsdb/querier.go + * func (b *blockBaseSeriesSet) Next() bool // Next, Err, Warnings — no At + * + * type blockSeriesSet struct{ blockBaseSeriesSet } + * func (b *blockSeriesSet) At() storage.Series + * + * go-implements links blockSeriesSet to storage.SeriesSet, and a call through + * `SeriesSet.Next` then runs blockBaseSeriesSet's Next. The interface-dispatch + * bridge linked an interface's methods only to the methods the implementing + * struct declares, so that call reached nothing: blockBaseSeriesSet has no At + * and implements nothing itself. The bridge now follows the embedding to the + * method Go's selector picks — the shallowest one, none when two tie — and + * stops at an embedded interface, whose method is a dynamic call again. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; +import type { Edge, Node } from '../src/types'; + +const BIG_METHODS = 45; +const big = (i: number) => `M${String(i).padStart(2, '0')}`; + +const FILES: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'storage/interface.go': `package storage + +type Series interface{ Labels() string } + +type ChunkSeries interface{ Labels() string } + +type SeriesSet interface { + Next() bool + At() Series + Err() error + Warnings() []string +} + +type ChunkSeriesSet interface { + Next() bool + At() ChunkSeries + Err() error + Warnings() []string +} +`, + 'tsdb/querier.go': `package tsdb + +import "example.com/app/storage" + +// Next, Err and Warnings for the two series sets below. No At: it is +// neither series set itself. +type blockBaseSeriesSet struct { + err error +} + +func (b *blockBaseSeriesSet) Next() bool { return false } +func (b *blockBaseSeriesSet) Err() error { return b.err } +func (b *blockBaseSeriesSet) Warnings() []string { return nil } + +type blockSeriesSet struct { + blockBaseSeriesSet +} + +func (b *blockSeriesSet) At() storage.Series { return nil } + +type blockChunkSeriesSet struct { + blockBaseSeriesSet +} + +func (b *blockChunkSeriesSet) At() storage.ChunkSeries { return nil } + +func newBlockSeriesSet() storage.SeriesSet { return &blockSeriesSet{} } +`, + 'promql/engine.go': `package promql + +import "example.com/app/storage" + +func expandSeriesSet(it storage.SeriesSet) int { + n := 0 + for it.Next() { + n++ + } + return n +} +`, + 'selector/selector.go': `package selector + +type Doer interface { + Do() + Name() string +} + +type Runner interface{ Do() } + +// Do from two levels down. +type inner struct{} + +func (inner) Do() {} + +type middle struct{ inner } + +type deep struct { + middle +} + +func (deep) Name() string { return "" } + +// near's Do at depth 1 hides far's at depth 2. +type near struct{} + +func (near) Do() {} + +type far struct{} + +func (far) Do() {} + +type farWrap struct{ far } + +type shadowed struct { + farWrap + near +} + +func (shadowed) Name() string { return "" } + +// Two Dos at depth 1: Go rejects t.Do(), no method runs. +type left struct{} + +func (left) Do() {} + +type right struct{} + +func (right) Do() {} + +type tie struct { + left + right +} + +func (tie) Name() string { return "" } + +// One type's Do at depth 2 along two paths: as ambiguous. +type core struct{} + +func (core) Do() {} + +type viaA struct{ core } + +type viaB struct{ core } + +type diamond struct { + viaA + viaB +} + +func (diamond) Name() string { return "" } + +// Do from the interface it embeds: whatever that holds runs. +type delegate struct{ Runner } + +func (delegate) Name() string { return "" } + +// The embedded interface's Do at depth 1 hides buried's at depth 2. +type buried struct{} + +func (buried) Do() {} + +type cover struct{ buried } + +type hidden struct { + cover + Runner +} + +func (hidden) Name() string { return "" } + +// Do from the defined type it embeds. +type Chain []func() + +func (c Chain) Do() {} + +type chained struct{ Chain } + +func (chained) Name() string { return "" } + +// full is a Doer itself: its own edge, whoever embeds it. +type fullWrap struct{ full } + +type full struct{} + +func (full) Do() {} +func (full) Name() string { return "" } +`, + // More of the interface than the cap: one method of its own, the rest from + // a base that lacks the last. + 'big/big.go': [ + 'package big', + '', + 'type Big interface {', + ...Array.from({ length: BIG_METHODS }, (_, i) => `\t${big(i)}()`), + '}', + '', + 'type bigBase struct{}', + '', + ...Array.from({ length: BIG_METHODS - 1 }, (_, i) => `func (bigBase) ${big(i)}() {}`), + '', + 'type bigOne struct{ bigBase }', + '', + `func (bigOne) ${big(BIG_METHODS - 1)}() {}`, + '', + ].join('\n'), +}; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-promoted-')); + for (const [rel, content] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}, 120_000); + +afterAll(() => { + cg?.destroy(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const meta = (e: Edge): Record => (e.metadata ?? {}) as Record; + +/** `file:line` of the first fixture line containing `text`. */ +function at(file: string, text: string): string { + const line = FILES[file]!.split('\n').findIndex((l) => l.includes(text)); + expect(line, `${text} in ${file}`).toBeGreaterThanOrEqual(0); + return `${file}:${line + 1}`; +} + +/** The one non-import node with this name in this file. */ +function one(name: string, file: string): Node { + const found = cg.getNodesByName(name).filter((n) => n.filePath === file && n.kind !== 'import'); + expect(found, `${name} in ${file}`).toHaveLength(1); + return found[0]!; +} + +/** An interface's method. */ +function methodOf(graph: CodeGraph, iface: Node, name: string): Node { + const m = graph + .getOutgoingEdgesFrom([iface.id], ['contains']) + .map((e) => graph.getNode(e.target)) + .find((n) => n?.name === name); + expect(m, `${iface.name}.${name}`).toBeTruthy(); + return m!; +} + +/** Where a call through the method dispatches, as `Target::m` or `Target::m <- promotedInto @registeredAt`. */ +function dispatch(graph: CodeGraph, method: Node): string[] { + return graph + .getOutgoingEdgesFrom([method.id], ['calls']) + .filter((e) => meta(e).synthesizedBy === 'interface-impl') + .map((e) => { + const target = graph.getNode(e.target)?.qualifiedName; + const into = meta(e).promotedInto; + return into === undefined ? `${target}` : `${target} <- ${into} @${meta(e).registeredAt}`; + }) + .sort(); +} + +describe('a call through a Go interface reaches the method embedding promotes', () => { + it('links the interface method to the embedded type that declares it', () => { + const seriesSet = one('SeriesSet', 'storage/interface.go'); + const chunkSet = one('ChunkSeriesSet', 'storage/interface.go'); + // blockSeriesSet's embedding, the first of the two. + const embedding = at('tsdb/querier.go', '\tblockBaseSeriesSet'); + // Both structs satisfy both interfaces by name, and both get Next from + // the same base: one edge, through the first of them. + for (const iface of [seriesSet, chunkSet]) { + expect(dispatch(cg, methodOf(cg, iface, 'Next'))).toEqual([`blockBaseSeriesSet::Next <- blockSeriesSet @${embedding}`]); + expect(dispatch(cg, methodOf(cg, iface, 'Err'))).toEqual([`blockBaseSeriesSet::Err <- blockSeriesSet @${embedding}`]); + expect(dispatch(cg, methodOf(cg, iface, 'At'))).toEqual(['blockChunkSeriesSet::At', 'blockSeriesSet::At']); + } + }); + + it('picks the provider the way Go\'s selector does', () => { + const doer = one('Doer', 'selector/selector.go'); + const file = 'selector/selector.go'; + expect(dispatch(cg, methodOf(cg, doer, 'Do'))).toEqual( + [ + // The defined type's method. + `Chain::Do <- chained @${at(file, 'type chained struct')}`, + // full's own edge, though fullWrap comes first. + 'full::Do', + // Two levels down, through deep's own embedding of middle. + `inner::Do <- deep @${at(file, '\tmiddle')}`, + // near at depth 1, not far at depth 2. + `near::Do <- shadowed @${at(file, '\tnear')}`, + ].sort() + ); + // Not linked: left and right (tie), core (two paths), the Runner that + // delegate and hidden embed, and buried behind it. + }); + + it('keeps the provider\'s own edge as it was', () => { + const doer = one('Doer', 'selector/selector.go'); + const doEdge = cg + .getOutgoingEdgesFrom([methodOf(cg, doer, 'Do').id], ['calls']) + .find((e) => cg.getNode(e.target)?.qualifiedName === 'full::Do'); + expect(doEdge).toMatchObject({ + provenance: 'heuristic', + metadata: { synthesizedBy: 'interface-impl', via: 'Do', registeredAt: at('selector/selector.go', 'func (full) Do()') }, + }); + expect(meta(doEdge!)).not.toHaveProperty('promotedInto'); + }); + + it('caps a struct\'s links into one interface, keeping its own method', () => { + const bigIface = one('Big', 'big/big.go'); + const targets = Array.from({ length: BIG_METHODS }, (_, i) => dispatch(cg, methodOf(cg, bigIface, big(i)))).flat(); + const embedding = at('big/big.go', 'type bigOne struct'); + expect(targets).toHaveLength(40); + expect(targets).toContain(`bigOne::${big(BIG_METHODS - 1)}`); + // The 39 slots left go to methods the base promotes. + const promoted = targets.filter((t) => t.startsWith('bigBase::')); + expect(promoted).toHaveLength(39); + for (const t of promoted) expect(t).toMatch(new RegExp(`^bigBase::M\\d\\d <- bigOne @${embedding}$`)); + }); + + it('shows the hop in codegraph_explore as a promoted method with its embedding', async () => { + const result = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'expandSeriesSet SeriesSet.Next blockBaseSeriesSet.Next', + }); + const text = result.content?.[0]?.text ?? ''; + expect(text).toContain( + `dynamic: interface → method promoted into blockSeriesSet @${at('tsdb/querier.go', '\tblockBaseSeriesSet')}` + ); + }); +}); + +describe('a sync that changes what the struct declares', () => { + let dir = ''; + let graph: CodeGraph | undefined; + + afterEach(() => { + graph?.destroy(); + graph = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = ''; + }); + + it('moves the link between the promoted method and the struct\'s own', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-promoted-sync-')); + const write = (rel: string, content: string) => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + }; + const wrap = 'package wrap\n\nimport "example.com/app/base"\n\ntype Wrapper struct {\n\tbase.Base\n}\n\nfunc (Wrapper) Name() string { return "" }\n'; + write('go.mod', 'module example.com/app\n\ngo 1.22\n'); + write('api/api.go', 'package api\n\ntype NamedCloser interface {\n\tClose() error\n\tName() string\n}\n'); + write('base/base.go', 'package base\n\ntype Base struct{}\n\nfunc (Base) Close() error { return nil }\n'); + write('wrap/wrap.go', wrap); + graph = await CodeGraph.init(dir, { index: true }); + const g = graph; + const close = () => dispatch(g, methodOf(g, g.getNodesByName('NamedCloser')[0]!, 'Close')); + + // Close comes from base.Base, embedded on line 6. + expect(close()).toEqual(['Base::Close <- Wrapper @wrap/wrap.go:6']); + write('wrap/wrap.go', `${wrap}\nfunc (Wrapper) Close() error { return nil }\n`); + await g.sync({ paths: ['wrap/wrap.go'] }); + expect(close()).toEqual(['Wrapper::Close']); + write('wrap/wrap.go', wrap); + await g.sync({ paths: ['wrap/wrap.go'] }); + expect(close()).toEqual(['Base::Close <- Wrapper @wrap/wrap.go:6']); + }, 60_000); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 8a35ce145..fa8f35425 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -3405,6 +3405,14 @@ export class ToolHandler { registeredAt, }; } + if (m?.synthesizedBy === 'interface-impl' && typeof m.promotedInto === 'string') { + // Go: the implementing struct gets this method from a type it embeds. + return { + label: `interface dispatch — runs the method \`${m.promotedInto}\` gets by embedding (dynamic dispatch)`, + compact: `dynamic: interface → method promoted into ${m.promotedInto}${at}`, + registeredAt, + }; + } if (m?.synthesizedBy === 'interface-impl') { return { label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`, diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index ebecb41a8..45d960250 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -802,13 +802,24 @@ const IFACE_OVERRIDE_LANGS = new Set([ 'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', 'go', 'rust', 'arkts', ]); +/** + * Whether a supertype edge out of a Go type is one of its embeddings (#2397): + * declared, never synthesized (go-implements writes those), into a struct, an + * interface or a defined type, and out of an interface only into an interface. + * `kindOf` answers those three kinds for a Go node id, null for anything else. + */ +function isGoEmbedding(e: Edge, kindOf: (id: string) => NodeKind | null): boolean { + if (e.provenance === 'heuristic') return false; + const to = kindOf(e.target); + return to !== null && (kindOf(e.source) !== 'interface' || to === 'interface'); +} /** * Go implicit interface satisfaction (#584). Go has no `implements` keyword — a * struct satisfies an interface structurally when its method set covers the * interface's. Synthesize the missing `implements` edge (struct → interface) by * matching method-NAME sets, so impl-navigation works and the interface-dispatch * bridge ({@link interfaceOverrideEdges}, now 'go'-enabled) can link an interface - * method call to the concrete overrides. + * method call to the concrete overrides, and to the methods embedding promotes. * * Both method sets include what embedding brings in, read off the declared * `extends`/`implements` edge each embedded type is. An interface has the @@ -881,9 +892,7 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr // The types one embeds: an interface embeds interfaces, a struct any of the // three. Every struct's and interface's are read up front in a few batched // queries rather than one query per type; a defined type's, when reached. - const isEmbedding = (e: Edge): boolean => - e.provenance !== 'heuristic' && - (typeKind.get(e.source) === 'interface' ? kindOf(e.target) === 'interface' : kindOf(e.target) !== null); + const isEmbedding = (e: Edge): boolean => isGoEmbedding(e, kindOf); const NO_EMBEDS: string[] = []; const embedMemo = new Map(); const typeIds = [...typeKind.keys()]; @@ -1121,6 +1130,118 @@ async function kotlinExpectActualEdges(queries: QueryBuilder, onYield: MaybeYiel return edges; } +/** The methods a Go struct runs for a name it does not declare, and the line of the embedding they come through. */ +interface GoPromotion { + methods: Node[]; + line: number; +} + +/** + * Which embedded type's method a Go struct runs for a name it does not + * declare, picked the way Go's selector picks it: breadth-first through the + * struct's embeddings, the shallowest depth holding the name wins, and the + * name selects nothing when it occurs more than once at that depth, one type + * reached along two paths included. A type met at a shallower depth is not + * walked again. An embedded interface brings its whole method set at its own + * depth, and a method it provides is a dynamic call once more, so that is no + * promotion either. Struct fields are not in the graph: a field that hides a + * promoted method is not seen. + */ +function goPromotions( + queries: QueryBuilder, + methodsOf: (id: string) => Node[] +): (structId: string, name: string) => GoPromotion | null { + const kinds = new Map(); + const kindOf = (id: string): NodeKind | null => { + let kind = kinds.get(id); + if (kind === undefined) { + const n = queries.getNodeById(id); + kind = n?.language === 'go' && (n.kind === 'struct' || n.kind === 'interface' || n.kind === 'type_alias') + ? n.kind + : null; + kinds.set(id, kind); + } + return kind; + }; + const embedMemo = new Map(); + const embeds = (id: string): Edge[] => { + let out = embedMemo.get(id); + if (!out) { + const targets = new Set(); + out = queries.getOutgoingEdges(id, ['extends', 'implements']).filter((e) => { + if (!isGoEmbedding(e, kindOf) || targets.has(e.target)) return false; + targets.add(e.target); + return true; + }); + embedMemo.set(id, out); + } + return out; + }; + const ifaceMemo = new Map>(); + const ifaceMethods = (id: string): Set => { + let names = ifaceMemo.get(id); + if (!names) { + names = new Set(); + const reached = new Set([id]); + const pending = [id]; + while (pending.length > 0) { + const at = pending.pop()!; + for (const m of methodsOf(at)) names.add(m.name); + for (const e of embeds(at)) { + if (reached.has(e.target)) continue; + reached.add(e.target); + pending.push(e.target); + } + } + ifaceMemo.set(id, names); + } + return names; + }; + const provides = (id: string, name: string): boolean => + kindOf(id) === 'interface' ? ifaceMethods(id).has(name) : methodsOf(id).some((m) => m.name === name); + + const memo = new Map(); + return (structId, name) => { + const key = `${structId}>${name}`; + const hit = memo.get(key); + if (hit !== undefined) return hit; + let found: GoPromotion | null = null; + // Each type at the current depth, with how many paths reach it and the + // line of the struct's own embedding the first of them starts with. + let level = new Map([[structId, { paths: 1, line: 0 }]]); + const met = new Set([structId]); + for (let depth = 0; level.size > 0; depth++) { + const next = new Map(); + for (const [id, at] of level) { + if (kindOf(id) === 'interface') continue; // its embeddings are its method set + for (const e of embeds(id)) { + if (met.has(e.target)) continue; + const to = next.get(e.target); + if (to) to.paths += at.paths; + else next.set(e.target, { paths: at.paths, line: depth === 0 ? (e.line ?? 0) : at.line }); + } + } + let paths = 0; + let provider: string | undefined; + for (const [id, at] of next) { + met.add(id); + if (!provides(id, name)) continue; + paths += at.paths; + provider = id; + } + if (provider !== undefined) { + if (paths === 1 && kindOf(provider) !== 'interface') { + found = { methods: methodsOf(provider).filter((m) => m.name === name), line: next.get(provider)!.line }; + } + break; + } + level = next; + } + memo.set(key, found); + return found; + }; +} + async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield): Promise { let scanned255 = 0; const edges: Edge[] = []; @@ -1158,6 +1279,14 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield protocolMemo.set(base.id, methods); return methods; }; + // A Go struct also satisfies an interface with the methods its embedded + // types promote into it (goImplementsEdges counts them), and a call through + // the interface then runs the embedded type's method. Those links are made + // after the loop, from what the struct's cap has left, so every override + // keeps its edge, and a provider that implements the interface itself keeps + // its own. + const goPromotion = goPromotions(queries, methodsOf); + const promoted: { cls: Node; methods: Node[]; left: number }[] = []; // Concrete-side kinds vary by language: `class` covers Java / Kotlin / // C# / TS / Swift-classes / Scala-classes; `struct` covers Swift value // types that conform to protocols. Iterate both. @@ -1171,10 +1300,15 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield const sups = queries.getOutgoingEdges(cls.id, ['implements', 'extends']); if (sups.length === 0) continue; const implMethods = methodsOf(cls.id).filter((n) => IFACE_OVERRIDE_LANGS.has(n.language)); - if (implMethods.length === 0) continue; + // A Go struct that satisfies an interface (a synthesized edge) may do so + // with promoted methods alone. Its declared `implements` edges are the + // interfaces it embeds, which provide whatever it lacks themselves. + const goStruct = cls.language === 'go' && cls.kind === 'struct'; + if (implMethods.length === 0 && !(goStruct && sups.some((s) => s.provenance === 'heuristic'))) continue; for (const sup of sups) { const base = queries.getNodeById(sup.target); if (!base || !IFACE_OVERRIDE_LANGS.has(base.language) || base.id === cls.id) continue; + const promotes = goStruct && sup.provenance === 'heuristic' && base.kind === 'interface'; // Group impl methods by name to handle OVERLOADS: an interface `list()` and // `list(params)` are distinct nodes and a call may resolve to either, so // link every base overload → every same-name impl overload (keying by name @@ -1185,9 +1319,15 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield if (arr) arr.push(m); else implByName.set(m.name, [m]); } let added = 0; + const unmatched: Node[] = []; for (const bm of baseMethodsOf(base)) { if (added >= MAX_CALLBACKS_PER_CHANNEL) break; - for (const m of implByName.get(bm.name) ?? []) { + const impls = implByName.get(bm.name); + if (!impls) { + if (promotes) unmatched.push(bm); + continue; + } + for (const m of impls) { if (added >= MAX_CALLBACKS_PER_CHANNEL) break; if (bm.id === m.id) continue; const key = `${bm.id}>${m.id}`; @@ -1204,9 +1344,43 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield added++; } } + if (unmatched.length > 0 && added < MAX_CALLBACKS_PER_CHANNEL) { + promoted.push({ cls, methods: unmatched, left: MAX_CALLBACKS_PER_CHANNEL - added }); + } } } } + // The wiring site of a promoted method is the struct's embedding it comes + // through: `type blockSeriesSet struct{ blockBaseSeriesSet }`. + for (const { cls, methods, left } of promoted) { + if ((++scanned255 & 63) === 0) await onYield(); + let budget = left; + for (const bm of methods) { + if (budget <= 0) break; + const promotion = goPromotion(cls.id, bm.name); + if (!promotion) continue; + for (const m of promotion.methods) { + if (budget <= 0) break; + const key = `${bm.id}>${m.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: bm.id, + target: m.id, + kind: 'calls', + line: bm.startLine, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'interface-impl', + via: m.name, + promotedInto: cls.name, + registeredAt: `${cls.filePath}:${promotion.line || cls.startLine}`, + }, + }); + budget--; + } + } + } return edges; }