GitNexus/gitnexus/test/integration/resolvers/zig.test.ts
Navid EMAD 506432017f
Some checks are pending
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Scorecard / Scorecard analysis (push) Waiting to run
fix(zig): model callable-value references, and stop reporting their absence as exact (#3219)
* fix(impact): stop reporting 'exact' over unmodelled callable-value references

A function named in VALUE position — `bridge.accessor(Element.getNamespaceUri,
null, .{})`, `{ onClick: handler }`, a comparator handed to a sort — is
registered somewhere rather than called. The registration is modelled (a
`value-ref` site becomes a USES edge; Kythe `ref` vs `ref/call`, Joern
METHOD_REF), but the invocation THROUGH the stored value is not: it happens
later via a struct field, a registry lookup or comptime reflection.

impact()/context() nonetheless reported such a target as `epistemic: 'exact'`.
For lightpanda-io/browser that meant the DOM `Element.namespaceURI` accessor
came back with two internal callers, LOW risk and a claim of completeness —
worse than no answer, because 'exact' tells the reader not to look further.
tools.ts defines 'lower-bound' as "the walk provably missed callers", which is
precisely this case.

computeEpistemicBoundary now probes for inbound USES edges stamped with the
value-ref reason and hedges when it finds any, contributing a boundary note and
a new `causes.callableValueReferences` (unit: distinct referrer symbols).

Read from the graph, not from index metadata: unlike a dropped receiver — which
leaves no edge to find and therefore needs a persisted summary — a value
reference IS in the graph. So the signal needs no re-index and no analyzer
change, and it works on indexes written before this commit.

The cause gets its own slot rather than joining `dispatchBoundary`: a value
referrer is neither an implementation nor an interface-level consumer, and the
two differ in what the reader should do about them — a dispatch boundary is
irreducible, a callable value usually becomes traceable once the provider
models the store/load that carries it.

Language-neutral: every provider that emits a `value-ref` capture participates.
The writer and the reader now share VALUE_REF_EDGE_REASON, because drift
between them would fail silently — the probe would match nothing and every
answer would go back to claiming certainty.

* fix(zig): emit `value-ref` for a callable named in value position

`mapReferenceKindToEdgeType` has handled the registration-vs-invocation case
since #2437, and TypeScript, JavaScript and C++ all emit `value-ref`. Zig
emitted zero — so a Zig function handed somewhere as a VALUE was absent from
the graph entirely.

That is not a corner: Zig's JS bridge is built out of this one shape,

    pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{});

and `bridge.{accessor,function,indexed,…}` appears 2,047 times across 257 files
in lightpanda-io/browser — the project's whole JS<->Zig surface, none of it
reaching the graph. `impact` on `Element.getNamespaceUri`, which IS the DOM
`Element.namespaceURI` accessor, answered with its two in-file callers.

Three query rules, tagging `@reference.value-ref` on: a bare identifier in
argument position (`bridge.accessor(_tagName, …)`), a qualified one
(`bridge.accessor(Element.getNamespaceUri, …)`), and a const binding initialiser
(`pub const defaultHandler = onReset;`). Everything downstream already existed —
no new edge type, no schema change, no capture-machinery change, no baseline
edited.

One grammar detail is load-bearing: in tree-sitter-zig, call arguments are
DIRECT children of `call_expression` — there is no `arguments` node, only
builtins have one — so the callee has to be consumed explicitly by `function:`.
Without that binding the same rule also matches the callee of `foo(bar)` and
mints a USES edge shadowing the call's own CALLS edge. There is a test for it.

The rules are deliberately broad — `js.Bridge(Element)` and `register(count)`
match too — because the callable gate in the property-dispatch pass
(Function/Method/Constructor only) is the filter, the same design that keeps
TypeScript's `{ port: DEFAULT_PORT }` from registering anything. Measured on the
real corpus: all 3,169 emitted edges land on a Method (3,146) or Function (23).

Deliberately NOT modelled: the terminal invoke. The value reaches
`Accessor.init` -> a struct field -> `Factory.zig` reflection (`inline for`,
`@typeInfo`) -> `Caller.zig`'s `@call(.auto, func, args)` over `func: anytype`.
Resolving that needs comptime evaluation. The point is to stop dropping the
reference; the shortfall is now reported as `epistemic: "lower-bound"` by the
preceding commit instead of being papered over.

Measured on lightpanda-io/browser (698 .zig files), wiped index both times:
30,222 nodes / 71,070 edges -> 30,222 nodes / 74,229 edges. The delta is
entirely `USES` (3,169 after vs 10 before, and every one is a value-ref); CALLS,
ACCESSES, IMPORTS, HAS_METHOD, DEFINES and the rest are unchanged — purely
additive, no node invented.

The fixture joins the existing `zig-idioms` corpus rather than adding a new
one, because `bench/receiver-resolution` uses `test/fixtures/lang-resolution` as
its `--check` corpus. That gate, `scope-capture` (15 languages),
`zig-cross-file-resolution` and every other bench `--check` pass unchanged.

* fix(review): resolve qualified value references through their owner, and stop
hedging on registrations the analyzer already followed

Five findings from the review bot on #3219; four valid, all addressed.

1. QUALIFIED VALUE REFERENCES RESOLVED BY TAIL NAME (the serious one).
   `bridge.accessor(Element.getNamespaceUri, …)` was resolved with
   `findCallableBindingInScope(site.inScope, site.name, …)`, which never sees
   the receiver and gives LOCAL bindings precedence. Reproduced:

       const Element = @This();
       pub fn getThing(...)                 // main.getThing
       pub const JsApi = struct {
           fn getThing(...)                 // JsApi.getThing
           pub const thing = bridge.accessor(Element.getThing, null, .{});
       };

   emitted `USES JsApi → JsApi.getThing` — a WRONG edge, which is worse than
   the missing edge this PR set out to fix.

   `resolveValueRefTarget` now resolves a site carrying an explicit receiver
   through `findClassBindingInScope` → `findOwnedMember` (the machinery
   `receiver-bound-calls` already uses), gated on `CALL_TARGET_TYPES` because
   `findOwnedMember` also answers with fields. A bare site keeps the lexical
   walk, which is what an unqualified name means. When the owner cannot be
   resolved the site is DECLINED rather than falling back: declining costs a
   reference, falling back mints a confident edge to the wrong function, and
   the missing reference is now reported as `lower-bound` anyway.

   Cost on lightpanda-io/browser: value-ref edges 3,169 → 2,799 (−12%). Those
   370 were tail-name coincidences, not registrations — all 94 `Element.zig`
   `JsApi` entries survive, the cross-container case resolves and is correctly
   attributed (`IntersectionObserverEntry.JsApi` → `IntersectionObserverEntry.
   getTarget#0`, not the enclosing observer), and both acceptance probes are
   unchanged: `getNamespaceUri` 7/3/LOW/lower-bound, `getTagNameLower`
   31/10/HIGH/exact.

2. A FAILED PROBE READ AS "NO BOUNDARY". `.catch(() => [])` turned an
   unanswerable query into count 0 and no note, so `exact` could be published
   on the strength of a question that was never asked. It now returns `null`
   and emits a boundary note. This file's own `loadMeta` comment states the
   rule: a probe failing must never read as certainty.

3. NOT EVERY VALUE REFERENCE IS AN UNMODELLED INVOCATION. Where
   `emitPropertyDispatchCalls` sweep 2 synthesized the dispatch (reason
   `property-dispatch`), the walk did NOT provably miss the caller, so hedging
   was noise over an answer that was computed — and a signal that fires on
   every JS/TS hook table stops carrying information. A second probe excludes
   those targets. Zig never sets a property key, so the motivating case is
   untouched. The exclusion is symbol-level, not per-edge, because the graph
   does not record which registration produced which synthesized call; that
   residual is documented at the call site.

4. `LIMIT 50` SILENTLY UNDERSTATED THE COUNT. `rows.length` over a capped row
   set published a ceiling as the documented symbol count. Replaced with
   `COUNT(DISTINCT other.id)` — bounded work without a bounded answer, the
   shape `countByType` in the same file already uses.

5. THE TEST DID NOT PIN THE CALLER COUNT its own comment promised. Added
   `expect(result.impactedCount).toBe(2)`.

New regression tests: qualified references bind the written owner and not the
nearer lexical match; an unresolvable receiver emits nothing rather than a
wrong edge; a dispatch-modelled registration stays `exact`; a probe that cannot
run hedges instead of claiming certainty.

Known limitation, pinned by a test rather than left implicit: when a `@This()`
alias's NAME differs from its container's (`const Element = @This();` inside
`main.zig`), the receiver does not resolve and the reference is declined. The
provider has `rewriteZigThisAlias` for this, but it is applied to type nodes
and extending it to reference receivers would change existing CALL-site
behaviour. Lightpanda's `const Foo = @This();` in `Foo.zig` convention makes
the names coincide, which is why the corpus is unaffected.

* fix(review): bump the parse-cache schema and resolve module-owned value references

Review round 2 on #3219. Four inline items, all reproduced against the
worktree before deciding.

P1 — the new captures could stay INERT on a warm parse cache. Adding
`@reference.value-ref` rules to `ZIG_SCOPE_QUERY` changes
`ParsedFile.referenceSites`, which is a PARSE-TIME fact, but `SCHEMA_BUMP`
stayed at 93. A repo indexed before this branch and re-analyzed after it
replays the old, empty site list for every unchanged `.zig` file — `--force`
included, since shards are content-addressed — so no USES edge is emitted, the
boundary probe measures a real zero, and `impact` on a registered accessor goes
back to `epistemic: "exact"`. #3399 un-fixed on the incremental path most users
are on, with every cold-run test still green. DECISIONS D1-1's "zero changes to
… the schema" conflated the graph schema with the cache schema.

Bumped 93 -> 98, not 94: #3190 claims 94 and #3179 claims 94 through 97 in one
PR. `incremental-parse-cache.test.ts` re-pinned, with 93-97 added to the taken
list. Re-check against origin/main and open PRs immediately before merging.

P2 — a qualified value reference through a MODULE was declined with no hedge.
R1-2 resolves a written receiver with `findClassBindingInScope`, which requires
`isClassLike`; a namespace-only `@import` handle is not class-like, so

    const dom_utils = @import("dom_utils.zig");   // no `@This()` in that file
    pub const comparator = bridge.accessor(dom_utils.compare, null, .{});

resolved to nothing. That is not the conservative half of R1-2's trade-off: a
declined site emits NO edge, so there is nothing for the probe to read and
`impact` on `compare` reports `exact`. Silence, not a hedge — and the pass
comment claiming otherwise was wrong on this path.

`resolveValueRefTarget` now tries the second kind of owner a qualified name can
have. `findNamespaceValueRefTarget` reads the file's `namespace` import edges
for the handle and the target module's own `origin: 'local'` module-scope
bindings for the member — the same channel `receiver-bound-calls` Case 1
already trusts for `dom_utils.compare()`, with Case 1's three guards for Case
1's reasons: `isNamespaceNameShadowed`, local-origin bindings only, and two
distinct defs under one name resolve nothing. `CALL_TARGET_TYPES` gates module
owners exactly as it gates container owners. Language-neutral: it reads generic
namespace import edges, names no language.

Still declined, deliberately: a receiver this index knows under no name at all
(the `@This()`-alias case, owners outside the workspace). There the alternative
is a confident edge to a lexically-nearer function the source did not name.

P2 — `impact-callable-value-references.test.ts` was in neither vitest list.
It opens a real engine via `withTestLbugDB(poolAdapter: true)`, so TESTING.md
puts it in the `lbug-db` include list and the `default` exclude list; it was in
neither, so `default` also collected it into the parallel pool. Added next to
its `impact-epistemic-lower-bound` sibling in both arrays.

P3 — DECISIONS.md was stale against HEAD and embedded host paths. D2-3 still
described the `LIMIT 50` that R1-5 removed and D1-3 still described the
receiver-blind resolution that R1-2 replaced; both now carry explicit
"superseded by" pointers. The `~/code/...` and mise-node paths are replaced
with placeholders.

Two smaller corrections the new cause made necessary:

- `formatImpactResult`'s `lower-bound` header hard-coded "callers binding via
  DI / dynamic dispatch", which now contradicts the value-reference bullet
  printed directly under it. The bullets carry the cause; the header only
  states that the count is a floor.
- `tools.ts` said a `causes.callableValueReferences` of 0 means "nothing was
  missed". The dispatch exclusion is symbol-level, not edge-level, so a target
  with both a followed registration and an unfollowed escape also reads 0. The
  docs now say a 0 means "no unfollowed registration was proven".

Fixture: `src/webapi/dom_utils.zig` (namespace-only) plus three cases in
`Element.zig` — the module-qualified registration, a non-callable module member,
and a `u8` parameter shadowing the handle. The shadow case was verified to FAIL
with the guard disabled, so it is not passing for an unrelated reason.

Gates: `tsc --noEmit` clean, `npm run build` clean, prettier clean.
`resolvers/` 3,630 passed / 3 skipped; `unit/scope-resolution` 2,010 passed;
`impact-callable-value-references` 7 passed under `lbug-db`;
`incremental-parse-cache` 40 passed; eval formatters 104 passed. Bench --check
all PASS with no baseline edited: receiver-resolution, zig-cross-file-resolution,
scope-emission, callable-value-flow, scope-capture (15 languages), python-scope.

* fix(review): guard the class receiver against a shadowing binding, and pin the dispatchability partition

Review round 3 (`gitnexus-check` bot on `cf53bbaa`). Three findings, each
reproduced against the code before deciding.

R3-1 (Error, valid, REPRODUCED) — a CLASS receiver could resolve through a
shadowing value binding. `findClassBindingInScope` is a class-only walk: it
filters the scope chain by `isClassLike`, so it steps over a nearer binding that
is a value and keeps climbing — and past the chain entirely, into a
qualified-name fallback that answers with the unique workspace definition of the
name. A `u8` parameter named `Ticker`, in a file that neither declares nor
imports the `Ticker` container another file defines, therefore emitted
`register(Ticker.fire)` as a confident USES edge to that container's method.
That is exactly the wrong-edge failure R1-2 exists to prevent, arriving through
the class channel instead of the lexical one.

Fixed with `isOwnerNameShadowedBySomethingElse` — a sibling of
`isNamespaceNameShadowed` with one extra clause. The plain namespace guard could
NOT be reused: a container is often its own local declaration
(`fn make() { const Local = struct {…}; register(Local.go); }`), and reading that
binding as its own shadow suppresses precisely the resolutions this path exists
to make — the #2723 mistake, one channel over. So a scope that binds the name
answers immediately, and the answer is "not shadowed" only when one of that
scope's own bindings IS the def just resolved.

Both halves are pinned and both were verified to fail when the guard is
weakened: the parameter case fails with no guard at all, the local-container
case fails with the plain `isNamespaceNameShadowed`.

R3-2 (Warning; mechanism correct, unreachable today; fragility fixed instead) —
the dispatch exclusion could suppress an unfollowed registration. The bot is
right about the code: sweep 2 synthesizes CALLS only for a registration whose
site carried a `propertyKey`, while the exclusion zeroes the note on ANY inbound
`property-dispatch` CALLS edge. It is not reachable in the current rule set, and
the reason is measured rather than assumed: `@reference.value-ref` is emitted by
exactly three languages — JavaScript (2 rules), TypeScript (2), Zig (3) — every
JS/TS rule also captures `@reference.property-key` (both are object-literal
shapes) and no Zig rule does. A dispatchable registration is therefore always a
JS/TS one, an undispatchable one always a Zig one, and they cannot meet on one
symbol.

Rejected: splitting the edge `reason` into dispatchable / undispatchable. It is
the precise fix, but it is a graph-content change that churns whichever side
keeps the old literal — the Zig, TypeScript and probe suites all pin
'scope-resolution: value-ref' by hand as a drift canary — and it buys nothing
against a case no rule can produce.

What was actually wrong is that the exclusion's soundness rested on a
coincidence recorded nowhere, in files nobody reading `local-backend.ts` would
open. Fixed at both ends: the exclusion site now states the invariant, the three
facts it rests on and the two options for when it breaks; and
`value-ref-dispatchability.test.ts` fails the day it does — a JS/TS rule for a
bare callback argument, a Zig rule that grows a key, or a fourth language
emitting `value-ref` at all. Verified to fire (adding a property key to a Zig
value-ref rule fails the Zig case), and its rule splitter has its own guard test
so the suite cannot pass vacuously. `ZIG_SCOPE_QUERY` is exported for that test
only.

R3-3 (Nit, valid) — a test comment claimed the wrong epistemic result. The
`declines a qualified reference whose receiver cannot be resolved` case said the
shortfall shows up as `lower-bound`. It does not: with no edge there is no
evidence and the target stays `exact`. The pass docstring was corrected in round
2 and this comment was missed. It now says the decline costs the reference AND
the hedge, and why that is still the right trade.

Gates: tsc --noEmit clean, npm run build clean, prettier clean.
`test/integration/resolvers` 3,632 passed / 3 skipped (70 files);
`test/unit/scope-resolution` 2,015 passed (120 files);
`impact-callable-value-references` 7 passed under `lbug-db`. Bench --check:
receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages),
scope-emission PASS with no baseline edited; callable-value-flow failed once on
its TIMING budget (2.006 > 1.9) with a byte-identical fingerprint, then passed
twice at 1.788 / 1.813 — machine load, not a regression.

* fix(review): let a file's own `@import` outrank the workspace-wide class fallback

Found by running the local preflight harness before pushing rather than after.
One of its five findings is a real hole in R2-2; the rest are documentation that
overclaimed.

R4-1 (valid, REPRODUCED) — the container channel preempted the file's own
`@import`. R2-2 added the module channel as a FALLBACK after
`findClassBindingInScope`, and that order is wrong: `findClassBindingInScope`
does not stop at the scope chain. When its `isClassLike` walk misses — and a
namespace handle binds a Module, so it always misses — it falls back to
`scopes.qualifiedNames`, a workspace-wide index, and answers with the unique def
of that name anywhere in the repo. A container named `dom_utils` in a file
`Element.zig` never imports therefore captured
`bridge.accessor(dom_utils.compare, …)`, binding
`Method:src/webapi/decoy.zig:dom_utils.compare#2` while the module channel that
would have answered correctly was never reached. R3-1's shadow guard cannot
catch it: the import binds at MODULE scope, which that guard treats as the floor.

Fixed by trying the module channel FIRST. An `@import` written in this file is
the strongest available statement about what the name means here and outranks a
global uniqueness guess; when the handle is not an import of this file the
channel answers nothing and the container path runs exactly as before. Pinned by
`decoy.zig` and a strengthened assertion on the existing module-owner test,
which fails on the old order.

R4-2 — the cause documentation named shapes nothing captures. `tools.ts`
illustrated `callableValueReferences` with "a callback argument", "a stored
function pointer" and `qsort(xs, n, sz, compareItems)`. Only Zig captures a call
argument or a const initialiser; JS/TS capture only object-literal property
values, and C has no value-ref rule, so the `qsort` example is counted in no
language. Both cause blocks now name the captured shapes and say that a bare
JS/TS callback argument is not among them, so a 0 does not rule it out.

R4-3 — the same block exempted itself from the re-index caveat this PR proves it
needs. "Read from the graph, so it needs no index-time metadata" is true of the
probe and false of the edges: an index built before a language emitted these
captures has none and reports 0 — the warm-cache failure R2-1 bumped
SCHEMA_BUMP for. It now says to re-analyze before reading a 0 as measured.

R4-4 — the dispatchability canary was narrower than its own promise. Its header
claimed it fails on "a fourth language emitting value-ref at all"; it reads
`languages/<dir>/query.ts`, so Vue — which owns no query and borrows
`emitTsScopeCaptures` / `emitJsScopeCaptures` — emits value-refs while the
assertion lists three languages, and a capture synthesized in code is invisible
to it. The case now asserts on query OWNERS, which is what it checks and is
sound because a delegating language inherits the rules it borrows; the header
states the synthesized-capture gap instead of letting a green tick imply it away.

R4-5 — the BARE docstring described a lexical walk that is not one.
`findCallableBindingInScope` applies the callable predicate WHILE walking, so a
nearer parameter or local is stepped over: the defect R3-1 fixed on the container
channel, unguarded here, pre-existing since #2437 and reachable in JS/TS. Out of
scope for #3399, so behaviour is unchanged and the sentence now says what the
walk does rather than implying a guarantee it does not give.

Gates: tsc --noEmit clean, npm run build clean, prettier clean.
`test/integration/resolvers` 3,632 passed / 3 skipped (70 files);
`test/unit/scope-resolution` 2,015 passed (120 files);
`impact-callable-value-references` 7 passed under `lbug-db`. Bench --check:
receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages),
scope-emission, callable-value-flow all PASS with no baseline edited.

* fix(review): honour the hub opt-in for value references, so `hub.fn` means one thing

Review round 5 (`gitnexus-check` bot on `1c7c05ff`). One finding, valid and
reproduced before fixing.

`findNamespaceValueRefTarget` accepted only `ref.origin === 'local'`. R2-2
recorded that as deliberate — "the `namespaceExportsIncludeImportedNames` hub
opt-in is a provider decision this language-neutral pass does not make" — and
that reasoning was wrong twice over. Zig sets the flag
(`languages/zig/scope-resolver.ts:41`, measured on ghostty and tigerbeetle before
it landed), and the pass does have the provider in scope: `runScopeResolution`
takes one and already forwards several of its hooks to other passes.

The result was the exact asymmetry R2-2 argued against in its own first
paragraph. A Zig hub declares nothing — every name it publishes it imported — so
requiring a local declaration declines every member reached through one:

    // hub.zig
    pub const scale = @import("dom_utils.zig").scale;

    // Element.zig
    const hub = @import("hub.zig");
    pub fn callsThroughTheHub(v: u8) u8 { return hub.scale(v); }   // resolved
    pub const scaled = bridge.accessor(hub.scale, null, .{});      // declined

One name meaning two different things depending on whether a `(` follows it.

Fixed by forwarding `provider.namespaceExportsIncludeImportedNames` into the pass
and consulting the published channel when it is set — the same question
`receiver-bound-calls` Case 1 asks, through the same `lookupBindingsAt` read
`findExportedDefIncludingImportedNames` performs for the CALL form. The pass
still names no language; it asks the provider, which is the sanctioned hook.

Precedence is unchanged where it mattered: a locally declared member still wins
over a republished one, two distinct defs under one name still resolve nothing,
and `CALL_TARGET_TYPES` still gates the answer — pinned by `hub.DEFAULT_NS`, a
re-exported CONSTANT, which stays unregistered. Languages that do not opt in are
unaffected: the parameter defaults to `false`, and passing `false` was verified
to fail the new hub test, so it is load-bearing. The fixture republishes a member
(`scale`) that nothing else uses, so the hub assertion cannot be satisfied by an
edge another case emitted.

Gates: tsc --noEmit clean, npm run build clean, prettier clean.
`test/integration/resolvers` 3,634 passed / 3 skipped (70 files);
`test/unit/scope-resolution` 2,015 passed (120 files);
`impact-callable-value-references` 7 passed under `lbug-db`. Bench --check:
receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages),
scope-emission, callable-value-flow all PASS with no baseline edited.

Also recorded in DECISIONS.md: `/autofix` returned "No successful autofix run"
because the `PR Autofix` run on `1c7c05ff` failed at `actions/upload-artifact`
with a 403 from GitHub's artifact storage, not because of anything in this
branch. This push triggers a fresh run.

* fix(review): inspect the module scope in the owner-shadow guard, and check the TSX suffix

The `gitnexus-check` pass on `bd6e577e` carried three findings of its own, and I
answered the later `1c7c05ff` pass without noticing them. Recording that as a
process failure too: bot reviews are per-head, and a later pass does not
necessarily repeat an earlier one's findings.

R6-1 (Error, valid, REPRODUCED) — the shadow guard stopped one rung short.
`isOwnerNameShadowedBySomethingElse` returned `false` on reaching the module
scope, justified as "a container declared there IS the binding, and the caller
already resolved it". That holds when the owner came from the scope chain and
fails when it came from the workspace-wide qualified-name fallback:

    // Gauge.zig — never imported by Element.zig
    const Gauge = @This();
    pub fn read(self: *Gauge) u8 { … }

    // Element.zig
    const Gauge = @import("dom_utils.zig").DEFAULT_NS;          // NOT a container
    pub const level = bridge.accessor(Gauge.read, null, .{});   // → Gauge.zig's read

`findClassBindingInScope` steps over the module-scope binding because it is not
class-like, answers from `scopes.qualifiedNames`, and the guard waved it through.

Worth recording: the first fixture attempt did NOT reproduce. A local
`const Gauge: u8 = 3;` also claims the workspace qualified name `Gauge`, leaving
two candidates, and the fallback refuses to guess between two — so the shape
defeated itself. Binding the name by IMPORT claims no qualified name, the
fallback stays unique, and it fires. A negative result on the first shape was not
evidence the finding was wrong.

Fixed by inspecting the module scope as the last rung instead of skipping it.
The identity exemption is what makes that safe where `isNamespaceNameShadowed`
cannot do it (#2723: a namespace import writes its own name into the module scope
and would read as its own shadow) — the binding that IS the owner exempts itself,
and only a binding to something else answers `true`. `lookupBindingsAt` is
consulted at that scope and only there, because an imported alias lives in the
finalized channel rather than in `scope.bindings`.

R6-2 — the hub re-export finding, already fixed in `5d8fe9d8`; the same defect
restated on the later head.

R6-3 (valid, fixed) — the dispatchability canary omitted the TSX suffix.
`getTsScopeQuery` analyzes a `.tsx` file with `TYPESCRIPT_SCOPE_QUERY +
TSX_JSX_QUERY_SUFFIX`, and the test read only the base, so a `value-ref` rule
added to the suffix would be emitted in TSX analysis with the canary green. The
suffix is now exported and concatenated into the check; verified load-bearing by
adding an unkeyed `jsx_expression` value-ref rule to it, which fails the
TypeScript case.

Gates: tsc --noEmit clean, npm run build clean, prettier clean.
`test/integration/resolvers` 3,635 passed / 3 skipped (70 files);
`test/unit/scope-resolution` 2,015 passed (120 files);
`impact-callable-value-references` 7 passed under `lbug-db`. Bench --check:
receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages),
scope-emission, callable-value-flow all PASS with no baseline edited.

* perf(bench): gate callable-value reference resolution on linear scaling

`resolveValueRefTarget` (#3399) replaced one lexical walk with four
channels, and the last of them — a qualified receiver resolved through
`findClassBindingInScope` — falls back to `scopes.qualifiedNames`, a
WORKSPACE-WIDE index consulted once per site. Keyed that is O(1); scanned
it is O(files) per site, and a registration table that costs O(sites)
today costs O(sites x files) tomorrow. Nothing in the suite can see that:
the fixtures are single-file, and the corpus it actually matters on is
lightpanda-io/browser, where `bridge.{accessor,function,…}` appears 2,047
times across 257 files.

`bench/value-ref-resolution/measure.mjs` builds two synthetic Zig corpora
of identical shape 4x apart in file count, and times ONLY the per-site
resolution loop — extraction, `reconcileOwnership` and finalize are setup.
`linear_factor` is `(t_large/t_small)/(N_large/N_small)`: measured
1.00-1.09 across runs, with `us_per_site` flat at 1.4-1.5 between the arms.

Correctness comes first, because a timing gate alone is satisfied by a fast
wrong answer: exact site/resolved/declined counts per arm plus an
order-independent sha256 over every (site -> resolved target) pair. The
corpus exercises all four channels — CONTAINER, NAMESPACE, HUB and BARE —
and carries two DECLINE controls per module (a non-callable namespace
member, a non-callable bare argument), so a widened callable gate moves
`declined` instead of hiding inside the timing.

Verified load-bearing rather than assumed: making the qualified lookup scan
a workspace-sized collection leaves the fingerprint IDENTICAL and takes
`linear_factor` to 3.752 against a slack of 1.375 — the regression class
this exists for is exactly the one no correctness gate can see.

Zig is the corpus because it is the only language whose provider sets
`namespaceExportsIncludeImportedNames`, so it is the only one that can
exercise the hub channel at all; the pass itself names no language.

`resolveValueRefTarget` is exported for the bench. Timing
`emitPropertyDispatchCalls` instead would fold the signal into edge
emission, and re-implementing the channel order in the bench would pin the
bench's idea of the function rather than the function.

* feat(zig): index every build package in the repo, not only the root one

Zig already had workspace setup — `loadZigBuildConfig` has parsed
`build.zig.zon` `.path` deps, the root `build.zig`'s named modules and each
build module's own `addImport` table since #1432, threaded through
`ScopeResolver.loadResolutionConfig` exactly as tsconfig is for TypeScript.
What it did not have is the part `tsconfigFor` supplies: per-package scope.

`loadZigBuildConfig` reads `<repoRoot>/build.zig{,.zon}` and nothing else, so
a repo laying its packages out as `packages/<name>/build.zig` — no root build
files at all — got `null`, and EVERY bare `@import("<module>")` in it went
unresolved. Cross-file resolution silently degraded to relative imports.
Measured on a two-package probe before this change: `config = null`,
`@import("core")` from `packages/app/src/main.zig` → `null`.

`loadZigWorkspaceIndex` discovers packages the way `findTsconfigFiles`
discovers configs — one bounded breadth-first walk that skips the hardcoded
ignore set — and `zigPackageFor` is the `tsconfigFor` analogue: the nearest
enclosing package governs a file, deepest-first, with no fall-through to an
outer package. Fall-through is what makes a vendored dependency's
`@import("config")` resolve to the outer repo's `config` module, the same
failure `loadTsconfigIndex` documents for a package declaring no `baseUrl`.

`resolveZigImportInternal` is UNCHANGED — impact analysis puts it at HIGH risk
with 6 dependents, and it does not need to move: it is handed one package's
config, and which config it receives is the only difference. Its 48 existing
tests pass untouched. A nested package's paths are rebased to repo-relative at
load time (`.path = "../core"` → `packages/core`, which the root-relative
reading rejects outright as an escape); the ROOT package keeps its raw
spelling, which is what `parseZigBuildZon` promises and its tests pin, so a
single-package repo is byte-identical.

`loadImportConfigs` — which runs unconditionally for every repo, Zig or not —
keeps calling the root-only loader, so no non-Zig repo pays for the walk. That
is the split TypeScript already has between the cheap `loadTsconfigPaths` and
the repo-walking `loadTsconfigIndex`, which `loadResolutionConfig` reaches only
during a language pass.

The `zig-monorepo` fixture carries the discriminating case rather than only the
happy path: `tool` binds the alias `core` to its OWN `src/core.zig`, so a
repo-wide flattened module map — the shape a workspace index invites — would
point `measure` at `packages/core`, a confident edge into a package `tool` does
not depend on. That is worse than the unresolved import this fixes, and only
per-package scoping keeps them apart.

Tests: 7 unit cases pinning the index (including `loadZigBuildConfig` answering
`null` on the same fixture, side by side) and 3 integration cases pinning the
EDGES — cross-package CALLS from the module root AND from a non-root file, and
`tool` not crossing over. Verified load-bearing: restricting the index to the
root package fails all three.

Second consumer caught by the integration test and fixed with it:
`populateZigWorkspaceStaticGating` reads the same `resolutionConfig`, per file
because two files of that pass can belong to different packages.

Gates: build clean, `tsc --noEmit` clean, prettier clean, eslint 0 errors.
`test/integration/resolvers` 3,768 passed / 4 skipped, `test/unit/scope-resolution`
2,015 passed. Bench `--check` with NO baseline edited: `receiver-resolution`
(whose corpus is `test/fixtures/lang-resolution`, where the fixture lands),
`zig-cross-file-resolution`, `value-ref-resolution`, `scope-capture` (15
languages), `import-target`, `callable-value-flow`, `scope-emission`.

* perf(zig): dequeue the package walk by head index, not `shift()`

`findZigPackageDirs` pushes children while it drains the queue, which keeps
the array in a mode where `Array.prototype.shift()` memmoves the whole
remainder rather than taking V8's left-trimming fast path — so the walk is
quadratic in the frontier, bounded only by `ZIG_SCAN_MAX_DIRS` (20,000).

Measured at that bound rather than estimated from the move count: 53 ms at
fan-out 4 and 81 ms at fan-out 20, against 0.8 ms with a head index — 66-106x,
and paid before a single config is read.

FIFO order is unchanged, and the package ordering never depended on the walk
anyway: `loadZigWorkspaceIndex` sorts by (dir length desc, localeCompare).
Memory is unchanged too — the entries were already retained by the pushes;
`shift()` only dropped the head.

`findTsconfigFiles` and `loadNodeWorkspacePackages` carry the same walk with
the same bound and are equally affected. Deliberately NOT fixed here: they are
pre-existing, outside this PR's diff, and reach TypeScript/Node import
resolution, which nothing in this change set covers. Filed separately.

Reported by gitnexus-check on aea0ab06.

* chore: drop DECISIONS.md from the branch

Review feedback: the working log does not belong in the repository. The
reasoning it carried that is still load-bearing lives in the code comments
and in the PR description.

* perf(bench): gate value-ref resolution on scaling alone, not wall clock

A millisecond ceiling measures the runner. This repo has been bitten by that
twice already — bench/callable-value-flow's widening_overhead failed at 2.07
and 1.975 against a 1.9 budget on a shared runner while the code was correct,
both times on a sub-11ms measurement — so the arm is dropped rather than
loosened. `ms_budget` is gone from both arms and from `--check`; `min_ms`
and `us_per_site` are still reported, and nothing compares them to anything.

The remaining timing gate is the ratio, reshaped after
bench/parse-dispatch-rounds/baselines.json: `_what` / `_triage` notes, a
`_measured` block recording samples for context, and min-of-15 reps instead
of 7 (bench/import-target measured N=5 tripping its own budget about one run
in twenty, N=15 holding).

Budget 1.6 — 1.40x the measured maximum over 12 runs (0.907 .. 1.146), the
~1.5x headroom its siblings use on ratios.

Re-verified rather than re-quoted, and the previous note was wrong: replacing
QualifiedNameIndex.get with a full scan moves linear_factor from ~1.0 to 2.07,
not the ~3.7 recorded. Only 1 of the 17 value-ref sites per module reaches
that workspace-wide fallback. 1.6 sits clear of both ends. The note also
records the trap the first attempt fell into: patching gitnexus-shared/src
changes nothing, because the bench resolves the built package.

* fix(review): settle namespace precedence on the name, before the type gate

findNamespaceValueRefTarget's local lookup applied CALL_TARGET_TYPES while
selecting, so a target module declaring a NON-callable under the name answered
nothing and fell through to the published channel — binding a re-exported
callable under a name the module's own declaration owns. findExportedDef does
not do that: it returns any local def and lets its caller's type gate reject
it, so findExportedDefIncludingImportedNames never reaches the imported names
for a name the file declares. `x.f` and `x.f()` must not disagree about which
module owns f.

Not reachable through valid Zig today (a container cannot declare a name
twice, and Zig is the only provider setting namespaceExportsIncludeImportedNames),
which is why the regression test builds the indexes directly instead of adding
a fixture — there is no valid source to write. Its second case fails with the
guard removed.

Three smaller corrections from the same review:

- ZigBuildZonConfig.pathDeps promised the raw `.path` string; a nested package
  stores the normalized repo-relative value. The interface now documents both
  spellings and why either is safe to hand to normalizeZigDepPath. Comment
  only — no behaviour change.
- The 'single-package repo' compatibility test ran against zig-idioms, which
  declares libs/geo as a path dep and that directory has its own build.zig, so
  the walk finds two packages and the name was a claim rather than a check. It
  now runs against libs/geo itself and asserts the package list is exactly
  ['']; a second test pins the multi-package case, including that a file inside
  libs/geo is governed by that package and not by the root.
- The lower-bound header asserted 'some callers are not traced'.
  callableValueReferenceBoundaries hedges when its probe could not RUN and says
  whether the symbol is registered is unknown, so the header claimed an
  omission nothing established. It now says the count may be incomplete and
  names no cause; the per-cause bullets underneath carry that.

* feat(zig): bind a `@This()` alias to the container it names

`@This()` IS the enclosing container, and `const Self = @This();` is how most
Zig files say so. The container is minted under the FILE STEM and the alias
bound nothing class-like: a file-level alias mints no Const at all
(isZigFileThisAlias suppresses it so it cannot shadow the type for `w: *Widget`),
a container-level one mints a Variable every isClassLike walk steps over. So
`Self.member` resolved to nothing — not a wrong edge, no edge, and a caller
list missing it is the false confidence #3399 is about. This was the PR's
declared known limitation.

bindZigThisAliases binds the alias name to its container definition in
indexes.bindingAugmentations — the sanctioned post-finalize channel (I8),
consulted only AFTER a scope's own bindings, so it can never outrank a real
local declaration and can only answer where nothing answered before. Nothing
is replaced or removed. No query, capture or SCHEMA_BUMP change.

It runs inside populateZigRangeBindings, sharing that pass's parsed tree: a
pass of its own would re-parse every Zig file on a cold tree cache. Only
aliases declared directly in a container body or at file level are bound — the
same set collectZigThisAliases recognizes — because a function-local alias
belongs in that function's scope.

Measured cold-index before/after, same command, same build:

  tigerbeetle (246 .zig)  CALLS 17,066 -> 17,140 (+74), USES 437 -> 443,
                          MEMBER_OF 5,131 -> 5,143; every other edge type
                          unchanged, all 24 node-label counts identical
  mach (132 .zig)         CALLS 7,600 -> 7,615 (+15), MEMBER_OF +1, USES flat

The +74 matches a source census of 72 `Alias.member(` call sites in
tigerbeetle files whose alias differs from the stem. Spot-checked end to end:
src/aof.zig:636 writes `try AOF.init(io, output_path)` inside AOFType, and the
edge AOFType.merge -> AOFType.init#2 is present after and absent before. Node
totals move only through the derived layers (Community 524->527, Process
774->763) — no source symbol added or removed.

Scale of what was dropped: 73 of ghostty's 185 `@This()` files, 93 of
tigerbeetle's 94 and 8 of mach's 42 spell the alias differently from the stem,
carrying 302 `Alias.member` references between them, 96 of those calls.

Fixture zig-idioms/src/webapi/Widget.zig exercises both paths — a file-level
`Self` and a container-level `Me` in Metrics — through a call and a
registration. Five cases; three fail with the binding disabled, and the two
that do not are the controls: Element.zig's stem-spelled alias must keep
resolving byte-identically, and the alias name must not become resolvable from
another file.

* docs(review): stop claiming what the alias pass does not do

Two comments asserted things the adjacent code does not support.

The alias pass's call site said it ran before the payload walk "so a subject
spelled through the alias resolves here too". It does not: the payload walk
types subjects through findReceiverTypeBinding, which reads typeBindings plus
the namespace/workspace type channels and never bindingAugmentations, where
bindZigThisAliases writes. Measured on `for (Self.items) |it|` — `it` is bound
neither before nor after. The pass is in that loop for the tree and nothing
else, which is now what the comment says.

Nor is that a gap to close by also writing a typeBinding: no container name has
one, the file stem included, so a payload subject written `Type.member`
resolves for no spelling at all. Giving the alias an entry would make it behave
unlike the container it names. Recorded at the call site so the next reader
does not re-derive it.

The module-shadow test said Element.zig declares `const Gauge: u8 = 3;`. It
binds `Gauge` by IMPORT, and the distinction is the point of the fixture: 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 the test exists for would never be reached. The comment now says which
binding it is and why the other shape would be self-defeating.

Comment-only: node and edge counts are byte-identical across a full re-index
(52,083 / 165,261 both sides).

* docs(zig): stop describing loadZigBuildConfig as root-only in the present tense

It takes a `packageDir` since this branch and reads
`path.join(repoRoot, packageDir, name)`; `loadZigWorkspaceIndex` is what
supplies it one, per package. Two comments still described the historical
root-only invocation as the function's current behaviour:

- the monorepo integration-test header, flagged by review;
- loadZigWorkspaceIndex's own docstring — the same sentence, in the function
  that calls it WITH a packageDir a few lines below, so fixing only the test
  copy would have left the worse of the two.

Both now attribute root-only reading to the CALL (no `packageDir`), which is
what the argument actually rests on: a monorepo has no root build files, so
that call answers null and every bare @import goes unresolved. The trailing
note about `loadImportConfigs` is reworded the same way — it calls the loader
for the root package alone; the loader is not root-bound.

Comment-only: node and edge counts byte-identical across a full re-index
(52,083 / 165,261 both sides), and detect-changes reports the two hunks
overlap no indexed symbol.

* fix(zig): a written namespace handle owns its own decline

`findNamespaceValueRefTarget` returning `undefined` conflated two different
answers: "no namespace import named this receiver" and "the module this file
named does not expose that member as a callable". Only the first should fall
through to the container channel. The second did too, and
`findClassBindingInScope`'s miss path answers from the WORKSPACE-wide
qualified-name index — so a same-named container in a file this one never
imported supplied the member the written module does not have.

The owner-shadow guard does not stop it, which is the part that is not obvious:
a plain `const utils = @import("utils.zig");` records a namespace IMPORT EDGE,
not a module-scope binding, so the guard finds nothing bound under the name and
reads the container as unshadowed. It catches `const Gauge = @import(x).MEMBER`
(a real binding) and misses the handle form.

Reproduced before fixing, not argued: `decoy.zig`'s `dom_utils` struct gains
`onlyOnDecoy`, a callable `dom_utils.zig` does not have, and `Element.zig`
registers `dom_utils.onlyOnDecoy`. That minted `JsApi -> onlyOnDecoy` — a
confident USES edge into a file `Element.zig` never imports, the wrong-edge
failure this PR exists to avoid, arriving through the container channel after
the namespace channel said no.

The channel now returns 'owned' for every outcome reached once the receiver is
established as this file's unshadowed namespace handle, and the caller declines
on it. A locally shadowed handle still falls through, because there the name
does not mean the import at that site and the container channel's guard is the
right decider.

Bench fingerprint and counts unchanged; no baseline edited.

* fix(zig): reject an absolute nested-package `.path` before rebasing it

The nested-package branch prefixes the package directory and THEN normalizes,
so an absolute `.path` stops looking absolute on the way: `packages/app/` plus
`/src` is `packages/app//src`, which is relative by inspection.
`normalizeZigDepPath` drops the empty segment and the dep lands on
`packages/app/src` — a directory that really exists — so a dependency pointing
outside the repository is fabricated into an in-repo resolution. The root
package was never affected: its prefix is empty, so the value reached the check
as written.

`isAbsoluteZigDepPath` is now asked of the value AS WRITTEN, before any
prefixing, and `normalizeZigDepPath` asks the same helper so the two cannot
drift. `..` is deliberately not handled there: `../core` escapes the package
but not the repo, and rebasing it is what the branch exists to do —
`normalizeZigDepPath` still rejects what escapes the ROOT afterwards.

Note for the reviewer: `path.posix.join(pkg, depPath)` does NOT fix this.
`join('packages/app/', '/dep')` is `packages/app/dep` — it strips the leading
slash too, producing the same fabricated path without rejecting anything.
Measured before writing the fix.

Regression test pins it through the real loader: `packages/app` declares
`.escapes = .{ .path = "/src" }`, and the test fails without the guard.

`impact normalizeZigDepPath` is HIGH (14 impacted, 4 direct, exact). The edit
is behaviour-preserving for that function — the same two conditions moved into
a named helper it calls — and its existing absolute-path suite, POSIX, Windows
drive and UNC spellings included, passes unchanged.

* docs(mcp): stop defining lower-bound as proof that callers were missed

The CLI header was corrected in round 8; the MCP tool contract still made the
assertion the CLI stopped making. `context` said lower-bound "means callers
exist that this view provably does not list" and `impact` said "the walk
provably missed callers" — but `callableValueReferenceBoundaries` also
publishes lower-bound when its probe could not RUN, and says in its own note
that whether the symbol is registered is unknown. A client following the
contract would read an unanswered question as evidence of an omission.

Both now define it as a FLOOR with two possible causes — the walk provably
missed callers, or a probe that would have established completeness could not
run — and point at `boundaries` for which. That keeps the common case exactly
as strong as it was; it only stops the contract asserting the one case it
cannot support. The `causes.callableValueReferences` bullet already documented
the probe-failure branch, so the headline was contradicting the body.

`local-backend.ts` quotes that definition to justify hedging; the quote is
updated to name which half it relies on.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-09-09 20:12:17 +00:00

1592 lines
81 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

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