mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
* fix(scope-resolution): a named receiver's member never resolves lexically (#2699) `lookupCore` Step 1 walked the lexical scope chain for every lookup, including explicit-receiver property reads. So `options.baseUrl` could bind to an unrelated function-local `const baseUrl` in the same file, and `config.extractVisibility(node)` to the enclosing class's own method. This is the residual half of the defect JS/TS block scopes narrowed in #2695. Blocks moved nested-block locals off the chain of a reference outside the block, which removed 114 false edges; a local declared directly in the function body stayed on it, and no amount of extra scopes reaches that case. Fixed at the cause instead: `recv.name` names a member of whatever `recv` denotes, so a binding of the bare tail name in an enclosing scope is never the right answer. Steps 2 and 3 (receiver type / owner members) are the legitimate routes. `this` and `self` are EXEMPT, and that exemption was measured, not assumed. Skipping Step 1 for every explicit receiver removed 711 edges on a 762-file corpus — but 2 of those were genuine: `self.srcIx` and `self.streamedAt(...)` after `const self = this`, reaching their own class's members through the class-body scope. For a self-receiver the members and the lexical chain legitimately overlap; for a named receiver they never do. Exempting the self names keeps both true edges and still removes 709 false ones, adding none. The removals were classified by reading source at the site, not by pattern- matching ids — an "is the target a member of the source's owner?" heuristic labelled 43 of them plausible and every one I then read was false: language = config.language; -> the class's own `language` dirMap.get(...) / exactMap.get(...) -> a sibling object-literal `get` return config.extractVisibility(n); -> the class's own method (self-edge) writer.close(); -> GraphEmitSink.close Residual, deliberately kept: a `this.x` read can still bind lexically to a same-named local. That is the price of the two true self-alias edges above. `INCREMENTAL_SCHEMA_VERSION` 19 -> 20: a v19 index holds these false CALLS/ACCESSES on every unchanged file and would keep serving them through the reuse gate. Test confirmed discriminating: it fails with the guard reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR * fix(typescript,javascript): a generator expression binding is a Function node (#2693) `const g = function* () {}` matched none of the closure-binding definition rules — they covered `arrow_function` and `function_expression` only — so the binding emitted a `Const` node. `buildGraphTargetIndex` admits callable nodes only, so `g()` resolved to nothing. Same defect shape as the `var` case #2693 already fixed: a different grammar node for the same construct, and the resulting graph node was not callable. Adds the four variable-binding shapes in both languages: `const`/`let` and `var`, each plain and exported. Purely additive — no existing pattern is reordered or rewritten, because the #2687 pre-scan dedup is order-dependent and collapsing the value/callable pair depends on which match wins. Deliberately NOT covered, and the query comment says so: a generator in an object-literal pair or a HOC wrapper still falls through anonymous. Those are rarer, and each additional pattern is another chance to disturb the dedup. `SCHEMA_BUMP` 26 -> 27: definition captures are parse-time, so a warm parse cache would replay the old ones verbatim — `--force` does not clear it. Two tests confirmed discriminating (they fail with the patterns reverted), plus a guard that the already-working generator DECLARATION form is unaffected, since it shares the emit path these were inserted beside. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR * fix(ingestion): keep caller attribution in lockstep with definition ids (#2699) The definition phase appends `localIdentity` to a nested callable's own name segment (`run.save@3:2`); `findEnclosingFunctionId` did not, so the two phases derived different ids for the same callable. The failure mode is silent — the caller id names a node that does not exist, so the edge is dropped rather than reported — which is why the parse-worker docblock calls this pair a lockstep guarantee and asks that both phases derive the prefix from one place. The condition is now byte-identical to the definition phase's (`nestedPrefix !== undefined`), so the two cannot diverge again. Scope of the claim, stated plainly: no reproducing case was found, and this changes nothing measurable on a 762-file TypeScript corpus. TS/JS resolve callers through `resolveCallerGraphId` in the graph bridge, not this path; `findEnclosingFunctionId` serves the `callExtractor` languages, and the corpus does not exercise a nested callable there. The review that raised it (P3) observed zero dangling edges, and "zero dangling" is also what silently dropped edges look like — so this closes a documented contract rather than a demonstrated bug, and carries no test of its own. Rides the `SCHEMA_BUMP` 26 -> 27 in the preceding commit: caller attribution runs in the worker, so a warm parse cache would replay the old ids. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR * docs(test): correct the block-scope header that this PR made false (#2699) Review finding (MEDIUM). The file header still described `lookupCore` Step 1 as walking the lexical chain for EVERY lookup, and called the function-body-local case "unchanged and still mis-resolves ... pre-existing and tracked separately". Commit59b892cain this same PR falsified both, and the describe block added ~80 lines lower in this same file asserts the opposite — a reader scoping future work from the header would have concluded the case was still open. Rewritten to state what the code does: Step 1 is skipped for a NAMED explicit receiver, the function-body case is fixed here, and the surviving residual is that a `this`/`self` read can still bind lexically to a same-named local — with the reason those two names are exempt (they keep the genuine `const self = this; self.member` reads that Step 1 resolves correctly). Also corrects a PRE-EXISTING staleness inherited from #2695 in the same paragraph block: "the genuine bare read of that same local must still emit its edge" describes a test that no longer exists, because TypeScript emits no `@reference.read` for bare identifiers at all. Fixed here rather than left adjacent to a freshly corrected sentence. Comments only — `detect_changes` reports 0 changed symbols across 1 file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR * refactor(ingestion): give the nested-callable id rule one definition (#2699) Review finding (LOW): the lockstep change in this PR shipped without a test. The plan called for a unit test asserting the two id-derivation phases agree. Two things changed that plan during execution, both recorded here. FIRST — there are THREE phases, not two. Re-verifying the plan's assumption (`grep -n localIdentity`) found a third call site: the worker-path node-id derivation in `processFileGroup` (parse-worker.ts:2316), whose own comment already acknowledged the coupling. `impact` on `localIdentity` corroborates: three direct dependents, all in the Workers module. So the invariant three phases must agree on is now ONE function, `nestedCallableQualifiedName`, and divergence requires deleting a call rather than editing a duplicated expression. SECOND — the planned `_forTest` alias seam does not work for this module. `parse-worker.ts` posts a `ready` message to `parentPort` at module scope, so value-importing it from a unit test throws before any test runs; the existing unit tests that reference it use `import type` only, which erases. The rules therefore move to a new pure module, `workers/callable-id.ts`. That is what makes them testable at all, rather than merely commented. Pure refactor — no id changes. Verified by the suites that assert exact node ids (`Function:svc.ts:run.save@7:2`, `Function:c.php:run.$save@3:2`): 74/74 green, and `detect_changes` reports only the three expected symbols and the two `processFileGroup` flows `impact` predicted. The test pins both halves: the rule's contract, and a structural assertion that no site has re-inlined `${prefix}.${localIdentity(...)}` — the unit assertions alone would still pass if a fourth phase spelled the rule out by hand, which is exactly how the divergence arose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR * fix(scope-resolution): give PHP's `$this` the same self-receiver exemption (#2699) Review finding (LOW). The Step-1 skip added in this PR exempts `this`/`self`, but the receiver name arrives as the reference node's RAW SOURCE TEXT — `extractExplicitReceiver` returns `cap.text` verbatim — so PHP's `$this->x` presents as the string "$this" and matched neither entry. PHP was the one supported language whose self-receiver got no exemption at all. Measured, and the measurement is why this is framed as consistency rather than a bug fix: - Corpus delta ZERO. 762-file TypeScript corpus, CALLS+ACCESSES set diff: 13179 -> 13179, added 0, removed 0. So no INCREMENTAL_SCHEMA_VERSION bump (stays 20), per the plan's decision rule. - No PHP shape found that DISCRIMINATES. Both the simple `$this->prop` / `$this->helper()` shapes and a closure reading `$this->…` inside a method that also declares a same-named local produce byte-identical edge sets with `$this` present and absent — Step 2 resolves the receiver's type first. The added test is therefore labelled a COMPANION INVARIANT, exactly as the `this.baseUrl` case beside it is, and does not claim to prove the fix. It is still worth making: the exemption is protective, and the 709-removed / 0-true-lost measurement that justified the narrow guard was TypeScript-only, so PHP's safety was never established by evidence. This closes that by construction. Two corrections to what the plan assumed, both found by checking: - The plan (and my first draft of this comment) claimed the codebase had no precedent for handling a sigil'd receiver name. FALSE: `THIS_RECEIVERS` in `core/ingestion/type-env.ts:244` has always listed `$this`, and it is the ingestion-side twin of this very list. The precedent does not merely exist, it validates the approach chosen here — list the spelling as data, do not strip sigils. - That twin also lists `Me`. Deliberately NOT mirrored: no entry in `SupportedLanguages` is Visual Basic, so it could only ever exempt a variable that happens to be called `Me`. The two lists are otherwise the same set with nothing enforcing it — a fifth instance of the twin-list drift class this PR keeps meeting. A drift guard is the right fix and is out of scope here; noted for follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR * fix(rust): resolve `Self` in scope-resolution type bindings (#2699) CI regression, caught by `tests / ubuntu / coverage` on13d5e738and traced to the named-receiver Step-1 skip earlier in this PR (59b892ca), not to the three commits above it — verified by reverting those three and reproducing the failure unchanged. `test/integration/resolvers/rust.test.ts > resolves fresh.validate() inside impl User via Self {} inference` failed: 192/192 on main, 191/192 on this branch. The fixture calls `fresh.validate()` where `let fresh = Self { .. }` inside `impl User` — a genuine call to `User::validate`, and a TRUE edge that the skip deleted. Root cause is a twin-channel disagreement, not the skip: - `type-extractors/rust.ts:142` substitutes `Self` -> the enclosing impl type into the TYPE-ENV channel via `findEnclosingImplType`. - `languages/rust/interpret.ts` recorded `@type-binding.type` verbatim, so the SCOPE-RESOLUTION channel bound `fresh: Self` — a type that does not exist, leaving the receiver's type unknown and Step 2 unable to resolve. `main` passed only because Step 1 still walked the lexical chain for named receivers: the impl scope binds `validate` by name, so the call resolved BY ACCIDENT. Stopping that walk turned a latent gap into a lost edge. The fix closes the gap rather than restoring the accident — `Self` is now substituted at capture-emit time in `languages/rust/captures.ts`, where the impl node is reachable, reusing the `findEnclosingImpl` + `syntheticCapture` idiom already in that file. CORRECTION to this PR's central claim. "709 removed / 0 added / 0 true edges lost" was measured on a 762-file TYPESCRIPT corpus and stated without that qualifier. Rust lost one true edge. The measurement stands for TypeScript; it did not generalise, and the PR body is being updated to say so. Scope of the breakage, measured rather than assumed: 1 failure in 2927 tests across all 51 resolver files. Every other language — Go, Java, C#, Kotlin, Swift, Python, PHP, Ruby, Dart, C++ — passes, which is why this is a targeted fix and not a revert of the skip. Re-baselined `bench/scope-capture` for RUST ONLY (655aed01 -> 7f1240b3); the other 14 language fingerprints are byte-identical. The drift is the intended output change and the reason is recorded in the baseline entry, per that file's own "explain, never re-baseline to make CI green" rule. Verified: rust resolvers 192/192; all 51 resolver files 2926 passed / 1 skipped / 0 failed; the 8 targeted suites 96/96; all 8 CI bench gates PASS; `tsc --noEmit` clean; `detect_changes` reports one touched symbol (`emitRustScopeCaptures`) and no affected flows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR * test(golden): refresh the Rust capture golden and the C# PDG snapshot (#2699) The two committed artifacts CI flagged after5f55fe46. They drifted for OPPOSITE reasons, so each was inspected before regenerating rather than refreshed on sight. RUST GOLDEN — drifted because5f55fe46CORRECTS the output. A `Self` type binding now records the enclosing impl's type instead of the literal `Self`, in both the `let x = Self { .. }` and `fn new() -> Self` forms. Blast radius verified exact: 5 fixtures drifted, all 5 contain `Self`, and every `Self`-bearing rust fixture is among them (rust-self-struct-literal, rust-constructor-type-inference, rust-default-constructor, rust-method-enrichment, rust-scoped-multi-file). C# PDG SNAPSHOT — drifted because the named-receiver Step-1 skip (59b892ca) REMOVED A FALSE EDGE. CALLS 7 -> 6, and the edge that went is: Demo.Resolve.Parse@142:12#1 -> Demo.Resolve.Parse@142:12#1 a self-call, from `int Parse(string v) => int.Parse(v);`. `int.Parse(v)` is System.Int32.Parse; the lexical chain was binding it to the enclosing local function that happens to also be called `Parse`. Same defect class as `writer.close()` -> GraphEmitSink.close. The snapshot's own comment says it exists so "a future refactor that silently rewires the C-family graph trips this gate" — it tripped correctly, and the rewiring is an improvement. Both failures were PRE-EXISTING on this PR from59b892ca, not from the three commits above it — verified by reverting those and reproducing unchanged. They went unseen because this PR's CI was never watched after its first push. Verified after regeneration, WITHOUT update flags so they must genuinely pass: rust-captures-golden 9/9; pipeline-pdg 31/31. The snapshot diff is 3 lines, all inside the C# entry — no other language's snapshot moved. `detect_changes` reports 0 changed symbols (test artifacts only). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184RmD24KFJidYqpM7v3XjR --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
541 lines
24 KiB
TypeScript
541 lines
24 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.
|
|
// 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
|
|
// INCREMENTAL_SCHEMA_VERSION in #2653/#2654.
|
|
// 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.
|
|
// 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.
|
|
const SCHEMA_BUMP = 27;
|
|
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,
|
|
});
|