fix(zig): declare the Union relation pairs — analyze aborted on any union

`gitnexus analyze` exited 1 on every Zig repository that declares a
`union` (including the zig-basic fixture itself): the member-ownership
commit made `union_declaration` a MEMBER_OWNER, so HAS_PROPERTY /
HAS_METHOD edges are emitted FROM a Union node, but `Union` was not in
LINKABLE_LABELS, so the schema's scope-bridge cross product never
generated a `FROM Union` pair and LadybugDB rejected the edge
(`labelPair: "Union|Property"`). Resolver tests stayed green because
they never write to the DB.

- `Union` joins LINKABLE_LABELS (also bridges `Tag{…}` constructor
  references); the three hand-written `→ Union` target pairs move to the
  generated half, per the STRUCTURAL_PAIR_DDL rule.
- structural-pair-coverage gains an optional-grammar corpus with
  zig-basic (`Union|Property`, `Union|Method` sentinels), skipped when
  the grammar is absent.
- Rust `union_item` note updated: the three gates it cited are widened.

Note for reviewers: the DDL fingerprint changes (#2808), so existing
indexes are rebuilt on next analyze.
This commit is contained in:
Navid EMAD 2026-08-18 12:13:02 +02:00
parent 5819f73df4
commit 307f7054de
No known key found for this signature in database
12 changed files with 191 additions and 16 deletions

View file

@ -36,14 +36,14 @@ const RUST_SCOPE_QUERY = `
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.enum
;; Declarations — union
;; Deliberately tagged @declaration.struct (→ Struct label), NOT a
;; @declaration.union: every registry-primary resolution gate —
;; isLinkableLabel (node-lookup.ts), CALLABLE_OR_TYPE_LIKE
;; (finalize-algorithm.ts), ClassLikeNodeLabel (class-types.ts) — includes
;; Struct but EXCLUDES Union, so a Union-labeled node would be an
;; unresolvable orphan. A Rust union is a type whose literal is a real
;; constructor, so Struct is both the resolvable and the semantically
;; honest label here. #1934 F71.
;; Tagged @declaration.struct (→ Struct label), NOT @declaration.union.
;; Historically forced: the registry-primary gates (isLinkableLabel,
;; CALLABLE_OR_TYPE_LIKE, ClassLikeNodeLabel) excluded Union, so a
;; Union-labeled node was an unresolvable orphan (#1934 F71). Zig support
;; widened all three for its union(enum) containers, so the label is now
;; resolvable — Struct is kept here on the semantic argument alone (a Rust
;; union is a type whose literal is a real constructor) and to leave the
;; graph node ids of existing Rust indexes unchanged.
(union_item
name: (type_identifier) @declaration.name
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.struct

View file

@ -273,6 +273,15 @@ export const LINKABLE_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
// are unreachable and label-agnostic fallback can alias it to a same-named
// Constructor or Method (#2801).
'Record',
// Union is linkable because Zig wires `union` / `union(enum)` as a member
// container: the definition phase emits HAS_METHOD / HAS_PROPERTY edges FROM
// the Union node (`union_declaration` in MEMBER_OWNER_NODE_TYPES) and the
// scope side dispatches methods on union receivers (`main → isEnergy` in
// test/integration/resolvers/zig.test.ts). Without this entry the schema
// never declares a `FROM Union` pair and `analyze` aborts on the first Zig
// repo that declares a union (reproduced on the zig-basic fixture itself).
// Also lets `Tag{ .energy = 5 }` constructor references bridge to the node.
'Union',
// Trait nodes are linkable so MRO builders can bridge PHP/Rust trait
// defs between scope-resolution DefIds and the graph's node ids.
// IMPLEMENTS edges from classes to traits are otherwise invisible to
@ -288,8 +297,8 @@ export const LINKABLE_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
//
// Covers every language that spells an alias this way — TypeScript, Kotlin,
// Dart and Rust all emit `@declaration.type_alias`. The remaining
// `CLASS_KINDS` entries (Typedef, Record, Union, Delegate, Annotation,
// Template) plausibly have the same gap, but nothing exercises them today
// `CLASS_KINDS` entries (Typedef, Delegate, Annotation, Template) plausibly
// have the same gap, but nothing exercises them today
// and adding labels no test covers is how this list drifts out of sync with
// what it claims.
'TypeAlias',

View file

@ -420,14 +420,14 @@ const ATTACHMENT_TARGET_LABELS: readonly NodeTableName[] = [
];
/**
* The 69 pairs NEITHER rule above generates — everything left after the two
* The 66 pairs NEITHER rule above generates — everything left after the two
* cross products are subtracted. Carried by CONTAINMENT, inheritance, imports
* and DI: a container label crossed with a contained label. No predicate
* describes that surface (any container can hold any definition).
*
* What survives here is characteristic, not arbitrary. Almost all of it is a
* TARGET no rule reaches — `CodeElement`, `Impl`, `Namespace`, `Template`,
* `Typedef`, `Union`, `Static`, `Section`, `Folder` are in neither
* `Typedef`, `Static`, `Section`, `Folder` are in neither
* `SCOPE_BRIDGE_TARGET_LABELS` nor {@link ATTACHMENT_TARGET_LABELS} — plus the
* `Impl|*` and `Template|*` member rows (Rust `impl`/`trait` bodies, C++
* templates), the two `Route|Process` / `Tool|Process` entry points whose
@ -455,7 +455,7 @@ const ATTACHMENT_TARGET_LABELS: readonly NodeTableName[] = [
* from it and so moves with any change to it.
*
* Folding this remainder into a third cross product
* (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union,
* (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef,
* Namespace, Impl, TypeAlias, Static, Template}`) would take the table to 641
* pairs and leave only ~29 lines here. `bench/schema-pairs` measures 641 at
* 1.22–1.43× on the historical reference box; current production's 461 pairs
@ -472,7 +472,6 @@ const ATTACHMENT_TARGET_LABELS: readonly NodeTableName[] = [
export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder,
FROM File TO CodeElement,
FROM File TO \`Typedef\`,
FROM File TO \`Union\`,
FROM File TO \`Namespace\`,
FROM File TO \`Impl\`,
FROM File TO \`Static\`,
@ -484,11 +483,9 @@ export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder,
FROM Function TO \`Namespace\`,
FROM Function TO \`Impl\`,
FROM Function TO \`Typedef\`,
FROM Function TO \`Union\`,
FROM Function TO CodeElement,
FROM Class TO \`Template\`,
FROM Class TO \`Impl\`,
FROM Class TO \`Union\`,
FROM Class TO \`Namespace\`,
FROM Class TO \`Typedef\`,
FROM Class TO CodeElement,

View file

@ -0,0 +1,7 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const geo = b.dependency("geo", .{});
const exe = b.addExecutable(.{ .name = "idioms", .root_source_file = b.path("src/main.zig") });
exe.root_module.addImport("geo", geo.module("geo"));
b.installArtifact(exe);
}

View file

@ -0,0 +1,17 @@
.{
.name = .idioms,
.version = "0.1.0",
.fingerprint = 0x9a2f1c4e7b3d5061,
.minimum_zig_version = "0.14.0",
.dependencies = .{
// Library laid out by `zig init` ≥ 0.12: src/root.zig, declared in its build.zig.
.geo = .{ .path = "libs/geo" },
// Older convention: src/<name>.zig, no addModule in build.zig.
.oldlib = .{ .path = "libs/oldlib" },
.remote = .{
.url = "https://example.invalid/remote.tar.gz",
.hash = "1220deadbeef",
},
},
.paths = .{ "build.zig", "build.zig.zon", "src" },
}

View file

@ -0,0 +1,4 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
_ = b.addModule("geo", .{ .root_source_file = b.path("src/root.zig") });
}

View file

@ -0,0 +1,9 @@
pub fn area(w: u32, h: u32) u32 {
return w * h;
}
pub const Point = struct {
x: i32 = 0,
pub fn shift(self: *Point, dx: i32) void {
self.x += dx;
}
};

View file

@ -0,0 +1 @@
pub fn legacy() void {}

View file

@ -0,0 +1,48 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const VERSION: []const u8 = "1.0";
pub var global_count: u32 = 0;
pub const Err = error{ Oops, Bad };
pub const Counter = struct {
const Self = @This();
count: u32 = 0,
pub fn init() Self {
return .{};
}
pub fn incr(self: *Self) void {
self.count += 1;
}
pub fn get(self: *const @This()) u32 {
return self.count;
}
pub fn twice(self: *Self) void {
self.incr();
self.incr();
}
};
/// Generic type constructor — Zig's only spelling of a generic type.
pub fn Stack(comptime T: type) type {
return struct {
const Self = @This();
items: []T = &.{},
pub fn init() Self {
return .{};
}
pub fn push(self: *Self, v: T) void {
_ = self;
_ = v;
}
pub fn top(self: Self) ?T {
_ = self;
return null;
}
pub fn clear(self: *Self) void {
self.items = &.{};
}
};
}

View file

@ -0,0 +1,45 @@
const std = @import("std");
const counter = @import("counter.zig");
// Alias of a namespace member — the most common way to bring a type into scope.
const Counter = counter.Counter;
// Single-member import straight off @import.
const Stack = @import("counter.zig").Stack;
// build.zig.zon path deps: geo declares src/root.zig in its build.zig,
// oldlib has no build.zig and relies on the src/<name>.zig convention.
const geo = @import("geo");
const oldlib = @import("oldlib");
// Removed from the language in 0.15, still everywhere in 0.11–0.14 code.
pub usingnamespace @import("mixin.zig");
pub fn main() void {
// call-return inference: the receiver of the constructor call names the type
var a = Counter.init();
a.incr();
// annotation-only binding — `= undefined` and 0.14+ decl literals `.init` / `.empty`
var b: Counter = undefined;
b.twice();
const c: Counter = .init();
_ = c.get();
// struct-literal constructor through the alias
const d = Counter{};
_ = d.get();
// generic instantiation: literal, static call on the instantiation, annotation
var s = Stack(u8){};
s.push(1);
var t = Stack(u8).init();
_ = t.top();
const u: Stack(u16) = .init();
u.clear();
// path deps
_ = geo.area(2, 3);
var p = geo.Point{};
p.shift(1);
oldlib.legacy();
// statement assignments share the variable_declaration node type with
// declarations in tree-sitter-zig 1.1.2 — none of these is a binding.
counter.global_count = 5;
counter.global_count += 1;
_ = counter.VERSION;
a = Counter.init();
a.incr();
}

View file

@ -0,0 +1 @@
pub fn mixed() void {}

View file

@ -36,6 +36,8 @@ import { FIXTURES, runPipelineFromRepo } from './resolvers/helpers.js';
import { RELATION_SCHEMA } from '../../src/core/lbug/schema.js';
import { parseRelationSchemaPairs, relPairKeyFor } from '../../src/core/lbug/rel-pair-routing.js';
import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
vi.setConfig({ testTimeout: 180_000 });
@ -156,6 +158,29 @@ const NON_BRIDGE_CORPUS = [
},
] as const satisfies readonly CorpusEntry[];
/**
* Same contract, for fixtures whose grammar is an npm optionalDependency and
* may be absent on the runner. Gated per language rather than per case so a
* missing grammar SKIPS (the pipeline drops the files by contract, and an
* empty emit would otherwise fail every sentinel for a reason that has
* nothing to do with the schema).
*/
const OPTIONAL_GRAMMAR_CORPUS = [
{
// Zig `union(enum)` is a member container: `union_declaration` sits in
// MEMBER_OWNER_NODE_TYPES, so the definition phase emits HAS_PROPERTY /
// HAS_METHOD FROM a `Union` node. Shipped once with `Union` off the
// generated grid — every resolver test passed on the in-memory graph while
// a real `analyze` of THIS fixture aborted at `assertDeclaredPair`
// (`Union|Property`). `Union` is now in `LINKABLE_LABELS`; take it out and
// both sentinels vanish from the DDL and this fails.
fixture: 'zig-basic',
language: SupportedLanguages.Zig,
emitter: 'zig union HAS_PROPERTY / HAS_METHOD',
sentinels: ['Union|Property', 'Union|Method'],
},
] as const satisfies readonly (CorpusEntry & { readonly language: SupportedLanguages })[];
/*
* NOTE — why this suite runs its own pipelines instead of reusing the resolver
* suites' graphs (measured, not assumed):
@ -222,4 +247,16 @@ describeIfWorkerBuilt('RELATION_SCHEMA covers the non-bridge emitters', () => {
}).toEqual({ undeclaredPairs: [], missingSentinelPairs: [] });
},
);
it.concurrent.each(OPTIONAL_GRAMMAR_CORPUS)(
'$fixture emits only declared FROM/TO pairs, and still reaches $emitter',
async ({ fixture, language, sentinels }, ctx) => {
if (!isLanguageAvailable(language)) ctx.skip();
const emitted = await pairsEmittedBy(fixture);
expect({
undeclaredPairs: [...emitted].filter((pair) => !DECLARED.has(pair)).sort(),
missingSentinelPairs: sentinels.filter((pair) => !emitted.has(pair)),
}).toEqual({ undeclaredPairs: [], missingSentinelPairs: [] });
},
);
});