fix(resolution): trait dispatch reaches union implementors (#1515)

Making unions first-class nodes leaves the third loss in #1515 open:
interfaceOverrideEdges enumerates its concrete side as ['class','struct'],
so a union implementor is skipped even though it now has a real node and a
real `implements` edge. "Who implements this trait" then answers wrongly
rather than incompletely — the struct beside it bridges and the union does
not.

Add 'union' to that tuple, plus a regression test that pins the Rust
trait -> union-impl hop (the struct implementor is the control proving the
synthesizer ran). Verified the test fails on the union assertion alone
before this change.

No EXTRACTION_VERSION bump: main is already at 25 against v1.5.0's 24, so
existing indexes are flagged stale for the next release regardless, and
over-bumping is what turns the re-index hint into noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-07 21:23:06 -05:00
co-authored by Claude Opus 5
parent e2195940fb
commit 5b0c4b8b93
3 changed files with 48 additions and 2 deletions
+46
View File
@@ -1073,6 +1073,52 @@ int invoke() { return Ops::run(); }
expect(outgoing.some((e) => e.kind === 'calls' && e.target === run!.id)).toBe(true);
});
it('bridges a Rust trait method to a union implementor (interface-impl)', async () => {
// A Rust union can `impl Trait` exactly as a struct can. Trait-dispatch
// synthesis enumerates concrete kinds explicitly, so a union implementor
// only becomes a candidate if `union` is in that list. Making unions
// first-class nodes is not enough on its own: without this, `Reg` has an
// `implements` edge and is still silently dropped from the fan-out, so
// "who implements this trait" answers wrongly rather than incompletely
// — the struct beside it resolves and the union does not (#1515).
fs.writeFileSync(
path.join(tempDir, 'lib.rs'),
`pub union Reg { pub raw: u32 }
pub struct Ctl { pub n: u32 }
pub trait Describe { fn describe(&self) -> String; }
impl Describe for Reg { fn describe(&self) -> String { "reg".into() } }
impl Describe for Ctl { fn describe(&self) -> String { "ctl".into() } }
`
);
cg = await CodeGraph.init(tempDir, { index: true });
const methods = cg.getNodesByKind('method');
const traitMethod = methods.find((n) => n.qualifiedName === 'Describe::describe');
const unionImpl = methods.find((n) => n.qualifiedName === 'Reg::describe');
const structImpl = methods.find((n) => n.qualifiedName === 'Ctl::describe');
expect(traitMethod, 'trait method should be in the graph').toBeDefined();
expect(unionImpl, 'union impl method should be in the graph').toBeDefined();
expect(structImpl, 'struct impl method should be in the graph').toBeDefined();
const synth = cg
.getOutgoingEdges(traitMethod!.id)
.filter((e) => e.kind === 'calls' && e.provenance === 'heuristic');
const targets = new Set(synth.map((e) => e.target));
// The struct implementor bridged before unions were nodes at all; it is
// the control that proves the synthesizer ran for this trait.
expect(targets.has(structImpl!.id), 'struct implementor should bridge').toBe(true);
expect(targets.has(unionImpl!.id), 'union implementor should bridge').toBe(true);
const unionEdge = synth.find((e) => e.target === unionImpl!.id);
expect(
(unionEdge!.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy
).toBe('interface-impl');
});
it('records instantiates for C++ stack/brace construction, targeting the class (#1035)', async () => {
// `Calculator calc(0)` (direct-init) and `Widget w{1, 2}` (brace-init)
// carry the constructor args directly on the declarator — there's no