/** * Chunk-level content-addressed parse cache. * * The pipeline always parses every file (correctness invariant: cross-file * resolution and downstream phases need full graph data). What this cache * does is skip the tree-sitter worker dispatch when a chunk's contents * haven't changed since the last run. * * Granularity: chunk-level. Files are assigned to a stable * `(language, hash(path) mod 128)` bucket, then packed to a 2 MiB (or * operator) byte budget *inside* that bucket. Membership does not depend * on worker count. The cache key is `sha256(joined(filePath:contentHash * for each file in the chunk, sorted))`. A content edit invalidates only * that file's pack; add/delete/rename only the affected bucket. * * Why not per-file: * - Workers process sub-batches and emit aggregated `ParseWorkerResult`s. * Splitting back to per-file would require reworking the worker contract. * - Chunk-level invalidation gives a useful speedup floor (98% on a single * 1-of-50 invalidated chunk) without touching the worker. * * `--force` still reuses content-addressed shards (it only rebuilds graph/FTS). * `useParseCache: false` reparses every file, writes a staging generation, and * publishes onto this cache only after a successful analysis. */ import { createHash } from 'crypto'; import { createRequire } from 'module'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { compareCodeUnits } from '../lib/utils.js'; import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.js'; import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sidecar.js'; /** * Cache version composed of: * - A schema bump knob (`SCHEMA_BUMP`) for hand-controlled invalidation * when ParseWorkerResult shape or upstream parse semantics change. * - The current `gitnexus` npm package version, read at module load. * Any release that ships an updated tree-sitter grammar or revised * extractor logic implies a version bump in package.json, which * automatically invalidates the on-disk cache. Without this, a user * running `npm i -g gitnexus@latest` after a parser-affecting * release would silently replay pre-upgrade ParseWorkerResults * against the new graph schema (Bugbot/Claude review on #1479). * * On version mismatch, `loadParseCache` returns an empty cache and the * next save overwrites the on-disk file with the new version baked in. */ // Bumped to 4 in #1983: on-disk parse-cache shards omit legacy DAG fields // (`calls`, `assignments`, `constructorBindings`) unused after RING4-1 (#942) // and the worker `parsedFiles` (the worker writes those to the disk ParsedFile // store instead). #2038 added a DURABLE, content-addressed ParsedFile store // (`parsedfile-cache/`, see parsedfile-store.ts) keyed by chunk hash that // mirrors THIS cache's lifecycle — version-gated by PARSE_CACHE_VERSION, pruned // in lockstep to the surviving keys. On a warm parse-cache hit the chunk's // ParsedFiles are restored from it, so scope-resolution does NOT re-extract on // the main thread (the #1983 OOM). Because the two stores share this version, // any future change to the `ParsedFile` serialization shape MUST bump // SCHEMA_BUMP so both invalidate in lockstep. // v35: Java/Kotlin Spring @Bean factory and @Resource side-channel facts // (#2413). ParsedFile results are content-addressed and replayed verbatim, so // the feature/schema inventory alone cannot invalidate pre-#2413 captures. // (Cut as v32 on this branch; `main` took 32 for #2742 and 33/34 for #2747 // first, so this series is renumbered at merge time — see the v21 note.) // v34: the receiver-chain capture is emitted by ALL 14 language emitters, not // just TypeScript. v33 landed with the TypeScript-only emission; the rollout to // the other 13 languages changed the capture set AGAIN, so a cache stamped 33 by // an intermediate build of that series is not equivalent to one stamped at this // commit — it would be treated as current while every non-TypeScript file // replayed pre-rollout captures, leaving the feature silently inert for 13 of 14 // languages. Bumped there so the version tracks the FINAL capture set rather // than the first divergence. // v33: TypeScript call matches carry `@reference.receiver-chain`, a compact // encoding of a receiver that is itself an expression. // v32: Rust items are qualified by their enclosing `mod` chain (#2742). The // qualified name is computed in the parse worker, so a warm cache replays the // old unqualified ids verbatim and the collapse persists. // v31: Rust `mod_item` gained `@declaration.namespace` and scoped call sites // gained `@reference.qualified-name` (#2730). Both are PARSE-TIME captures, so a // warm cache replays the old capture set verbatim: `rawQualifiedName` comes back // undefined and no Namespace def exists to hang a module prefix on, which turns // the whole module-qualified resolution tier into a no-op on unchanged files. // Re-checked against origin/main at commit time per the v29 note below. // v29: closure-binding declaration rules for PHP/Rust/Kotlin/Ruby/Dart, a Rust // graph node for `let f = || …`, a Dart closure scope, and function-local VALUES // (Variable/Const/Property/Static) qualified by their enclosing callable plus // position (#2699 parts A1 + B). All parse-time, so a warm cache would replay // the old captures and the pre-qualification ids verbatim. // // This is 29 and not 28 because of the exact collision the v21 note below warns // about: this branch cut at 27 and bumped to 28, while #2415 bumped 27 -> 28 and // merged FIRST. Re-checking against origin/main at merge time — not at branch // time — is what caught it; leaving it at 28 would have shipped this change with // NO parse-cache invalidation, so every warm cache keeps serving the pre-fix // captures and ids. // v28: Java/Kotlin capture side-channels persist Spring condition facts and // annotation-source line numbers (#2415). // v26: the enclosing-callable walk stops at class bodies and anonymous-class // construction sites (#2699 follow-up); a v25 cache replays worker results carrying the // wrong Java anonymous-class ids. Cached results are replayed verbatim — including // across `--force` — so without this bump a warm cache keeps serving them. // v25: function-local callables are qualified by their enclosing-callable chain // plus their own position, and JS/TS gain block scopes (#2699). Both the node // ids AND the scope tree in a cached worker result are therefore stale. Cached // results are replayed verbatim — including across `--force` — so without this // bump a warm cache keeps serving the colliding ids and the block-less scopes. // v24: function scopes carry `Scope.ownsReceivers`, marking the JS/TS forms // that bind their own `this` (#2701). The flag lives on the cached `Scope`, so // without this bump a warm cache replays scopes that lack it and every `this` // inside an ordinary `function` keeps resolving to the enclosing class — // verified by probe: `--force` alone does NOT re-derive it. // v23: closure bindings emit callable nodes in Dart, Ruby, Java, C# and PHP // (plus JS/TS `var`), and Dart/PHP gain the scope declarations and flow // captures their forms were missing (#2693). Cached worker results are replayed // verbatim, so without this bump a warm cache keeps serving the old labels. // v22: `const X = ` emits one `Function` node // instead of a `Function` plus an edgeless `Const` twin (#2687). Cached worker // results are replayed verbatim — including across `--force` — so without this // bump a warm cache keeps serving the old two-node set. // v21: TWO changes share this number — a collision, not a typo. #2632 // (Java/Kotlin Spring DI facts: constructor, field/property and method // injection sites plus bean-name and @Primary provider metadata) bumped 20 -> 21 // and merged first; #2653 (Java local class/enum/record/interface captures using // javac-compatible, source-type-relative JLS 13.1 identities and // declaration-to-block scopes, #2562) had branched at 20, bumped to 21 as well, // and merged second — so it shipped with NO invalidation of its own. An index // already stamped 21 by the first change was treated as current by the second // and kept serving stale local-class identities from the warm cache. Harmless // now (anything below the current value is rejected), and left as-is because // both genuinely shipped as 21 — renumbering would misstate history. Read this // as the reason to re-check SCHEMA_BUMP against origin/main immediately before // merging, not just when the branch is cut; the same collision hit // the DB schema version in #2653/#2654 (that constant is gone — the DB side is // a derived fingerprint now, see SCHEMA_FINGERPRINT; SCHEMA_BUMP below is still // hand-maintained because no declarative artifact describes a capture set). // v27: generator EXPRESSIONS bound to a name emit a callable definition capture, // and nested-callable caller attribution appends the localIdentity suffix the // definition phase already used. Both are parse-time, so a warm cache would // otherwise replay the old captures and ids verbatim. // v30/schema v22: CommonJS export capture emission (#2723) — new @definition/@declaration // captures for exports.X, aliased receivers, module-level `this`, re-export // forwarding and `module.exports = fn`, plus prototype/`this` Methods. A warm // cache would otherwise replay the pre-fix captures verbatim. // v20: Java/Kotlin capture side-channels persist package and class-annotation // facts for shared Spring Bean resolution. // v19: Java enum constant bodies emit E$N Class nodes; anonymous naming uses // JLS 13.1 immediate-host chains (#2555). // v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity. // v16: direct callee identity. // v36: bound-callable graph `startLine` follows the initializer so multi-line // closure bindings join the scope channel (#2735). Warm cache would otherwise // keep serving wrapper-line startLines and drop the CALLS edge. // v37: Java/Kotlin capture side-channels include Spring AOP owner/advice facts // (#2416). Warm cache entries at v36 do not carry those facts and would silently // omit ADVISED_BY evidence. // v38: Swift nested conditional-compilation directives are blanked before the // parse (#2771), so a class body that previously error-recovered away now // survives. The chunk key hashes raw on-disk bytes and `preprocessSource` runs // after it is computed, so unchanged Swift files would otherwise replay their // pre-fix `ParseWorkerResult` verbatim — including across `--force`. Allocated // on `main`, NOT by this branch — kept so the number is not reused a third time. // v39: receiver-chain wire format v2 — the encoded chain gained name-free // `await` and `index` step kinds, so the VERSION prefix moved 1 -> 2 and every // persisted chain string changed. A v2 decoder REFUSES a v1 payload (that is // the point: a chain missing its await or index hop decodes cleanly as a // different, shorter chain and would type the receiver against the wrong // member), so a stale cache replays chains this build silently discards — // the feature degrades to the text cascade with no error anywhere. Bumped so // the stale cache is rejected rather than half-read. // // NUMBERED 39, AFTER TWO REALLOCATIONS. This branch first used 37; `main` took // 37 for Spring AOP (#2416) mid-flight, so it moved to 38; `main` then took 38 // for the Swift directive fix (#2771), landing on the branch's number AGAIN. // That is the EIGHTH collision in this series and the SECOND exact clash — two // incompatible schemas claiming one number, twice running. The lesson is not // "pick a bigger number": it is that the check must happen immediately before // merge, because the window between review and merge is exactly when `main` // allocates. Re-check against origin/main before merging this. // v40: inference-typed class fields emit type-binding captures in SIX languages // (#2807) — TypeScript/JavaScript `public_field_definition|field_definition` with a // `new_expression` value and `this. = new X()`; Python `self.x = Outer()`; // Ruby `@ivar = Foo.new`; Swift optional property annotations; Dart inferred-type // and final field declarations plus constructor-body field writes. Every one of // these is PARSE-TIME capture emission, so a warm cache replays the pre-fix // capture set verbatim for byte-unchanged files and the new receiver edges never // appear — silently, with no error, exactly the v27/v30 failure mode. `analyze` // skips tree-sitter dispatch for unchanged chunks (GUARDRAILS.md), so a plain // re-analyze does NOT surface them without this bump. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING — main was also at 39 // when this was allocated, and this file records eight prior collisions. // v41: the v40 Dart field-write binding gained its READ-side mask (#2807 review) // — a Dart class-member body that rebinds one of its class's field names now // emits `@receiver-owner.shadowed-fields` on its synthesized `@scope.function` // match, which becomes `Scope.ownsReceivers`. Parse-time capture emission again, // so a v40 warm cache replays scope matches with no marker and the receiver walk // still reaches the class field — i.e. it keeps serving the WRONG edge this // bump's fix removes, silently. Same bump-or-nothing situation as v40. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // v42: the v40/v41 Python constructor-field arm stopped accepting a DOTTED // callee (#2807 review). `self.svc = f.Alpha()` no longer emits a // `@type-binding.constructor` capture at all, which is what removes the // fabricated edge to the same-named class `Alpha` and what stops // `self.conn = Registry.get()` displacing an earlier real `self.conn = Outer()`. // A within-PR re-bump, not a collision fix: v41 was allocated by this same // unmerged branch, so v41-stamped caches exist only on it — but they exist on // every reviewer's and CI runner's checkout of it, and parse-time emission means // they replay the pre-fix capture set for byte-unchanged files and keep serving // the fabricated edge. main is at 39, so 40/41/42 are all this branch's. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // v43: Go embedded fields now emit `@reference.embedded-pointer` when written // as `*T` rather than `T` (#2813 exact method sets). Go's method-set rules make // the two forms genuinely different — `struct{ Base }` does NOT get Base's // pointer-receiver methods in its value method set while `struct{ *Base }` does // — so structural interface satisfaction cannot be exact without the spelling. // This is PARSE-TIME capture emission, so a warm cache replays the pre-fix // capture set for byte-unchanged files and the distinction never appears: // silently, with no error, the v27/v30 failure mode. `analyze` skips tree-sitter // dispatch for unchanged chunks (GUARDRAILS.md), so a plain re-analyze does NOT // surface it without this bump. // // 42 -> 43: this branch originally allocated 43 while sitting at 39, because // main had already taken 40/41/42 for #2807. main has since merged those and // this branch rebased onto it, so 43 remains the next free number and the // value is unchanged by the rebase — the reason it was chosen is simply now // visible in the history above. RE-CHECK AGAINST origin/main IMMEDIATELY // BEFORE MERGING; this file records eight prior collisions, two EXACT. // Moved 43 -> 44 for #2842's TypeScript heritage capture, which now emits // `@reference.inherits` for `interface_declaration` and // `abstract_class_declaration`. That is PARSE-TIME emission, so a v43 warm // cache would serve entries that are missing those matches entirely — the // exact failure a bump exists to prevent. Verified against origin/main at // a857f4c5a, which is still on 43, so 44 is free. RE-CHECK BEFORE MERGE. // 44 -> 45: #2837 re-anchors Go's struct/interface captures from the // `type_declaration` onto the `type_spec` (`languages/go/query.ts` // @scope.class/@declaration.struct/@declaration.interface, and GO_QUERIES // @definition.struct/@definition.interface in `tree-sitter-queries.ts`). Every // Go file declaring a type therefore emits DIFFERENT capture ranges — measured: // 70 fixture digests moved with zero change in capture COUNT — and a grouped // `type (...)` block emits nodes it previously did not emit at all. Parse-time, // so a warm cache would replay pre-fix ParsedFiles and the fix would be a silent // no-op on every incremental analyze while still passing every cold-run test. // // This branch originally took 44 and it COLLIDED: #2842 above merged first and // claimed it. The ninth entry in this ledger, and the third EXACT clash. Worth // recording HOW it was caught, because the pin test cannot catch it — both PRs // asserted `toBe(44)`, which passes even when main is already 44, so the two // capture schemas would have shared one PARSE_CACHE_VERSION and the durable // ParsedFile store would have replayed pre-fix ParsedFiles verbatim for one of // them. Only comparing against origin/main at MERGE time surfaces it. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // 45 -> 46 for the JavaScript bare-identifier read captures (A2), which emit // `@reference.read.identifier` in value positions (call arguments, // default-parameter values, return statements) so a module-scope `const` read // only by bare name finally mints a reference site, plus the object-literal // `@definition.property` rule and the TypeScript shape-member captures. All // PARSE-TIME emission, so a warm cache serves entries carrying none of those // matches and the new nodes and edges never appear — observed directly while // developing: a full `analyze --force` produced a byte-identical graph and read // as a failed hypothesis until the cache was cleared by hand. // // This branch originally took 45 and it COLLIDED: #2837 above merged first and // claimed it. The TENTH entry in this ledger and the FOURTH exact clash, caught // exactly as the note above says it must be — by comparing against origin/main // at merge time, not at review time. The pin test cannot catch it: both sides // asserted `toBe(45)`, which passes while main is already 45, so two capture // schemas would have shared one PARSE_CACHE_VERSION and the durable ParsedFile // store would have replayed pre-fix ParsedFiles verbatim for one of them. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // // 46 -> 47: method-level Spring `@RequestMapping` now emits wildcard routes // and one route per static `RequestMethod.X` value. These decorator routes live // in ParseWorkerResult and are replayed verbatim on warm cache hits, so keeping // the previous version would make the fix a no-op for every unchanged Java file. // PR #2856 claims 46, so this branch owns 47. Verified against upstream/main at // 021ac3037 (still 45). RE-CHECK BEFORE MERGE. // // ── The FIFTH clash, and the first one the ledger's own convention prevented ── // #2857 above merged while this branch sat waiting, and it did the right thing: // it read this PR's claim on 46 and took 47 instead of colliding. That left the // clash one step further up — 46 was safe, but THIS branch's own 47 (below) was // not, and neither was anything after it. Every entry from here down has been // renumbered +1 at merge time. Nothing about the capture sets changed; only the // numbers did, which is the whole point of re-checking at merge rather than at // review. Ledger entries 11 through 15. // // SIXTH clash, same shape, one merge later: #2833 then took 48 for a generic- // receiver fix, so this branch's chain shifted +1 AGAIN and now runs 49-53. // The capture sets have never moved; only the numbers have. This is the cost of // a single global counter with concurrent PRs, and #2860 is the mechanical fix. // // 48 -> 49 for the round-2 capture work: object literals behind an // identity-preserving wrapper (`const X = Object.freeze({ ... })`) now mint // `@definition.property` for their keys. Parse-time like every entry above, and // this one was ALSO observed as a false negative first: `analyze --force` // against a fixture carrying the new shape returned the pre-change node set and // read as "the query does not match", until the on-disk cache was removed by // hand and the same run produced the node. `--force` re-runs the pipeline but // still serves ParsedFiles from the durable store, so it does not substitute // for this bump. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // // 49 -> 50 for the TypeScript object-literal captures (R3-3): named // object-literal keys and the identity-wrapper form now mint `@definition.property` // in TYPESCRIPT_QUERIES, as they already did for JavaScript. Parse-time, so a // warm cache would replay ParsedFiles carrying none of those matches and the // keys would stay invisible. // // #2860 adds a CI check comparing this against the base branch — the merge-time // re-check this ledger has asked for by hand across ten entries and four exact // clashes. It is NOT on this branch, so until that one merges the re-check // below is still manual. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // 50 -> 51 for the return-shape and shorthand captures (R3-4): keys of an // anonymous literal in return position, and shorthand keys in both that and the // variable-bound form. Parse-time again. // // The v34 hazard, and this branch has already tripped it: a build stamped 48 // (now 50) was installed and used to analyze two repos BEFORE these captures // existed, so caches stamped 48 exist that carry none of them. Within one PR the version // only has to differ from main's, but an INTERMEDIATE build of the same series // is a different capture set wearing the same number — which is exactly what // the note above records for 33/34. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // 51 -> 52 is NOT needed for R3-5: that pass is scope-resolution, not // parse-time capture, so a warm cache replays ParsedFiles that already carry // everything it reads. Recorded because the reflex on this branch has been to // bump, and a bump nobody needs still forces every user a full re-parse. // // 51 -> 52 IS needed for dispatch-guard routes (R3-7): the JS/TS providers now // implement `extractDecoratorRoutes`, and decorator routes are worker output // carried in the parse cache. A warm cache replays a worker result whose // `decoratorRoutes` predates the extractor entirely, so every hand-rolled route // stays invisible and `route_map` keeps answering empty — the exact symptom the // change exists to fix, wearing the mask of "the extractor does not work". // // 52 -> 53 for the same-file constant folding that followed it. The v34 hazard // again, and this branch has now tripped it TWICE: a build stamped 50 (now 52) // was used to analyze before folding existed, so those caches carry the unfolded // route set. Caught by measuring — the post-folding run came back suspiciously // fast and would have reported the pre-folding number, which is precisely how // "an intermediate build of the same series is a different capture set wearing // the same number" shows up in practice. Within one PR the version only has to // differ from main's; against a cache YOU wrote, it has to differ from itself. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // // 47 -> 48: #2833 makes a generic-typed FIELD usable as a call receiver. Three // parse-time changes ride on this one value: // - C++ (`languages/cpp/query.ts`) gains `field_declaration` rules whose // `type:` is a `template_type` or a `qualified_identifier` wrapping one. // The rules that existed all required a bare `type_identifier`, so // `Repo repo;` and `std::vector items;` matched NONE of them and // the member got no type binding at all — new captures where there were none. // - Python (`languages/python/interpret.ts`) reduces a subscripted type its // container allow-lists do not claim to its base name, so `Repo[User]` binds // as `Repo`. That rewrites `TypeRef.rawName`, which is serialized into the // cached ParsedFile. // - `SymbolDefinition.typeParameters` — the DECLARED parameter list // (`template `, `class Box`), captured nowhere // before and on a different axis from the existing `templateArguments`. Six // per-language declaration queries gained `@declaration.type-parameters` and // `scope-extractor.ts` reads it onto every class-like def. // A warm cache would replay the pre-fix ParsedFiles, so every file served from // it would carry the old captures while passing every cold-run test — the exact // failure this constant exists to prevent. // // WHAT THE BUMP DOES NOT COVER. It invalidates the PARSE half only. Whether the // re-parsed captures reach the graph is a separate gate: `isIncremental` // (`core/run-analyze.ts`) tests `!options.force`, an existing meta, // `!schemaFingerprintMismatch(...)`, feature parity, non-empty `fileHashes` and // a git repo — SCHEMA_BUMP appears in none of them — and an incremental run then // writes back only `hashDiff.toWrite`, logging the rest as "unchanged file rows // preserved". SCHEMA_FINGERPRINT is a hash of node/relation DDL, which this // branch does not touch, so it is byte-identical and moves nothing either. // Net: after this bump an incremental analyze re-parses an unchanged file // correctly but keeps its existing rows, and the new edges land on the next full // rebuild (`--force`, or any run whose runner identity or DDL moved). That is // the pre-existing contract for every capture change, not a regression here. // // THIS BRANCH COLLIDED TWICE, which is why it lands on 48 rather than 46. // It first took 46 (the C++/Python captures) and then 47 (typeParameters), both // verified free against origin/main at 021ac3037. By merge time main had moved: // #2856 claims 46 and #2857 took 47 and merged first. The eleventh entry in this // ledger and the FOURTH and FIFTH exact clashes — and note what caught them. // Not the pin test: this branch asserted `toBe(47)` and so did #2857, and both // pass, because a literal pin cannot see the other side. Only diffing // origin/main at the moment of merge surfaces it. Every value this branch // published (46, 47) is superseded by 48, so a warm cache stamped with either is // correctly invalidated. // // 53 -> 54 for W2-8: `@declaration.type-parameters` is now captured on generic // FUNCTIONS, generator functions and type ALIASES in TYPESCRIPT_SCOPE_QUERY, not // only on class/interface declarations. Parse-time emission, so a warm cache // replays ParsedFiles whose defs carry no parameter list and the shadowing guard // that consumes it silently does nothing — the feature would look implemented // and be inert, which is the failure this constant exists to prevent. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // 54 -> 55 for W2-9: the dispatch-guard verb walk now tracks boolean POLARITY, // so `(req.method === 'GET' ? false : true) && pathname === '/x'` no longer // reports GET — the one method that branch guarantees the request does not have // — and `!!(req.method === 'GET')` no longer loses its verb. Routes are emitted // at parse time and replayed verbatim from a warm cache, so without this bump an // already-indexed repo keeps serving the inverted verb and the fix looks inert. // Same reason 51 and 52 were taken for R3-7. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // 55 -> 56 for R3-8 (part 1): a dispatch guard's verb walk now returns ALL the // methods a guard serves, so `(req.method === 'GET' || req.method === 'POST') && // pathname === '/x'` emits two routes instead of reporting GET alone, and a // disjunction with a non-verb operand emits none instead of the first verb it // saw. Routes are parse-time output replayed verbatim from a warm cache. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // 56 -> 57 for R3-8 (part 2): `pathname.match(RE)` is read as a route test // alongside `RE.test(pathname)`, a bound match takes its verb from where the // binding is TESTED rather than where it is bound, a regex named by a same-file // const resolves, and `regexToRoutePath` accepts a CAPTURING segment wildcard // (`([^/]+)`) — the form every real dispatcher writes and the one it refused. // All parse-time route output, replayed verbatim from a warm cache. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // 57 -> 58 for #2897: the fetch capture no longer requires a LITERAL url, so a // call passing a variable is recorded as an outward-action site. Measured, 44 of // 47 fetch calls in this repo pass a variable, so the R3-6 sink signal was // absent from 94% of them. Parse-time capture output replayed verbatim from a // warm cache, so without the bump an indexed repo keeps its empty sink set and // the fix looks inert. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // 58 -> 59 for the #2899 REVIEW FOLLOW-UP to the dispatch-guard route walk. Two // route-output changes, both parse-time and both replayed verbatim from a warm // cache, so without this bump an already-indexed repo keeps serving the wrong // routes and both fixes look implemented while being inert: // (a) `matchBindings` / `tested` are keyed on (enclosing function, name) // instead of the bare identifier. A same-named non-match binding in // ANOTHER function used to mint a fabricated verbed route under the wrong // handler — reproduced: `DELETE /api/live/positions/{param1}/replay // handler=handleSettings` — which then EVICTED the true verb-less route // through `reconcileDispatchGuardRoutes`. `buildRegexConstantMap` refuses // a name rebound to a non-regex for the same reason. // (b) `verbsFromTernary` INTERSECTS the operands of a conjunction instead of // taking the first non-empty set. `(GET||POST) ? (POST||PUT) : false` // emitted GET and POST where only POST is reachable, and // `GET ? POST : false` emitted GET for an unsatisfiable guard. // Both changes strictly REMOVE routes, so a stale cache serves strictly more // wrong answers than a cold one — which is exactly the state this constant // exists to make unreachable. Same reason 55, 56 and 57 were taken. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // // 59 -> 60 for #2864's `ParsedImport.reexportsName` plus the `@import.publishes` // capture that gates it. The FIELD is the easy half to miss: it is not a // capture, but `parsedfile-store.ts` serializes the whole ParsedFile // generically, so a new optional property on `ParsedImport` is part of the // cached shape all the same. Without the bump a warm cache replays pre-fix // `ParsedImport`s carrying no flag, `isNamedReexport`'s strict `=== true` takes // the old path, and the whole fix is a silent no-op on incremental analyze while // every cold-run test passes — landing hardest on `__init__.py`, the // rarest-changing and highest-cache-hit files in a Python repo. The MARKER makes // it a capture change too, confirmed independently by // `bench/python-scope/measure.mjs` drifting. // // This branch is the SIXTH exact clash, and the first one the re-check caught // where the number did NOT have to move. It staged 60 while main was 53, // deliberately clearing the two claims visible at the time (#2899 and #2891). // #2899 then merged and cascaded main 53 -> 59 in five steps — far past the 54 // its diff appeared to claim, because reading a PR's LAST bump hunk understates a // branch that bumps repeatedly. 60 survived only because it was chosen above the // highest claim rather than at main + 1; had it been staged at 54 it would now be // buried four deep inside main's own ledger. Take the next free value above every // in-flight MAXIMUM, not above origin/main. // // Still open at this commit: #2891 also claims 59, which main now holds. That is // a live exact clash for #2891 to renumber, not for this branch. // // 60 -> 62 for the two optional `ParsedImport` fields the cycle-checker fix // adds: `typeOnly` (TS `import type`) and `runsOnlyWhenCalled` (an import // written inside a function body). This is #2864's lesson arriving again, in // the same shape and for the same reason — neither field is a capture, but // `parsedfile-store.ts` serializes the whole ParsedFile generically, so both are // part of the cached shape. Without the bump a warm cache replays pre-fix // `ParsedImport`s carrying no flag, the strict `=== true` reads in // `finalize-algorithm.ts` and `imports-to-edges.ts` take the untagged path, and // `check --cycles` keeps reporting the erased and deferred imports this branch // exists to stop reporting — a silent no-op on incremental analyze while every // cold-run test in the branch passes. The failure is toward OVER-reporting, so // it is loud rather than dangerous, but it is still the whole fix not applying. // (`typeOnly` also has a capture half — `@import.type-only` from // `typescript/import-decomposer.ts` — so that side would drift a capture bench; // `runsOnlyWhenCalled` is decided in scope-extractor Pass 3 from the scope tree // and has no marker at all, which is exactly the half that gets missed.) // // 63, not 62, and not 61: main holds 60, #2935 claims 61, and #2936 claims 62. // Per the rule three paragraphs up, this is the next free value above every // in-flight MAXIMUM, not above origin/main. #2891's 59 is already buried by main // and is theirs to renumber; #1616's 2 is stale. // // This staged 62 first and was correct when written. #2936 opened four hours // later and also took 62 — bumping for #2917's implicit Java record-component // accessors, a genuinely different cached shape — because it re-checked against // main (60) rather than against the in-flight claims, which is the SEVENTH exact // clash and the same mistake the ledger above keeps recording. Moving rather // than standing on seniority: 63 is above every claim, so it is correct whichever // of the two merges first, and needs no coordination to stay correct. An exact // clash is the dangerous shape precisely because neither side invalidates the // other — a warm cache written by #2936's build would be read as valid by this // one, and the accessor definitions it materializes are not in this branch's // ParsedFile shape at all. // // 63 -> 64 for Java enum heritage plus annotated class, record, interface, // enum, and explicit-super base names emitting corrected captures (#2918). // Warm v63 ParsedFiles lack those captures and must be re-extracted. // 64 -> 66 adds the synthetic-declaration sidecar used to keep anonymous class // implementations from evicting ordinary implementors at the dispatch cap. // That PR published a v64 head first, so 66 kept all the shapes in flight at the // time distinct. (It also named a v65 claim from this branch; that claim was // superseded before either landed — see the 66 -> 67 entry below. Nothing holds // 65 now.) // // 66 -> 67 for #2917's implicit Java record-component accessor definitions and // scope declarations. A warm cache would otherwise replay ParsedFiles without // the synthesized accessors. This branch staged 65 before #2918's 66 landed on // main; 67 is the next free value above every in-flight claim (main 66, #2939's // 64), which is the ledger rule above — re-check against the claims, not just // against main. // // 67 -> 68 for #2912's `ReferenceSite.typeArguments`: the generic arguments a // heritage reference was written with (`: IValidator`), derived at // EXTRACTION time from the anchor's spelling. A warm cache replays `inherits` // sites with the field absent, absence is the fail-open "unknown", and // generic-instantiation filtering therefore degrades to the pre-fix fan-out on // exactly the unchanged files — silent, and passing every cold-run test. // // This branch staged 64 when main held 60 and #2935/#2936/#2934 claimed 61/62/63. // All three have since landed and cascaded main to 67, burying 64 inside main's // own ledger — the EIGHTH time the re-check moved a number, and the reason the // re-check is a merge step rather than a one-time choice. 68 is the next free // value above every in-flight claim at this merge (main 67, #2891's 59, #1616's // stale 2), which is the rule above: above every claim, not above origin/main. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // // 68 -> 69 added #2969's JS/TS data-route-table decoratorRoutes. A warm v68 // cache would replay unchanged worker results without those routes. Version 70 // then adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so Java // and Kotlin caches persist scheduled, event, messaging, and managed-job facts. // // 70 -> 71 adds the Java constant-route capture set (#2980): // `route-extractors/java-const-resolver.ts`, the `spring.ts` operand branch, // and the parse-worker's provider-driven constant harvest. A warm pre-feature // cache replays those files' worker results with `moduleConstants` absent and // `routePathOperands` unset, so every constant-based Spring route on an // unchanged file is silently dropped — the feature is inert until something // else invalidates the cache. // // This branch briefly reasoned that no bump was needed because the ledger // "already sits at 70, whose capture set post-dates and includes this harvest". // It does not: v70 was cut by fe3d7e56b for Spring non-HTTP handler facts // (#2417 / #2891), an ancestor of this PR's base, and it cannot include a // harvest that does not exist on main. Because // `PARSE_CACHE_VERSION = ${SCHEMA_BUMP}+${GITNEXUS_PKG_VERSION}` and // package.json is untouched here, leaving 70 makes the key BYTE-IDENTICAL // before and after this merge — precisely the inert-feature trap the v33/v34 // notes above warn about. Exposure is bounded by the package version (a // released upgrade invalidates anyway), but same-version warm caches — dev // builds, CI caches, anyone who indexed with an unreleased build — replay the // stale captures. // // 72, not 71: open PR #3017 (`fix/nest-decorator-routes`, NestJS decorator route // indexing) already claims 71, with an identical pin test. Re-checking // origin/main alone would not catch that — main is 70 and stays 70 until one of // the two merges, at which point the second lands a byte-identical // PARSE_CACHE_VERSION and is inert. This is exactly the rule the ledger states // and the v37/v38 clash it was written for: the next free value above every // IN-FLIGHT claim, not above origin/main. Every open PR touching gitnexus/ was // scanned; #3017 is the only other claimant. // // 72 -> 74 adds import-proven Convex endpoint metadata to Const/Function worker // output. A warm v72 cache has no convexEndpointFactory property, so the MCP // impact probe would keep claiming exact results for unchanged endpoints. The // parse-cache bump makes unchanged files re-parse; analyzer runner identity // drift separately forces the graph re-emit (run-analyze.ts), and an id/schema // migration needs both guarantees. Version 73 is intentionally skipped because // concurrent PR #3046 (fixes #3041) claims it. Re-check main and open PRs // immediately before merge. // // 74 -> 76 makes object-literal Function and Method members owner-qualified // (#3041). A warm v74 cache replays the old collapsed callable ids and omits // the new Const/Variable -> callable HAS_METHOD ownership edges, so this bump // makes unchanged files re-parse rather than waiting for a source edit. The // persisted graph is rebuilt separately when `analyzerRunnerIdentitiesEqual` // detects the changed analyzer build in run-analyze.ts. Both guards are // required; a parse-cache bump alone must never be read as a graph rebuild. // Version 75 is intentionally skipped because concurrent PR #3017 claims it. // // 74 -> 75 adds #3009's NestJS decorator routes to the JS/TS decoratorRoutes // channel. Same reasoning as 69: a warm pre-feature cache replays unchanged // worker results, which for every already-indexed NestJS repo means replaying // the empty route set this change exists to fix — the fix would appear to do // nothing until something else invalidated the cache. // // 75, not 71 (this branch's original claim) and not 74: origin/main cascaded // past both while this PR was open. #2980 took 71, #3046 claims 73, and the // Convex endpoint-metadata change took 74 (skipping 73 for exactly that // reason). 75 is the next free value above origin/main AND above every // in-flight claim — the rule the ledger states, not "one above main". Every // open PR touching gitnexus/src/storage/parse-cache.ts was scanned at this // merge: #3046 (73) and #1616 (a stale 2) are the only other claimants. // RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. // // 75 -> 76 for the NestJS multi-path (array) form: `@Get(['a','b'])` now yields // one `decoratorRoutes` entry per path where it previously yielded none. That // changes worker output for the same file, and 75 was already claimed earlier // on this same branch — so a warm cache written by a dev or CI build at v75, // before the array form landed, would replay the pre-feature captures under a // BYTE-IDENTICAL `PARSE_CACHE_VERSION` and the array form would be inert. The // package version is untouched here, so it cannot rescue that case. Same-branch // re-bumping is unusual, but the ledger's rule is about what a warm cache can // replay, not about how the value was reached. // // 76 -> 77 because 76 was NOT free. While this branch sat in review, origin/main // advanced to ac68f5254 and #3046 took 76 — the value this branch already held. // `gitnexus/package.json` is 1.6.9 on both sides, so `PARSE_CACHE_VERSION` was // the byte-identical string `76+1.6.9` on two branches that changed // incompatible worker output. Every warm cache would have been reused across // both features, making both inert while every test stayed green. // // The sharpest part: #3046 skipped 75 BECAUSE this branch held it, then took // 76 — and this branch had meanwhile moved 75 -> 76 for the array form. Two // PRs each doing the bookkeeping correctly still collided, because each // re-checked once and neither re-checked after the other moved. That is what // the re-check line below is for, and why it says AT MERGE rather than // when you pick the number. // // 78 was claimed concurrently by #3060 while this branch was in review. Both // branches keep the same package version, so sharing 78 would replay // incompatible worker output without a textual merge conflict. This branch // therefore takes 79, the next free value above origin/main and every open PR // found by the contents-API scan at their exact head SHAs. // // 79 -> 80 for #3088: parse-cache membership is `(language, sha256(path) mod // 128)` then the byte budget *inside* that bucket. Worker count is no longer a // membership input, so a warm v79 cache keyed sequential scan-order packs (and // on multi-worker hosts, pool×2 MiB mega-chunks) must miss. Sidecar-era // ParsedFile stores (#3086/#3087) share PARSE_CACHE_VERSION, so both stores // invalidate in lockstep. origin/main at allocation is 79; open PRs that still // touch gitnexus/src/storage/parse-cache.ts claim 78 (#3060), 71 (#2840), and // 2 (#1616) — none claim 80. RE-CHECK AGAINST origin/main AND OPEN PRs // IMMEDIATELY BEFORE MERGING. // // WHY THIS IS STILL A HAND-PICKED NUMBER, when `SCHEMA_FINGERPRINT` next door // is a derived sha256 that cannot collide. The derivation exists and already // runs: `resolveAnalyzerRunnerIdentity` computes `build.digest` over the // analyzer build tree on every analyze. It is not used here because it moves on // ANY build change — a comment-only edit, this paragraph included — so every // dev and CI rebuild would force a full re-parse of every repo. That is the // expensive half of the trade, and `PARSE_CACHE_VERSION` already carries the // package version, so this counter's only job is separating builds that SHARE // one: dev, CI, unreleased. Exactly the population a whole-build digest would // punish, and exactly the population that hits the exact-clash failure. // // So the counter stays, and the open cost is that a bump nobody makes is // invisible: a capture change with no bump ships inert and no check can see it. // #2860 (a base-branch CI comparison) closes the "not greater than main" axis // only. A digest over just the determinant subset — the language queries plus // `route-extractors/` and `workers/` module content — would close the missing- // bump axis without invalidating on unrelated churn, and is the real follow-up. // RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. // // 80 -> 81: ParsedFile and parse-cache shards are one immutable `.v8` envelope // each (no JSON/path/generation siblings). A v80 index still names `.json` // keys and would skip workers while scope-resolution found nothing — the // #1983 main-thread reparse. origin/main at allocation is 80. // 81 -> 82: Java and Kotlin ParsedFile capture side channels now carry // programmatic Spring lookup facts. A warm v81 cache has no such facts, so it // would skip workers and silently omit the new INJECTS edges. origin/main at // allocation is 81. // 82 -> 83: Java Lombok @Data/@Getter/@Setter accessor synthesis emits // synthetic Method nodes, HAS_METHOD edges, and matching scope captures into // ParseWorkerResult / ParsedFile. A warm v82 cache replays pre-Lombok worker // output and silently omits those callables. origin/main at allocation is 82. // 83 -> 84: Kotlin val/var properties synthesize JVM get/set Method nodes // (same provider hook as Java Lombok). A warm v83 cache omits those callables. // origin/main at allocation is 83 (Java Lombok on this branch). // 84 -> 85: JVM synthetic accessor captures now use the declaration // qualified_name key consumed by scope extraction, and Kotlin accessor planning // follows JvmAbi naming plus conservative @JvmName suppression. A warm v84 // cache can replay stale names and declaration metadata. // 85 -> 86: Kotlin interface property accessors now record isAbstract on the // synthetic Method. A warm v85 cache replays them as concrete. // 86 -> 87: ModuleConstants gained wildcardImports (static-import-asterisk // materialization) — a warm v86 cache has no wildcard bindings, so folding // would skip them and drop wildcard-imported route constants. origin/main at // allocation is 86 (#2885). // 87 -> 88: Java ModuleConstants now preserves unfoldable declaration names // across worker/cache replay so wildcard expansion cannot resurrect an imported // member hidden by a local field. A warm v87 cache lacks that shadow metadata. // 88 -> 89: the same side channels now carry Spring messaging facts — the // arguments of non-HTTP handler annotations (`@KafkaListener(topics = ...)`) // and a new `springMessageProducerFacts` list for template publishes // (`KafkaTemplate.send`, `RabbitTemplate`/`JmsTemplate.convertAndSend`, // `StreamBridge.send`). Both are parse-time worker output replayed verbatim // from `ParsedFile.captureSideChannel`, so a warm v88 cache would skip workers // and hand the annotation facts back with no `args` and the producer list // empty. Measured on the fixture app: a warm all-cache-hit run // (`usedWorkerPool=false`, `reparsedFileCount=0`) reproduces 6 Java and 7 // Kotlin producer facts purely from the store, which is exactly the state a // pre-change cache would have served as zero. origin/main at allocation is 88. // RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING — this // entry was allocated 83 first, and five bumps landed upstream before it merged. // 89 -> 90 (#2865): route decorator captures now carry `handlerName` in // `decoratorRoutes`, which is what lets `resolveRouteHandlerSymbols` stamp // `handlerSymbolId` and the routes phase emit the definition-level // HANDLES_ROUTE edge. The field is minted in the parse WORKER and persisted // verbatim, so a warm v89 cache replays handler-less decorator routes: every // route loses its definition-level association on incremental analyze while // every cold-run test passes — the inert-feature trap the entries above record. // 89 is now taken by merged #3128. 90 is the next free value above origin/main // (89) and above every in-flight claim found by scanning open PRs' // parse-cache.ts at their exact head SHAs (highest other open claim was still // ≤88). RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. // 90 -> 91 (#3130): Kotlin providers now emit Spring decoratorRoutes plus // ModuleConstants shadow metadata. A warm v90 cache would replay unchanged // Kotlin files with neither route candidates nor the constant declarations // needed to fold them, leaving the new ingestion path silently inert. // 91 -> 92 (#1432): the shared callable-flow reader (`callable-flow-captures.ts`) // no longer names a callee by simple name for a MEMBER call and gates a // field-stored-callable invoke on a visible member store — parse-time capture // facts for Kotlin / C++ / C# / TypeScript member calls change (the // scope-capture bench re-baselined all four), and Zig files are captured for // the first time, with rules that changed within the PR (qualified struct // literals, enum-variant field bindings, receiver tagging). A warm v91 cache // replays the old facts verbatim, `--force` included: a reviewer re-testing a // later head of this PR on an index built from an earlier one measured a // byte-identical graph until `parse-cache/` and `parsedfile-cache/` were // deleted by hand. 92 is the next free value above origin/main (91) at merge // time. RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. // v93: Zig call captures inside a comptime-false branch carry // `@reference.static-gated` (feat/zig-static-gated-edges); the site gains // `staticGated` and the CALLS edge a BOOLEAN column. // v94: Objective-C now elides bare, file-scope macro markers before parsing. // A warm v93 cache can retain error-recovered trees and provider facts that // omit Objective-C declarations following markers such as RCT_EXTERN_C_END. // v95: Objective-C header classification no longer treats framework `#import` // alone as Objective-C syntax. A warm v94 cache can replay Objective-C worker // output for a C++ header during `--force`, even though the current classifier // routes that same header through the C++ provider. // v96: Objective-C macro-marker preprocessing recognizes all C preprocessing // whitespace before a directive or bare marker. A warm v95 cache can retain // error-recovered facts for sources that begin those lines with form feed or // vertical tab. // v97: Objective-C macro-marker preprocessing recognizes comment-prefixed // directives and rejects invalid numeric marker prefixes. A warm v96 cache can // replay error-recovered facts from the previous normalization behavior. // v98 (#3219): `ZIG_SCOPE_QUERY` gained three `@reference.value-ref` rules — // bare call argument, qualified call argument (with `@reference.receiver`), and // const-binding initialiser — so a Zig callable named in VALUE position now // produces a `value-ref` entry in `ParsedFile.referenceSites` where it produced // none before. These captures are PARSE-TIME facts, so a warm pre-v98 cache // replays unchanged `.zig` files with zero value-ref sites, `--force` included // (shards are content-addressed): `emitPropertyDispatchCalls` then emits no // USES edge, `callableValueReferenceBoundaries` measures a real zero, and // `impact` on a registered accessor republishes `epistemic: "exact"` — the exact // #3399 defect this change exists to close, silently un-fixed. // v99: ParsedImport retains declaredAtScope and export evidence changes in // #3190. Old durable ParsedFiles lack the facts needed for scoped binding; // invalidate both stores so warm indexing actually applies the correction. // origin/main took 98 for #3219; 99 is the next free value. // v100 (#3253): Rust import captures preserve the leading `::` that selects // the extern prelude. Old warm captures erase it and cannot distinguish an // absolute library import from a same-named local module. Reparse both stores. // v101 (#3294 review): Rust bare-keyword glob imports retain crate/self/super // instead of an empty target path; restricted pub(...) imports are no longer // captured as unrestricted reexports. Re-extract both facts on warm indexes. // v102: ParsedFile gained callResultAssignmentSites; old durable shards do // not carry the exact assignment identity required by return-type replay. // v103 (#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 (route rows are parse-time facts), so both stores re-extract. // v104 (#3339 review): TS/JS pair-HOC queries now name object-pair // `mutation(withAuth(arrow))` handlers. Warm caches replay the pre-fix // capture set (anonymous arrows, no Function name), so both stores re-extract. // v112 (#3354): callable-value flow now follows each branch of `a ?? f`, // `a || f`, and `c ? f : g`. Warm caches replay the pre-fix flow facts, which // have no flow for those assignments, so both stores re-extract. 105-111 are // claimed by open PR #3326 (Elixir). // Same v112: the `valueAlternatives` provider hook extends it to Kotlin // `?:`/`if`, Swift/Dart `??`/`?:`, and Python `x if c else y`, and keeps a // Ruby multi-statement `if` one opaque source. // v113 (#3371): `.ipynb` code cells are extracted to Python before parse. // Warm caches keyed on raw JSON would replay empty/failed Python parses. // v114 (#2965): C and C++ angle includes are real wildcard imports with // `isSystem`. Warm shards stored those captures as absent, so incremental // analyze never asked the resolver to search include paths. 113 is #3371. // v115 (#3390): Python call captures now carry `@reference.arity` when the // argument count is statically known. Warm v114 ParsedFiles lack that fact, so // arity-aware method filtering would remain inert for every unchanged file. // v116 (#3390 follow-up): Python's private capture side-channel now records // simple-positional call sites and fixed positional method capacity. Warm v115 // ParsedFiles lack those facts, so conservative mixin subtype dispatch would // suppress unchanged one-argument callers. // v117 (#3390 private-only successor): simple-positional call entries now carry // their count privately, while ordinary Python references no longer receive // synthetic arity. Warm v116 ParsedFiles have neither equivalent fact. // v118 (#3398): decorated Python methods with unproven decorator identity no // longer publish subtype positional capacity. Warm v117 side-channel snapshots // would retain that capacity and could emit a false concrete call target. // v119 (#3396): Python subtype method capacities now omit receiverless ordinary // methods. Warm v118 ParsedFiles would replay a false compatible target. // v120 (#3394): decorated Python method receiver bindings now distinguish // unproven decorators from instance receivers. Warm v119 ParsedFiles would // replay a fabricated `self` binding or lack the uncertainty marker entirely. // v121 (#3399 follow-up): Python decorator identity now ignores trailing // comments, honors rebinding of builtin descriptor names visible where the // decorator is evaluated, and withholds subtype capacity from descriptor // stacks. Warm v120 captures carry the old verdicts. // v122 (#3414): Python decorator identity models restoring helper calls and // treats match-pattern captures and nested nonlocal rebinds as shadowing. // Warm v121 captures carry the old verdicts. // v123 (#3408): FastAPI include records now carry `host`, router imports carry // `modulePath`, and unprefixed child includes are emitted. Warm v122 records // lack them, so nested router-prefix propagation would stay inert. // v124 (#3402): Go files now emit gin/echo `decoratorRoutes` carrying a // `handlerReceiver` hint. Warm v123 Go worker results carry no routes. // v125 (#3402): Go route hints now honor lexical declarations and captured writes; // namespace imports retain whether their local name comes from the package clause. // v126 (#3446): SDK positional tool registrations now emit tool definitions, // exact handler identities, and an opt-out from unrelated file-level flows. // Warm v125 worker results omit these definitions and must be re-extracted. // v127 (#3450): Destructured member writes invalidate SDK registration evidence. // Warm v126 worker results can retain false tools after a method replacement. // v128 (#3450): SDK namespace imports now prove positional tool receivers. // Warm v127 worker results omit these definitions and must be re-extracted. // v129 (#3499): Python/PHP/Ruby declaration bindings now preserve language- // specific module visibility. Warm v128 ParsedFiles retain the old binding // placement and can lose valid calls when finalized by the new projection. // v130 (#3502): Python globals use exact function/class block ownership and // Ruby ordinary-method binding uses AST markers. Warm v129 ParsedFiles retain // incorrect declaration scopes and must be re-extracted. // v131 (#3504): Python namespace imports retain explicit alias syntax. Warm // v130 ParsedFiles lack this fact and can bind a root-spelled alias to the // package root instead of the imported module. // v132 (#3487, #3505): merge the parallel Express route capture changes. // Require semantic handler arguments, retain route(path) builders, and preserve // app.all as method-agnostic. Both branches used v131 for different captures, // so invalidate either branch's cache before reusing the combined schema. // v133 (#3505): route(path) builders emit only chained verb registrations // with semantic handler arguments. Warm v132 caches can retain phantom GET // routes for bare builders and lose the actual methods of chained handlers. // v134: lexical local-import ownership, activation, lookup positions and // provider side-channel facts now survive worker transport and durable reuse. // Explicit JS/JSX embedded scripts retain their provider capture semantics. // Invalidate both stores together: v133 can replay file-wide import ownership // or TypeScript captures for an explicitly JavaScript embedded script. // v135: review fixes retain unambiguous conditional Python imports and ordinary // mutable TS/JS type facts, and preserve unchanged destructured CJS re-exports. // v134 stores contain the old blocked claims and must be extracted again. // v136: Python parameter annotations retain enclosing lookup ownership; Vue // JSX/TSX captures and multi-block declarations retain their source grammar // and original graph positions. Both stores must re-extract v135 captures. // v137: Python module-accessor syntax proofs and exact assigned-call provenance // join the existing subtype capture payload. Re-extract v136 in both stores. // v138: provider-owned source occurrences and new flow visitors/explicit region // ownership must not reuse older capture or CFG side channels. // v139: real rename fixtures expose missing field/read/write chains, callable // scopes and declaration metadata. Re-extract both stores with provider fixes. // v140: Ruby accessor captures retain reader visibility and singleton ownership. // Older worker and durable captures cannot prove property-reader dispatch. // v141: detached method owners retain names without fabricated graph ids. // Re-extract both worker and durable captures before workspace ownership runs. // v142: fetch-wrapper consumers are AST candidates emitted by providers. // Re-extract v141 shards: main used 141 for detached Go ownership, while // this branch used it for wrapper candidates. Neither contains both changes. // v143 (#3561): combine v142 wrapper candidates with the route corrections. // Older caches may retain phantom middleware and stored route-metadata edges. // The PR also used v142 without wrapper candidates; invalidate both histories. // v144: provider provenance facts replace receiver-name guesses; mount paths // are recomputed from facts after cache replay, before route winner selection. const SCHEMA_BUMP = 144; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from // gitnexus/src/storage/parse-cache.ts (or its dist/ equivalent). const here = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ path.join(here, '..', '..', 'package.json'), // src/storage → gitnexus/ path.join(here, '..', '..', '..', 'package.json'), // dist/storage → gitnexus/ ]; const requireCJS = createRequire(import.meta.url); for (const c of candidates) { try { const pkg = requireCJS(c); if (typeof pkg?.version === 'string') return pkg.version; } catch { /* try next candidate */ } } } catch { /* fall through to fallback */ } return '0.0.0-unknown'; })(); export const PARSE_CACHE_VERSION = `${SCHEMA_BUMP}+${GITNEXUS_PKG_VERSION}`; /** SHA-256 hex of a string or buffer (paths for bucket ids, contents for cache keys). */ const sha256Hex = (input: Buffer | string): string => createHash('sha256') .update(typeof input === 'string' ? Buffer.from(input) : input) .digest('hex'); /** Stable parse-cache bucket count (#3088). Changing this requires SCHEMA_BUMP. */ export const PARSE_CACHE_BUCKET_COUNT = 128; /** Bucket id for cache membership: `sha256(path) mod N` without IEEE-754 truncation. */ export const parseCacheBucketId = (filePath: string): number => Number(BigInt(`0x${sha256Hex(filePath)}`) % BigInt(PARSE_CACHE_BUCKET_COUNT)); export type ParseCachePackFile = { path: string; size: number; language: string }; /** * Pack files into parse-cache chunks: group by (language, bucket id), sort * paths inside the group, then cut at `byteBudget`. Bucket visit order is * the lexicographic order of `${language}\\0${bucketId}` keys (deterministic, * independent of scan order and worker count). */ export const packParseCacheChunks = ( files: readonly ParseCachePackFile[], byteBudget: number, ): string[][] => { const buckets = new Map(); for (const file of files) { const key = `${file.language}\0${parseCacheBucketId(file.path)}`; const list = buckets.get(key); if (list) list.push(file); else buckets.set(key, [file]); } const chunks: string[][] = []; for (const key of [...buckets.keys()].sort()) { const group = buckets.get(key)!; group.sort((a, b) => compareCodeUnits(a.path, b.path)); let current: string[] = []; let bytes = 0; for (const file of group) { if (current.length > 0 && bytes + file.size > byteBudget) { chunks.push(current); current = []; bytes = 0; } current.push(file.path); bytes += file.size; } if (current.length > 0) chunks.push(current); } return chunks; }; const LEGACY_CACHE_FILENAME = 'parse-cache.json'; const CACHE_DIRNAME = 'parse-cache'; /** * Per-run staging root for `useParseCache: false`. Parse-cache shards and the * ParsedFile stores write here so a crash cannot mix a new generation into the * live `.gitnexus/parse-cache` / `parsedfile-cache` trees. `saveParseCache` * publishes onto the live `storagePath` only after a successful analysis. */ export const COLD_PARSE_REBUILD_DIRNAME = 'parse-rebuild'; /** Deterministic staging path — tests only. Production uses {@link createColdParseRebuildDir}. */ export const getColdParseRebuildDir = (storagePath: string): string => path.join(storagePath, COLD_PARSE_REBUILD_DIRNAME); /** * Unique per analyze process so concurrent `--no-parse-cache` runs on * different branch slots (shared `.gitnexus`, separate index locks) do not * delete each other's staging tree. */ export const createColdParseRebuildDir = async (storagePath: string): Promise => { await fs.mkdir(storagePath, { recursive: true }); return fs.mkdtemp(path.join(storagePath, `${COLD_PARSE_REBUILD_DIRNAME}.`)); }; const CACHE_INDEX_FILENAME = 'index.json'; /** Keys on disk always come from `computeChunkHash` — 64-char lowercase hex. */ const CHUNK_CACHE_KEY_HEX_RE = /^[a-f0-9]{64}$/; const isValidChunkCacheKey = (chunkHash: string): boolean => CHUNK_CACHE_KEY_HEX_RE.test(chunkHash); /** On-disk shape for the legacy single-file format. */ interface ParseCacheFile { version: string; /** key = chunk hash (hex) → cached chunk result list. */ entries: Record; } /** On-disk shape for the sharded directory format. */ interface ShardedParseCacheIndex { version: string; keys: string[]; } /** Runtime view: keyed Map for fast lookup; mutated in place during a run. */ export interface ParseCache { version: string; entries: Map; /** * Hashes referenced (hit OR miss-and-stored) by the current run. * The parse phase populates this as it processes chunks; the orchestrator * uses it as input to `pruneCache` before saving so entries that no * longer correspond to any chunk in the current scan are discarded. * Transient — never serialized to disk. */ usedKeys: Set; /** * Hashes this run decided it cannot vouch for — its durable generation could * not be reset, or its chunk was worker-quarantined (#3204). `saveParseCache` * refuses them, so neither a pre-existing `.v8` nor the chunk's durable * directory survives into the next run. Kept separate from `usedKeys` * because the orchestrator re-adds keys to that set after the parse phase * (#2106 sibling fold), which would undo a deletion. * Transient — never serialized to disk. */ staleKeys?: Set; /** * When set, chunk payloads are loaded from / flushed to sharded files on * demand instead of retaining every chunk in `entries` for the whole run * (#1983 — Linux kernel OOM from duplicate in-memory cache + graph). * May be a per-run staging directory (`getColdParseRebuildDir`) while the * live index root is passed separately to `saveParseCache`. */ storagePath?: string; /** Index of chunk hashes known to exist under `storagePath/parse-cache/`. */ onDiskKeys?: Set; } /** Stable hash of a single file's contents — used by callers to compose a chunk hash. */ export const fileContentHash = (content: Buffer | string): string => sha256Hex(content); /** * Compute the canonical cache key for a chunk's contents. * * `entries` is the list of (filePath, file content hash) for every file * in the chunk. We sort by filePath before hashing so chunks composed of * the same files in different order produce the same key. */ /** PDG/CFG cache namespace (#2081 M1) — every input that changes the * WORKER-EMITTED `cfgSideChannel` must be folded into the chunk key, and * ONLY those. The classification test for a future option: does the worker * see it (workerData) and does it change the bytes the worker writes to the * shard? `pdgMaxEdgesPerFunction` famously fails that test — it is applied * at EMIT time on the main thread (scope-resolution run.ts), the worker * never receives it, and the cached output is byte-identical across cap * values; folding it in (as a prior review round did) only forced a * spurious full re-parse on every cap change (#2099 F3). Options that * change the PERSISTED GRAPH but not the shard belong in the RepoMeta pdg * stamp (incremental-eligibility), not here. */ export interface PdgCacheKey { readonly pdg?: boolean; /** Per-function source-line cap (changes WHICH functions get a CFG — * applied in the worker, so it shapes the cached shard). Callers must * pass the RESOLVED value (the production call site in parse-impl.ts * applies the worker's default before folding) so an explicit-default * run shares the default run's keys — this function folds whatever it * is given verbatim. */ readonly maxFunctionLines?: number; } export const computeChunkHash = ( entries: Array<{ filePath: string; contentHash: string }>, pdg: boolean | PdgCacheKey = false, ): string => { const sorted = [...entries].sort((a, b) => (a.filePath < b.filePath ? -1 : 1)); const joined = sorted.map((e) => `${e.filePath}:${e.contentHash}`).join('\n'); const opts: PdgCacheKey = typeof pdg === 'boolean' ? { pdg } : pdg; // pdg-off path keeps its pre-#2081 chunk-KEY format verbatim. Note this does // NOT mean caches survive the M1 upgrade: SCHEMA_BUMP 4→5 changed // PARSE_CACHE_VERSION, and both loadParseCache (below) and the durable // parsedfile-store index hard-invalidate on it — every user pays one full // cold re-parse on upgrade regardless of --pdg. Keeping the key format // stable only means no SECOND invalidation class is introduced here. if (!opts.pdg) return sha256Hex(joined); // Fold the worker-visible --pdg configuration into the key: the boolean // plus `maxFunctionLines` (decides which functions get a CFG at all, in the // worker). Without it a warm chunk built under one cap is served to a run // with a different cap → a stale/under-built CFG: the #2038-class // option-blind-key trap. `def` marks an unset (default) value so two // default-cap runs share a key. The emit-time edge cap is deliberately // absent — see the PdgCacheKey doc comment. // // NAMESPACE VERSION (`pdg:6`): bumped when the worker-emitted // `cfgSideChannel` SHAPE changes for pdg-mode runs only — pdg:1→2 in #2083 // M3 U1 (TsHarvester emits taint `sites` on StatementFacts); pdg:2→3 in the // #2227 follow-up U1 (every C-family / TS harvester now stamps the call-site // anchor `SiteRecord.at`, which the resolved-callee-id join reads); pdg:3→4 in // the #2227 tri-review-2 U4 (the Rust harvester now emits a `kind:'new'` site // for `struct_expression`, a new worker-output site the join consumes); pdg:4→5 // in the FU-C call-summary soundness fix (the TS harvester now stamps // `BindingEntry.formalIndex` on param bindings so the PDG call-summary keys // return-flow on the enclosing FORMAL position, not the flattened binding // ordinal — a warm chunk lacking it would route the harvest to its conservative // empty-summary fallback). A warm chunk built by a worker predating the relevant // change carries a stale site shape, so the join skips it and // `BasicBlock.calleeIds` is silently empty (or missing the struct constructor) // even though `callees` is populated — exactly the #2225-class shape skew this // version token exists to prevent. Invalidates pdg-mode chunks and their durable // parsedfile-cache entries; flag-off chunk keys never reach this line and stay // byte-identical, so non-pdg users pay nothing. Deliberately NOT a SCHEMA_BUMP — // that gates the whole cache version and would force a full cold re-parse on // EVERY user (the M1 bump comment above records that cost). // pdg:5→6 preserves parse-buffer call anchors when embedded graph lines are // remapped, restoring the exact-position resolved-callee join (#3532). const ns = `pdg:6;maxFn=${opts.maxFunctionLines ?? 'def'}`; return sha256Hex(`${ns}\n${joined}`); }; /** * JSON replacer that round-trips Map/Set instances through plain JSON. * * `ParseWorkerResult.parsedFiles[*].scopes[*].typeBindings` is a * `ReadonlyMap`; without this transform it serializes * to `{}` and downstream code that iterates / `.get()`s on it crashes * with "is not iterable". Applied symmetrically by `mapReviver` on * load so the in-memory shape stays Map-typed. */ const MAP_TAG = '__$mapEntries$__'; const SET_TAG = '__$setValues$__'; export const mapReplacer = (_key: string, value: unknown): unknown => { if (value instanceof Map) return { [MAP_TAG]: Array.from(value.entries()) }; if (value instanceof Set) return { [SET_TAG]: Array.from(value.values()) }; return value; }; export const mapReviver = (_key: string, value: unknown): unknown => { if (value && typeof value === 'object') { const v = value as Record; if (Array.isArray(v[MAP_TAG])) return new Map(v[MAP_TAG] as [unknown, unknown][]); if (Array.isArray(v[SET_TAG])) return new Set(v[SET_TAG] as unknown[]); } return value; }; const getLegacyCachePath = (storagePath: string): string => path.join(storagePath, LEGACY_CACHE_FILENAME); const getCacheDirPath = (storagePath: string): string => path.join(storagePath, CACHE_DIRNAME); const getCacheIndexPath = (storagePath: string): string => path.join(getCacheDirPath(storagePath), CACHE_INDEX_FILENAME); const getCacheChunkPath = (storagePath: string, chunkHash: string): string => path.join(getCacheDirPath(storagePath), `${chunkHash}.v8`); /** * Drop fields that are not replayed by `mergeChunkResults` / parse-impl after * RING4-1 (#942). Shrinks on-disk shards and peak RSS during cold runs. */ export const slimParseWorkerResultsForCache = ( chunkResults: readonly ParseWorkerResult[], ): ParseWorkerResult[] => { const slim: ParseWorkerResult[] = []; for (const result of chunkResults) { slim.push({ ...result, calls: [], assignments: [], constructorBindings: [], parsedFiles: [], // #2112: a clone-safety skip list is per-run telemetry, not graph data — // replay ignores it. Drop it so it doesn't bloat the cached shard. skippedPaths: [], }); } return slim; }; const readParseCacheChunkFromDisk = async ( storagePath: string, chunkHash: string, ): Promise => { if (!isValidChunkCacheKey(chunkHash)) return undefined; try { const chunkPath = getCacheChunkPath(storagePath, chunkHash); const v8Hit = await tryLoadV8Cache(chunkPath); if (v8Hit?.kind === 'hit' && Array.isArray(v8Hit.value)) { return v8Hit.value as ParseWorkerResult[]; } return undefined; } catch { return undefined; } }; /** Load one chunk shard. Does not retain it in `cache.entries`. */ export const loadParseCacheChunk = async ( cache: ParseCache, chunkHash: string, ): Promise => { const inMemory = cache.entries.get(chunkHash); if (inMemory !== undefined) return inMemory; if (cache.storagePath && cache.onDiskKeys?.has(chunkHash)) { return readParseCacheChunkFromDisk(cache.storagePath, chunkHash); } return undefined; }; /** * Cache directories already created this process. `persistParseCacheChunk` runs * once per cache-miss chunk; without this guard every miss re-issues a redundant * `mkdir` syscall (hundreds on a large cold repo) (#1983). Storage paths are * process-scoped, so the Set stays bounded. */ const createdCacheDirs = new Set(); /** Drop the mkdir memo after the staging tree is wiped so the next persist recreates it. */ export const forgetCreatedParseCacheDir = (storagePath: string): void => { createdCacheDirs.delete(getCacheDirPath(storagePath)); }; /** * Persist one chunk shard and avoid retaining it in RAM for the rest of the * run. Falls back to `cache.entries` when `storagePath` is unset (unit tests). */ export const persistParseCacheChunk = async ( cache: ParseCache, chunkHash: string, chunkResults: readonly ParseWorkerResult[], ): Promise => { const slim = slimParseWorkerResultsForCache(chunkResults); if (cache.storagePath) { const cacheDir = getCacheDirPath(cache.storagePath); if (!createdCacheDirs.has(cacheDir)) { await fs.mkdir(cacheDir, { recursive: true }); createdCacheDirs.add(cacheDir); } const chunkPath = getCacheChunkPath(cache.storagePath, chunkHash); let ok = await writeV8CacheFile(chunkPath, slim); if (!ok) { await fs.mkdir(cacheDir, { recursive: true }); createdCacheDirs.add(cacheDir); ok = await writeV8CacheFile(chunkPath, slim); } if (!ok) { cache.entries.set(chunkHash, slim); return; } cache.onDiskKeys ??= new Set(); cache.onDiskKeys.add(chunkHash); cache.entries.delete(chunkHash); return; } cache.entries.set(chunkHash, slim); }; /** * Retire a chunk this run cannot vouch for — its durable ParsedFile generation * could not be reset, or its chunk was worker-quarantined (#3204). * * `saveParseCache` refuses a stale key, so no pre-existing `.v8` is copied * forward and the durable store — pruned to exactly the keys that save * returns — drops the chunk in the same step. The two deletes matter because * `loadParseCacheChunk` reads `entries` and `onDiskKeys` and does NOT consult * `staleKeys`: without them a second lookup of the same hash inside this run * would still serve the retired shard. */ export const markParseCacheChunkStale = (cache: ParseCache, chunkHash: string): void => { cache.staleKeys ??= new Set(); cache.staleKeys.add(chunkHash); cache.entries.delete(chunkHash); cache.onDiskKeys?.delete(chunkHash); }; const loadLegacyParseCache = async (storagePath: string): Promise => { const cachePath = getLegacyCachePath(storagePath); try { const raw = await fs.readFile(cachePath, 'utf-8'); const data = JSON.parse(raw, mapReviver) as ParseCacheFile; if ( typeof data !== 'object' || data === null || data.version !== PARSE_CACHE_VERSION || typeof data.entries !== 'object' || data.entries === null ) { return emptyCache(storagePath); } const entries = new Map(); for (const [k, v] of Object.entries(data.entries)) { if (Array.isArray(v)) entries.set(k, v as ParseWorkerResult[]); } return { version: PARSE_CACHE_VERSION, entries, usedKeys: new Set(), storagePath }; } catch { return emptyCache(storagePath); } }; const loadShardedParseCache = async (storagePath: string): Promise => { const indexPath = getCacheIndexPath(storagePath); try { const raw = await fs.readFile(indexPath, 'utf-8'); const data = JSON.parse(raw) as ShardedParseCacheIndex; if ( typeof data !== 'object' || data === null || data.version !== PARSE_CACHE_VERSION || !Array.isArray(data.keys) ) { return emptyCache(storagePath); } const onDiskKeys = new Set(); for (const chunkHash of data.keys) { if (typeof chunkHash === 'string' && isValidChunkCacheKey(chunkHash)) { onDiskKeys.add(chunkHash); } } // Lazy: index only — load individual shards on cache hit (#1983). return { version: PARSE_CACHE_VERSION, entries: new Map(), usedKeys: new Set(), storagePath, onDiskKeys, }; } catch { return null; } }; /** * Load the parse cache. Returns an empty cache on any failure (missing * file, corrupt JSON, version mismatch). Never throws on a normal load. */ export const loadParseCache = async (storagePath: string): Promise => { const sharded = await loadShardedParseCache(storagePath); if (sharded) return sharded; return loadLegacyParseCache(storagePath); }; /** * Persist the cache to disk using a temp directory + rename. * * Writes shards under `${cacheDir}.tmp`, then removes the old `cacheDir` and * renames the temp directory into place. There is a crash window after * `rm(cacheDir)` and before `rename(tmpDir, cacheDir)` where no cache exists; * that is acceptable — `loadParseCache` yields empty and the next run * reparses. This is not a single atomic swap of the whole tree, but avoids * leaving a half-written shard set visible to readers. */ export const saveParseCache = async (storagePath: string, cache: ParseCache): Promise => { await fs.mkdir(storagePath, { recursive: true }); const cacheDir = getCacheDirPath(storagePath); const tmpDir = `${cacheDir}.tmp`; await fs.rm(tmpDir, { recursive: true, force: true }); await fs.mkdir(tmpDir, { recursive: true }); // A stale key is dropped here rather than at the failure site: the // orchestrator folds sibling-branch keys back into `usedKeys` after the parse // phase (#2106), so this is the last point that sees the final key set. The // exclusion also reaches the durable store, which prunes to the keys this // function returns — both stores drop the chunk together (#3204). const keys = [...cache.usedKeys] .filter((key) => isValidChunkCacheKey(key) && !cache.staleKeys?.has(key)) .sort(); // Track hashes whose shard was actually written/copied this save. A hash can // be in `usedKeys` without a backing shard — its in-memory serialize threw, or // its on-disk copy failed/was-absent (e.g. a worker-quarantined chunk added to // usedKeys but never persisted). Writing such a hash into `index.keys` would // make the next load reference a shard that doesn't exist (#1983). Build the // index from what we persisted, not from the raw usedKeys snapshot. const writtenKeys: string[] = []; for (const chunkHash of keys) { const chunkPath = path.join(tmpDir, `${chunkHash}.v8`); const inMemory = cache.entries.get(chunkHash); if (inMemory !== undefined) { if (await writeV8CacheFile(chunkPath, inMemory)) { writtenKeys.push(chunkHash); } continue; } // Cold rebuilds persist mid-run under `cache.storagePath` (staging). Prefer // that generation over a same-hash shard still sitting in the live dir so // we never publish a mixed old/new pair. Sibling-branch keys (#2106) that // this run did not rewrite still copy from the live path. const stagedPath = cache.storagePath !== undefined && cache.storagePath !== storagePath ? getCacheChunkPath(cache.storagePath, chunkHash) : undefined; const livePath = getCacheChunkPath(storagePath, chunkHash); const fromStaged = Boolean(stagedPath && cache.onDiskKeys?.has(chunkHash)); const sourcePath = fromStaged && stagedPath ? stagedPath : livePath; if (await copyV8CacheIfPresent(sourcePath, chunkPath)) { writtenKeys.push(chunkHash); } } const index: ShardedParseCacheIndex = { version: cache.version, keys: writtenKeys, }; await fs.writeFile(path.join(tmpDir, CACHE_INDEX_FILENAME), JSON.stringify(index), 'utf-8'); await fs.rm(cacheDir, { recursive: true, force: true }); await fs.rename(tmpDir, cacheDir); await fs.rm(getLegacyCachePath(storagePath), { force: true }); // The authoritative final key set actually backed by a shard on disk. // Callers (the durable ParsedFile store) prune to exactly these so the two // content-addressed stores stay coherent — a chunk is cached iff BOTH have it. return writtenKeys; }; /** * Drop entries whose hashes are not in `usedHashes`. Called at the end * of a run so chunks that no longer correspond to any current chunk * don't keep their stale entries forever. */ export const pruneCache = (cache: ParseCache, usedHashes: ReadonlySet): number => { let removed = 0; for (const k of cache.entries.keys()) { if (!usedHashes.has(k)) { cache.entries.delete(k); removed++; } } if (cache.onDiskKeys) { for (const k of cache.onDiskKeys) { if (!usedHashes.has(k)) { cache.onDiskKeys.delete(k); removed++; } } } return removed; }; export const emptyParseCache = (storagePath?: string): ParseCache => ({ version: PARSE_CACHE_VERSION, entries: new Map(), usedKeys: new Set(), storagePath, onDiskKeys: storagePath ? new Set() : undefined, }); const emptyCache = emptyParseCache;