/** * 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::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/.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/.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("")` 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..()` — 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 = ;` 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::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 `.#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; 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..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 `#`. const methods = new Map(); 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(); 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; 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(); 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 `/build.zig{,.zon}` and nothing else, so a repo whose packages // live under `packages//` had no config at all and every bare // `@import("")` 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/'); }); });