GitNexus/gitnexus/test/unit/zig-import-resolver.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

955 lines
42 KiB
TypeScript
Raw 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.

/**
* Unit tests for the Zig import resolver, covering both relative-path
* imports and bare-name imports resolved through build.zig.zon.
*/
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveZigImportInternal } from '../../src/core/ingestion/import-resolvers/zig.js';
import {
loadZigBuildConfig,
loadZigWorkspaceIndex,
parseZigBuildModules,
parseZigRootModules,
parseZigBuildModuleRoots,
parseZigBuildZon,
zigPackageFor,
} from '../../src/core/ingestion/language-config.js';
const FIXTURES = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../fixtures/lang-resolution',
);
describe('resolveZigImportInternal', () => {
it('returns null for stdlib / builtin / root', () => {
const files = new Set<string>(['src/main.zig']);
expect(resolveZigImportInternal('src/main.zig', 'std', files)).toBeNull();
expect(resolveZigImportInternal('src/main.zig', 'builtin', files)).toBeNull();
expect(resolveZigImportInternal('src/main.zig', 'root', files)).toBeNull();
});
it('resolves "./foo.zig" relative to the importer', () => {
const files = new Set<string>(['src/main.zig', 'src/foo.zig']);
expect(resolveZigImportInternal('src/main.zig', './foo.zig', files)).toBe('src/foo.zig');
});
it('resolves "foo.zig" without a "./" prefix as filesystem-relative', () => {
const files = new Set<string>(['src/main.zig', 'src/foo.zig']);
expect(resolveZigImportInternal('src/main.zig', 'foo.zig', files)).toBe('src/foo.zig');
});
it('resolves "../sibling/file.zig" with parent traversal', () => {
const files = new Set<string>(['src/a/main.zig', 'src/b/util.zig']);
expect(resolveZigImportInternal('src/a/main.zig', '../b/util.zig', files)).toBe(
'src/b/util.zig',
);
});
it('rejects parent traversal above the repository root instead of aliasing a root file', () => {
// `currentDir.pop()` on an empty stack used to swallow the `..`, so
// `../bar.zig` from `main.zig` resolved to the unrelated repo-root `bar.zig`.
const files = new Set<string>(['main.zig', 'bar.zig', 'src/a.zig', 'x.zig']);
expect(resolveZigImportInternal('main.zig', '../bar.zig', files)).toBeNull();
expect(resolveZigImportInternal('src/a.zig', '../../x.zig', files)).toBeNull();
// One level up from src/ is still inside the repo.
expect(resolveZigImportInternal('src/a.zig', '../bar.zig', files)).toBe('bar.zig');
});
it('rejects absolute import paths instead of reading them as importer-relative', () => {
// The path walker skipped every empty component, so the leading `/` of
// `/foo.zig` vanished and it resolved to `src/foo.zig` — an in-repo edge
// for an import Zig itself rejects as outside the module path.
const files = new Set<string>(['src/main.zig', 'src/foo.zig', 'foo.zig', 'main.zig']);
expect(resolveZigImportInternal('src/main.zig', '/foo.zig', files)).toBeNull();
expect(resolveZigImportInternal('main.zig', '/foo.zig', files)).toBeNull();
expect(resolveZigImportInternal('src/main.zig', '/src/foo.zig', files)).toBeNull();
// Backslash-spelled absolute paths normalize to the same rejection.
expect(resolveZigImportInternal('src/main.zig', '\\foo.zig', files)).toBeNull();
// A drive-qualified spelling carries a `/` after normalization and used to
// take the importer-relative branch (probing `src/C:/foo.zig`).
const drive = new Set<string>([...files, 'src/C:/foo.zig']);
expect(resolveZigImportInternal('src/main.zig', 'C:\\foo.zig', drive)).toBeNull();
expect(resolveZigImportInternal('src/main.zig', 'C:/foo.zig', drive)).toBeNull();
// The relative spelling next to it still resolves.
expect(resolveZigImportInternal('src/main.zig', 'foo.zig', files)).toBe('src/foo.zig');
});
it('returns null for a bare name when no build.zig.zon is supplied', () => {
const files = new Set<string>(['src/main.zig', 'vendor/ziggit/src/ziggit.zig']);
expect(resolveZigImportInternal('src/main.zig', 'ziggit', files)).toBeNull();
});
it('resolves a bare name via a `.path` build.zig.zon dep (`<root>/src/<name>.zig`)', () => {
const files = new Set<string>(['src/main.zig', 'vendor/ziggit/src/ziggit.zig']);
const buildZon = { pathDeps: new Map([['ziggit', 'vendor/ziggit']]) };
expect(resolveZigImportInternal('src/main.zig', 'ziggit', files, buildZon)).toBe(
'vendor/ziggit/src/ziggit.zig',
);
});
it('resolves a bare name to `<root>/src/root.zig` — the `zig init` library root since 0.12', () => {
// 0.12+ `zig init` writes src/root.zig for libraries (0.14 writes both
// root.zig and main.zig). Knowing only src/<name>.zig and src/main.zig
// left every such dep unresolved.
const files = new Set<string>(['src/main.zig', 'libs/geo/src/root.zig']);
const zon = { pathDeps: new Map([['geo', 'libs/geo']]) };
expect(resolveZigImportInternal('src/main.zig', 'geo', files, zon)).toBe(
'libs/geo/src/root.zig',
);
});
it('prefers the root the dep’s own build.zig declares over every conventional layout', () => {
// A dep can call its module root anything (`lib/geo.zig`); when its
// build.zig says so, that beats src/root.zig even if both exist.
const files = new Set<string>([
'src/main.zig',
'libs/geo/lib/geo.zig',
'libs/geo/src/root.zig',
'libs/geo/src/main.zig',
]);
const zon = {
pathDeps: new Map([['geo', 'libs/geo']]),
moduleRoots: new Map([['geo', ['libs/geo/lib/geo.zig']]]),
};
expect(resolveZigImportInternal('src/main.zig', 'geo', files, zon)).toBe(
'libs/geo/lib/geo.zig',
);
});
it('falls back to `<root>/src/main.zig` when no `<name>.zig` exists', () => {
const files = new Set<string>(['src/main.zig', 'vendor/ziggit/src/main.zig']);
const buildZon = { pathDeps: new Map([['ziggit', 'vendor/ziggit']]) };
expect(resolveZigImportInternal('src/main.zig', 'ziggit', files, buildZon)).toBe(
'vendor/ziggit/src/main.zig',
);
});
it('resolves a `.path = "."` dep against the repo root without a leading slash', () => {
// `normalizeDepPath('.')` is '' — the candidates used to become
// `/src/<name>.zig`, which can never match a repo-relative file key.
const files = new Set<string>(['src/main.zig', 'src/mylib.zig', 'examples/demo.zig']);
for (const dot of ['.', './']) {
const buildZon = { pathDeps: new Map([['mylib', dot]]) };
expect(resolveZigImportInternal('examples/demo.zig', 'mylib', files, buildZon)).toBe(
'src/mylib.zig',
);
}
// …and the `src/main.zig` fallback for a root dep too.
const buildZon = { pathDeps: new Map([['root_pkg', '.']]) };
expect(resolveZigImportInternal('examples/demo.zig', 'root_pkg', files, buildZon)).toBe(
'src/main.zig',
);
});
it('returns null for absolute `.path` deps, POSIX and Windows spellings alike', () => {
// `normalizeZigDepPath` promises null for anything outside the repo; a
// `/`-only check let `C:\\local_dep` through as the relative `C:/local_dep`,
// and an absolute check that ran BEFORE backslash normalization let the
// UNC `\\\\server\\share\\local_dep` (and root-relative `\\local_dep`)
// through as relative paths. The `root.zig` entries below are the files
// those misreadings WOULD resolve — each spelling must reject, not merely
// miss.
const files = new Set<string>([
'src/main.zig',
'C:/local_dep/src/dep.zig',
'local_dep/src/dep.zig',
'local_dep/src/root.zig',
'server/share/local_dep/src/root.zig',
]);
for (const abs of [
'/local_dep',
'C:\\local_dep',
'c:/local_dep',
'D:\\x\\local_dep',
'\\\\server\\share\\local_dep',
'\\local_dep',
]) {
const zon = { pathDeps: new Map([['dep', abs]]) };
expect(resolveZigImportInternal('src/main.zig', 'dep', files, zon)).toBeNull();
}
});
it('returns null for `.path` deps that escape the repo root (`..`)', () => {
const files = new Set<string>(['src/main.zig']);
const buildZon = { pathDeps: new Map([['ziggit', '../ziggit']]) };
expect(resolveZigImportInternal('src/main.zig', 'ziggit', files, buildZon)).toBeNull();
});
it('returns null when the conventional layout file is missing', () => {
const files = new Set<string>(['src/main.zig', 'vendor/ziggit/lib/something.zig']);
const buildZon = { pathDeps: new Map([['ziggit', 'vendor/ziggit']]) };
expect(resolveZigImportInternal('src/main.zig', 'ziggit', files, buildZon)).toBeNull();
});
it('resolves the repo’s OWN module (root build.zig `addModule`) with no build.zig.zon at all', () => {
// Lightpanda: `const lp = @import("lightpanda");` in 378 of 567 files,
// declared only by the root build.zig — bare names were resolved through
// zon path deps alone, so the package's own root module had ZERO IMPORTS
// edges and every `lp.X` downstream stayed unresolved.
const files = new Set<string>(['src/main.zig', 'src/lightpanda.zig', 'src/browser/page.zig']);
const config = {
pathDeps: new Map<string, string>(),
rootModules: new Map([['lightpanda', 'src/lightpanda.zig']]),
};
expect(resolveZigImportInternal('src/browser/page.zig', 'lightpanda', files, config)).toBe(
'src/lightpanda.zig',
);
// From the root file itself (the circular self-import build.zig allows).
expect(resolveZigImportInternal('src/lightpanda.zig', 'lightpanda', files, config)).toBe(
'src/lightpanda.zig',
);
// A generated module (`addOptions().createModule()`) is not declared and
// must not resolve to anything.
expect(resolveZigImportInternal('src/main.zig', 'build_config', files, config)).toBeNull();
// A declared root that is not among the indexed files resolves nothing.
const stale = {
pathDeps: new Map<string, string>(),
rootModules: new Map([['gone', 'src/gone.zig']]),
};
expect(resolveZigImportInternal('src/main.zig', 'gone', files, stale)).toBeNull();
});
it('never resolves std / builtin / root through a same-named root module', () => {
const files = new Set<string>(['src/main.zig', 'src/std.zig']);
const config = {
pathDeps: new Map<string, string>(),
rootModules: new Map([['std', 'src/std.zig']]),
};
expect(resolveZigImportInternal('src/main.zig', 'std', files, config)).toBeNull();
});
it('consults root modules BEFORE build.zig.zon path deps', () => {
// A `.path = "."` self-dep and an `addModule` for the same name must agree
// on the addModule root — the build.zig declaration is the source of truth
// for what the name means, the zon layout heuristics are a fallback.
const files = new Set<string>(['src/main.zig', 'src/root.zig', 'lib/pkg.zig']);
const config = {
pathDeps: new Map([['pkg', '.']]),
rootModules: new Map([['pkg', 'lib/pkg.zig']]),
};
expect(resolveZigImportInternal('src/main.zig', 'pkg', files, config)).toBe('lib/pkg.zig');
// Without the root module, the zon fallback still applies.
expect(
resolveZigImportInternal('src/main.zig', 'pkg', files, { pathDeps: config.pathDeps }),
).toBe('src/root.zig');
// A root module whose file is not indexed is authoritative all the same:
// null, never the same-named zon dep's file (a different declaration).
const stale = { ...config, rootModules: new Map([['pkg', 'lib/gone.zig']]) };
expect(resolveZigImportInternal('src/main.zig', 'pkg', files, stale)).toBeNull();
});
it('resolves a bare name through the importer’s OWN build module table, not the first-declared alias', () => {
// Two executables each `addImport("config", …)` their own config.zig. The
// flat `rootModules` map keeps the first, so every tool/ file imported
// app's config. The per-module table is consulted first, by membership.
const files = new Set<string>([
'src/app/main.zig',
'src/app/config.zig',
'src/app/util.zig',
'src/tool/main.zig',
'src/tool/config.zig',
]);
const config = {
pathDeps: new Map<string, string>(),
rootModules: new Map([['config', 'src/app/config.zig']]),
buildModules: [
{ root: 'src/app/main.zig', imports: new Map([['config', 'src/app/config.zig']]) },
{ root: 'src/tool/main.zig', imports: new Map([['config', 'src/tool/config.zig']]) },
],
};
expect(resolveZigImportInternal('src/tool/main.zig', 'config', files, config)).toBe(
'src/tool/config.zig',
);
expect(resolveZigImportInternal('src/app/main.zig', 'config', files, config)).toBe(
'src/app/config.zig',
);
// A non-root file is attributed to the module whose root shares its
// directory (deepest prefix).
expect(resolveZigImportInternal('src/app/util.zig', 'config', files, config)).toBe(
'src/app/config.zig',
);
// A file outside every module directory falls back to the flat map.
expect(resolveZigImportInternal('examples/demo.zig', 'config', files, config)).toBe(
'src/app/config.zig',
);
});
it('fails closed when same-directory modules disagree on an alias, instead of taking the first', () => {
// `src/main.zig` (exe) and `src/root.zig` (lib) share a directory — the
// `zig init` layout. A third file there cannot be attributed; resolving
// through either would be a confident wrong edge, and the flat fallback
// would only restore the first-wins answer, so the chain stops at null.
const files = new Set<string>([
'src/main.zig',
'src/root.zig',
'src/util.zig',
'src/cfg_exe.zig',
'src/cfg_lib.zig',
]);
const config = {
pathDeps: new Map<string, string>(),
rootModules: new Map([['cfg', 'src/cfg_exe.zig']]),
buildModules: [
{ root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg_exe.zig']]) },
{ root: 'src/root.zig', imports: new Map([['cfg', 'src/cfg_lib.zig']]) },
],
};
expect(resolveZigImportInternal('src/util.zig', 'cfg', files, config)).toBeNull();
// The roots themselves are unambiguous: each is its own module.
expect(resolveZigImportInternal('src/main.zig', 'cfg', files, config)).toBe('src/cfg_exe.zig');
expect(resolveZigImportInternal('src/root.zig', 'cfg', files, config)).toBe('src/cfg_lib.zig');
// Agreement is not a conflict: two modules binding one alias to one root
// resolve it for their shared directory.
const agreeing = {
...config,
buildModules: [
{ root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg_lib.zig']]) },
{ root: 'src/root.zig', imports: new Map([['cfg', 'src/cfg_lib.zig']]) },
],
};
expect(resolveZigImportInternal('src/util.zig', 'cfg', files, agreeing)).toBe(
'src/cfg_lib.zig',
);
});
it('falls through to the flat root-module map when the containing module does not bind the name', () => {
// A module table answers only for the aliases it declares; the package's
// own `addModule` name stays importable from everywhere, as before.
const files = new Set<string>(['src/main.zig', 'src/lib.zig', 'src/cfg.zig']);
const config = {
pathDeps: new Map<string, string>(),
rootModules: new Map([['mylib', 'src/lib.zig']]),
buildModules: [{ root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg.zig']]) }],
};
expect(resolveZigImportInternal('src/main.zig', 'mylib', files, config)).toBe('src/lib.zig');
// A table entry whose root is not indexed is not an answer either — and
// it does not fall through: the module's table is the authority for the
// alias, so a same-named repo-wide `addModule("cfg")` pointing elsewhere
// must not answer in its place (gitnexus-check on 5299c552).
const stale = {
...config,
rootModules: new Map([
['mylib', 'src/lib.zig'],
['cfg', 'src/cfg.zig'],
]),
buildModules: [{ root: 'src/main.zig', imports: new Map([['cfg', 'src/gone.zig']]) }],
};
expect(resolveZigImportInternal('src/main.zig', 'cfg', files, stale)).toBeNull();
// …while a file OUTSIDE that module (`src/lib.zig` shares the root's
// directory and so belongs to it; `other/x.zig` does not) still reaches
// the repo-wide name.
const outside = new Set<string>([...files, 'other/x.zig']);
expect(resolveZigImportInternal('other/x.zig', 'cfg', outside, stale)).toBe('src/cfg.zig');
});
it('fails closed when one same-directory module binds the alias to an unindexed root', () => {
const files = new Set<string>(['src/a.zig', 'src/b.zig', 'src/helper.zig', 'src/config.zig']);
const config = {
pathDeps: new Map<string, string>(),
rootModules: new Map<string, string>(),
buildModules: [
{ root: 'src/a.zig', imports: new Map([['cfg', 'src/gone.zig']]) },
{ root: 'src/b.zig', imports: new Map([['cfg', 'src/config.zig']]) },
],
};
expect(resolveZigImportInternal('src/helper.zig', 'cfg', files, config)).toBeNull();
});
it('returns null for an unknown bare name not in build.zig.zon', () => {
const files = new Set<string>(['src/main.zig']);
const buildZon = { pathDeps: new Map([['ziggit', 'vendor/ziggit']]) };
expect(resolveZigImportInternal('src/main.zig', 'mystery_pkg', files, buildZon)).toBeNull();
});
});
describe('parseZigBuildZon', () => {
it('extracts `.path = "..."` deps and skips `.url`-based deps', () => {
const raw = `
.{
.name = "myproject",
.version = "0.1.0",
.dependencies = .{
.ziggit_pkg = .{
.url = "https://github.com/.../archive/abc.tar.gz",
.hash = "1220abc",
},
.local_dep = .{
.path = "../local_dep",
},
.vendor_dep = .{
.path = "vendor/foo",
},
},
.paths = .{ "" },
}
`;
const cfg = parseZigBuildZon(raw);
expect(cfg).not.toBeNull();
expect(cfg!.pathDeps.get('local_dep')).toBe('../local_dep');
expect(cfg!.pathDeps.get('vendor_dep')).toBe('vendor/foo');
// .url-based deps are intentionally absent
expect(cfg!.pathDeps.has('ziggit_pkg')).toBe(false);
});
it('ignores commented-out entries and `.path` lines', () => {
// A `// .path = "vendor/foo"` inside an entry used to be captured as a real
// dep because the regex ran over raw source. `//` inside a `.url` string
// must survive the strip — it is not a comment.
const raw = `
.{
.dependencies = .{
// .disabled = .{ .path = "vendor/disabled" },
.remote = .{
.url = "https://github.com/x/y/archive/abc.tar.gz",
// .path = "vendor/remote-override",
.hash = "1220abc",
},
.live = .{ .path = "vendor/live" }, // trailing note: .path = "nope"
},
}
`;
const cfg = parseZigBuildZon(raw);
expect(cfg).not.toBeNull();
expect([...cfg!.pathDeps.entries()]).toEqual([['live', 'vendor/live']]);
});
it('is not derailed by braces inside comments or string literals', () => {
// Every `{`/`}` used to count toward the block depth, so a `// }` comment or
// a `}` inside a string closed the `.dependencies` block early and dropped
// every dep after it.
const raw = `
.{
.dependencies = .{
.first = .{
.url = "https://example.com/weird}name{.tar.gz",
.hash = "1220x", // } stray brace in a comment
},
// } another one
.second = .{ .path = "vendor/second" },
.third = .{ .path = "vendor/third" },
},
.paths = .{ "" },
}
`;
const cfg = parseZigBuildZon(raw);
expect(cfg).not.toBeNull();
expect([...cfg!.pathDeps.entries()]).toEqual([
['second', 'vendor/second'],
['third', 'vendor/third'],
]);
});
it('does not take a `.dependencies = .{` spelled inside a string literal as the block header', () => {
// The header search was a raw regex over the whole text: a `.name` (or
// `.description`) value that spells `.dependencies = .{ … }` matched
// first, the parser started at that embedded brace, and returned the
// fake `.path` dep instead of the real top-level block.
const raw = `
.{
.name = ".dependencies = .{ .fake = .{ .path = \\"vendor/fake\\" } }",
.version = "0.0.0",
.dependencies = .{
.real = .{ .path = "vendor/real" },
},
.paths = .{ "" },
}
`;
const cfg = parseZigBuildZon(raw);
expect(cfg).not.toBeNull();
expect([...cfg!.pathDeps.entries()]).toEqual([['real', 'vendor/real']]);
});
it('takes the top-level `.dependencies` block, not a same-named field nested in an earlier struct', () => {
// Only a direct field of the file's `.{ … }` (brace depth 1) is the
// manifest's dependency map; a nested `.dependencies = .{` seen first
// used to be selected and the real map ignored.
const zon = `
.{
.name = .pkg,
.metadata = .{
.dependencies = .{
.decoy = .{ .path = "decoy" },
},
},
.dependencies = .{
.real = .{ .path = "libs/real" },
},
}
`;
const cfg = parseZigBuildZon(zon);
expect(cfg?.pathDeps.get('real')).toBe('libs/real');
expect(cfg?.pathDeps.has('decoy')).toBe(false);
});
it('returns null when no `.dependencies` block is present', () => {
const raw = `.{ .name = "x", .version = "0.0.0", .paths = .{""} }`;
expect(parseZigBuildZon(raw)).toBeNull();
});
it('ignores a `.path` nested inside an entry — only a direct field makes a path dep', () => {
// A URL dep whose body carries a nested object with its own `.path` must
// not be reported as a path dep: the resolver would otherwise add an
// import edge to an unrelated `<root>/<nested path>` for `@import("only_url")`.
const raw = `
.{
.dependencies = .{
.only_url = .{
.url = "https://x",
.hash = "1220y",
.meta = .{ .path = "vendor/unrelated" },
},
.real = .{ .path = "vendor/real", .extra = .{ .path = "vendor/nested" } },
},
}
`;
const cfg = parseZigBuildZon(raw);
expect(cfg).not.toBeNull();
expect([...cfg!.pathDeps.entries()]).toEqual([['real', 'vendor/real']]);
});
it('returns null when the deps block has no `.path` entries', () => {
const raw = `
.{
.dependencies = .{
.only_url = .{ .url = "https://x", .hash = "1220y" },
},
}
`;
expect(parseZigBuildZon(raw)).toBeNull();
});
});
describe('parseZigBuildModuleRoots', () => {
it('reads `addModule("<name>", .{ .root_source_file = b.path("…") })`, preferring the named module', () => {
const buildZig = `
const std = @import("std");
pub fn build(b: *std.Build) void {
const lib = b.addStaticLibrary(.{ .name = "geo", .root_source_file = b.path("src/lib_entry.zig") });
_ = b.addModule("helpers", .{ .root_source_file = b.path("src/helpers.zig") });
_ = b.addModule("geo", .{
.root_source_file = b.path("src/root.zig"),
.target = b.standardTargetOptions(.{}),
});
b.installArtifact(lib);
}
`;
// The module named like the dep comes first; the others stay as ordered
// fallbacks (an importer's `@import("geo")` maps to the "geo" module).
expect(parseZigBuildModuleRoots(buildZig, 'geo')).toEqual([
'src/root.zig',
'src/lib_entry.zig',
'src/helpers.zig',
]);
});
it('skips roots that are not a static `b.path("….zig")` and normalizes `./`', () => {
const buildZig = `
_ = b.addModule("x", .{ .root_source_file = .{ .cwd_relative = "/abs/x.zig" } });
_ = b.addModule("y", .{ .root_source_file = b.path("./src/y.zig") });
_ = b.addModule("z", .{ .root_source_file = generated.getPath() });
_ = b.addModule("w", .{ .root_source_file = b.path("../outside.zig") });
`;
expect(parseZigBuildModuleRoots(buildZig, 'y')).toEqual(['src/y.zig']);
});
it('still names the module when a nested field precedes `.root_source_file`', () => {
// `.imports = &.{ .{ … } }` closes an inner `}` before the root field; a
// `[^}]*` regex ended there and demoted "dep" to an unnamed fallback,
// so `@import("dep")` resolved to whichever root came first in the file.
const buildZig = `
pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{ .name = "tool", .root_source_file = b.path("src/main.zig") });
_ = b.addModule("dep", .{
.imports = &.{ .{ .name = "util", .module = util } },
.root_source_file = b.path("lib/root.zig"),
});
// _ = b.addModule("dep", .{ .root_source_file = b.path("lib/commented_out.zig") });
b.installArtifact(exe);
}
`;
expect(parseZigBuildModuleRoots(buildZig, 'dep')).toEqual(['lib/root.zig', 'src/main.zig']);
});
it('returns [] for a build.zig that declares no module root', () => {
expect(parseZigBuildModuleRoots('pub fn build(b: *std.Build) void { _ = b; }', 'x')).toEqual(
[],
);
});
});
describe('parseZigRootModules', () => {
it('maps the root build.zig’s named modules to their root files (Lightpanda-shaped build.zig)', () => {
// Excerpt of Lightpanda's build.zig: `addModule` names the package's own
// module, `addImport` re-aliases it (circular self-import), the options
// module is generated (`addOptions().createModule()`) and `v8` comes from
// a `.url` dep (`dep.module("v8")`) — neither of those is an in-repo file.
const buildZig = `
const std = @import("std");
const Build = std.Build;
pub fn build(b: *Build) !void {
var opts = b.addOptions();
opts.addOption([]const u8, "version", version_string);
const lightpanda_module = b.addModule("lightpanda", .{
.root_source_file = b.path("src/lightpanda.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
});
lightpanda_module.addImport("lightpanda", lightpanda_module); // allow circular "lightpanda" import
lightpanda_module.addImport("build_config", opts.createModule());
// A createModule binding named only through addImport.
const testing_mod = b.createModule(.{
.root_source_file = b.path("./src/testing.zig"),
.imports = &.{
.{ .name = "lightpanda", .module = lightpanda_module },
},
});
lightpanda_module.addImport("testing", testing_mod);
// \`.imports\` naming form, bare identifier.
_ = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.imports = &.{ .{ .name = "lp_alias", .module = lightpanda_module } },
});
// Struct-field operand — not statically recoverable, skipped.
_ = b.createModule(.{
.root_source_file = b.path("src/other.zig"),
.imports = &.{ .{ .name = "lightpanda_cfg", .module = config.lightpanda_module } },
});
// The addModule("v8", …) below lives in a string and a comment: neither counts.
const note = "b.addModule(\\"decoy\\", .{ .root_source_file = b.path(\\"src/decoy.zig\\") })";
// _ = b.addModule("commented", .{ .root_source_file = b.path("src/commented.zig") });
_ = note;
}
fn linkV8(b: *Build, mod: *Build.Module) void {
const dep = b.dependency("v8", .{});
mod.addImport("v8", dep.module("v8"));
const translate_c = b.addTranslateC(.{ .root_source_file = b.path("include/curl.h") });
mod.addImport("curl", translate_c.createModule());
mod.addImport("computed", b.createModule(.{ .root_source_file = generated.getPath() }));
}
`;
expect(parseZigRootModules(buildZig)).toEqual(
new Map([
['lightpanda', 'src/lightpanda.zig'],
['testing', 'src/testing.zig'],
['lp_alias', 'src/lightpanda.zig'],
]),
);
});
it('keeps the first declaration of a name and ignores non-.zig / escaping roots', () => {
const buildZig = `
_ = b.addModule("x", .{ .root_source_file = b.path("src/x.zig") });
_ = b.addModule("x", .{ .root_source_file = b.path("src/other.zig") });
_ = b.addModule("h", .{ .root_source_file = b.path("include/h.h") });
_ = b.addModule("out", .{ .root_source_file = b.path("../outside.zig") });
_ = b.addModule("abs", .{ .root_source_file = .{ .cwd_relative = "/abs/x.zig" } });
`;
expect(parseZigRootModules(buildZig)).toEqual(new Map([['x', 'src/x.zig']]));
});
it('takes the outer module root, not a nested `.imports` inline root', () => {
const buildZig = `
_ = b.addModule("dep", .{
.imports = &.{ .{ .name = "nested", .root_source_file = b.path("src/nested.zig") } },
.root_source_file = b.path("lib/root.zig"),
});
`;
expect(parseZigBuildModuleRoots(buildZig, 'dep')[0]).toBe('lib/root.zig');
expect(parseZigRootModules(buildZig).get('dep')).toBe('lib/root.zig');
});
it('ignores createModule bindings that are not `b.createModule`', () => {
const buildZig = `
const decoy = config.createModule(.{ .root_source_file = b.path("src/decoy.zig") });
exe.addImport("name", decoy);
const real = b.createModule(.{ .root_source_file = b.path("src/real.zig") });
exe.addImport("ok", real);
`;
expect(parseZigRootModules(buildZig)).toEqual(new Map([['ok', 'src/real.zig']]));
});
it('returns an empty map for a build.zig that names no module', () => {
expect(parseZigRootModules('pub fn build(b: *std.Build) void { _ = b; }').size).toBe(0);
});
});
describe('parseZigBuildModules', () => {
it('keeps each module’s addImport aliases in ITS OWN table (two modules, one alias, two roots)', () => {
// The review trigger: `app` and `tool` each bind "config". One flat map
// kept app's; the per-module tables keep both, each on its module.
const buildZig = `
pub fn build(b: *std.Build) void {
const app_config = b.createModule(.{ .root_source_file = b.path("src/app/config.zig") });
const tool_config = b.createModule(.{ .root_source_file = b.path("src/tool/config.zig") });
const app = b.addExecutable(.{ .name = "app", .root_source_file = b.path("src/app/main.zig") });
app.root_module.addImport("config", app_config);
const tool_mod = b.createModule(.{
.root_source_file = b.path("src/tool/main.zig"),
.imports = &.{ .{ .name = "config", .module = tool_config } },
});
const tool = b.addExecutable(.{ .name = "tool", .root_module = tool_mod });
tool.root_module.addImport("extra", app_config);
}
`;
expect(parseZigBuildModules(buildZig)).toEqual([
{ root: 'src/app/config.zig', imports: new Map() },
{ root: 'src/tool/config.zig', imports: new Map() },
{ root: 'src/app/main.zig', imports: new Map([['config', 'src/app/config.zig']]) },
{
root: 'src/tool/main.zig',
imports: new Map([
['config', 'src/tool/config.zig'],
// `tool.root_module` IS `tool_mod`: the artifact alias lands on it.
['extra', 'src/app/config.zig'],
]),
},
]);
});
it('names addModule modules, binds an inline `.root_module = b.createModule(…)` to its artifact, and keeps the first alias', () => {
const buildZig = `
pub fn build(b: *std.Build) void {
const lib = b.addModule("mylib", .{ .root_source_file = b.path("./src/lib.zig") });
lib.addImport("mylib", lib);
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig") }),
});
exe.root_module.addImport("mylib", lib);
exe.root_module.addImport("mylib", exe.root_module); // second binding of a name: ignored
exe.root_module.addImport("gen", opts.createModule()); // generated: no file
unbound.addImport("x", lib); // receiver bound to nothing: ignored
}
`;
expect(parseZigBuildModules(buildZig)).toEqual([
{ name: 'mylib', root: 'src/lib.zig', imports: new Map([['mylib', 'src/lib.zig']]) },
{ root: 'src/main.zig', imports: new Map([['mylib', 'src/lib.zig']]) },
]);
});
it('resolves `addImport("api", dep.module("core"))` through the dep’s declared modules, and nothing without them', () => {
const buildZig = `
pub fn build(b: *std.Build) void {
const corelib = b.dependency("corelib", .{ .target = target });
const exe = b.addExecutable(.{ .name = "app", .root_source_file = b.path("src/main.zig") });
exe.root_module.addImport("api", corelib.module("core"));
exe.root_module.addImport("other", corelib.module("missing"));
exe.root_module.addImport("v8", v8.module("v8")); // not a b.dependency binding
}
`;
const depModules = new Map([['corelib', new Map([['core', 'libs/corelib/src/core.zig']])]]);
expect(parseZigBuildModules(buildZig, depModules)).toEqual([
{ root: 'src/main.zig', imports: new Map([['api', 'libs/corelib/src/core.zig']]) },
]);
// The zon dep (or its build.zig) unknown: the alias is left unresolved
// rather than guessed.
expect(parseZigBuildModules(buildZig)).toEqual([{ root: 'src/main.zig', imports: new Map() }]);
});
it('ignores calls, aliases and roots spelled in comments or strings', () => {
const buildZig = `
pub fn build(b: *std.Build) void {
const m = b.createModule(.{ .root_source_file = b.path("src/m.zig") });
// const decoy = b.createModule(.{ .root_source_file = b.path("src/decoy.zig") });
// m.addImport("commented", m);
const s = "m.addImport(\\"quoted\\", m)";
_ = s;
}
`;
expect(parseZigBuildModules(buildZig)).toEqual([{ root: 'src/m.zig', imports: new Map() }]);
expect(parseZigBuildModules('pub fn build(b: *std.Build) void { _ = b; }')).toEqual([]);
});
});
describe('loadZigBuildConfig (zig-buildmodules fixture)', () => {
it('carries per-module tables, with dep.module(…) aliases resolved through the dep’s build.zig', async () => {
const config = await loadZigBuildConfig(path.join(FIXTURES, 'zig-buildmodules'));
expect(config).not.toBeNull();
const byRoot = new Map(config!.buildModules!.map((m) => [m.root, m.imports]));
expect(byRoot.get('src/app/main.zig')).toEqual(
new Map([
['config', 'src/app/config.zig'],
['api', 'libs/corelib/src/core.zig'],
]),
);
expect(byRoot.get('src/tool/main.zig')).toEqual(new Map([['config', 'src/tool/config.zig']]));
expect(byRoot.get('src/shared/a.zig')).toEqual(new Map([['clash', 'src/shared/clash_a.zig']]));
expect(byRoot.get('src/shared/b.zig')).toEqual(new Map([['clash', 'src/shared/clash_b.zig']]));
// The flat map is still there as the repo-wide fallback — and shows the
// first-wins collapse the per-module tables exist to avoid.
expect(config!.rootModules?.get('config')).toBe('src/app/config.zig');
});
});
describe('loadZigBuildConfig (zig-rootmodule fixture: build.zig, no build.zig.zon)', () => {
it('still yields a config carrying the root build.zig’s modules', async () => {
// Before: no build.zig.zon → null → every bare-name import in the repo
// unresolved, including the repo's own module.
const config = await loadZigBuildConfig(path.join(FIXTURES, 'zig-rootmodule'));
expect(config).not.toBeNull();
expect(config!.pathDeps.size).toBe(0);
expect(config!.rootModules).toEqual(new Map([['core', 'src/core.zig']]));
});
});
describe('loadZigBuildConfig (zig-idioms fixture)', () => {
it('reads the root build.zig’s own named modules alongside the zon path deps', async () => {
const config = await loadZigBuildConfig(path.join(FIXTURES, 'zig-idioms'));
// addModule("idioms", …src/idioms.zig) + the self-addImport; the
// build_config (addOptions) module and geo (dependency module) are not
// in-repo files.
expect(config!.rootModules).toEqual(new Map([['idioms', 'src/idioms.zig']]));
});
it('reads each path dep’s build.zig for its module roots and leaves deps without one to the layout fallback', async () => {
const config = await loadZigBuildConfig(path.join(FIXTURES, 'zig-idioms'));
expect(config).not.toBeNull();
expect([...config!.pathDeps.keys()].sort()).toEqual(['geo', 'oldlib']);
// geo/build.zig: addModule("geo", .{ .root_source_file = b.path("src/root.zig") })
expect(config!.moduleRoots?.get('geo')).toEqual(['libs/geo/src/root.zig']);
// oldlib has no build.zig → no entry; the resolver falls back to
// src/root.zig → src/oldlib.zig → src/main.zig.
expect(config!.moduleRoots?.has('oldlib')).toBe(false);
});
});
describe('loadZigWorkspaceIndex / zigPackageFor (zig-monorepo fixture)', () => {
const MONOREPO = path.join(FIXTURES, 'zig-monorepo');
/** Every `.zig` the fixture indexes, repo-relative — what `allFilePaths` holds. */
const files = new Set<string>([
'packages/core/build.zig',
'packages/core/src/root.zig',
'packages/app/build.zig',
'packages/app/src/main.zig',
'packages/app/src/util.zig',
'packages/tool/build.zig',
'packages/tool/src/core.zig',
'packages/tool/src/main.zig',
]);
it('finds the packages the root-only loader cannot see at all', async () => {
// The regression this exists for: with no build.zig at the repo root the
// root-only loader has nothing to read and answers null, so every bare
// `@import` in the repo is unresolvable. Asserted side by side so the
// difference is the test rather than a claim in a comment.
expect(await loadZigBuildConfig(MONOREPO)).toBeNull();
const index = await loadZigWorkspaceIndex(MONOREPO);
expect(index).not.toBeNull();
expect(index!.packages.map((p) => p.dir)).toEqual([
'packages/core',
'packages/tool',
'packages/app',
]);
});
it('rebases a package-relative `.path` dep to repo-relative', async () => {
// `app/build.zig.zon` writes `.path = "../core"`, which means nothing
// against `allFilePaths` (repo-relative keys) and is rejected outright by
// `normalizeZigDepPath` as an escape when read from the repo root.
const app = zigPackageFor(await loadZigWorkspaceIndex(MONOREPO), 'packages/app/src/main.zig');
expect(app!.pathDeps.get('core')).toBe('packages/core');
expect(app!.moduleRoots?.get('core')).toEqual(['packages/core/src/root.zig']);
});
it('resolves a cross-package dependency from every file of the dependent package', async () => {
const index = await loadZigWorkspaceIndex(MONOREPO);
// The module root, and a file that is NOT the module root: membership is
// the root plus what it reaches, so both must resolve the alias.
for (const from of ['packages/app/src/main.zig', 'packages/app/src/util.zig']) {
expect(resolveZigImportInternal(from, 'core', files, zigPackageFor(index, from))).toBe(
'packages/core/src/root.zig',
);
}
});
it('keeps one alias bound to two different roots in two packages apart', async () => {
// The discriminating case. `tool` binds `core` to its OWN src/core.zig. A
// repo-wide module map — the shape a workspace index invites — would answer
// `packages/core/src/root.zig` here: 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 index = await loadZigWorkspaceIndex(MONOREPO);
const from = 'packages/tool/src/main.zig';
expect(resolveZigImportInternal(from, 'core', files, zigPackageFor(index, from))).toBe(
'packages/tool/src/core.zig',
);
});
it('does not leak one package’s modules to files outside it', async () => {
// `core` declares module `core`; `tool` never depends on `app`. A file in
// one package must not resolve another package's module name.
const index = await loadZigWorkspaceIndex(MONOREPO);
const from = 'packages/core/src/root.zig';
expect(resolveZigImportInternal(from, 'app', files, zigPackageFor(index, from))).toBeNull();
});
it('governs a file by the NEAREST enclosing package', async () => {
const index = await loadZigWorkspaceIndex(MONOREPO);
expect(zigPackageFor(index, 'packages/core/src/root.zig')!.rootModules?.get('core')).toBe(
'packages/core/src/root.zig',
);
// A path under no package at all resolves to no config rather than to the
// first package in the list.
expect(zigPackageFor(index, 'docs/notes.zig')).toBeNull();
});
it('keeps a genuinely single-package repo byte-identical to the root-only loader', async () => {
// `libs/geo` is one directory with one build.zig and no nested marker
// anywhere below it, so the workspace walk finds exactly one package and
// this really does pin the no-op case. `zig-idioms` cannot: it declares
// `libs/geo` as a path dep and that directory has its own build.zig, so the
// walk finds TWO packages there — the assertion below is the one the next
// test makes, and naming this file "single-package" would be the claim, not
// the check.
const single = path.join(FIXTURES, 'zig-idioms', 'libs', 'geo');
const index = await loadZigWorkspaceIndex(single);
expect(index!.packages.map((p) => p.dir)).toEqual(['']);
expect(index!.packages[0]!.config).toEqual(await loadZigBuildConfig(single));
expect(zigPackageFor(index, 'src/root.zig')).toEqual(await loadZigBuildConfig(single));
});
it('leaves the ROOT package of a multi-package repo as the root-only loader saw it', async () => {
// The root package is a scope like any other and `dir: ''` matches every
// file no deeper package claims, so a file outside `libs/geo` must still
// get exactly what `loadZigBuildConfig` alone used to answer.
const idioms = path.join(FIXTURES, 'zig-idioms');
const index = await loadZigWorkspaceIndex(idioms);
expect(index!.packages.map((p) => p.dir)).toEqual(['libs/geo', '']);
const root = index!.packages.find((p) => p.dir === '');
expect(root).toBeDefined();
expect(root!.config).toEqual(await loadZigBuildConfig(idioms));
expect(zigPackageFor(index, 'src/idioms.zig')).toEqual(await loadZigBuildConfig(idioms));
// …and a file INSIDE the nested package is governed by that package, not
// by the root — the regression the misnamed version of this test could not
// have caught, because it never looked below `src/`.
expect(zigPackageFor(index, 'libs/geo/src/root.zig')).toEqual(
await loadZigBuildConfig(idioms, 'libs/geo'),
);
});
it('rejects an ABSOLUTE `.path` in a nested package instead of rebasing it in', async () => {
// `packages/app` declares `.escapes = .{ .path = "/src" }`. Absolute, so it
// names something outside this repository — but the nested branch prefixes
// the package directory before normalizing, and `packages/app/` + `/src` is
// `packages/app//src`, which is relative by inspection. The empty segment is
// then dropped and the dep lands on `packages/app/src`, a directory that
// really exists here: an out-of-repo dependency fabricated into an in-repo
// resolution. `isAbsoluteZigDepPath` asks the question of the value AS
// WRITTEN, before any prefixing.
//
// `path.posix.join` would NOT have fixed this: it strips the leading slash
// too, producing the same `packages/app/src` without rejecting anything.
const app = zigPackageFor(await loadZigWorkspaceIndex(MONOREPO), 'packages/app/src/main.zig');
expect(app!.pathDeps.has('escapes')).toBe(false);
expect(app!.moduleRoots?.has('escapes') ?? false).toBe(false);
// The legitimate package-relative dep beside it is untouched.
expect(app!.pathDeps.get('core')).toBe('packages/core');
});
});