GitNexus/gitnexus/test/unit/incremental-parse-cache.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

1045 lines
45 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { mkdtemp, rm, readdir, writeFile, readFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import {
PARSE_CACHE_VERSION,
PARSE_CACHE_BUCKET_COUNT,
computeChunkHash,
fileContentHash,
packParseCacheChunks,
parseCacheBucketId,
loadParseCache,
loadParseCacheChunk,
persistParseCacheChunk,
saveParseCache,
pruneCache,
slimParseWorkerResultsForCache,
getColdParseRebuildDir,
createColdParseRebuildDir,
type ParseCache,
} from '../../src/storage/parse-cache.js';
import { writeV8CacheFile } from '../../src/storage/v8-sidecar.js';
import type { ParseWorkerResult } from '../../src/core/ingestion/workers/parse-worker.js';
const minimalResult = (overrides: Partial<ParseWorkerResult> = {}): ParseWorkerResult => ({
nodes: [],
relationships: [],
symbols: [],
imports: [],
calls: [],
assignments: [],
heritage: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
routerIncludes: [],
routerImports: [],
toolDefs: [],
ormQueries: [],
constructorBindings: [],
fileScopeBindings: [],
parsedFiles: [],
skippedLanguages: {},
fileCount: 0,
...overrides,
});
describe('computeChunkHash', () => {
it('produces a stable hex hash for a fixed set of (filePath, contentHash) entries', () => {
const entries = [
{ filePath: 'a.ts', contentHash: 'h-a' },
{ filePath: 'b.ts', contentHash: 'h-b' },
{ filePath: 'c.ts', contentHash: 'h-c' },
];
const h1 = computeChunkHash(entries);
const h2 = computeChunkHash(entries);
expect(h1).toBe(h2);
expect(h1).toMatch(/^[a-f0-9]{64}$/);
});
it('is order-independent (same files in different order → same hash)', () => {
const order1 = [
{ filePath: 'a.ts', contentHash: 'h-a' },
{ filePath: 'b.ts', contentHash: 'h-b' },
];
const order2 = [
{ filePath: 'b.ts', contentHash: 'h-b' },
{ filePath: 'a.ts', contentHash: 'h-a' },
];
expect(computeChunkHash(order1)).toBe(computeChunkHash(order2));
});
it('changes when any file content changes', () => {
const before = [
{ filePath: 'a.ts', contentHash: 'h-a' },
{ filePath: 'b.ts', contentHash: 'h-b' },
];
const after = [
{ filePath: 'a.ts', contentHash: 'h-a' },
{ filePath: 'b.ts', contentHash: 'h-b-NEW' }, // b.ts content changed
];
expect(computeChunkHash(before)).not.toBe(computeChunkHash(after));
});
it('changes when chunk membership changes (file added or removed)', () => {
const small = [
{ filePath: 'a.ts', contentHash: 'h-a' },
{ filePath: 'b.ts', contentHash: 'h-b' },
];
const bigger = [...small, { filePath: 'c.ts', contentHash: 'h-c' }];
expect(computeChunkHash(small)).not.toBe(computeChunkHash(bigger));
});
});
describe('fileContentHash', () => {
it('hashes a string deterministically', () => {
expect(fileContentHash('hello')).toBe(fileContentHash('hello'));
expect(fileContentHash('hello')).not.toBe(fileContentHash('hello!'));
expect(fileContentHash('hello')).toMatch(/^[a-f0-9]{64}$/);
});
it('handles Buffer input identical to its string form', () => {
const s = 'sentinel';
expect(fileContentHash(Buffer.from(s))).toBe(fileContentHash(s));
});
});
describe('PARSE_CACHE_VERSION', () => {
// 35 -> 36 for the bound-callable start-line join (#2735), 36 -> 37 for
// Java/Kotlin Spring AOP capture side-channels (#2416), 37 -> 38 for the Swift
// conditional-directive parse-semantics change (#2771), 38 -> 39 for
// receiver-chain wire format v2: every persisted chain string changed prefix
// and a v2 decoder refuses v1 by design, so a stale cache replays chains this
// build silently discards. 39 -> 40 for inference-typed field captures in six
// languages (#2807) — all parse-time emission, so a warm cache replays the
// pre-fix capture set for byte-unchanged files and the new receiver edges
// never appear.
//
// This pin has now earned its keep EIGHT times, and twice it caught an EXACT
// clash rather than a near-miss: main took 37 for #2416 while this branch
// already used 37, and then took 38 for #2771 after this branch had moved to
// 38. Both times two incompatible schemas claimed one number. Note when the
// second clash was caught — after review, while the branch sat waiting to
// merge — which is precisely the window in which `main` allocates. Re-check
// against origin/main immediately before merge, not at review time.
// Moved 42 -> 43 for #2813's `@reference.embedded-pointer` capture, which is
// parse-time emission and so cannot be served from a v42 warm cache.
// Moved 43 -> 44 for #2842's TypeScript heritage capture (interface and
// abstract-class `@reference.inherits`), which is parse-time emission and so
// cannot be served from a v43 warm cache.
// Moved 44 -> 45 for #2837 (Go struct/interface captures re-anchored from
// `type_declaration` to `type_spec`). This branch first took 44 and COLLIDED
// with #2842 above, which merged first — the ninth entry in the ledger and the
// third EXACT clash. Note what this pin could and could not do: it cannot
// detect the tie (both branches asserted `toBe(44)`, which passes when main is
// already 44); only the merge-time diff against origin/main surfaced it. What
// the pin DOES do is fail loudly the moment the constant and this expectation
// drift apart, which is what forces the re-check to happen at all.
// Moved 45 -> 46 for the JavaScript bare-identifier read captures, the
// object-literal `@definition.property` rule and the TypeScript shape-member
// captures (A1/A2/A4/A5) — all parse-time, so a v45 warm cache serves entries
// carrying neither the new reference sites nor the new Property nodes.
//
// This branch first took 45 and COLLIDED with #2837 above, which merged
// first: the TENTH ledger entry and the FOURTH exact clash, and the second in
// a row. Same lesson as the note above — the pin cannot detect the tie, since
// both sides asserted `toBe(45)` and that passes while main is already 45.
// Only the merge-time diff against origin/main surfaces it.
//
// Moved 46 -> 47 for method-level Spring `@RequestMapping` routes (#2857):
// cached ParseWorkerResults otherwise replay the pre-fix empty route set.
// That PR read this branch's claim on 46 and took 47 rather than colliding —
// the FIFTH clash, and the first the ledger's convention actually prevented.
// It only moved the collision up one step, though: this branch's own 47 and
// everything above it had to be renumbered +1 at merge time. Capture sets
// unchanged; only the numbers moved.
//
// Moved 51 -> 52 for dispatch-guard routes (R3-7): the JS/TS providers now
// implement `extractDecoratorRoutes`, and decorator routes are worker output
// carried in the cache. A v50 warm cache replays a worker result whose
// `decoratorRoutes` predates the extractor, so `route_map` keeps answering
// empty — the exact symptom the change fixes, disguised as "it does not work".
// Moved 52 -> 53 for the same-file constant folding that followed, because a
// build stamped 50 (now 52) had already been used to analyze without it.
//
//
// Moved 47 -> 48 for #2833's three parse-time changes: C++
// `field_declaration` captures for `template_type` and qualified generic
// member types (those members had NO type binding before), a Python interpret
// change that reduces `Repo[User]` to `Repo` in `TypeRef.rawName`, and the new
// `SymbolDefinition.typeParameters` field read from a
// `@declaration.type-parameters` capture in six languages. All three are
// serialized into the cached ParsedFile, so an older warm cache replays
// pre-fix bindings and the fix is a silent no-op on incremental analyze while
// every cold-run test still passes.
//
// 48, not 46, because this branch collided TWICE: it staged 46 and then 47,
// both free when written, and by merge time #2856 claimed 46 and #2857 took 47
// and merged first. This assertion is exactly what CANNOT detect that — the
// branch asserted `toBe(47)` and so did #2857, and both passed. What this pin
// does do is fail loudly the moment the constant and this expectation drift
// apart, which is what forces the merge-time diff against origin/main to
// happen at all.
// Moved 53 -> 54 for W2-8: type parameters are captured on generic functions
// and aliases, not just class-likes, so the shadowing guard has data to read.
// Moved 54 -> 55 for W2-9: the dispatch-guard verb walk tracks boolean polarity,
// so a ternary can no longer report the verb it excludes. Routes are emitted at
// parse time, so a warm cache would replay the inverted verb indefinitely.
// Moved 55 -> 56 for R3-8 part 1: the verb walk returns every method a guard
// serves, so a multi-method guard emits several routes where it emitted one.
// Moved 56 -> 57 for R3-8 part 2: `.match()` dispatch, bound-match test sites,
// named regex consts, and capturing segment wildcards in `regexToRoutePath`.
// Moved 57 -> 58 for #2897: fetch sites are captured without a literal URL.
// Moved 58 -> 59 for the #2899 review follow-up: the dispatch-guard walk keys
// match bindings on (enclosing function, name) instead of the bare identifier,
// and a ternary conjunction INTERSECTS its operands instead of taking the first
// non-empty set. Both strictly remove routes, so a warm cache would keep
// serving a fabricated verbed route that evicts the true one.
// Moved 59 -> 60 for #2864's `ParsedImport.reexportsName` and the
// `@import.publishes` capture gating it — a serialized ParsedFile field AND a
// capture change, the first being the easy-to-miss half. 60 was staged while
// main was 53, chosen above every in-flight MAXIMUM rather than at main + 1;
// #2899 then cascaded main to 59, and 60 survived only because of that choice.
// Moved 60 -> 62 for the cycle-checker fix's two optional `ParsedImport`
// fields, `typeOnly` and `runsOnlyWhenCalled`. Neither is a capture, but
// `parsedfile-store.ts` serializes the whole ParsedFile generically, so both
// are part of the cached shape — the same half of #2864 that was easy to miss.
// A warm cache would replay untagged imports, the strict `=== true` reads
// would take the untagged path, and `check --cycles` would keep reporting the
// erased and deferred imports the branch exists to stop reporting: a silent
// no-op on incremental analyze while every cold-run test passes.
// Main subsequently advanced through 63. Values above it must remain distinct
// from both published branch heads and every active in-flight claim.
// Moved 63 -> 64 for Java enum and annotated heritage captures (#2918),
// then 64 -> 66 for the synthetic-declaration sidecar, both now on main.
// Moved 66 -> 67 for #2917's implicit Java record-component accessor
// definitions and scope declarations. This branch staged 65 before #2918's 66
// landed; 67 is the next free value above every in-flight claim (main 66,
// #2939's 64), re-checked against the claims rather than against main alone.
// Moved 67 -> 68 for #2912's `ReferenceSite.typeArguments` — heritage generic
// arguments derived at extraction time, so a warm cache replays `inherits`
// sites without them and instantiation-aware dispatch degrades silently to
// the pre-fix fan-out. This branch staged 64 above the claims live at the
// time (61, 62, 63); all three landed and cascaded main to 67, so 68 is the
// next free value above every claim at merge — the rule, re-applied.
// Version 69 added #2969's JS/TS data-route-table decoratorRoutes. Version 70
// adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so it is
// the next free value after both cache payload changes.
// Moved 70 -> 71 for #2980's Java constant-route capture set (moduleConstants
// + routePathOperands). 72 -> 74 added import-proven Convex endpoint metadata,
// skipping 73 because open PR #3046 claims it.
// Version 75 adds #3009's NestJS decorator routes to the same JS/TS
// decoratorRoutes channel, so a warm pre-feature cache cannot replay the empty
// route set that change fixes. This branch originally claimed 71; origin/main
// cascaded past it (71 to #2980, 74 to Convex) while the PR was open, so 71
// would now be BELOW main and the reuse gate would never fire. 75 is the next
// free value above origin/main and above every in-flight claim (#3046 at 73,
// #1616 at a stale 2) — the rule, re-applied at merge, not at authoring time.
// Moved 75 -> 76 within this same branch for the NestJS array form, then
// 76 -> 77 because 76 turned out not to be free: origin/main reached 76 via
// #3046 while this branch was in review, and package.json is 1.6.9 on both
// sides, so the cache key was the byte-identical `76+1.6.9` on two branches
// with incompatible worker output. #3046 had skipped 75 precisely because
// this branch held it. Two PRs each doing the bookkeeping correctly still
// collided, because each re-checked once and neither re-checked after the
// other moved — which is why the rule is re-applied AT MERGE, not when the
// number is picked.
// Moved 89 -> 90 for #2865's decorator-route `handlerName` after #3128
// merged and took 89. origin/main is 89; 90 is the next free value and
// still unused by other open PRs' parse-cache.ts heads — the same
// collision the paragraph above describes, caught this time by re-checking
// at merge.
// Moved 90 -> 91 for #3130's Kotlin Spring decoratorRoutes and Kotlin
// ModuleConstants shadow metadata, both persisted worker output.
// Moved 91 -> 92 for #1432 (Zig): the shared callable-flow reader's member-call
// capture facts change for Kotlin / C++ / C# / TypeScript, and Zig is captured
// for the first time with rules that moved within the PR — a warm cache from
// an earlier head of that branch replayed the old facts across `--force`.
// Moved 92 -> 93 for #3161 (Zig static gating): call captures inside a
// comptime-false branch gain the `@reference.static-gated` marker, a
// parse-time fact a warm cache from an earlier head would replay without.
// Moved 94 -> 95 for #3179: Objective-C framework-import-only header
// classification changed parse-worker output for the same file content.
// Moved 95 -> 96 for #3179: Objective-C macro-marker preprocessing now
// recognizes form feed and vertical tab as C preprocessing whitespace.
// Moved 96 -> 97 for #3179: comment-prefixed directives and invalid numeric
// marker prefixes change the parse-time normalization result.
// Moved 97 -> 98 for #3219 (Zig callable-value references): `ZIG_SCOPE_QUERY`
// gained three `@reference.value-ref` rules, so a `.zig` file now yields
// `value-ref` entries in `ParsedFile.referenceSites` where it yielded none.
// A warm pre-v98 cache replays the old, empty site list for every unchanged
// 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"`: the #3399 defect,
// silently un-fixed on exactly the incremental path most users are on.
// 94-97 belong to #3179, which merged first; 98 is the next value above it.
// Re-checked against origin/main and every open PR touching parse-cache.ts at
// merge time — the rule the paragraphs above were written by PRs that each
// checked only once — and the remaining open claims (#3190 at 94, #2840 at
// 71, #1616 at 2) all sit below main and must re-bump themselves.
it('pins SCHEMA_BUMP to 98 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(98);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
for (const taken of [
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97,
]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}
});
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {
// Looks like "1+1.6.4" — schema bump prefix + actual gitnexus version
expect(PARSE_CACHE_VERSION).toMatch(/^\d+\+\d+\.\d+\.\d+/);
});
});
describe('packParseCacheChunks (#3088)', () => {
const files = [
{ path: 'src/a.ts', size: 100, language: 'typescript' },
{ path: 'src/b.ts', size: 100, language: 'typescript' },
{ path: 'pkg/c.py', size: 100, language: 'python' },
];
const budget = 2 * 1024 * 1024;
const packKey = (chunk: string[]): string =>
`${files.find((f) => f.path === chunk[0])?.language ?? 'typescript'}\0${parseCacheBucketId(chunk[0])}`;
it('is independent of scan order', () => {
expect(packParseCacheChunks(files, budget)).toEqual(
packParseCacheChunks([...files].reverse(), budget),
);
});
it('add/delete only rewrites packs in the affected (language, bucket)', () => {
const a = packParseCacheChunks(files, budget);
const added = { path: 'AAA.ts', size: 150_000, language: 'typescript' };
const withNew = packParseCacheChunks([...files, added], budget);
const addedKey = packKey([added.path]);
const untouched = (packs: string[][]) =>
packs.filter((c) => packKey(c) !== addedKey).map((c) => c.join('|'));
expect(untouched(withNew).sort()).toEqual(untouched(a).sort());
expect(withNew.some((c) => c.includes(added.path))).toBe(true);
const withoutB = packParseCacheChunks(
files.filter((f) => f.path !== 'src/b.ts'),
budget,
);
const removedKey = packKey(['src/b.ts']);
const leftover = (packs: string[][]) =>
packs.filter((c) => packKey(c) !== removedKey).map((c) => c.join('|'));
expect(leftover(withoutB).sort()).toEqual(leftover(a).sort());
expect(withoutB.every((c) => !c.includes('src/b.ts'))).toBe(true);
});
it('parseCacheBucketId uses the full sha256 digest, not an IEEE-754 prefix', () => {
const path = 'src/foo.ts';
const hex = fileContentHash(path);
const full = Number(BigInt(`0x${hex}`) % BigInt(PARSE_CACHE_BUCKET_COUNT));
const truncated = Number.parseInt(hex.slice(0, 8), 16) % PARSE_CACHE_BUCKET_COUNT;
expect(parseCacheBucketId(path)).toBe(full);
expect(parseCacheBucketId(path)).toBeGreaterThanOrEqual(0);
expect(parseCacheBucketId(path)).toBeLessThan(PARSE_CACHE_BUCKET_COUNT);
expect(full).not.toBe(truncated);
});
});
describe('pruneCache', () => {
it('drops entries whose hashes are not in the used-set', () => {
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map<string, ParseWorkerResult[]>([
['hash-A', [minimalResult()]],
['hash-B', [minimalResult()]],
['hash-C', [minimalResult()]],
]),
usedKeys: new Set<string>(['hash-A']),
};
const removed = pruneCache(cache, cache.usedKeys);
expect(removed).toBe(2);
expect([...cache.entries.keys()].sort()).toEqual(['hash-A']);
});
it('returns 0 when every entry is in use', () => {
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map<string, ParseWorkerResult[]>([
['hash-A', [minimalResult()]],
['hash-B', [minimalResult()]],
]),
usedKeys: new Set<string>(['hash-A', 'hash-B']),
};
expect(pruneCache(cache, cache.usedKeys)).toBe(0);
expect(cache.entries.size).toBe(2);
});
it('drops onDiskKeys entries not in the used-set and counts them', () => {
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map<string, ParseWorkerResult[]>(),
usedKeys: new Set<string>(['disk-A']),
onDiskKeys: new Set<string>(['disk-A', 'disk-B', 'disk-C']),
};
const removed = pruneCache(cache, new Set(['disk-A']));
expect(removed).toBe(2);
expect([...(cache.onDiskKeys ?? [])].sort()).toEqual(['disk-A']);
});
});
describe('loadParseCache / saveParseCache (round-trip)', () => {
it('round-trips an empty cache', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const fs = await import('fs/promises');
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
};
await saveParseCache(dir, cache);
await expect(fs.access(path.join(dir, 'parse-cache', 'index.json'))).resolves.toBeUndefined();
await expect(fs.access(path.join(dir, 'parse-cache.json'))).rejects.toThrow();
const loaded = await loadParseCache(dir);
expect(loaded.version).toBe(PARSE_CACHE_VERSION);
expect(loaded.entries.size).toBe(0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('returns an empty cache when the file is missing', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const loaded = await loadParseCache(dir);
expect(loaded.entries.size).toBe(0);
expect(loaded.usedKeys.size).toBe(0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('returns an empty cache on version mismatch (next-run regen)', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
// Write a cache file with a different version directly
const fs = await import('fs/promises');
await fs.writeFile(
path.join(dir, 'parse-cache.json'),
JSON.stringify({ version: 'foreign-99', entries: { h: [] } }),
'utf-8',
);
const loaded = await loadParseCache(dir);
expect(loaded.entries.size).toBe(0); // mismatch → empty
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('returns an empty cache on corrupt JSON', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const fs = await import('fs/promises');
await fs.writeFile(path.join(dir, 'parse-cache.json'), '{not-json', 'utf-8');
const loaded = await loadParseCache(dir);
expect(loaded.entries.size).toBe(0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('loads a legacy single-file cache for backwards compatibility', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const fs = await import('fs/promises');
await fs.writeFile(
path.join(dir, 'parse-cache.json'),
JSON.stringify({
version: PARSE_CACHE_VERSION,
entries: {
legacyChunk: [minimalResult({ fileCount: 7 })],
},
}),
'utf-8',
);
const loaded = await loadParseCache(dir);
expect(loaded.entries.size).toBe(1);
expect(loaded.entries.get('legacyChunk')?.[0]?.fileCount).toBe(7);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('skips corrupt or missing shards while loading the sharded cache index', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const fs = await import('fs/promises');
const cacheDir = path.join(dir, 'parse-cache');
const goodKey = 'a'.repeat(64);
const missingKey = 'b'.repeat(64);
const badKey = 'c'.repeat(64);
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'index.json'),
JSON.stringify({
version: PARSE_CACHE_VERSION,
keys: [goodKey, missingKey, badKey],
}),
'utf-8',
);
await writeV8CacheFile(path.join(cacheDir, `${goodKey}.v8`), [
minimalResult({ fileCount: 3 }),
]);
await fs.writeFile(path.join(cacheDir, `${badKey}.v8`), '{not-json', 'utf-8');
const loaded = await loadParseCache(dir);
expect(loaded.entries.size).toBe(0);
expect(loaded.onDiskKeys?.size).toBe(3);
const chunk = await loadParseCacheChunk(loaded, goodKey);
expect(chunk?.[0]?.fileCount).toBe(3);
// A shard listed in the index but absent on disk, and a corrupt-JSON
// shard, both resolve to undefined (graceful cache miss) — not a throw.
expect(await loadParseCacheChunk(loaded, missingKey)).toBeUndefined();
expect(await loadParseCacheChunk(loaded, badKey)).toBeUndefined();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('round-trips Map and Set values through the JSON replacer/reviver', async () => {
// ParsedFile.scopes[*].typeBindings is a ReadonlyMap<string, TypeRef>.
// Without the replacer/reviver pair, JSON.stringify collapses Maps to
// {} and downstream code that does .get() / iterates entries crashes
// with "is not iterable". This test pins the round-trip behaviour.
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const fs = await import('fs/promises');
const innerMap = new Map<string, string>([
['k1', 'v1'],
['k2', 'v2'],
]);
const innerSet = new Set<string>(['s1', 's2']);
// Stash the live Map/Set inside a synthetic ParseWorkerResult — we
// only need the serializer to traverse them. Casting to bypass the
// strict shape isn't a problem here: this test is about JSON
// round-tripping of arbitrary nested Map/Set values, not full
// ParseWorkerResult contents.
const fake = minimalResult({
parsedFiles: [
{
filePath: 't.ts',
// Cast through unknown to satisfy the readonly Scope shape
// while still smuggling a live Map into the serializer's
// traversal path — see comment block above.
scopes: [{ id: 's1', typeBindings: innerMap, extras: innerSet }],
} as unknown as ParseWorkerResult['parsedFiles'][number],
],
});
const chunkKey = 'd'.repeat(64);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map<string, ParseWorkerResult[]>([[chunkKey, [fake]]]),
usedKeys: new Set([chunkKey]),
};
await saveParseCache(dir, cache);
const persisted = await fs.readdir(path.join(dir, 'parse-cache'));
expect(persisted).toContain('index.json');
expect(persisted).toContain(`${chunkKey}.v8`);
const loaded = await loadParseCache(dir);
const reloaded = (await loadParseCacheChunk(loaded, chunkKey))?.[0];
expect(reloaded).toBeDefined();
const scope = (reloaded as ParseWorkerResult).parsedFiles[0]?.scopes[0] as unknown as {
typeBindings?: unknown;
extras?: unknown;
};
expect(scope.typeBindings).toBeInstanceOf(Map);
expect((scope.typeBindings as Map<string, string>).get('k1')).toBe('v1');
expect((scope.typeBindings as Map<string, string>).size).toBe(2);
expect(scope.extras).toBeInstanceOf(Set);
expect((scope.extras as Set<string>).has('s2')).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('ignores traversal-like and non-hex keys in sharded index.json', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const fs = await import('fs/promises');
const cacheDir = path.join(dir, 'parse-cache');
await fs.mkdir(cacheDir, { recursive: true });
const safeKey = 'e'.repeat(64);
await fs.writeFile(
path.join(cacheDir, 'index.json'),
JSON.stringify({
version: PARSE_CACHE_VERSION,
keys: ['../evil', '/absolute', 'G'.repeat(64), safeKey],
}),
'utf-8',
);
await writeV8CacheFile(path.join(cacheDir, `${safeKey}.v8`), [
minimalResult({ fileCount: 9 }),
]);
const loaded = await loadParseCache(dir);
expect(loaded.onDiskKeys?.size).toBe(1);
const chunk = await loadParseCacheChunk(loaded, safeKey);
expect(chunk?.[0]?.fileCount).toBe(9);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('writes one shard file per cache entry (three distinct keys)', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const fs = await import('fs/promises');
const k1 = '1'.repeat(64);
const k2 = '2'.repeat(64);
const k3 = '3'.repeat(64);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map<string, ParseWorkerResult[]>([
[k1, [minimalResult({ fileCount: 1 })]],
[k2, [minimalResult({ fileCount: 2 })]],
[k3, [minimalResult({ fileCount: 3 })]],
]),
usedKeys: new Set([k1, k2, k3]),
};
await saveParseCache(dir, cache);
const cacheDir = path.join(dir, 'parse-cache');
const names = await fs.readdir(cacheDir);
expect(names).toContain('index.json');
expect(names.filter((n) => n.endsWith('.v8')).length).toBe(3);
const loaded = await loadParseCache(dir);
expect(loaded.onDiskKeys?.size).toBe(3);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('returns empty when sharded index version mismatches even if legacy parse-cache.json is valid', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const fs = await import('fs/promises');
const cacheDir = path.join(dir, 'parse-cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'index.json'),
JSON.stringify({ version: 'foreign-sharded-1', keys: [] }),
'utf-8',
);
await fs.writeFile(
path.join(dir, 'parse-cache.json'),
JSON.stringify({
version: PARSE_CACHE_VERSION,
entries: { legacyChunk: [minimalResult({ fileCount: 42 })] },
}),
'utf-8',
);
const loaded = await loadParseCache(dir);
expect(loaded.entries.size).toBe(0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('second saveParseCache replaces the first sharded cache', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const fs = await import('fs/promises');
const k1 = '4'.repeat(64);
const k2 = '5'.repeat(64);
await saveParseCache(dir, {
version: PARSE_CACHE_VERSION,
entries: new Map([[k1, [minimalResult()]]]),
usedKeys: new Set([k1]),
});
await saveParseCache(dir, {
version: PARSE_CACHE_VERSION,
entries: new Map([[k2, [minimalResult({ fileCount: 99 })]]]),
usedKeys: new Set([k2]),
});
const names = await fs.readdir(path.join(dir, 'parse-cache'));
expect(names).not.toContain(`${k1}.v8`);
expect(names).toContain(`${k2}.v8`);
const loaded = await loadParseCache(dir);
expect(loaded.onDiskKeys?.size).toBe(1);
const chunk = await loadParseCacheChunk(loaded, k2);
expect(chunk?.[0]?.fileCount).toBe(99);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('removes legacy parse-cache.json after a successful sharded save', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const fs = await import('fs/promises');
await fs.writeFile(
path.join(dir, 'parse-cache.json'),
JSON.stringify({
version: PARSE_CACHE_VERSION,
entries: { oldLegacy: [minimalResult({ fileCount: 5 })] },
}),
'utf-8',
);
const k = '6'.repeat(64);
await saveParseCache(dir, {
version: PARSE_CACHE_VERSION,
entries: new Map([[k, [minimalResult({ fileCount: 6 })]]]),
usedKeys: new Set([k]),
});
await expect(fs.access(path.join(dir, 'parse-cache.json'))).rejects.toThrow();
const loaded = await loadParseCache(dir);
const chunk = await loadParseCacheChunk(loaded, k);
expect(chunk?.[0]?.fileCount).toBe(6);
expect(loaded.onDiskKeys?.has(k)).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('slimParseWorkerResultsForCache drops legacy DAG fields', () => {
const raw = minimalResult({
calls: [{ filePath: 'a.c', calleeName: 'f', line: 1 } as never],
assignments: [
{ filePath: 'a.c', sourceId: 's', receiverText: 'x', propertyName: 'y', line: 1 },
],
constructorBindings: [{ filePath: 'a.c', bindings: [] }],
parsedFiles: [
{
filePath: 'a.c',
moduleScope: 'm',
scopes: [],
parsedImports: [],
localDefs: [],
referenceSites: [],
},
],
scopeExtractionFailures: ['a.c'],
});
const slim = slimParseWorkerResultsForCache([raw])[0];
expect(slim.calls).toEqual([]);
expect(slim.assignments).toEqual([]);
expect(slim.constructorBindings).toEqual([]);
expect(slim.parsedFiles).toEqual([]);
expect(slim.scopeExtractionFailures).toEqual(['a.c']);
expect(slim.fileCount).toBe(raw.fileCount);
});
it('slimParseWorkerResultsForCache preserves nodes (incremental exportedTypeMap depends on them)', () => {
const raw = minimalResult({
nodes: [
{
id: 'Function:a.ts:foo',
label: 'Function',
properties: { name: 'foo', filePath: 'a.ts', isExported: true },
},
] as ParseWorkerResult['nodes'],
});
const slim = slimParseWorkerResultsForCache([raw])[0];
// `nodes` (and `symbols`) must survive slimming — on a warm cache hit they
// are what mergeChunkResults replays to rebuild the ExportedTypeMap.
expect(slim.nodes).toEqual(raw.nodes);
expect(slim.nodes).toHaveLength(1);
});
it('round-trips scope extraction failures through a persisted cache shard', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const key = 'e'.repeat(64);
await saveParseCache(dir, {
version: PARSE_CACHE_VERSION,
entries: new Map([[key, [minimalResult({ scopeExtractionFailures: ['src/broken.ts'] })]]]),
usedKeys: new Set([key]),
});
const loaded = await loadParseCache(dir);
const replayed = await loadParseCacheChunk(loaded, key);
expect(replayed?.[0]?.scopeExtractionFailures).toEqual(['src/broken.ts']);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('persistParseCacheChunk writes to disk without retaining in-memory entries', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const key = '7'.repeat(64);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
storagePath: dir,
onDiskKeys: new Set(),
};
await persistParseCacheChunk(cache, key, [minimalResult({ fileCount: 11 })]);
expect(cache.entries.has(key)).toBe(false);
expect(cache.onDiskKeys?.has(key)).toBe(true);
const chunk = await loadParseCacheChunk(cache, key);
expect(chunk?.[0]?.fileCount).toBe(11);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('saveParseCache excludes a usedKeys hash whose shard was never persisted (no phantom index key)', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const realKey = 'a'.repeat(64);
const phantomKey = 'b'.repeat(64); // in usedKeys but has no entry and no on-disk shard
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map([[realKey, [minimalResult({ fileCount: 3 })]]]),
usedKeys: new Set([realKey, phantomKey]),
};
await saveParseCache(dir, cache);
const loaded = await loadParseCache(dir);
expect(loaded.onDiskKeys?.has(realKey)).toBe(true);
// The phantom key was never written, so it must not appear in the index.
expect(loaded.onDiskKeys?.has(phantomKey)).toBe(false);
expect((await loadParseCacheChunk(loaded, realKey))?.[0]?.fileCount).toBe(3);
expect(await loadParseCacheChunk(loaded, phantomKey)).toBeUndefined();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('saveParseCache copies a persisted-but-evicted shard (copyFile branch) and round-trips', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const key = 'c'.repeat(64);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set([key]),
storagePath: dir,
onDiskKeys: new Set(),
};
// persist writes the shard to the live dir and evicts it from `entries`,
// so saveParseCache must hit the copyFile branch to carry it forward.
await persistParseCacheChunk(cache, key, [minimalResult({ fileCount: 42 })]);
expect(cache.entries.has(key)).toBe(false);
await saveParseCache(dir, cache);
const loaded = await loadParseCache(dir);
expect(loaded.onDiskKeys?.has(key)).toBe(true);
expect((await loadParseCacheChunk(loaded, key))?.[0]?.fileCount).toBe(42);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('recreates a memoized shard directory after a long-lived process replaces it', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
try {
const firstKey = 'd'.repeat(64);
const secondKey = 'e'.repeat(64);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set([firstKey]),
storagePath: dir,
onDiskKeys: new Set(),
};
await persistParseCacheChunk(cache, firstKey, [minimalResult({ fileCount: 1 })]);
await rm(path.join(dir, 'parse-cache'), { recursive: true, force: true });
cache.usedKeys = new Set([secondKey]);
await persistParseCacheChunk(cache, secondKey, [minimalResult({ fileCount: 2 })]);
await saveParseCache(dir, cache);
const loaded = await loadParseCache(dir);
expect((await loadParseCacheChunk(loaded, secondKey))?.[0]?.fileCount).toBe(2);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('writes a V8 shard and loads it with Map-preserving semantics', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-v8-'));
try {
const innerMap = new Map<string, string>([
['k1', 'v1'],
['k2', 'v2'],
]);
const innerSet = new Set<string>(['s1', 's2']);
const fake = minimalResult({
fileCount: 9,
imports: [
{
typeBindings: innerMap,
extras: innerSet,
} as unknown as ParseWorkerResult['imports'][number],
],
});
const key = 'f'.repeat(64);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set([key]),
storagePath: dir,
onDiskKeys: new Set(),
};
await persistParseCacheChunk(cache, key, [fake]);
const names = await readdir(path.join(dir, 'parse-cache'));
expect(names).toEqual(expect.arrayContaining([`${key}.v8`]));
expect(names.some((n) => n.endsWith('.json') && n !== 'index.json')).toBe(false);
const loaded = await loadParseCacheChunk(cache, key);
expect(loaded?.[0]?.fileCount).toBe(9);
const smuggled = loaded?.[0]?.imports[0] as unknown as {
typeBindings?: unknown;
extras?: unknown;
};
expect(smuggled.typeBindings).toBeInstanceOf(Map);
expect([...(smuggled.typeBindings as Map<string, string>)]).toEqual([
['k1', 'v1'],
['k2', 'v2'],
]);
expect(smuggled.extras).toBeInstanceOf(Set);
expect([...(smuggled.extras as Set<string>)].sort()).toEqual(['s1', 's2']);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('treats a corrupt parse-cache V8 shard as a miss', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-v8-fb-'));
try {
const key = 'a'.repeat(64);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set([key]),
storagePath: dir,
onDiskKeys: new Set(),
};
await persistParseCacheChunk(cache, key, [minimalResult({ fileCount: 4 })]);
await writeFile(path.join(dir, 'parse-cache', `${key}.v8`), Buffer.from([1, 2, 3]));
const loaded = await loadParseCacheChunk(cache, key);
expect(loaded).toBeUndefined();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('saveParseCache copies an existing V8 shard', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-v8-copy-'));
try {
const key = 'c'.repeat(64);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set([key]),
storagePath: dir,
onDiskKeys: new Set(),
};
await persistParseCacheChunk(cache, key, [minimalResult({ fileCount: 42 })]);
const liveV8 = await readFile(path.join(dir, 'parse-cache', `${key}.v8`));
await saveParseCache(dir, cache);
expect(await readdir(path.join(dir, 'parse-cache'))).toEqual(
expect.arrayContaining([`${key}.v8`, 'index.json']),
);
expect(await readFile(path.join(dir, 'parse-cache', `${key}.v8`))).toEqual(liveV8);
const loaded = await loadParseCache(dir);
expect((await loadParseCacheChunk(loaded, key))?.[0]?.fileCount).toBe(42);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('misses when the V8 shard is absent', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-v8-legacy-'));
try {
const key = 'b'.repeat(64);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set([key]),
storagePath: dir,
onDiskKeys: new Set(),
};
await persistParseCacheChunk(cache, key, [minimalResult({ fileCount: 7 })]);
await rm(path.join(dir, 'parse-cache', `${key}.v8`), { force: true });
const loaded = await loadParseCacheChunk(cache, key);
expect(loaded).toBeUndefined();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('persists cold-rebuild shards under staging without touching the live parse-cache dir', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-stage-'));
try {
const liveKey = 'a'.repeat(64);
const stagedKey = 'b'.repeat(64);
await saveParseCache(dir, {
version: PARSE_CACHE_VERSION,
entries: new Map([[liveKey, [minimalResult({ fileCount: 1 })]]]),
usedKeys: new Set([liveKey]),
});
const staging = getColdParseRebuildDir(dir);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set([liveKey, stagedKey]),
storagePath: staging,
onDiskKeys: new Set(),
};
await persistParseCacheChunk(cache, stagedKey, [minimalResult({ fileCount: 99 })]);
const liveNames = await readdir(path.join(dir, 'parse-cache'));
expect(liveNames).toContain(`${liveKey}.v8`);
expect(liveNames).not.toContain(`${stagedKey}.v8`);
const stagedNames = await readdir(path.join(staging, 'parse-cache'));
expect(stagedNames).toContain(`${stagedKey}.v8`);
const saved = await saveParseCache(dir, cache);
expect(saved.sort()).toEqual([liveKey, stagedKey].sort());
const loaded = await loadParseCache(dir);
expect((await loadParseCacheChunk(loaded, liveKey))?.[0]?.fileCount).toBe(1);
expect((await loadParseCacheChunk(loaded, stagedKey))?.[0]?.fileCount).toBe(99);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('prefers a staged shard over a same-hash live shard when publishing', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-pref-'));
try {
const key = 'c'.repeat(64);
await saveParseCache(dir, {
version: PARSE_CACHE_VERSION,
entries: new Map([[key, [minimalResult({ fileCount: 1 })]]]),
usedKeys: new Set([key]),
});
const staging = getColdParseRebuildDir(dir);
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set([key]),
storagePath: staging,
onDiskKeys: new Set(),
};
await persistParseCacheChunk(cache, key, [minimalResult({ fileCount: 7 })]);
await saveParseCache(dir, cache);
const loaded = await loadParseCache(dir);
expect((await loadParseCacheChunk(loaded, key))?.[0]?.fileCount).toBe(7);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('createColdParseRebuildDir returns distinct directories under the same storage root', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-uniq-'));
try {
const a = await createColdParseRebuildDir(dir);
const b = await createColdParseRebuildDir(dir);
expect(a).not.toBe(b);
expect(a.startsWith(path.join(dir, 'parse-rebuild.'))).toBe(true);
expect(b.startsWith(path.join(dir, 'parse-rebuild.'))).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});