fix(go): an unexported interface method is satisfied only from its own package (#2746)

An unexported Go method name belongs to the package that declares it.
goImplementsEdges matched method names only, so an interface asking for
an unexported method was linked to every type in any package with a
method of that name: each of prometheus's `refresher { refresh(...) }`
interfaces (one per discovery package) went to about 37 providers in
other packages, such as discovery/aws.EC2Discovery, none of which Go
lets satisfy it.

Method sets now hold an unexported name together with the directory of
the file that declares the method (goMethodKey), on both sides. Where
the method is declared is what counts, not where the type is: etcd's
gRPC servers outside api/etcdserverpb keep their KVServer edges,
because the UnsafeKVServer or UnimplementedKVServer they embed declares
mustEmbedUnimplementedKVServer in the interface's package.

The interface-dispatch bridge uses the same key, so a call through the
interface reaches the method of the interface's package: not the
type's own namesake from another package, and not one Go's selector
would skip at a shallower embedding depth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 13:31:48 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6e24711590
commit e450ae3a63
3 changed files with 389 additions and 31 deletions
+67 -31
View File
@@ -828,6 +828,21 @@ function isGoEmbedding(e: Edge, kindOf: (id: string) => NodeKind | null): boolea
const to = kindOf(e.target);
return to !== null && (kindOf(e.source) !== 'interface' || to === 'interface');
}
/** An exported Go identifier starts with an upper-case letter, in Unicode's sense. */
const GO_EXPORTED_NAME = /^\p{Lu}/u;
/**
* What a Go method matches an interface's method by: its name, and for an
* unexported name the package that declares it as well. Go makes the
* `refresh` declared in discovery/hetzner and the one declared in
* discovery/aws two different methods, so neither satisfies the other's
* interface. A package is a directory, the method's own file's, so a method
* that embedding promotes keeps the package of the type declaring it.
*/
function goMethodKey(m: Node): string {
if (GO_EXPORTED_NAME.test(m.name)) return m.name;
const file = m.filePath.replace(/\\/g, '/');
return `${file.slice(0, Math.max(file.lastIndexOf('/'), 0))}\0${m.name}`;
}
/**
* The top-level items of the Go bracketed list opening at `text[open]` — the
* parameters of `(ctx context.Context, a, b int)` are `ctx context.Context`, `a`
@@ -946,6 +961,15 @@ function goGrpcServerArity(signature: string | undefined): string | null {
* never followed (this pass writes them), and an embedding that resolved to
* nothing (`io.Closer`) adds nothing.
*
* Methods match by name, and an unexported name by its package too
* ({@link goMethodKey}). prometheus has a `refresher { refresh(…) }` in each of
* several discovery packages, and only a type with a `refresh` declared in that
* package satisfies it, its own or one embedding brings in: aws's
* EC2Discovery, with a `refresh` of package aws, is no hetzner refresher. etcd's
* gRPC servers sit outside the generated package and still satisfy its
* `KVServer`, whose `mustEmbedUnimplementedKVServer` the `UnsafeKVServer` or
* `UnimplementedKVServer` they embed declares there.
*
* Each wanted method also needs a declaration with its parameter and result
* counts, read off the stored signatures: Go wants the signatures identical,
* and the counts are what the text settles for certain — a cache's `Get(ctx,
@@ -988,8 +1012,10 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr
for (const i of goInterfaces) typeKind.set(i.id, 'interface');
const kindOf = (id: string): NodeKind | null => typeKind.get(id) ?? null;
// Memoized: an embedded base is read once, however many types embed it. Each
// method's stored signature rides along for the arity check below.
// A type's own methods, each by its goMethodKey, so every method set below
// holds an unexported name with its package. Each method's stored signature
// rides along under the same key for the arity check below. Memoized: an
// embedded base is read once, however many types embed it.
const ownMemo = new Map<string, Set<string>>();
const ownSignatures = new Map<string, Map<string, (string | undefined)[]>>();
const ownMethods = (id: string): Set<string> => {
@@ -1000,10 +1026,11 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr
for (const e of queries.getOutgoingEdges(id, ['contains'])) {
const n = queries.getNodeById(e.target);
if (!n || n.kind !== 'method') continue;
names.add(n.name);
const list = signatures.get(n.name);
const key = goMethodKey(n);
names.add(key);
const list = signatures.get(key);
if (list) list.push(n.signature);
else signatures.set(n.name, [n.signature]);
else signatures.set(key, [n.signature]);
}
ownMemo.set(id, names);
ownSignatures.set(id, signatures);
@@ -1063,10 +1090,10 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr
return true;
};
// Each method name in a type's method set → the arities (`params/results`)
// its declarations there have, or null when one of their signatures doesn't
// read (then the name rules nothing out). Built only for the pairs whose
// names already match.
// Each method in a type's method set, by its goMethodKey, → the arities
// (`params/results`) its declarations there have, or null when one of their
// signatures doesn't read (then the name rules nothing out). Built only for
// the pairs whose names already match.
const arityMemo = new Map<string, Map<string, Set<string> | null>>();
const methodArities = (id: string): Map<string, Set<string> | null> => {
let arities = arityMemo.get(id);
@@ -1166,7 +1193,9 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr
};
// A struct that needs promoted methods waits for the types declaring all
// of them, every defined type among them: under the cap, those are what
// the interface-dispatch bridge links a call through the interface to.
// the interface-dispatch bridge links a call through the interface to. A
// namesake it declares of an unexported method from another package is
// not one of them: the method it needs is promoted.
const throughEmbedding: Node[] = [];
let wanted: Map<string, Set<string> | null> | undefined;
for (const s of candidates) {
@@ -1330,27 +1359,29 @@ 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. */
/** The methods a Go struct runs for a method 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
* Which embedded type's method a Go struct runs for a method 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
* struct's embeddings, the shallowest depth holding the method wins, and it
* 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.
* promoted method is not seen. The method is asked for by its
* {@link goMethodKey}: a namesake of an unexported method declared in another
* package is another method, so it neither provides nor hides it.
*/
function goPromotions(
queries: QueryBuilder,
methodsOf: (id: string) => Node[]
): (structId: string, name: string) => GoPromotion | null {
): (structId: string, key: string) => GoPromotion | null {
const kinds = new Map<string, NodeKind | null>();
const kindOf = (id: string): NodeKind | null => {
let kind = kinds.get(id);
@@ -1386,7 +1417,7 @@ function goPromotions(
const pending = [id];
while (pending.length > 0) {
const at = pending.pop()!;
for (const m of methodsOf(at)) names.add(m.name);
for (const m of methodsOf(at)) names.add(goMethodKey(m));
for (const e of embeds(at)) {
if (reached.has(e.target)) continue;
reached.add(e.target);
@@ -1397,13 +1428,13 @@ function goPromotions(
}
return names;
};
const provides = (id: string, name: string): boolean =>
kindOf(id) === 'interface' ? ifaceMethods(id).has(name) : methodsOf(id).some((m) => m.name === name);
const provides = (id: string, key: string): boolean =>
kindOf(id) === 'interface' ? ifaceMethods(id).has(key) : methodsOf(id).some((m) => goMethodKey(m) === key);
const memo = new Map<string, GoPromotion | null>();
return (structId, name) => {
const key = `${structId}>${name}`;
const hit = memo.get(key);
return (structId, key) => {
const memoKey = `${structId}>${key}`;
const hit = memo.get(memoKey);
if (hit !== undefined) return hit;
let found: GoPromotion | null = null;
// Each type at the current depth, with how many paths reach it and the
@@ -1425,19 +1456,19 @@ function goPromotions(
let provider: string | undefined;
for (const [id, at] of next) {
met.add(id);
if (!provides(id, name)) continue;
if (!provides(id, key)) 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 };
found = { methods: methodsOf(provider).filter((m) => goMethodKey(m) === key), line: next.get(provider)!.line };
}
break;
}
level = next;
}
memo.set(key, found);
memo.set(memoKey, found);
return found;
};
}
@@ -1524,17 +1555,22 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield
// 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
// alone would drop all but one and miss the resolved overload).
const implByName = new Map<string, Node[]>();
// alone would drop all but one and miss the resolved overload). A Go
// interface's unexported method is matched in its own package only
// (goMethodKey): a namesake the type declares elsewhere is another
// method, and the one the interface asks for is promoted.
const keyOf = base.language === 'go' && base.kind === 'interface' ? goMethodKey : (m: Node): string => m.name;
const implsByKey = new Map<string, Node[]>();
for (const m of implMethods) {
const arr = implByName.get(m.name);
if (arr) arr.push(m); else implByName.set(m.name, [m]);
const key = keyOf(m);
const arr = implsByKey.get(key);
if (arr) arr.push(m); else implsByKey.set(key, [m]);
}
let added = 0;
const unmatched: Node[] = [];
for (const bm of baseMethodsOf(base)) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
const impls = implByName.get(bm.name);
const impls = implsByKey.get(keyOf(bm));
if (!impls) {
if (promotes) unmatched.push(bm);
continue;
@@ -1569,7 +1605,7 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield
let budget = left;
for (const bm of methods) {
if (budget <= 0) break;
const promotion = goPromotion(cls.id, bm.name);
const promotion = goPromotion(cls.id, goMethodKey(bm));
if (!promotion) continue;
for (const m of promotion.methods) {
if (budget <= 0) break;