GitNexus/gitnexus/test/unit/incremental-parse-cache.test.ts
Gergő Magyar e06c2dd13e
fix(impact): fail closed on id-less targets and follow ??/||/?: callable values (#3354) (#3373)
* fix(impact): fail closed on id-less targets and follow ??/||/?: callable values (#3354)

#3354 reports `impact` returning a byte-identical 1037/CRITICAL/`exact`
result for three unrelated targets, with only `target.name` differing.
The reporter's `target` had no `id` and no `filePath`, which the traversal
path always emits, and a synthetic reproduction of their monorepo (pnpm,
Cloudflare worker-configuration.d.ts in five packages, Hono, a Durable
Object) resolves every target correctly on main. So the identical result
was not reproduced. The repro did surface two real gaps and one hardening
point:

- `_runImpactBFS` now throws when the target has no node id. Every
  caller already catches, so impact reports `impactedCount: null,
  risk: UNKNOWN` instead of a normal-looking blast radius that cannot be
  about this symbol.
- Callable-value flow followed only a single designator on the RHS, so
  `const sweep = env.__sweep ?? runSweep; await sweep(env)` produced no
  flow and `scheduled` was missing as a caller of `runSweep`, while the
  result still claimed `epistemic: exact`. Each branch of `??`, `||`,
  `or`, and `?:` now flows into the binding (language-neutral: operator
  and field names, no language checks). Parse cache bumped 104 -> 112
  (105-111 are claimed by open PR #3326).
- A whitespace-only `target_uid` (strict adapters materialize omitted
  optional strings) is treated as omitted and falls back to the name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(mcp): treat a non-string uid as omitted instead of throwing (#3354)

Review follow-up on #3373. `query.uid?.trim()` called `.trim()` on a
client-supplied value, and the MCP envelope is not type-validated, so
`context({uid: 42})` threw a TypeError that `context()` does not catch.
Before #3373 the same input ended as a structured not_found.

A non-string uid now counts as omitted, which matches how
normalizeToolParams already treats a non-string `target_uid`. The impact
and trace not-found messages print a trimmed uid only when it is a
non-blank string, so a strict adapter sending " " sees the name it
searched for instead of `' '`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ingestion): expand ??/?:/ternary branches through a provider hook (#3354)

Review follow-up on #3373. The shared value-alternatives rule keys on
tree-sitter field names, and several grammars spell the same construct
differently, so the expansion never fired for them:
- Kotlin `elvis_expression` has no fields.
- Swift uses `value`/`if_nil` and `if_true`/`if_false`.
- Dart uses `first`/`second`, and its conditional has no `condition` field.
- Python `a if c else b` has no fields.
The `'?:'` operator entry was dead, since no bundled grammar emits it.

Add an optional `valueAlternatives` hook to CallableFlowCaptureOptions,
consulted before the shared rule, and implement it in the Kotlin, Swift,
Dart, Python and Ruby providers. Shared code still names no language.

Ruby's statement-bodied `if`/`unless`/`elsif` also carries
`condition`/`consequence`/`alternative`, so the shared ternary rule dug
an identifier out of an arbitrary statement (`g = h; 0` flowed `h`) and
produced a wrong CALLS edge. The Ruby hook now keeps a multi-statement
branch as one opaque source, as before #3373.

Tests: new provider fixtures for Python, Kotlin, Swift, Dart and Ruby
(including a Ruby negative), and TS chain, parenthesized, callable-left
and `&&` negative cases. Captures goldens gain one entry each for the
new fixtures. SCHEMA_BUMP stays 112 (same unreleased PR).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ingestion): keep long ||/??/?: chains linear in callable-flow capture (#3354)

Expanding value-selecting sources into per-branch flows made long chains
super-linear and, past a few thousand operands, a stack overflow. A
generated `w === "k0" || w === "k1" || ...` keyword table took 6.7 s at
1000 operands and 48 s at 2000, and threw RangeError at 8000.

Two causes, both fixed without changing any emitted capture:

- valueAlternatives recursed once per operator level and spread the
  partial results at every level. It is now an explicit stack that
  writes to one output array. The left-to-right order, the paren
  unwrapping, and the provider-hook contract (`[node]` means opaque) are
  unchanged.
- Every alternative ran its visibility walks from its own leaf, which
  can be as deep as the chain is long, all the way to the root. Also,
  tree-sitter's `parent` re-descends from the root, so each step costs
  the node's depth. The walks now jump between "anchor" nodes, the only
  nodes any check can match: region ids and formal owners. The nearest
  anchor is memoized per node across the file, and parents come from a
  map recorded by the one DFS the synthesizer already does.

After the fix: 0.34 s / 0.23 s / 1.6 s at 1000 / 2000 / 8000 operands.
That is within about 1.2x of main without the expansion; what remains
is tree-sitter query time. Capture fingerprints are byte-identical
before and after the fix for all 16 scope-capture bench languages and
for the Python harness.

A `typescript-deep-chain` case added to the scope-capture bench guards
the scaling: 1.11-1.22 now, 7.5-8.0 before. A unit test pins a
10000-operand chain: it must still yield the seed for a callable
operand, the copy for a formal at the deepest leaf, and the invoke.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(kotlin): see through braced if-branches in callable alternatives (#3354)

tree-sitter-kotlin wraps a braced branch as
`control_structure_body > statements > <expr>`, so for any non-empty
block kotlinValueAlternatives saw exactly one named child (`statements`)
and pushed the wrapper itself as the branch value. operandSyntax emits
nothing for a `statements` node, so
`val run = if (c) { ::f } else { ::g }; run()` produced no flow edges,
and the "multi-statement block stays opaque" guard could never fire.

Descend one level through `statements` and require exactly one
non-comment expression there. Empty blocks (`{}` has no named children),
multi-statement blocks, and `if` without `else` still return the whole
`if` as one opaque source. The doc comment now describes that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ruby): skip only the multi-statement branch in callable alternatives (#3354)

rubyValueAlternatives returned `[node]` (opaque) as soon as one branch
held more than one statement. Two things went wrong because of that:

- At the top level, `run = if c then g = h; 0 else method(:f) end`
  dropped the single-statement `else`, so `f` got no flow edge.
- In an elsif chain, the outer `if` pushed the `elsif` node as a branch.
  The shared loop called the hook on it again, got `[elsif]` back, and
  used the whole elsif subtree as one source. So in
  `if a then method(:run_a) elsif b then g = h; 0 else method(:run_b) end`
  the `run_b` edge was lost.

The hook now walks the elsif chain itself and skips each multi-statement
branch, while every single-statement branch still becomes an
alternative. This cannot add a wrong edge: each emitted alternative is a
value the conditional really evaluates to, and nothing is taken from the
skipped branch. Before, that branch did not contribute a resolvable
value either. A whole conditional used as a source becomes a
qualified-name seed, which resolves to nothing when it has more than one
identifier leaf. With a single identifier leaf, it could even seed the
condition variable. When no branch is a single statement, the
conditional still stays one opaque source, as before.

Ruby captures golden: ruby-callable-alternatives/app.rb goes from 33 to
58 capture groups. 23 of them come from the new fixture functions
(checked against the old source). The other 2 are the new branch
alternatives, `statement_if -> run_sweep` and `elsif_chain -> run_b`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ingestion): flow the right operand of && / and into callable bindings (#3354)

`a && b` / `a and b` yields `a` when it is falsy and `b` otherwise. A
falsy value is never a callable, so the right operand is the only one
that can be invoked later. The capture left `&&` unexpanded, and the
compound source became a qualified seed that resolves to nothing:
`const run = x && f; run()` gained no edge to `f` while impact claimed
`exact`. Python `x and f or g` reached only `g`.

The shared expansion now maps `&&` / `and` to the right branch only.
It recurses, so `x and f or g` reaches both `f` and `g`. Where `&&`
yields a boolean (Java, C#, Go, Rust, C, C++, PHP, Zig), the
destination cannot be invoked, so the flow never meets a call. A
before/after CALLS diff over all 83 lang-resolution fixtures that
contain `&&` or `and` shows exactly one new edge,
logicalAnd -> runAndRight. PHP and Ruby bind low-precedence `and`
looser than `=`, so `$g = $x and $y` never reaches this rule.

The Python golden digest changes only for the extended
python-callable-alternatives fixture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ingestion): keep operator branches of a value-selecting source opaque (#3354)

The fan-out sent every branch of `??` / `||` / `or` / `?:` to
emitAssignmentFact on its own, including branches that compute a value.
`Handlers.fallback === run || fb` then emitted the comparison as a seed
whose qualified text sliced to receiver `Handlers`, member `run`, and
resolveBoundMemberCandidates minted a CALLS edge to `Handlers.run`. The
same happened for `this.state !== run ?? this.fallback`,
`this.state + run || this.fallback`, and Python
`self.state != run or self.fallback`. Before the fan-out, the whole
compound source was one opaque seed.

A branch that is a binary operator expression now contributes nothing.
The check uses the field vocabulary valueBranches already reads (a
`left`/`right` pair, or an `operator`/`operators`/`op` token after the
expression start), not grammar type names. Member accesses that field
their `.`/`->` as `operator` (Ruby `call`, C/C++ `field_expression`) also
field a member name through the list memberParts uses, now shared as
memberNameNode, so they stay designators. Unary `&f`/`*fp` lead with
their operator and stay designators. Call results were already dropped
by emitAssignmentFact, and lambdas and callable references are unchanged.

CALLS-edge diff over 87 lang-resolution fixtures (505 -> 501 edges):
only the four false edges above were removed, and none were added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(ingestion): pin the LEFT operand of ?? / or / elvis per provider (#3354)

The Python, Kotlin, Swift and Dart "override ?? fn" cases put an
unresolvable parameter on the left, so a fan-out that kept only the
last operand still passed them. Each fixture now adds a callableLeft
case with a real function on the left and a parameter on the right
(`run_left or fallback`, `::runLeft ?: fallback`, `runLeft ?? fallback`),
and each language asserts `callableLeft → runLeft`.

Mutation check: dropping the left branch from the shared `??`/`||`/`or`
rule and making the four provider hooks return only their last operand
fails all four new tests, while the existing right-operand tests still
pass.

Capture goldens were regenerated with UPDATE_GOLDEN=1:
- python app.py: 35 -> 85 groups. 35 -> 75 is stale drift from
  8ad0d8db7, which added the comparison cases without regenerating.
  75 -> 85 is the new case: 2 declarations, 2 scopes, the variable,
  the `run()` reference, and 4 callable-flow captures (seed -> run_left,
  copy <- fallback, formal, invoke).
- swift App.swift: 25 -> 36 groups for the same new case, plus
  Swift's type-binding for the fallback parameter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(bench): re-baseline scope-capture fingerprints for #3354 callable alternatives

This PR makes a callable chosen by a value-selecting source flow every
branch it can yield (??, ||, or, && right operand, ternary, statement
if, elvis). It also keeps an operator branch opaque. Both change
@callable-flow captures, and the PR adds *-callable-alternatives
regression fixtures that these benches glob.

To verify, the BASE (merge-base 233ca2849) and HEAD emitters were run
over the same HEAD fixture corpus plus the synthetic source. Every
added or removed match is an @callable-flow.* match on one of those
sources. The pre-existing corpus is byte-identical for all 16
scope-capture languages and for Python. Emitter delta, then corpus
growth:

- ruby:       +5/-0   app.rb (+1 file, +58 groups)
- swift:      +6/-3   App.swift (+1 file, +36 groups)
- dart:       +6/-3   app.dart (+1 file, +33 groups)
- kotlin:     +8/-2   App.kt (+1 file, +71 groups)
- typescript: +18/-14 4 files (+288 groups)
- python:     +11/-7  app.py (+1 file, +85 groups)

The removed matches are whole-expression seeds and copies that carried
a qualified name of the compound source. They are replaced by one
seed or copy per operand. Synthetic scaling counts are unchanged and
every scaling ratio stays under budget. Per-language notes are in
baselines.json under _rebaselined_3354_callable_alternatives.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(mcp): share one blank-uid check across impact, trace and symbol lookup (#3354)

The "trimmed uid, or omitted when blank/non-string" rule was inlined four
times, three of them trimming twice. nonBlankUid() owns it now; behaviour is
unchanged. The whitespace and empty target_uid tests collapse into one it.each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(cfg): correct the Kotlin and Python grammar-field notes (#3354)

The Kotlin CFG visitor claimed no control-flow node has fields; a parse of
the vendored grammar shows if_expression fields condition/consequence/
alternative (when/for/while/do/try/elvis are fieldless). The Python harvest
note listed conditional_expression's children like field names; the node is
fieldless and they are positional. Both notes were misleading reviewers of
the #3354 value-alternatives hooks. Comment-only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(ingestion): skip optional-grammar suites in callable-alternatives providers test (#3354)

Kotlin, Swift and Dart grammars are optional installs. Guard their describe
blocks with isLanguageAvailable, as swift.test.ts and dart.test.ts do, so an
install without one of them skips instead of failing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(ingestion): probe the Dart parser before enabling its providers suite (#3354)

isLanguageAvailable only proves the module loaded; tree-sitter-dart can still
fail on setLanguage. Probe loadParser/loadLanguage and skip on failure, the
same guard dart.test.ts uses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 21:43:24 +01:00

1061 lines
46 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.
// Moved 98 -> 99 for #3190: lexical import provenance and corrected export
// evidence. origin/main took 98 for #3219 and 99 for #3190.
// Moved 99 -> 100 for #3253: retain absolute Rust import qualifiers.
// Moved 100 -> 101 for #3294 review: retain keyword glob paths and distinguish
// restricted pub(...) imports from unrestricted reexports.
// Moved 101 -> 102 for #3273: preserve exact call-result assignment facts
// required by post-resolution Swift return-type replay.
// Moved 102 -> 103 for #3339 review: tRPC route extraction changed —
// nested-router paths, a tightened entry gate, and controller-less routes
// now bind handlers via a same-file CALLS edge. Warm caches replay the flat
// pre-fix capture set verbatim, so both stores re-extract.
// Moved 103 -> 104 for #3339 review: pair-HOC queries name
// `mutation(withAuth(arrow))` object-pair handlers. Warm caches replay
// anonymous arrows, so both stores re-extract.
// Moved 104 -> 112 for #3354: callable-value flow follows `??`/`||`/`?:`
// branches. 105-111 are claimed by open PR #3326.
it('pins SCHEMA_BUMP to 112 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(112);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly
// while being wrong, which is exactly how the 37/38 exact clashes landed.
// Every nearby historical or in-flight value is rejected.
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, 98, 99, 100, 101, 102, 103,
104, 105, 106, 107, 108, 109, 110, 111,
]) {
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 });
}
});
});