mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-28 06:22:22 +00:00
`'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>
1625 lines
82 KiB
TypeScript
1625 lines
82 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|