GitNexus/gitnexus/test/integration/resolvers/zig.test.ts
Gergo Magyar 78946ff30a test(scope): give the #3498 resolver rows unique titles
`'resolves $source.$member to $targets'` reads `$source.$member` as the
nested property `source.$member`, so every row rendered as
`resolves undefined to [...]`. Two Zig rows with the same target then had
identical titles, and the CI execution audit (`test-completeness.ts`)
rejects ambiguous test identities. The titles now name the member and the
calling function as separate placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:18:32 +00:00

1625 lines
82 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Zig: container types, methods, calls, and @import resolution.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
edgeSet,
FIXTURES,
getNodesByLabel,
getNodesByLabelFull,
getRelationships,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
import { describeGrammarPresence, optionalGrammarGate } from '../../helpers/optional-grammar.js';
// Vendored `tree-sitter-zig`: on a platform without a prebuild the grammar
// is absent and the pipeline skips `.zig` files by contract, so these
// suites skip too (Swift/Dart pattern).
// Under GITNEXUS_REQUIRE_ZIG=1 the skip is not acceptable — the presence
// assertion below fails the job instead of letting Zig vanish from a green run.
const zig = optionalGrammarGate(SupportedLanguages.Zig);
const zigAvailable = zig.available;
describeGrammarPresence(zig);
describe.skipIf(!zigAvailable)('Zig basic resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-basic'), () => {});
}, 60000);
it('detects the Pioneer struct and State enum', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('Pioneer');
expect(getNodesByLabel(result, 'Enum')).toContain('State');
});
it('labels `union(enum)` declarations as Union (not Class)', () => {
expect(getNodesByLabel(result, 'Union')).toContain('Tag');
// Negative-side check: Tag must NOT also appear under Class.
expect(getNodesByLabel(result, 'Class')).not.toContain('Tag');
});
it('extracts top-level functions from main.zig', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('main');
expect(fns).toContain('helper');
});
it('extracts struct methods (tick, reset) as Methods', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('tick');
expect(methods).toContain('reset');
});
it('extracts union(enum) methods as Methods (Union is class-like)', () => {
expect(getNodesByLabel(result, 'Method')).toContain('isEnergy');
});
it('dispatches method calls on a union receiver (main → isEnergy)', () => {
// Pins the `isClassLike('Union')` widening in scope/walkers.ts: without
// it `populateClassOwnedMembers` finds no class-like def in the Tag
// scope, the method gets no ownerId, and dispatch silently drops.
expect(edgeSet(getRelationships(result, 'CALLS'))).toContain('main → isEnergy');
});
it('resolves the relative @import("./pioneer.zig") to pioneer.zig', () => {
const imports = getRelationships(result, 'IMPORTS');
const internal = imports.filter((e) => e.targetFilePath.endsWith('pioneer.zig'));
expect(internal.length).toBeGreaterThan(0);
expect(internal[0].sourceFilePath).toContain('main.zig');
});
it('emits a CALLS edge for the free call main → helper', () => {
const calls = getRelationships(result, 'CALLS');
expect(edgeSet(calls)).toContain('main → helper');
});
it('emits a CALLS edge for the receiver-bound method call main → tick', () => {
const calls = getRelationships(result, 'CALLS');
// `var p = pioneer.Pioneer{…}; p.tick()` — constructor-inferred receiver
// type through the namespace import, dispatched onto Pioneer.tick.
expect(edgeSet(calls)).toContain('main → tick');
});
});
describe.skipIf(!zigAvailable)('Zig scope captures — variable bindings', () => {
it('binds only the declared name, never the initializer identifier', async () => {
// `(variable_declaration (identifier) @declaration.name)` without a
// first-child anchor ALSO matches the RHS identifier of `const h = helper;`
// and mints a phantom local named `helper` in the enclosing block. That
// phantom shadows the real function for every later reference in the
// block, so `helper()` below silently lost its CALLS edge — and the
// callable-value-flow seed for `h` had nothing to resolve against.
const { emitZigScopeCaptures } =
await import('../../../src/core/ingestion/languages/zig/captures.js');
const source = [
'fn helper() void {}',
'pub fn main() void {',
' const h = helper;',
' helper();',
'}',
'',
].join('\n');
const variableNames = emitZigScopeCaptures(source, 'main.zig')
.filter((m) => m['@declaration.variable'] !== undefined)
.map((m) => m['@declaration.name']?.text);
expect(variableNames).toEqual(['h']);
});
});
describe.skipIf(!zigAvailable)('Zig export, opaque and test declarations (ffi.zig)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-basic'), () => {});
}, 60000);
it('marks `export fn` (C-ABI, no `pub`) as exported', () => {
// `export` is the strongest visibility Zig has — FFI entry points are
// declared this way and never carry `pub`. A `pub`-only checker left
// every C-ABI symbol private in the graph.
const cAdd = getNodesByLabelFull(result, 'Function').find((n) => n.name === 'c_add');
expect(cAdd).toBeDefined();
expect(cAdd!.properties.isExported).toBe(true);
});
it('models `opaque {}` as a Struct that owns its methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('Handle');
expect(getNodesByLabel(result, 'Method')).toContain('close');
expect(edgeSet(getRelationships(result, 'HAS_METHOD'))).toContain('Handle → close');
});
it('owns container members through the binding name (HAS_METHOD / HAS_PROPERTY)', () => {
// tree-sitter-zig containers are anonymous; the owner walk used to climb
// past them and NO Zig member ever got an owner edge, so `context(Pioneer)`
// listed no methods and the struct's fields dangled off the File.
expect(edgeSet(getRelationships(result, 'HAS_METHOD'))).toEqual(
expect.arrayContaining(['Pioneer → tick', 'Pioneer → reset', 'Tag → isEnergy']),
);
expect(edgeSet(getRelationships(result, 'HAS_PROPERTY'))).toEqual(
expect.arrayContaining(['Pioneer → energy', 'State → idle', 'Tag → energy']),
);
});
it('dispatches a method call on an opaque receiver (release → close)', () => {
expect(edgeSet(getRelationships(result, 'CALLS'))).toContain('release → close');
});
it('never mints a nameless Property for an empty container body', () => {
// tree-sitter-zig 1.1.2 recovers `struct {}` / `opaque {}` as one
// container_field with a zero-width MISSING identifier.
expect(getNodesByLabel(result, 'Struct')).toContain('Empty');
expect(getNodesByLabel(result, 'Property')).not.toContain('');
});
it('captures named tests as Functions, quoted, so `test "release"` and `fn release` stay distinct nodes', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('"c_add adds"');
expect(fns).toContain('"release"');
// Both must exist as separate nodes — an unquoted test name would have
// merged onto Function:<file>:release and fabricated a self-call.
expect(fns.filter((n) => n === 'release')).toHaveLength(1);
expect(fns.filter((n) => n === '"release"')).toHaveLength(1);
});
it('attributes calls inside a named test to the test node, not the file', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toContain('"c_add adds" → c_add');
expect(calls).toContain('"release" → release');
expect(calls).not.toContain('release → release');
});
it('does not create a graph node for an anonymous `test {}`', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns.some((n) => n.startsWith('test@') || n === 'test')).toBe(false);
});
});
/**
* `zig-idioms`: the shapes real Zig is written in that `zig-basic` does not
* exercise. Each case names the idiom and what breaks without the rule.
*/
describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => {
let result: PipelineResult;
let calls: string[];
let imports: string[];
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-idioms'), () => {});
calls = edgeSet(getRelationships(result, 'CALLS'));
imports = getRelationships(result, 'IMPORTS').map(
(e) => `${path.basename(e.sourceFilePath)} → ${e.targetFilePath}`,
);
}, 90000);
it('mints Const / Variable nodes for `pub const` / `pub var` (incl. error sets and type aliases)', () => {
// ZIG_QUERIES had no @definition.const / @definition.variable at all, so
// `zigVariableConfig` never ran and `pub const VERSION`, error sets and
// aliases like `const Allocator = std.mem.Allocator` were absent from
// the graph.
expect(getNodesByLabel(result, 'Const')).toEqual(
expect.arrayContaining(['VERSION', 'Err', 'Allocator']),
);
expect(getNodesByLabel(result, 'Variable')).toContain('global_count');
});
it('never mints a Const for a container or an @import binding, nor for a statement assignment', () => {
// `const Counter = struct {…}` is the Struct node; `const counter =
// @import(…)` is the import binding; `counter.global_count = 5;` and
// `_ = counter.VERSION;` are assignments that tree-sitter-zig 1.1.2 parses
// as keyword-less `variable_declaration`s.
const consts = getNodesByLabel(result, 'Const');
// (`const Counter = counter.Counter;` IS a Const node — an alias — but
// the struct itself is not duplicated as one: exactly one `Counter` Const,
// from main.zig.)
expect(getNodesByLabelFull(result, 'Const').filter((n) => n.name === 'Counter')).toHaveLength(
1,
);
expect(consts).not.toContain('counter');
expect(consts).not.toContain('std');
expect(consts).not.toContain('_');
expect(getNodesByLabel(result, 'Variable')).not.toContain('_');
});
it('types a receiver from a constructor CALL (`var a = Counter.init(); a.incr()`)', () => {
expect(calls).toContain('main → incr');
});
/**
* #3399 — a callable named in VALUE position.
*
* `src/webapi/Element.zig` is the JS-API binding-table idiom verbatim:
* `pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{});`
* registers a Zig function with the JS bridge instead of calling it. Zig
* emitted NO `value-ref` capture at all, so every one of those references was
* dropped — 2,047 of them across 257 files in lightpanda-io/browser, the whole
* JS<->Zig surface — and `impact` on a public DOM accessor answered with its
* two in-file callers and the verdict `epistemic: "exact"`.
*
* These assert USES and not CALLS on purpose. A registration is not an
* invocation (Kythe `ref` vs `ref/call`; Joern METHOD_REF), and the call that
* eventually happens goes through comptime reflection this analyzer cannot
* follow. The claim being pinned is the reference, not the dispatch.
*/
describe('callable values (#3399)', () => {
let uses: string[];
let valueRefs: string[];
let valueRefTargetIds: string[];
beforeAll(() => {
const edges = getRelationships(result, 'USES');
uses = edgeSet(edges);
valueRefTargetIds = edges
.filter((e) => e.rel.reason === 'scope-resolution: value-ref')
.map((e) => e.rel.targetId)
.sort();
// The reason text is spelled out rather than imported from
// `VALUE_REF_EDGE_REASON`, deliberately and as `typescript-value-refs.test.ts`
// already does: `impact`'s epistemic probe matches this exact string in
// the stored graph, so a change to the constant's VALUE (as opposed to
// its name) must fail a test rather than quietly agree with itself on
// both sides.
valueRefs = edgeSet(edges.filter((e) => e.rel.reason === 'scope-resolution: value-ref'));
});
it('records a QUALIFIED function value handed to a call (`bridge.accessor(Element.getNamespaceUri, …)`)', () => {
expect(valueRefs).toContain('JsApi → getNamespaceUri');
});
it('records a BARE function value handed to a call (`bridge.accessor(_tagName, …)`)', () => {
expect(valueRefs).toContain('JsApi → _tagName');
});
it('records a function value in a const initialiser (`pub const defaultHandler = onReset;`)', () => {
expect(valueRefs).toContain('Element → onReset');
});
it('records a function passed into a `comptime f: anytype` parameter and stored', () => {
// The shape the non-goal is about: `register(onTick)` stores the value in
// a module-level field and nothing in the file ever calls `onTick`. The
// terminal invoke needs comptime evaluation and is NOT modelled — but the
// reference must survive, or `onTick` reads as dead code.
expect(valueRefs).toContain('boot → onTick');
expect(calls).not.toContain('boot → onTick');
});
it('emits USES, never CALLS, for a registration', () => {
// The whole distinction: if these became CALLS, `impact` would claim the
// accessor is invoked from the binding table, which is not a fact the
// analyzer has.
expect(calls).not.toContain('JsApi → getNamespaceUri');
expect(calls).not.toContain('JsApi → _tagName');
});
it('does not mint a value reference for a non-callable argument', () => {
// `Bridge(Element)` passes a TYPE. The property-dispatch pass keeps only
// Function/Method/Constructor targets, which is what makes the broad
// capture rules safe — the same gate that stops TypeScript's
// `{ port: DEFAULT_PORT }` from registering anything.
expect(valueRefs).not.toContain('JsApi → Element');
expect(uses.filter((u) => u === 'JsApi → Element')).toHaveLength(0);
});
it('leaves a method that is only ever CALLED untouched', () => {
// The control. `getTagNameLower` is called twice and registered nowhere;
// a change that sprayed USES edges over every method would satisfy every
// assertion above and still be wrong.
expect(calls).toContain('describe → getTagNameLower');
expect(calls).toContain('_tagName → getTagNameLower');
expect(valueRefs.filter((v) => v.endsWith(' → getTagNameLower'))).toEqual([]);
});
it('binds a QUALIFIED reference to the owner that was written, not the nearest lexical match', () => {
// `JsApi` declares its own `getLocalName` next to
// `bridge.accessor(Element.getLocalName, …)`. `walkScopeChain` gives that
// local binding precedence, so resolving the registration by tail name
// alone attaches it to the SIBLING — a confidently wrong edge, which is a
// worse failure than the missing edge this whole change is about. The
// written receiver is the only thing that tells them apart.
const local = valueRefTargetIds.filter((id) => id.endsWith('.getLocalName#0'));
expect(local).toEqual(['Method:src/webapi/Element.zig:Element.getLocalName#0']);
expect(local).not.toContain('Method:src/webapi/Element.zig:JsApi.getLocalName#0');
});
it('declines a qualified reference whose receiver cannot be resolved', () => {
// `bridge.accessor(unresolvable_ns.tick, …)` names an owner this index
// does not have, while a file-level `tick` sits in the lexical chain
// waiting to be mis-bound. Emitting nothing is the safe direction, but be
// exact about what it buys: no edge means no evidence, and `impact` on
// `tick` therefore stays `epistemic: "exact"` — this decline costs the
// reference AND the hedge. It is still the right trade, because the
// alternative is a confident edge to a function the source did not name,
// and a wrong edge is worse than a missing one. See
// `resolveValueRefTarget`'s docstring for the same distinction, and the
// module-owner case below for the half of it that IS recoverable.
expect(valueRefTargetIds.filter((id) => id.includes('.tick#'))).toEqual([]);
expect(valueRefs).not.toContain('JsApi → tick');
});
it('records a QUALIFIED function value owned by a MODULE, not a container (`bridge.accessor(dom_utils.compare, …)`)', () => {
// `dom_utils` is a namespace-only file — no `@This()`, so no container
// symbol to look the member up on. Resolving only through class-like
// owners declines here, and a decline is SILENT: with no USES edge the
// boundary probe measures a real zero and `impact` on `compare` goes back
// to `epistemic: "exact"`, which is the defect, not a conservative answer.
// The member-CALL path already resolves `dom_utils.compare()` through the
// file's namespace import; the registration reads the same channel.
expect(valueRefs).toContain('JsApi → compare');
// And it must be dom_utils.zig's `compare`, not `decoy.zig`'s. That file
// declares a CONTAINER also called `dom_utils`, with its own `compare`,
// and `Element.zig` never imports it. `findClassBindingInScope` does not
// stop at the scope chain: a namespace handle binds a Module, so the
// `isClassLike` walk misses and its workspace-wide `qualifiedNames`
// fallback answers with that unique container — preempting the `@import`
// this very file wrote. The written import has to outrank a global guess.
expect(valueRefTargetIds.filter((id) => id.includes('compare'))).toEqual([
'Function:src/webapi/dom_utils.zig:compare',
]);
});
it('resolves a value reference through a HUB the same way a call through it resolves', () => {
// `hub.zig` declares nothing: every name it publishes it imported
// (`pub const normalize = @import("dom_utils.zig").normalize;`). That is
// the shape `ScopeResolver.namespaceExportsIncludeImportedNames` exists
// for, and `receiver-bound-calls` honours it — so `hub.scale(v)`
// resolves. Accepting only locally-declared members here would make
// `bridge.accessor(hub.scale, …)` decline, and one name would mean
// two different things depending on whether a `(` followed it.
expect(calls).toContain('callsThroughTheHub → scale');
expect(valueRefs).toContain('JsApi → scale');
expect(valueRefTargetIds.filter((id) => id.includes('scale'))).toEqual([
'Function:src/webapi/dom_utils.zig:scale',
]);
});
it('applies the callable gate through a HUB too', () => {
expect(valueRefs).not.toContain('JsApi → DEFAULT_NS');
});
it('applies the callable gate to a MODULE owner too', () => {
// `dom_utils.DEFAULT_NS` is a module-scope constant. Widening the owner
// channel must not widen what counts as a registration, or every
// `bridge.accessor(mod.SOME_CONST, …)` in a binding table starts claiming
// a callable was registered.
expect(valueRefs).not.toContain('JsApi → DEFAULT_NS');
expect(valueRefTargetIds.filter((id) => id.includes('DEFAULT_NS'))).toEqual([]);
});
it('declines a module-qualified reference whose handle is locally shadowed', () => {
// `shadowsTheModuleHandle(dom_utils: u8)` names a PARAMETER, not the
// file-level `@import`. Reading through the import here would attach the
// registration to a module this site never named — the same wrong-edge
// failure `isNamespaceNameShadowed` prevents on the member-call path, and
// the reason the module channel is guarded rather than merely added.
expect(valueRefs).not.toContain('shadowsTheModuleHandle → normalize');
expect(valueRefTargetIds.filter((id) => id.includes('normalize'))).toEqual([]);
});
it('declines a container-qualified reference whose owner name is locally shadowed', () => {
// `shadowsAContainerName(Ticker: u8)` names a PARAMETER. This file neither
// declares nor imports `Ticker.zig`'s container, so
// `findClassBindingInScope` walks past the parameter (it filters by
// `isClassLike`) and its qualified-name fallback answers with the unique
// workspace `Ticker` — a struct the source never named at this site.
// Verified to emit `shadowsAContainerName → fire` without the guard.
expect(valueRefs).not.toContain('shadowsAContainerName → fire');
expect(valueRefTargetIds.filter((id) => id.includes('Ticker'))).toEqual([]);
});
it('still binds a reference whose container IS the local declaration', () => {
// `registersALocalContainer` declares `Local` in its own body and registers
// `Local.go`. The shadow guard above must exempt the container it just
// resolved, or the nearer binding — which is that container — reads as its
// own shadow and every function-local registry stops registering.
expect(valueRefs).toContain('registersALocalContainer → go');
});
it('declines a container-qualified reference shadowed at MODULE scope', () => {
// `Element.zig` binds `Gauge` at module scope to something that is NOT a
// container, and never imports `Gauge.zig`, which declares one. The class
// walk filters by `isClassLike`, steps over that binding, and its
// workspace-wide qualified-name fallback answers with the other file's
// struct. The shadow guard has to inspect the MODULE scope to catch it —
// stopping one rung short, as it did, permitted precisely this case.
//
// The binding is an IMPORT (`const Gauge = @import("dom_utils.zig").DEFAULT_NS;`),
// not a local `const Gauge: u8 = 3;`, and that is the difference between a
// live case and a self-defeating one: a local declaration would ALSO claim
// the workspace qualified name `Gauge`, leaving two candidates, and the
// fallback refuses to guess between two — so the case this test exists for
// would never be reached. See the fixture's own note at `Element.zig:30-35`.
expect(valueRefs).not.toContain('JsApi → read');
expect(valueRefTargetIds.filter((id) => id.includes('Gauge'))).toEqual([]);
});
it('declines a namespace member the written module does not have, rather than reaching a same-named container', () => {
// The fall-through the channel order creates, and the guard that closes
// it. `dom_utils` IS a namespace import here, but `dom_utils.zig` has no
// `onlyOnDecoy`, so the namespace channel declines — and declining is not
// the end: `findClassBindingInScope` runs next, its `isClassLike` walk
// misses (an import binds a Module), and its WORKSPACE-WIDE
// `qualifiedNames` fallback answers with `decoy.zig`'s same-named struct,
// which does declare `onlyOnDecoy`. Only `isOwnerNameShadowedBySomethingElse`
// stands between that and a confident edge into a file this one never
// imported — the wrong-edge failure, arriving through the container
// channel after the namespace channel said no.
expect(valueRefs).not.toContain('JsApi → onlyOnDecoy');
expect(valueRefTargetIds.filter((id) => id.includes('onlyOnDecoy'))).toEqual([]);
});
it('does not mint a value reference for the CALLEE of an ordinary call', () => {
// `register(onTick)` must produce ONE value reference (the argument), not
// two: without binding the callee to the `function:` field the same rule
// also matches `register` itself and every call in the repo would emit a
// USES edge shadowing its own CALLS edge.
expect(valueRefs).not.toContain('boot → register');
expect(calls).toContain('boot → register');
});
});
/**
* `@This()` aliases (#3219 review round 8).
*
* `@This()` IS the enclosing container, and `const Self = @This();` is how
* most Zig files say so. The container itself is minted under the FILE STEM,
* and the alias bound nothing class-like — a file-level alias mints no Const
* at all, a container-level one mints a Variable that every `isClassLike`
* walk steps over — so `Self.member` resolved to nothing at all: not a wrong
* edge, no edge. On the corpora at hand that is 302 `Alias.member`
* references (ghostty 73 files, tigerbeetle 93, mach 8), 96 of them calls.
*
* `bindZigThisAliases` binds the alias name to its container in the
* post-finalize augmentation channel, so a compiler's answer and this
* index's answer agree. Both spellings of the alias are exercised:
* `Widget.zig`'s file-level `Self` and `Metrics`' container-level `Me`.
*/
describe('@This() aliases (#3219)', () => {
let valueRefs: string[];
let valueRefTargetIds: string[];
beforeAll(() => {
// Recomputed here rather than shared with the block above: these are
// sibling describes, and a shared `beforeAll` would make the order of
// the two blocks load-bearing.
const edges = getRelationships(result, 'USES').filter(
(e) => e.rel.reason === 'scope-resolution: value-ref',
);
valueRefs = edgeSet(edges);
valueRefTargetIds = edges.map((e) => e.rel.targetId).sort();
});
it('resolves a qualified CALL written through a file-level alias', () => {
// `Widget.zig` writes `const Self = @This();` and calls `Self.width(self)`.
expect(calls).toContain('describeWidth → width');
expect(
getRelationships(result, 'CALLS')
.filter((e) => e.source === 'describeWidth')
.map((e) => e.rel.targetId),
).toEqual(['Method:src/webapi/Widget.zig:Widget.width#0']);
});
it('resolves a qualified REGISTRATION written through a file-level alias', () => {
// The #3399 shape spelled the ordinary way: `binder.accessor(Self.width, …)`.
// Declining it cost the reference AND the hedge — no edge means no
// evidence, so `impact` on `width` went back to claiming `exact`.
expect(valueRefs).toContain('WidgetApi → width');
expect(valueRefTargetIds.filter((id) => id.includes('Widget.width'))).toEqual([
'Method:src/webapi/Widget.zig:Widget.width#0',
]);
});
it('resolves a qualified call through a CONTAINER-level alias', () => {
// `Metrics` declares `const Me = @This();`, which mints a Variable beside
// the Struct — the binding is there, it is just not class-like, so the
// walk stepped over it and kept climbing.
expect(calls).toContain('readTwice → read');
expect(
getRelationships(result, 'CALLS')
.filter((e) => e.source === 'readTwice')
.map((e) => e.rel.targetId),
).toEqual([
'Method:src/webapi/Widget.zig:Metrics.read#0',
'Method:src/webapi/Widget.zig:Metrics.read#0',
]);
});
it('leaves a stem-spelled alias resolving exactly as it did', () => {
// The control. `Element.zig` writes `const Element = @This();`, so its
// qualified references already resolved through the stem binding. The
// alias binding is an ADDITION to the augmentation channel, consulted
// only after a scope's own bindings, so it must move nothing here.
expect(valueRefTargetIds.filter((id) => id.endsWith('.getNamespaceUri#0'))).toEqual([
'Method:src/webapi/Element.zig:Element.getNamespaceUri#0',
]);
expect(valueRefs).toContain('JsApi → getNamespaceUri');
});
it('does not make the alias name resolvable from another file', () => {
// `Self` and `Me` are container-private: Zig has no way to import them,
// and the binding is appended at the declaring scope only. If it leaked
// to the workspace channels, every file in a repo would see one
// arbitrary `Self` — 66 files in ghostty declare that exact name.
const targets = valueRefTargetIds.concat(
getRelationships(result, 'CALLS').map((e) => e.rel.targetId),
);
// The alias's OWN def (`Metrics.Me`, a Variable) must never be an edge
// target — matched on the last segment so `Metrics.read` is not read as
// a hit on `Me`.
expect(targets.filter((id) => /[:.](Self|Me)(#\d+)?$/.test(id))).toEqual([]);
});
});
it('types a receiver from its ANNOTATION (`var b: Counter = undefined; b.twice()`, `const c: Counter = .init(); c.get()`)', () => {
// The declared type is the ONLY type source for `= undefined` and for
// 0.14+ decl literals (`.init`, `.empty`), which current std uses for
// every container constructor.
expect(calls).toContain('main → twice');
expect(calls).toContain('main → get');
});
it('follows an alias of a namespace member as a named import (`const Counter = counter.Counter;`)', () => {
// Every receiver above is typed through the alias — none resolves if the
// scope-side binding is a plain local shadowing the import (the graph
// still carries the alias as a Const node in main.zig, which is what it is).
expect(calls).toContain('main → get');
expect(calls).toContain('main → init');
});
it('owns a generic type constructor’s members and dispatches on its instantiations', () => {
// `pub fn Stack(comptime T: type) type { return struct {…}; }` — the
// returned container had no owner (methods hung off the File) and
// `Stack(u8){}` / `Stack(u8).init()` / `: Stack(u16)` typed nothing.
expect(getNodesByLabel(result, 'Struct')).toContain('Stack');
expect(getNodesByLabel(result, 'Function')).toContain('Stack');
expect(edgeSet(getRelationships(result, 'HAS_METHOD'))).toEqual(
expect.arrayContaining(['Stack → push', 'Stack → top', 'Stack → clear']),
);
expect(edgeSet(getRelationships(result, 'HAS_PROPERTY'))).toContain('Stack → items');
expect(calls).toEqual(expect.arrayContaining(['main → push', 'main → top', 'main → clear']));
});
it('imports the file behind `const X = @import("x.zig").X` and `usingnamespace @import(...)`', () => {
// Both forms lost the file-level IMPORTS edge: the rule needed
// `builtin_function` as a DIRECT child of the declaration.
expect(imports).toContain('main.zig → src/mixin.zig');
// counter.zig is imported twice from main.zig (namespace + member); the
// edge is deduped, so its presence proves at least one form resolved and
// `Stack` (member form only) dispatching proves the other.
expect(imports).toContain('main.zig → src/counter.zig');
expect(calls).toContain('main → push');
});
it('imports every file behind an `@import` in EXPRESSION position (the `Interfaces = .{ @import(…), … }` table)', () => {
// Both query sets only matched `@import` as the value of a const/var or
// under `usingnamespace`, so a registration table of inline imports
// (Lightpanda's bridge.zig: ~290 modules) produced NO file edges — the
// modules looked unreferenced. Neither element binds a name; each is
// still a dependency of main.zig.
expect(imports).toContain('main.zig → src/webapi/AbortController.zig');
expect(imports).toContain('main.zig → src/webapi/AbortSignal.zig');
// and it mints no Const for the tuple elements — only for the table
expect(getNodesByLabel(result, 'Const')).toContain('Interfaces');
});
it('resolves a member call whose receiver is an inline import (`@import("dump.zig").root(…)`)', () => {
// The receiver text is the builtin itself, not a `const` handle; the
// inline import is bound as a namespace import under that very text so
// the shared namespace-receiver lookup lands in dump.zig.
expect(imports).toContain('main.zig → src/dump.zig');
expect(calls).toContain('main → root');
});
it('resolves a build.zig.zon path dep to the root its build.zig declares (src/root.zig)', () => {
// `zig init` ≥ 0.12 lays libraries out as src/root.zig; the resolver only
// knew src/<name>.zig and src/main.zig, so every such dep was unresolved.
expect(imports).toContain('main.zig → libs/geo/src/root.zig');
expect(calls).toContain('main → area');
expect(calls).toContain('main → shift');
});
it('still resolves the older src/<name>.zig convention when the dep has no build.zig', () => {
expect(imports).toContain('main.zig → libs/oldlib/src/oldlib.zig');
expect(calls).toContain('main → legacy');
});
it('resolves `@import("<own module>")` through the ROOT build.zig’s addModule (Lightpanda: `@import("lightpanda")`)', () => {
// Bare names were resolved through build.zig.zon path deps only, so the
// package's own root module — `b.addModule("idioms", .{ .root_source_file
// = b.path("src/idioms.zig") })`, re-imported into itself via addImport —
// had no IMPORTS edge and nothing reached through it resolved.
expect(imports).toContain('main.zig → src/idioms.zig');
expect(calls).toContain('main → boot');
// …and a type reached through the module namespace dispatches.
expect(calls).toContain('main → reset');
});
it('does not fabricate an edge for a generated module (`addOptions().createModule()`)', () => {
// `build_config` exists only at build time; there is no file to import.
expect(imports.some((e) => e.startsWith('main.zig → ') && /build_config/.test(e))).toBe(false);
});
it('a re-assignment (`a = Counter.init();`) is not a declaration and does not shadow the typed binding', () => {
// Guarded on the scope side by the literal `"const"` / `"var"` in the
// query and on the structure side by `isZigKeywordDeclaration`.
// main → incr resolves twice through the same binding (before and after
// the re-assignment); an untyped phantom `a` would drop the second.
expect(
getRelationships(result, 'CALLS').filter((e) => e.source === 'main' && e.target === 'incr')
.length,
).toBeGreaterThanOrEqual(2);
});
it('types a receiver by the FIELD it is read from (`self.counter.incr()`, `self.ptr.incr()`) — F5', () => {
// A container's field types were never bound on its Class scope, so the
// compound resolver (`typeOfMemberOnClass`) found nothing for `counter`
// and `self.<field>.<method>()` — Lightpanda's dominant cross-object call
// shape — resolved 9 of 2803 times (0.3 %). Plain, pointer and optional-
// pointer field types all reduce to the nominal `Counter`.
const viaField = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'viaField' && e.target === 'incr',
);
// `self.counter.incr()` and `self.ptr.incr()` — two sites, one callee, and
// the callee lives in counter.zig, not in holder.zig.
expect(viaField.length).toBeGreaterThanOrEqual(2);
expect(viaField.every((e) => e.targetFilePath.endsWith('counter.zig'))).toBe(true);
// (`if (self.opt) |c| c.incr()` — the payload capture — is F6 territory
// and is deliberately not asserted here.)
});
it('types a local ALIAS of a field (`const c = self.counter; c.get()`, `var p = self.ptr; p.twice()`) — F5', () => {
// `const c = self.counter;` binds nothing on the scope side without the
// alias rule; the alias keeps the RHS path (`self.counter`) as its type
// and the compound resolver re-resolves it as a receiver chain.
expect(calls).toContain('viaAlias → get');
expect(calls).toContain('viaAlias → twice');
});
});
/**
* `zig-rootmodule`: a repo with a root `build.zig` and NO `build.zig.zon`.
* Its only bare-name import is the module its own build.zig declares through
* a `createModule` binding that `addImport("core", core_mod)` names.
*/
describe.skipIf(!zigAvailable)(
'Zig own root module without build.zig.zon (zig-rootmodule fixture)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-rootmodule'), () => {});
}, 60000);
it('resolves `@import("core")` to src/core.zig and the `core.start()` call through it', () => {
// The resolution config was null without a build.zig.zon, so the repo's
// own module never resolved: no IMPORTS edge, no call through `core.`.
const imports = getRelationships(result, 'IMPORTS').map(
(e) => `${path.basename(e.sourceFilePath)} → ${e.targetFilePath}`,
);
expect(imports).toContain('main.zig → src/core.zig');
expect(edgeSet(getRelationships(result, 'CALLS'))).toContain('main → start');
});
},
);
describe.skipIf(!zigAvailable)('Zig file-structs (zig-filestruct fixture)', () => {
// In Zig every file is a struct; one with top-level FIELDS is an
// instantiable type named after the file (`Page.zig` declares `Page`), and
// its top-level `fn`s are that type's methods. Lightpanda spells 73 % of its
// types this way, and before this modelling `page.getArena()` on a
// `page: *Page` parameter resolved 23 of 993 times (2.3 %) in that corpus:
// `impact` on `Page.getArena` reported 0 callers for 159 call sites.
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-filestruct'), () => {});
}, 60000);
it('declares a Struct named after the file for a file with top-level fields', () => {
const structs = getNodesByLabel(result, 'Struct');
expect(structs).toContain('Page');
expect(structs).toContain('Session');
// The name is the FILE STEM, not the `@This()` alias (`SigHandler`).
expect(structs).toContain('Sighandler');
expect(structs).not.toContain('SigHandler');
// A namespace file (no fields) is NOT a type, even with a `@This()` alias.
expect(structs).not.toContain('util');
});
it('owns top-level fns and fields as Methods / Properties of that Struct', () => {
// Member ids are owner-qualified exactly like `const T = struct {…}` members.
expect(result.graph.getNode('Method:src/Page.zig:Page.getArena#0')).toBeDefined();
expect(result.graph.getNode('Method:src/Session.zig:Session.findFrame#1')).toBeDefined();
expect(result.graph.getNode('Method:src/Sighandler.zig:Sighandler.arm#0')).toBeDefined();
expect(result.graph.getNode('Property:src/Page.zig:Page.session')).toBeDefined();
expect(result.graph.getNode('Function:src/Page.zig:getArena')).toBeUndefined();
// Namespace-file fns keep their Function ids.
expect(getNodesByLabel(result, 'Function')).toContain('helper');
expect(getNodesByLabel(result, 'Method')).not.toContain('helper');
const hasMethod = edgeSet(getRelationships(result, 'HAS_METHOD'));
expect(hasMethod).toContain('Page → getArena');
expect(hasMethod).toContain('Sighandler → arm');
const hasProp = edgeSet(getRelationships(result, 'HAS_PROPERTY'));
expect(hasProp).toContain('Page → session');
expect(hasProp).toContain('Session → label');
});
it('does not mint a Const for the file-level `@This()` self-alias of a file-struct', () => {
// `const Page = @This();` names the file's own type; a Const beside the
// Struct would shadow it for every `x: *Page`. A namespace file's alias
// (`const util = @This();`) stays a Const — there is no type to shadow.
const consts = getNodesByLabel(result, 'Const');
expect(consts).not.toContain('Page');
expect(consts).not.toContain('SigHandler');
expect(consts).toContain('util');
});
it('dispatches method calls on receivers typed by another file-struct', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
// parameter annotation `page: *Page` (Page = @import("Page.zig"))
expect(calls).toContain('useParam → getArena');
expect(calls).toContain('findFrame → getArena');
// `var q: Page = undefined` and the call-return rule `var p = Page.init(&s)`
expect(calls).toContain('main → getArena');
expect(calls).toContain('main → bump');
expect(calls).toContain('main → findFrame');
// the alias-spelled receiver `self: *SigHandler` inside Sighandler.zig
expect(calls).toContain('arm → check');
// `var h: Sighandler = .{}` — annotation naming the file stem
expect(calls).toContain('main → arm');
// namespace-member calls keep working beside the type
expect(calls).toContain('main → init');
expect(calls).toContain('main → helper');
// and `self.getArena()` inside the file-struct itself
expect(calls).toContain('bump → getArena');
});
it('republishes a `pub const X = @import("X.zig")` so a third file reaches the type through the hub', () => {
// Lightpanda's `lightpanda.zig` is one long list of `pub const X =
// @import("...")`; `const lp = @import("lightpanda"); const Arena =
// lp.Arena;` is how most files name their types. The re-export must
// publish the TYPE (the file-struct), not just the module.
expect(edgeSet(getRelationships(result, 'CALLS'))).toContain('viaHubAlias → getArena');
});
it('keeps the file-struct type reachable through the namespace import binding', () => {
// `const Page = @import("Page.zig")` binds both the module (`Page.init`)
// and the type it declares. Two Struct defs named `Page` in different
// files must NOT be conflated: `Session` and `Page` each dispatch to
// their own methods.
const calls = getRelationships(result, 'CALLS');
const target = calls.find((e) => e.source === 'findFrame' && e.target === 'getArena');
expect(target?.targetFilePath).toMatch(/Page\.zig$/);
const nameCall = calls.filter((e) => e.target === 'name');
expect(nameCall.every((e) => e.targetFilePath.endsWith('Session.zig'))).toBe(true);
});
it('dispatches through a file-struct FIELD typed by an imported file-struct (`self.session.name()`) — F5', () => {
// `session: *Session` sits at the top level of Page.zig, whose Class
// scope spans the file: the field's type binding must land there (not be
// hoisted to the Module scope like the member NAMES are), and `Session`
// must resolve through the import binding's type twin. Before F5 the
// scope had no typeBindings at all and neither call resolved.
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toContain('sessionName → name');
// `const s = self.session; s.name()` — alias of the field
expect(calls).toContain('sessionLabel → name');
const targets = getRelationships(result, 'CALLS').filter((e) => e.target === 'name');
expect(targets.length).toBeGreaterThanOrEqual(2);
expect(targets.every((e) => e.targetFilePath.endsWith('Session.zig'))).toBe(true);
});
});
/**
* F7 — type aliases (`src/aliases.zig` + `src/generic.zig` in zig-filestruct).
* `const X = <type expr>;` is a Const in the graph; on the scope side the
* alias name must be bound to the value's type so receivers written through
* it dispatch. Before the fix every case below resolved nothing.
*/
describe.skipIf(!zigAvailable)('Zig type aliases (zig-filestruct fixture, aliases.zig)', () => {
let result: PipelineResult;
let calls: string[];
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-filestruct'), () => {});
calls = edgeSet(
getRelationships(result, 'CALLS').filter((e) => e.sourceFilePath.endsWith('aliases.zig')),
);
}, 60000);
it('keeps every alias a Const (graph ids unchanged) — the type lives on the scope side', () => {
const consts = getNodesByLabelFull(result, 'Const').filter((n) =>
n.properties.filePath.endsWith('aliases.zig'),
);
expect(consts.map((n) => n.name)).toEqual(
expect.arrayContaining(['LocalAlias', 'T2', 'B', 'P', 'max', 'bridge']),
);
expect(getNodesByLabel(result, 'TypeAlias')).toEqual([]);
});
it('dispatches through an alias of a same-file struct (`const LocalAlias = Local;`)', () => {
// b1: class-name receiver typed by the alias binding (Case 4)
expect(calls).toContain('b1 → mk');
// b2: `var l = LocalAlias.mk()` chains l → LocalAlias → Local
expect(calls).toContain('b2 → mk');
expect(calls).toContain('b2 → go');
});
it('dispatches through an alias of an alias / import (`const T2 = Thing;`)', () => {
expect(calls).toContain('b3 → make');
expect(calls).toContain('b4 → make');
expect(calls).toContain('b4 → run');
});
it('dispatches through an alias of an INSTANTIATED generic type constructor (`const B = generic.List(u8);`)', () => {
// `B.init()` — the alias binds `generic.List` (comptime args dropped), Case 3
expect(calls).toContain('b5 → init');
// `var b = B{}` (constructor-inferred → B → generic.List)
expect(calls).toContain('b6 → push');
// `var x: B = .{}` (annotation → B → generic.List)
expect(calls).toContain('b7 → push');
// the same alias declared INSIDE a fn body (`const R = generic.List(u16);`)
expect(calls).toContain('b8 → init');
expect(calls).toContain('b8 → push');
});
it('dispatches through an alias of a namespace import that is a file-struct (`const P = Page;`)', () => {
expect(calls).toContain('b10 → getArena');
});
it('a VALUE alias (`var cur = orig; cur.go()`) chains to the value’s type, as Rust’s `let x = y`', () => {
expect(calls).toContain('b11 → go');
});
it('a value const / value call is not a type alias and gains no edge', () => {
// `const helperResult = generic.Thing.make();` is a call, not an alias;
// `const max = 5;` is a literal. Neither may bind a phantom type that
// resolves `main`'s discards to anything.
expect(calls.filter((c) => c.startsWith('main → '))).toEqual([]);
});
// F6 — locals bound WITHOUT an annotation (src/flow.zig). Before this, the
// call-return rule needed the `call_expression` as the DIRECT value child,
// so `const p = try Page.make(s)` (the shape of 2,551 Lightpanda sites),
// `… catch return`, `… orelse return` typed nothing; no fn had a
// return-type binding, so `const t = makeThing()` / `const el =
// node.asElement()` typed nothing; and payload captures (`for … |*p|`,
// `if (o) |p|`, `while (it.next()) |p|`) had no binding at all. Every
// edge below is a method call on such a local.
it('types a local through try / catch / orelse / parens around a constructor call', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toContain('viaTry → bump');
expect(calls).toContain('viaCatch → bump');
expect(calls).toContain('viaOrelse → name');
expect(calls).toContain('viaParens → bump');
// `try Page{ .session = s }` — a wrapped struct literal
expect(calls).toContain('viaTryLiteral → bump');
});
it('types a local from the callee’s RETURN type: free call, member call on a local, member call on self', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
// `const p = makeLocal();` — `fn makeLocal() Page`
expect(calls).toContain('viaFreeCall → bump');
// `const s = p.getSession();` — `p: *Page`, `fn getSession(self: *Page) *Session`.
// The RECEIVER is a Page: had the old "receiver names the type" rule
// applied to a value receiver, `s` would be a Page and `s.name()` would
// find no method (or the wrong one).
expect(calls).toContain('viaMemberReturn → name');
// `const p = self.current();` inside `Runner`
expect(calls).toContain('go → bump');
// A TitleCase callee (`const L = List(u8)`) is a type constructor: the free
// call itself resolves, and NOTHING else — `List ↦ type` must not bind.
expect(calls.filter((c) => c.startsWith('viaTypeConstructor →'))).toEqual([
'viaTypeConstructor → List',
]);
});
it('types payload captures from the subject: for |*p| / |p|, for (…, 0..) |p, i|, if (o) |p|, if (call) |s|, while (it.next()) |p|', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toContain('forSlice → bump');
expect(calls).toContain('forSlice → getArena');
expect(calls).toContain('forIndexed → bump');
expect(calls).toContain('ifOptional → bump');
// `if (p.maybeSession()) |s|` — the payload of the METHOD's `?*Session`
expect(calls).toContain('ifCallOptional → name');
// `while (s.next()) |p|` — `fn next(self: *Session) ?*Page`
expect(calls).toContain('whileNext → bump');
});
it('types one-layer projections bound to a local: items[i], opt.?, ptr.*', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toContain('viaIndex → bump');
expect(calls).toContain('viaUnwrap → bump');
expect(calls).toContain('viaDeref → bump');
// A pointer CAPTURE is deref-able too: `for (pages) |*p|` records `*Page`
// (not `Page`), so `const q = p.*;` still sees the pointer layer.
expect(calls).toContain('viaPtrCaptureDeref → bump');
});
});
describe.skipIf(!zigAvailable)('Zig function-local and anonymous containers (F8)', () => {
// reflect.zig mirrors Lightpanda's reflection.zig: a generic type
// constructor whose builder fns each declare `const R = struct { fn get…
// fn set… }`. Sorter.zig hosts the anonymous shapes: two `std.sort.pdq(…,
// struct { fn lessThan … }.lessThan)` comparators in one fn (ImportMap.zig
// has three), `const byteSize = struct { fn it … }.it;` (build.zig), a
// field typed `?struct { min, max }`, and a test-local `const State`.
// Before this modelling every `R` was one `Struct:…:R` with one `R.get`,
// and every comparator's `lessThan` was an OWNERLESS `Method:<file>:lessThan`
// — the corpus gate counted 14 ownerless Methods and 55 fns without a node.
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-filestruct'), () => {});
}, 60000);
const idsIn = (label: string, file: string): string[] => {
const ids: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === label && String(n.properties.filePath).endsWith(file)) ids.push(n.id);
});
return ids.sort();
};
it('keys a function-local `const R = struct` by its enclosing callable, so two builders own two `R.get`s', () => {
expect(idsIn('Struct', 'reflect.zig')).toEqual([
'Struct:src/reflect.zig:Accessor',
'Struct:src/reflect.zig:Reflect',
'Struct:src/reflect.zig:Reflect.string$R',
'Struct:src/reflect.zig:Reflect.url$R',
]);
// Both `get`s exist, under distinct owner-qualified ids…
expect(result.graph.getNode('Method:src/reflect.zig:Reflect.string$R.get#0')).toBeDefined();
expect(result.graph.getNode('Method:src/reflect.zig:Reflect.url$R.get#0')).toBeDefined();
// …and nothing is left under the bare binding name.
expect(result.graph.getNode('Struct:src/reflect.zig:R')).toBeUndefined();
expect(result.graph.getNode('Method:src/reflect.zig:R.get#0')).toBeUndefined();
const hasMethod = getRelationships(result, 'HAS_METHOD').map(
(e) => `${e.rel.sourceId} → ${e.rel.targetId}`,
);
expect(hasMethod).toContain(
'Struct:src/reflect.zig:Reflect.string$R → Method:src/reflect.zig:Reflect.string$R.get#0',
);
expect(hasMethod).toContain(
'Struct:src/reflect.zig:Reflect.url$R → Method:src/reflect.zig:Reflect.url$R.get#0',
);
});
it('marks a struct-literal construction site (`return Accessor{…}`) on its CALLS edge, unlike an invocation', () => {
// A struct literal `T{ .f = x }` (no parens) is deliberately modelled as a
// CALLS edge to the type — the same shape Rust `T { .. }` and Go `T{}`
// produce. The PR #1432 review found it indistinguishable from a real
// call: the marker in `reason` is what lets a consumer tell "constructs an
// instance of" apart from "invokes". `Reflect.string` / `Reflect.url`
// each `return Accessor{ .get = R.get, .set = R.set }` (reflect.zig).
// Same-file free-call fallback vocabulary, suffixed because the Zig
// resolver opts into `markConstructionSites`.
const calls = getRelationships(result, 'CALLS');
const reasonsOf = (source: string, target: string): string[] =>
calls.filter((e) => e.source === source && e.target === target).map((e) => e.rel.reason);
expect(reasonsOf('string', 'Accessor')).toEqual(['local-call (constructor)']);
expect(reasonsOf('url', 'Accessor')).toEqual(['local-call (constructor)']);
// The invocation next door keeps its plain reason: `util.helper()` in
// main.zig is a call, not a construction site.
expect(reasonsOf('main', 'helper')).toHaveLength(1);
expect(reasonsOf('main', 'helper')[0]).not.toContain('(constructor)');
// Every marked edge targets a container type: a construction site can
// never point at a callable.
const constructionSites = calls.filter((e) => e.rel.reason.endsWith('(constructor)'));
expect(constructionSites.length).toBeGreaterThan(0);
expect(constructionSites.map((e) => e.targetLabel)).toEqual(
constructionSites.map(() => 'Struct'),
);
});
it('gives anonymous containers a host + ordinal identity, so no Method is ownerless and same-named fns never collide', () => {
expect(idsIn('Struct', 'Sorter.zig')).toEqual([
'Struct:src/Sorter.zig:Sorter',
'Struct:src/Sorter.zig:Sorter$1', // `bounds: ?struct { min, max }`
'Struct:src/Sorter.zig:Sorter$2', // `const byteSize = struct { fn it }.it`
'Struct:src/Sorter.zig:Sorter.sortBoth$1', // first comparator
'Struct:src/Sorter.zig:Sorter.sortBoth$2', // second comparator
'Struct:src/Sorter.zig:Sorter.test@L41$State', // test-local `const State`
]);
// Two `lessThan`s in one file → two nodes, each owned by its own Struct.
expect(
result.graph.getNode('Method:src/Sorter.zig:Sorter.sortBoth$1.lessThan#3'),
).toBeDefined();
expect(
result.graph.getNode('Method:src/Sorter.zig:Sorter.sortBoth$2.lessThan#3'),
).toBeDefined();
expect(result.graph.getNode('Method:src/Sorter.zig:Sorter$2.it#1')).toBeDefined();
const hasMethod = getRelationships(result, 'HAS_METHOD').map(
(e) => `${e.rel.sourceId} → ${e.rel.targetId}`,
);
expect(hasMethod).toContain(
'Struct:src/Sorter.zig:Sorter.sortBoth$1 → Method:src/Sorter.zig:Sorter.sortBoth$1.lessThan#3',
);
expect(hasMethod).toContain(
'Struct:src/Sorter.zig:Sorter.sortBoth$2 → Method:src/Sorter.zig:Sorter.sortBoth$2.lessThan#3',
);
expect(hasMethod).toContain(
'Struct:src/Sorter.zig:Sorter$2 → Method:src/Sorter.zig:Sorter$2.it#1',
);
// The anonymous field type owns its fields (they were `Sorter.min` before).
const hasProp = getRelationships(result, 'HAS_PROPERTY').map(
(e) => `${e.rel.sourceId} → ${e.rel.targetId}`,
);
expect(hasProp).toContain(
'Struct:src/Sorter.zig:Sorter$1 → Property:src/Sorter.zig:Sorter$1.min',
);
// No ownerless Method anywhere in the fixture: every Method id is `<owner>.<name>#N`.
const ownerless: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Method' && /^Method:[^:]+:[^.]+#\d+$/.test(n.id)) ownerless.push(n.id);
});
expect(ownerless).toEqual([]);
});
it('attributes calls inside such containers to the right node, on both ends', () => {
const calls = getRelationships(result, 'CALLS').map(
(e) => `${e.rel.sourceId} → ${e.rel.targetId}`,
);
// From inside each `R.get` — the caller is THAT builder's `R.get`.
expect(calls).toContain(
'Method:src/reflect.zig:Reflect.string$R.get#0 → Function:src/reflect.zig:readAttr',
);
expect(calls).toContain(
'Method:src/reflect.zig:Reflect.url$R.get#0 → Function:src/reflect.zig:normalize',
);
// A `const Self = @This();` inside a local container still names THAT
// container: `check(self: *const Self)` dispatches `self.get()` to `url$R.get`.
expect(calls).toContain(
'Method:src/reflect.zig:Reflect.url$R.check#0 → Method:src/reflect.zig:Reflect.url$R.get#0',
);
// From inside each anonymous comparator (and the test-local State).
expect(calls).toContain(
'Method:src/Sorter.zig:Sorter.sortBoth$1.lessThan#3 → Method:src/Sorter.zig:Sorter.before#2',
);
expect(calls).toContain(
'Method:src/Sorter.zig:Sorter.sortBoth$2.lessThan#3 → Method:src/Sorter.zig:Sorter.before#2',
);
expect(calls).toContain(
'Method:src/Sorter.zig:Sorter.test@L41$State.kill#0 → Method:src/Sorter.zig:Sorter.before#2',
);
// INTO a test-local container: `State{}` and `state.kill()` from the test
// resolve to the qualified Struct / Method (the qualified key must survive
// the class extractor's whitespace normalization — a test-string host would not).
expect(calls).toContain(
'Function:src/Sorter.zig:Sorter."Sorter: local state" → Struct:src/Sorter.zig:Sorter.test@L41$State',
);
expect(calls).toContain(
'Function:src/Sorter.zig:Sorter."Sorter: local state" → Method:src/Sorter.zig:Sorter.test@L41$State.kill#0',
);
});
});
describe.skipIf(!zigAvailable)(
'Zig qualified struct literals (`mod.T{…}`) as construction sites',
() => {
// The 2026-09-02 review re-test found that only same-file literals were
// tracked: 163 `mod.Type{ … }` sites in a real project produced no CALLS
// edge at all. A first attempt captured them as free constructors, which
// resolve by the simple tail — `c.Thing{}` bound to a.zig's `Thing` although
// c.zig defines none. Captured WITH the receiver they take the namespace
// path `mod.fn()` takes, which resolves inside the module the receiver is
// bound to: this suite pins the qualifier being honoured, not just the
// edge appearing.
let result: PipelineResult;
let structCalls: ReturnType<typeof getRelationships>;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-qualified-literal'), () => {});
structCalls = getRelationships(result, 'CALLS').filter((e) => e.targetLabel === 'Struct');
}, 60000);
const edgesFrom = (source: string): string[] =>
structCalls
.filter((e) => e.source === source)
.map((e) => `${e.target} @ ${e.targetFilePath} [${e.rel.reason}]`)
.sort();
it('binds each same-named `Thing` to the module its qualifier names, marked as a construction site', () => {
expect(edgesFrom('useA')).toEqual(['Thing @ src/a.zig [import-resolved (constructor)]']);
expect(edgesFrom('useB')).toEqual(['Thing @ src/b.zig [import-resolved (constructor)]']);
});
it('emits nothing when the qualifier’s module has no such member (`c.Thing{}`)', () => {
// A tail-only resolution would have picked a.zig's or b.zig's `Thing`.
expect(edgesFrom('useMissing')).toEqual([]);
});
it('emits nothing for an external qualifier (`std.Thread.Mutex{}`), even with a same-named local and imported `Mutex`', () => {
expect(edgesFrom('useExternal')).toEqual([]);
});
it('keeps the same-file literal and the single-hop qualified literal apart by reason vocabulary', () => {
expect(edgesFrom('useLocal')).toEqual([
'Mutex @ src/d.zig [import-resolved (constructor)]',
'Mutex @ src/main.zig [local-call (constructor)]',
]);
});
},
);
describe.skipIf(!zigAvailable)(
'Zig qualified struct literals — zig-basic (`pioneer.Pioneer{…}`, `pioneer.Tag{…}`)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-basic'), () => {});
}, 60000);
it('tracks a qualified struct AND union literal as marked construction sites', () => {
const marked = getRelationships(result, 'CALLS')
.filter((e) => e.source === 'main' && e.rel.reason.endsWith('(constructor)'))
.map((e) => `${e.target}:${e.targetLabel} [${e.rel.reason}]`)
.sort();
expect(marked).toEqual([
'Pioneer:Struct [import-resolved (constructor)]',
'Tag:Union [import-resolved (constructor)]',
]);
});
},
);
describe.skipIf(!zigAvailable)('Zig hub modules (`pub const X = @import(…)` re-exports)', () => {
// Audit of three real projects (tigerbeetle, mach, ghostty, 2026-09-02):
// a hub file made only of re-exports owns NO local binding, so the
// local-only export lookup answered nothing for `terminal.Terminal.init()`,
// `t: stdx.Thing`, `var p = stdx.PRNG.from_seed()`. Measured before → after:
// CALLS into ghostty's `src/terminal/` from outside it 46 → 253, into
// tigerbeetle's `stdx` hub from outside it 837 → 1500. The published names
// live in the finalized channel; `namespaceExportsIncludeImportedNames` lets
// the namespace paths read it.
let result: PipelineResult;
let calls: string[];
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-hub'), () => {});
calls = getRelationships(result, 'CALLS')
.filter((e) => e.sourceFilePath.endsWith('main.zig'))
.map((e) => `${e.source} → ${e.target} @ ${e.targetFilePath}`);
}, 60000);
it('resolves a static call through a hub-republished NAMED type (`stdx.Thing.make()`)', () => {
expect(calls).toContain('c1_hub_named_static → make @ src/stdx/thing.zig');
});
it('resolves a static call through a hub-republished MODULE (`stdx.PRNG.from_seed()`, `PRNG = @import("prng.zig")`)', () => {
// ghostty's `terminal.Terminal.init(…)` shape (150 sites).
expect(calls).toContain('c2_hub_module_static → from_seed @ src/stdx/prng.zig');
});
it('types a receiver ANNOTATED with the hub path (`t: stdx.Thing`, `p: stdx.PRNG`)', () => {
// tigerbeetle: 289 `: stdx.Type` annotations.
expect(calls).toContain('c3_hub_named_annotation → m @ src/stdx/thing.zig');
expect(calls).toContain('c4_hub_module_annotation → next @ src/stdx/prng.zig');
});
it('types a receiver from a constructor call through the hub (`var p = stdx.PRNG.from_seed()`)', () => {
expect(calls).toContain('c6_hub_call_return_typing → next @ src/stdx/prng.zig');
expect(calls).toContain('c6_hub_call_return_typing → from_seed @ src/stdx/prng.zig');
});
it('types a generic instantiation annotated through the hub (`h: stdx.BoundedArrayType(u8, 4)`)', () => {
expect(calls).toContain('c10_hub_generic_annotation → count @ src/stdx/bounded_array.zig');
});
it('resolves a hub-republished free function (`stdx.helper()`)', () => {
expect(calls).toContain('c11_hub_reexported_fn → helper @ src/stdx/util.zig');
});
it('still resolves the alias-then-use shape (`const PRNG = stdx.PRNG; PRNG.from_seed()`)', () => {
expect(calls).toContain('c5_alias_then_static → from_seed @ src/stdx/prng.zig');
expect(calls).toContain('c5_alias_then_static → next @ src/stdx/prng.zig');
});
it('never resolves a name through the hub that the hub does not publish', () => {
// `stdx.secret` (a private `const secret = @import(…)`) is not referenced
// by the fixture because it would not compile; the guard here is that no
// call from main.zig lands in util.zig except through the published
// `helper` — nothing leaks via the private import.
const intoUtil = calls.filter((c) => c.endsWith('@ src/stdx/util.zig'));
expect(intoUtil).toEqual(['c11_hub_reexported_fn → helper @ src/stdx/util.zig']);
});
});
describe.skipIf(!zigAvailable)(
'Zig enum variants as typed receivers (`Op.create.event_max()`)',
() => {
let result: PipelineResult;
let calls: string[];
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-hub'), () => {});
calls = getRelationships(result, 'CALLS')
.filter((e) => e.sourceFilePath.endsWith('main.zig'))
.map((e) => `${e.source} → ${e.target}`);
}, 60000);
it('dispatches a method called on a variant reached through the enum type', () => {
// tigerbeetle writes `Operation.<variant>.event_max(…)` 147 times. The
// variant has no written type; its type is the enum, so the field walk
// that already handles `self.session.name()` types `Op.create` as `Op`.
expect(calls).toContain('c7_enum_variant_receiver → event_max');
});
it('dispatches a method on an enum-typed parameter (`op: Op`)', () => {
expect(calls).toContain('c8_enum_param_receiver → event_max');
});
it('dispatches on a variant reached THROUGH THE MODULE (`opmod.Op.lookup.event_max()`)', () => {
// The receiver `opmod.Op.lookup` is three hops: the module handle, the
// enum inside it, the variant (a value of the enum). Split once at the
// last dot, `opmod.Op` was looked up as a namespace key that does not
// exist and the site resolved to nothing — the fixture line was
// committed but never asserted (PR #1432 review, 8.10). The chain walk
// seeds the compound resolver at the class `Op` and reads `lookup` as
// its variant.
expect(calls).toContain('c9_enum_qualified_variant_receiver → event_max');
});
},
);
describe.skipIf(!zigAvailable)(
'Zig receivers named after their type (`counter: *Counter`, `pool: *@This()`)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-receivers'), () => {});
}, 60000);
it('labels container-typed first parameters as receivers: instance methods, receiver out of the arity', () => {
// `self` is a convention. tigerbeetle names the receiver after its type
// (777 of 1127 methods), mach writes `pool: *@This()` (764 of 833); both
// used to be `isStatic: true` with the receiver counted in `#<arity>`.
const methods = new Map<string, { isStatic: boolean }>();
result.graph.forEachNode((n) => {
if (n.label === 'Method') methods.set(n.id, { isStatic: n.properties.isStatic === true });
});
expect(methods.get('Method:src/counter.zig:Counter.incr#0')?.isStatic).toBe(false);
expect(methods.get('Method:src/counter.zig:Counter.incr_self#0')?.isStatic).toBe(false);
expect(methods.get('Method:src/counter.zig:Counter.by_value#0')?.isStatic).toBe(false);
expect(methods.get('Method:src/counter.zig:Pool.release#1')?.isStatic).toBe(false);
expect(methods.get('Method:src/counter.zig:Op.event_max#0')?.isStatic).toBe(false);
expect(methods.get('Method:src/stdx/prng.zig:prng.next#0')?.isStatic).toBe(false);
// A factory keeps its static label and full arity.
expect(methods.get('Method:src/stdx/prng.zig:prng.from_seed#1')?.isStatic).toBe(true);
// Nothing is left under the old receiver-counted ids.
expect(methods.has('Method:src/counter.zig:Counter.incr#1')).toBe(false);
expect(methods.has('Method:src/counter.zig:Pool.release#2')).toBe(false);
});
it('labels a file-struct receiver typed by the file stem (`ledger: *Ledger` in `Ledger.zig`)', () => {
// `Ledger.zig` declares no `Self` alias: the stem is the only spelling of
// its type, and only the file path can supply it. The structure phase
// used to build these methods without the path, so `add` came out static
// as `Ledger.add#2` while the scope side (which has the path) resolved
// `ledger.add(3)` to `Ledger.add#1` — an id that did not exist.
const methods = new Map<string, boolean>();
result.graph.forEachNode((n) => {
if (n.label === 'Method') methods.set(n.id, n.properties.isStatic === true);
});
expect(methods.get('Method:src/Ledger.zig:Ledger.add#1')).toBe(false);
expect(methods.get('Method:src/Ledger.zig:Ledger.sum#0')).toBe(false);
expect(methods.get('Method:src/Ledger.zig:Ledger.empty#0')).toBe(true);
expect(methods.has('Method:src/Ledger.zig:Ledger.add#2')).toBe(false);
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toEqual(
expect.arrayContaining([
'use_file_struct_receiver → empty',
'use_file_struct_receiver → add',
'use_file_struct_receiver → sum',
]),
);
});
it('dispatches calls onto those methods exactly as onto `self` methods', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toEqual(
expect.arrayContaining([
'use_named_receiver → incr',
'use_named_receiver → incr_self',
'use_named_receiver → by_value',
'use_this_receiver → release',
]),
);
});
},
);
describe.skipIf(!zigAvailable)(
'Zig qualified chains, deep aliases and result-location sites (PR #1432 review, 8.3–8.12)',
() => {
// One fixture per finding of the adversarial review, each with the decoy
// that made the old answer WRONG rather than merely missing: two nested
// `Item`s, `A.work` declared before `B.work`, two sibling fns binding `m`
// to different files, a hub republishing a module, a fieldless file type.
let result: PipelineResult;
/** `caller → targetId reason`, callers in main.zig only. */
let calls: string[];
let nodeIds: Set<string>;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-chains'), () => {});
calls = getRelationships(result, 'CALLS')
.filter((e) => e.sourceFilePath.endsWith('main.zig'))
.map((e) => `${e.source} → ${e.rel.targetId} ${e.rel.reason ?? ''}`);
nodeIds = new Set<string>();
result.graph.forEachNode((n) => nodeIds.add(n.id));
}, 60000);
it('8.5 — keys a container nested in a container by its owner (`A.Item` ≠ `B.Item`)', () => {
// `nested.zig` declares `A.Item` and `B.Item`, each with `run`. By
// binding name alone both types and both methods collapsed onto ONE
// `Struct:…:Item` / `Item.run` — ownership and call targets were
// irrecoverably false. The identity is owner-qualified, like Java's
// `Outer.Inner`; the scope side still binds the lexical `Item`.
expect(nodeIds.has('Struct:src/nested.zig:A.Item')).toBe(true);
expect(nodeIds.has('Struct:src/nested.zig:B.Item')).toBe(true);
expect(nodeIds.has('Struct:src/nested.zig:Outer.Inner')).toBe(true);
expect(nodeIds.has('Struct:src/nested.zig:Item')).toBe(false);
expect(nodeIds.has('Method:src/nested.zig:A.Item.run#0')).toBe(true);
expect(nodeIds.has('Method:src/nested.zig:B.Item.run#0')).toBe(true);
expect(nodeIds.has('Method:src/nested.zig:Item.run#0')).toBe(false);
const owners = getRelationships(result, 'HAS_METHOD')
.filter((e) => e.targetFilePath.endsWith('nested.zig'))
.map((e) => `${e.source} → ${e.target}`)
.sort();
expect(owners).toEqual(['A.Item → run', 'B.Item → run', 'Outer.Inner → inner_m']);
// …and each construction / call lands on ITS type.
expect(calls).toContain(
'f_nested → Struct:src/nested.zig:A.Item import-resolved (constructor)',
);
expect(calls).toContain(
'f_nested → Struct:src/nested.zig:B.Item import-resolved (constructor)',
);
expect(calls).toContain('f_nested → Method:src/nested.zig:A.Item.run#0 import-resolved');
expect(calls).toContain('f_nested → Method:src/nested.zig:B.Item.run#0 import-resolved');
});
it('8.3 — a MODULE-level value receiver passes itself as `self`, so the callback joins `cb`, not `self`', () => {
// `global_runner.run(target_global)` against `run(self, cb)`: only a
// fn-local head got the implicit receiver prepended, so `target_global`
// sat at actual 0, joined `self@0`, and the `run → target_global` edge
// was missing while the fn-local spelling `r.run(target_local)` had its
// edge. Same for the annotated `var global_runner2: Runner = undefined`.
const flow = getRelationships(result, 'CALLS')
.filter((e) => e.rel.reason === 'callable-value-flow')
.map((e) => `${e.source} → ${e.target}`);
expect(flow).toEqual(
expect.arrayContaining([
'run → target_local',
'run → target_global',
'run → target_global2',
]),
);
});
it('8.4 — a deep alias keeps its written owner (`@import("lib.zig").B.work` is `B.work`, not the first `work`)', () => {
// `lib.zig` declares `A.work` BEFORE `B.work`. The alias used to become a
// named import of the tail `work`, and the first `work` in the module —
// `A.work` — answered with exact confidence. Both the inline-import
// spelling (`chosen`) and the handle spelling (`chosen2 = lib.B.work`)
// must land on `B.work`; nothing may land on `A.work`.
const deep = calls.filter((c) => c.startsWith('f_deep_alias → '));
expect(deep).toContain('f_deep_alias → Method:src/lib.zig:B.work#0 import-resolved');
expect(deep.some((c) => c.includes('A.work'))).toBe(false);
});
it('8.6 — result-location `.init(…)` and `.{…}` emit the call and the construction the annotation implies', () => {
// `const a: Counter = .init(1);` typed `a` but the `init` CALL was absent
// from the graph; `const b: Counter = .{ .n = 2 };` had no construction
// event. `return .init(3)` in a fn returning `Counter` likewise.
expect(calls).toContain(
'f_result_location → Method:src/counter.zig:Counter.init#1 import-resolved',
);
expect(calls).toContain(
'f_result_location → Struct:src/counter.zig:Counter import-resolved (constructor)',
);
expect(calls).toContain(
'f_return_decl_literal → Method:src/counter.zig:Counter.init#1 import-resolved',
);
// The variables themselves stay typed by the annotation.
expect(calls).toContain(
'f_result_location → Method:src/counter.zig:Counter.get#0 import-resolved',
);
});
it('8.9 — sibling fns binding the same local `m` to different files each resolve through their own import', () => {
// `f_sib_a` binds `const m = @import("qa.zig")`, `f_sib_b` binds
// `@import("qb.zig")`. Finalization flattens both onto the module scope:
// `m → [qa.zig, qb.zig]`, and Case 1 took the first target for BOTH —
// `f_sib_b → qa.zig`'s `Thing` and `hello` (wrong edges, not missing).
const a = calls.filter((c) => c.startsWith('f_sib_a → '));
const b = calls.filter((c) => c.startsWith('f_sib_b → '));
expect(a).toEqual(
expect.arrayContaining([
'f_sib_a → Struct:src/qa.zig:Thing import-resolved (constructor)',
'f_sib_a → Function:src/qa.zig:hello import-resolved',
'f_sib_a → Method:src/qa.zig:Thing.qa_only#0 import-resolved',
]),
);
expect(b).toEqual(
expect.arrayContaining([
'f_sib_b → Struct:src/qb.zig:Thing import-resolved (constructor)',
'f_sib_b → Function:src/qb.zig:hello import-resolved',
'f_sib_b → Method:src/qb.zig:Thing.qb_only#0 import-resolved',
]),
);
expect(b.some((c) => c.includes('src/qa.zig'))).toBe(false);
expect(a.some((c) => c.includes('src/qb.zig'))).toBe(false);
});
it('8.10 — qualified chains are walked segment by segment: hub-republished module, nested type', () => {
// `hub.sub.Thing{}` (`hub.zig`: `pub const sub = @import("sub.zig");`),
// `nested.Outer.Inner{}` and `hub.sub.Thing.make()` all arrive with a
// receiver whose prefix is not a namespace KEY of main.zig; the one-hop
// split at the last dot asked for `hub.sub` / `nested.Outer` as exact
// keys and resolved nothing.
const hop = calls.filter((c) => c.startsWith('f_multihop → '));
expect(hop).toEqual(
expect.arrayContaining([
'f_multihop → Struct:src/sub.zig:Thing import-resolved (constructor)',
'f_multihop → Method:src/sub.zig:Thing.sub_m#0 import-resolved',
'f_multihop → Method:src/sub.zig:Thing.make#0 import-resolved',
'f_multihop → Struct:src/nested.zig:Outer.Inner import-resolved (constructor)',
'f_multihop → Method:src/nested.zig:Outer.Inner.inner_m#0 import-resolved',
'f_multihop → Method:src/op.zig:Op.event_max#0 import-resolved',
]),
);
});
it('8.11 — inline-import and generic-instantiation literals are construction events', () => {
// `@import("qa.zig").Thing{}`: the module is the receiver of a
// construction exactly as of a member call, but only the call shape was
// bound as a namespace. `List(u8){}` / `lists.List(u8){}`: the type
// head is a call_expression, which neither constructor rule matched, so
// only the inner `List(u8)` invocation reached the graph and the outer
// aggregate event had no site.
const gen = calls.filter((c) => c.startsWith('f_inline_generic → '));
expect(gen).toEqual(
expect.arrayContaining([
'f_inline_generic → Struct:src/qa.zig:Thing import-resolved (constructor)',
'f_inline_generic → Method:src/qa.zig:Thing.qa_only#0 import-resolved',
'f_inline_generic → Struct:src/lists.zig:List import-resolved (constructor)',
'f_inline_generic → Method:src/lists.zig:List.push#0 import-resolved',
]),
);
});
it('8.12 — a FIELDLESS file type (`Empty.zig`: `const Self = @This(); fn ping(self: *Self)`) keeps its Struct', () => {
// Keyed on top-level fields alone, `Empty.zig` was a namespace: no
// `Struct`, `ping` a free `Function`, and `Empty{}` / `e.ping()` from an
// importer resolved nothing. The receiver typed as the file's own type
// is the second signal.
expect(nodeIds.has('Struct:src/Empty.zig:Empty')).toBe(true);
expect(nodeIds.has('Method:src/Empty.zig:Empty.ping#0')).toBe(true);
expect(nodeIds.has('Function:src/Empty.zig:ping')).toBe(false);
expect(nodeIds.has('Const:src/Empty.zig:Self')).toBe(false);
expect(calls).toContain(
'f_fieldless → Struct:src/Empty.zig:Empty import-resolved (constructor)',
);
expect(calls).toContain('f_fieldless → Method:src/Empty.zig:Empty.ping#0 import-resolved');
// A namespace-only file (no field, no self-typed receiver) is still not a type.
expect(nodeIds.has('Struct:src/qa.zig:qa')).toBe(false);
expect(nodeIds.has('Struct:src/sub.zig:sub')).toBe(false);
expect(nodeIds.has('Function:src/qa.zig:hello')).toBe(true);
});
it('keeps a type nested in a FILE-struct reachable through the file handle (`Host.Inner{}`)', () => {
// A file-level container is already namespaced by its file, so its
// identity stays the binding name; the chain `Host.Inner` walks the
// file-struct's class to the nested type.
expect(calls).toContain(
'f_filestruct_nested → Struct:src/Host.zig:Inner import-resolved (constructor)',
);
expect(calls).toContain(
'f_filestruct_nested → Method:src/Host.zig:Inner.m#0 import-resolved',
);
expect(calls).toContain(
'f_filestruct_nested → Method:src/Host.zig:Host.touch#0 import-resolved',
);
});
},
);
// ── Monorepo: several build packages, no build.zig at the repo root ──────────
//
// The layout the root-only config loader could not see. `loadZigBuildConfig`
// read `<repoRoot>/build.zig{,.zon}` and nothing else, so a repo whose packages
// live under `packages/<name>/` had no config at all and every bare
// `@import("<module>")` in it went unresolved — cross-file resolution silently
// degraded to relative imports. It now takes a `packageDir` and
// `loadZigWorkspaceIndex` walks the repo for the packages to hand it, which is
// the change these assert.
//
// They assert the EDGES, not the config: the unit tests in
// `test/unit/zig-import-resolver.test.ts` pin the index, and this pins that the
// index actually reaches symbol resolution.
describe.skipIf(!zigAvailable)('Zig monorepo package resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-monorepo'), () => {});
}, 60000);
it('resolves a cross-package @import declared by the package’s own build files', () => {
// `packages/app` depends on `packages/core` through `.path = "../core"` —
// package-relative, and rejected outright when read from the repo root.
const imports = getRelationships(result, 'IMPORTS').filter((e) =>
e.sourceFilePath.includes('packages/app/src/main.zig'),
);
expect(imports.map((e) => e.targetFilePath).join('\n')).toContain('packages/core/src/root.zig');
});
it('emits CALLS across the package boundary, from the module root and from a non-root file', () => {
const calls = getRelationships(result, 'CALLS');
const crossPackage = calls.filter(
(e) =>
e.sourceFilePath.includes('packages/app/') &&
e.targetFilePath.includes('packages/core/src/root.zig'),
);
// `run` is in the module ROOT, `clamp` in a sibling file of the same
// package: membership is the root plus what it reaches, so both must
// resolve the alias — a fix that only worked for module roots would pass an
// assertion on `run` alone.
expect(edgeSet(crossPackage)).toContain('run → retryBudget');
expect(edgeSet(crossPackage)).toContain('clamp → retryBudget');
});
it('binds one alias to two different roots in two packages without crossing them', () => {
// The discriminating case, and the reason the index is scoped per package
// rather than flattened repo-wide: `tool` binds `core` to its OWN
// src/core.zig. Flattened, `measure` would call into `packages/core` — a
// confident edge into a package `tool` does not depend on, which is worse
// than the unresolved import this change set out to fix.
const fromTool = getRelationships(result, 'CALLS').filter((e) =>
e.sourceFilePath.includes('packages/tool/src/main.zig'),
);
expect(edgeSet(fromTool)).toContain('measure → retryBudget');
expect(fromTool.map((e) => e.targetFilePath).join('\n')).toContain(
'packages/tool/src/core.zig',
);
expect(fromTool.map((e) => e.targetFilePath).join('\n')).not.toContain('packages/core/');
});
});
// #3498: an inline `rp.make().m()` on a namespace module takes `make`'s return
// type; file-struct handles, including a lowercase file with fields, keep
// naming the struct. A bound `const x = rp.make()` is not typed yet: the
// importing file cannot see whether the target is a namespace or a struct.
describe.skipIf(!zigAvailable)('Zig namespace factory result types (#3498)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'zig-namespace-factory-return'),
() => {},
);
}, 60000);
it.each([
{ source: 'aliasCall', member: 'm', targets: [] },
{ source: 'inlineCall', member: 'm', targets: ['Method:mod.zig:C.m#0'] },
{ source: 'fileStructCall', member: 'm', targets: ['Method:Page.zig:Page.m#0'] },
{ source: 'lowercaseFileStructCall', member: 'm', targets: ['Method:Page.zig:Page.m#0'] },
{
source: 'lowercaseFileStructFileCall',
member: 'listen',
targets: ['Method:config.zig:config.listen#0'],
},
])('resolves $member called from $source to $targets', ({ source, member, targets }) => {
expect(
getRelationships(result, 'CALLS')
.filter((edge) => edge.source === source && edge.target === member)
.map((edge) => edge.rel.targetId),
).toEqual(targets);
});
});