mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-27 06:12:20 +00:00
* test(go): pin calls through an interface-typed struct field (#2813) A call through an interface-typed struct field never reaches the implementation: the CALLS edge stops at the interface DECLARATION, so `impact()` on the implementing method reports 0 callers. This commit adds the executable statement of that defect; the fixes follow. Two stacked defects produce it, and either alone is enough to reproduce — which is why no existing fixture could observe it: D1 `buildDetectionIndexes` skips every POINTER-receiver method, so a struct whose methods are all `func (r *T)` has an empty method set, structurally satisfies nothing, and gets no IMPLEMENTS edge. Go's rule is that the method set of *T includes pointer-receiver methods, and idiomatic Go stores *T in an interface-typed field. D2 Case 0 (compound receiver) emits its primary edge and short-circuits without the interface-dispatch fan-out Case 4 performs. A struct field receiver `s.orderRepo` contains a dot and so always takes Case 0; a local or parameter receiver is a bare name and reaches Case 4. Every implementor in both pre-existing structural-dispatch fixtures uses a VALUE receiver, and the one pointer-receiver type is pinned as a negative (`not.toContain('PointerOnlyThing -> PointerOnly')`), so the corpus could not see D1 by construction. The new fixture is pointer-receiver throughout, cross-package, and carries concrete-field controls in the same structs. Failing-first, verified against this tree: 7 of the 11 new assertions fail and 4 pass. The 4 that pass are exactly the controls that must not regress — the primary edge to the interface declaration, the concrete-field call, the absence of fan-out on a concrete field, and the partial-signature negative — so the suite discriminates rather than merely failing. Two recorded artifacts move here because the FIXTURE was added, not because capture output changed: - test/fixtures/go-captures-golden/expected-captures.json — regenerated additively (32 insertions, 0 deletions). - bench/scope-capture/baselines.json — go fingerprint, fixture_count 102 -> 110. Both are regenerated in this commit rather than deferred to the end of the series: the fixture is their only cause, no later commit touches capture emission, so they cannot re-drift and every commit stays green. The check that this is corpus growth and not a capture regression is that go was the only one of 15 language fingerprints to move on the same run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(go): count pointer-receiver methods toward structural interface satisfaction (#2813) D1 of two stacked defects. `buildDetectionIndexes` skipped every method whose receiver is a pointer, so a struct declaring `func (r *OrderRepo) DeleteItem(...)` had an EMPTY method set, structurally satisfied nothing, and produced no IMPLEMENTS edge at all. Go's method-set rule is per-type, and there are two types involved: the method set of `T` holds only value-receiver methods, while the method set of `*T` holds both. #1966 implemented the `T` reading, which is exactly right for `T` — and leaves `*T` permanently empty. GitNexus models one Struct node per type with no separate `*T` node, so only one of the two can be represented, and the `T` reading is the one idiomatic Go almost never uses: methods take pointer receivers so they can mutate, and `*T` is what gets stored in an interface-typed field. The cost was silence rather than caution. With no IMPLEMENTS edge, a call through an interface-typed field resolved to the interface DECLARATION and `impact()` on the implementing method returned 0 callers — byte-identical to a symbol that genuinely has none, which is what made the reporter's blast-radius check unusable rather than merely incomplete. This picks the `*T` reading: the graph now answers "which types provide this interface's behaviour", and no longer proves `var x I = T{}` invalid. The trade is deliberate and was checked against every consumer of IMPLEMENTS before being made — MRO/METHOD_IMPLEMENTS derivation, community clustering, the receiver-dispatch fan-out index, and the epistemic heritage probe. None performs value-assignability checking. Two negative pins encoded the #1966 decision and are REVERSED here rather than deleted, each keeping a comment that explains why the polarity moved: - go.test.ts: `PointerOnlyThing -> PointerOnly` now expected to be emitted. - go-hooks.test.ts: the pointer-receiver-only unit case now expects the implementor instead of `undefined`. `goReceiverKind` is still stamped in method-owners.ts — it is the hook a future value/pointer-aware model would read — but is deliberately no longer a filter. Its now-dead local predicate and type alias are removed so the file no longer carries a helper asserting the reverted rule. Measured on the #2813 fixture, this commit alone: the two IMPLEMENTS assertions flip to passing (6 pass, up from 4) while the five interface-dispatch fan-out assertions still fail — those are D2, fixed in the next commit. Keeping the two commits separate is what makes that attribution visible. Go unit suite: 91 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(resolution): fan out interface dispatch from a compound receiver (#2813) D2 of two stacked defects, and the one that closes the issue. Case 0 (compound receiver) emitted its primary edge and short-circuited without the interface-dispatch fan-out that Case 4 performs, so a call whose receiver is a struct FIELD stopped at the interface's method DECLARATION and never reached any implementation. The gap was a property of receiver SYNTAX rather than of types. Case 0 is selected by `receiverName.includes('.')`, so a field receiver (`s.orderRepo`) always lands there, while the very same interface reached through a local or a parameter is a bare name and falls through to Case 4 — which fans out correctly. Field-held interfaces, i.e. dependency injection, were the half that silently lost every implementation edge; the pre-existing fixtures exercise the local and parameter forms only, which is why the suite was green. The fix is the call Case 4 already makes, placed after Case 0's primary `tryEmitEdge` and before its `handledSites.add`. It stays language-agnostic (AGENTS.md section 42): `emitInterfaceDispatchFor` self-gates on `ownerDef.type !== 'Interface'`, so a receiver that folds to a Struct emits nothing extra and no language check is needed. Confidence is Case 0's own 0.85 literal, not Case 4's site.kind-dependent value — Case 0 has no read/write arm to mirror. The case ladder itself is untouched: invariant I4 in contract/scope-resolver.ts makes the ordering a contract, so the fan-out is added INSIDE Case 0 rather than by reordering or merging cases. Also flips a second, previously unnoticed encoding of the #1966 value-only reading that the full sweep surfaced: the exact-set assertion at go.test.ts:361 enumerates every structural IMPLEMENTS edge, and D1 correctly adds `PointerOnlyThing -> PointerOnly` to it. It is D1 fallout rather than D2's, but D1 had already landed; recording it here with its reason beats amending a commit whose separate measurability is the point. Measured: - #2813 suite: 11 of 11 pass (was 7 failing after D1 alone, which fixed only the two IMPLEMENTS rows). - go.test.ts: 160 passed. - Full cross-language sweep, test/integration/resolvers: 3027 passed, 1 skipped, across 52 files. The single failure in that run was the exact-set assertion above, fixed here; no other language regressed. `detect_changes` rates this HIGH (6 affected flows, all EmitReceiverBoundCalls at step 1) — inherent to editing a hub symbol in the resolution pipeline. The sweep above is the empirical answer to that label. An existing index must be re-analyzed to show the new edges; this changes what the resolver produces, not how it is stored, so no SCHEMA_BUMP applies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(go): pin the heritage edges that make impact() hedge an interface-bound count (#2813) The epistemic half of the issue, resolved by MEASUREMENT rather than by new code, and pinned at its mechanism. The reporter's disqualifying complaint was that `impact()` reported `impactedCount 0, epistemic "exact", risk LOW` for a method reachable only through an interface-typed field — byte-identical to what it reports for a symbol that genuinely has no callers. A zero therefore could not be used defensively, which was the entire use case. That verdict comes from `computeEpistemicBoundary`, which has two producers and neither fired: the call sites were not DROPPED (they resolved, just to the interface declaration, so the #2744 receiver-typing producer saw nothing), and its heritage probe walks IMPLEMENTS/METHOD_IMPLEMENTS edges out of the queried symbol — of which there were none, because the pointer-receiver exclusion (D1) meant no such edge was ever emitted. Restoring those edges fixes the epistemics as a side effect, so the planned conditional change to local-backend.ts is NOT needed. Measured on this fixture against the fixed tree: impact(OrderRepo.DeleteItem, upstream) before: impactedCount 0, epistemic "exact" after: impactedCount 3, epistemic "lower-bound", with an interface boundary note; the three callers are OrderHandlers.Delete, PickService.StartSession and WaveService.Release — all correct. impact(CartRepo.Get, upstream) [concrete receiver, no interface] after: impactedCount 1, epistemic "exact" The second row is the one that matters for trust: the hedge discriminates instead of firing on everything, so "exact" still means exact. This test asserts the METHOD_IMPLEMENTS edges the probe walks. Pinning the mechanism keeps the resolver suite from reaching into the MCP layer while still failing loudly if the edges regress; the impact() numbers above are recorded in the commit message and PR body rather than re-asserted here. #2813 suite: 12 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(go): model Go method sets exactly so interface satisfaction is decidable (#2813) Replaces the approximate structural-interface model with the rules the Go spec actually defines, so the graph answers what the compiler answers instead of a useful-but-wrong summary of it. Three answers were provably wrong before; all three are now exact and covered. Method sets (go.dev/ref/spec#Method_sets): MS(T) = methods declared with receiver T MS(*T) = methods declared with receiver *T OR T Promotion (#Struct_types): S embeds T -> MS(S) and MS(*S) get promoted methods with receiver T; MS(*S) ALSO gets those with receiver *T S embeds *T -> MS(S) AND MS(*S) both get receiver T or *T Identifier identity (#Uniqueness_of_identifiers): "Two identifiers are different if they are spelled differently, OR IF THEY APPEAR IN DIFFERENT PACKAGES AND ARE NOT EXPORTED." func (b *Base) Ping() // pointer receiver type ByValue struct{ Base } type ByPointer struct{ *Base } type before exact answer Base IMPLEMENTS only *Base implements ByValue IMPLEMENTS only *ByValue implements ByPointer IMPLEMENTS the VALUE type implements All three were the same edge. Two of the three were wrong, and nothing in the graph could tell them apart. Worse, in a different direction: package sealed; type Sealed interface { seal() } package foreign; func (t *T) seal() {} `foreign.T` cannot implement `sealed.Sealed` in Go — `seal` is unexported, so the two identifiers are DIFFERENT. Matching on the bare name emitted a FALSE IMPLEMENTS edge, and the interface-dispatch fan-out then turned it into an impossible CALLS edge. That is the entire basis of the sealed-interface idiom. - `methodSetKey` qualifies UNEXPORTED method names with their declaring package, leaving exported names unqualified (which is what makes cross-package satisfaction work at all). Exactness, not a heuristic: the sealed case now emits no edge, while the legitimate same-package implementor is retained. - `collectStructMethodEntries` builds MS(T) and MS(*T) together and applies the promotion table above. The embed FORM is load-bearing, so it is now captured: `@reference.embedded-pointer` records `*T` versus `T`, which the parser previously discarded (the `*` is an unnamed token). - Detection returns `{ structDefId, receiverForm }`. `receiverForm: 'pointer'` means the value type does NOT implement and only `*T` does — the fact `var x I = T{}` turns on. - The form rides in the edge `reason` (`-structural-implements-pointer`). Relationships carry no arbitrary properties, so a new field would change the relation DDL, move SCHEMA_FINGERPRINT and force a rebuild for a fact a string already expresses. Value-form implementors keep the ORIGINAL unsuffixed reason, so a consumer matching the old string now sees exactly the assignable set — which is what that string always claimed to mean. - `emitInterfaceDispatchFor` walks the SUBTYPE CLOSURE (IMPLEMENTS + EXTENDS) and skips bodiless declarations, instead of stopping at depth 1. Two reproduced Java shapes emitted an edge to a second abstract declaration while the only class with a body got nothing: a sub-interface that re-declares the method, and an abstract base between interface and implementation. Both now reach the implementation and neither emits the declaration edge. - The fan-out is bounded by `MAX_INTERFACE_DISPATCH_FANOUT` (32, `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and reports what it dropped, mirroring `MAX_PROPERTY_DISPATCH_FANOUT`. A bare cap would silently discard valid dispatch targets, which is the same false-safe silence this issue is about. - Corrects a rationale comment that was factually wrong about the code 70 lines above it (Case 0 DOES branch on `site.kind`, at :713-716; what it lacks is a read/write branch in its reason/confidence computation). - Updates both copies of the case-ladder contract, which still described the fan-out as Case-4-exclusive. The embed-pointer marker is PARSE-TIME capture emission, so a warm cache would replay the pre-marker capture set and the distinction would never appear — silently, the v27/v30 failure mode. 43 and not 40 because origin/main allocated 40, 41 and 42 while this branch was in review, which is exactly the window this file's history records both prior EXACT clashes landing in. Pin moved with it. RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGE. - Go unit: 93 passed, including new rows pinning that `populateGoOwners` stamps `goReceiverKind` (previously the field had no reader and could rot silently) and that a pointer-receiver-only type implements in POINTER form only. - Cross-language sweep, test/integration/resolvers: 3034 passed, 1 skipped, 52 files, zero regressions. - scope-capture bench: PASS (15 languages). Go is the ONLY fingerprint that moved, which is the check that this is a Go capture change and not a cross-language regression; rebaselined with rationale. - Also closes review gaps in this PR's own tests: the concrete-field control was vacuous with respect to the type gate (repointed at a struct that IS an implementor), the two-service-file row could not distinguish the two files it is named for (both ends now file-qualified), plus new rows for signature mismatch, emitted confidence, and an exact N-by-M fan-out bound. An existing index must be re-analyzed; the schema bump forces it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
662 lines
32 KiB
TypeScript
662 lines
32 KiB
TypeScript
/**
|
|
* 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. The parse phase chunks files into ~20MB byte
|
|
* budgets. The cache key is `sha256(joined(filePath:contentHash for each
|
|
* file in the chunk, sorted))`. A change to a single file invalidates only
|
|
* that file's chunk — typically 1 of ~50 chunks on a 1000-file repo.
|
|
*
|
|
* 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.
|
|
*
|
|
* Survives `--force` because it's content-addressed: the same bytes always
|
|
* produce the same key. `--force` only matters for the LadybugDB writeback;
|
|
* the cache itself is always safe to reuse.
|
|
*/
|
|
|
|
import { createHash } from 'crypto';
|
|
import { createRequire } from 'module';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.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 = <arrow | function-expression>` 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.<field> = 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.
|
|
const SCHEMA_BUMP = 43;
|
|
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}`;
|
|
|
|
const LEGACY_CACHE_FILENAME = 'parse-cache.json';
|
|
const CACHE_DIRNAME = 'parse-cache';
|
|
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<string, ParseWorkerResult[]>;
|
|
}
|
|
|
|
/** 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<string, ParseWorkerResult[]>;
|
|
/**
|
|
* 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<string>;
|
|
/**
|
|
* 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).
|
|
*/
|
|
storagePath?: string;
|
|
/** Index of chunk hashes known to exist under `storagePath/parse-cache/`. */
|
|
onDiskKeys?: Set<string>;
|
|
}
|
|
|
|
/** SHA-256 hex of a single string or buffer. */
|
|
const sha256Hex = (input: Buffer | string): string =>
|
|
createHash('sha256')
|
|
.update(typeof input === 'string' ? Buffer.from(input) : input)
|
|
.digest('hex');
|
|
|
|
/** 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:5`): 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).
|
|
const ns = `pdg:5;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<string, TypeRef>`; 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<string, unknown>;
|
|
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}.json`);
|
|
|
|
/**
|
|
* 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<ParseWorkerResult[] | undefined> => {
|
|
if (!isValidChunkCacheKey(chunkHash)) return undefined;
|
|
try {
|
|
const chunkRaw = await fs.readFile(getCacheChunkPath(storagePath, chunkHash), 'utf-8');
|
|
const chunkData = JSON.parse(chunkRaw, mapReviver) as ParseWorkerResult[];
|
|
return Array.isArray(chunkData) ? chunkData : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
/** Load one chunk shard. Does not retain it in `cache.entries`. */
|
|
export const loadParseCacheChunk = async (
|
|
cache: ParseCache,
|
|
chunkHash: string,
|
|
): Promise<ParseWorkerResult[] | undefined> => {
|
|
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<string>();
|
|
|
|
/**
|
|
* 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<void> => {
|
|
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 payload = JSON.stringify(slim, mapReplacer);
|
|
await fs.writeFile(getCacheChunkPath(cache.storagePath, chunkHash), payload, 'utf-8');
|
|
cache.onDiskKeys ??= new Set<string>();
|
|
cache.onDiskKeys.add(chunkHash);
|
|
cache.entries.delete(chunkHash);
|
|
return;
|
|
}
|
|
cache.entries.set(chunkHash, slim);
|
|
};
|
|
|
|
const loadLegacyParseCache = async (storagePath: string): Promise<ParseCache> => {
|
|
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<string, ParseWorkerResult[]>();
|
|
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<string>(), storagePath };
|
|
} catch {
|
|
return emptyCache(storagePath);
|
|
}
|
|
};
|
|
|
|
const loadShardedParseCache = async (storagePath: string): Promise<ParseCache | null> => {
|
|
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<string>();
|
|
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<string, ParseWorkerResult[]>(),
|
|
usedKeys: new Set<string>(),
|
|
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<ParseCache> => {
|
|
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<string[]> => {
|
|
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 });
|
|
|
|
const keys = [...cache.usedKeys].filter(isValidChunkCacheKey).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}.json`);
|
|
const inMemory = cache.entries.get(chunkHash);
|
|
if (inMemory !== undefined) {
|
|
let payload: string;
|
|
try {
|
|
payload = JSON.stringify(inMemory, mapReplacer);
|
|
} catch {
|
|
continue;
|
|
}
|
|
await fs.writeFile(chunkPath, payload, 'utf-8');
|
|
writtenKeys.push(chunkHash);
|
|
continue;
|
|
}
|
|
const existingPath = getCacheChunkPath(storagePath, chunkHash);
|
|
try {
|
|
await fs.copyFile(existingPath, chunkPath);
|
|
writtenKeys.push(chunkHash);
|
|
} catch {
|
|
/* shard missing — skip; next run treats as cache miss */
|
|
}
|
|
}
|
|
|
|
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<string>): 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;
|
|
};
|
|
|
|
const emptyCache = (storagePath?: string): ParseCache => ({
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map<string, ParseWorkerResult[]>(),
|
|
usedKeys: new Set<string>(),
|
|
storagePath,
|
|
onDiskKeys: storagePath ? new Set<string>() : undefined,
|
|
});
|