gitnexus 1.6.12-rc.39 → 1.6.12-rc.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,8 +16,8 @@
16
16
  */
17
17
  import { BindingAccumulator, enrichExportedTypeMap, } from '../binding-accumulator.js';
18
18
  import { mergeChunkResults, dispatchChunkParseRound } from '../parsing-processor.js';
19
- import { fileContentHash, computeChunkHash, loadParseCacheChunk, persistParseCacheChunk, PARSE_CACHE_VERSION, packParseCacheChunks, } from '../../../storage/parse-cache.js';
20
- import { clearParsedFileStore, persistParsedFileChunk, loadParsedFilesForPaths, getDurableParsedFileDir, loadDurableParsedFileIndex, prepareDurableParsedFileChunk, durableChunkHasShards, } from '../../../storage/parsedfile-store.js';
19
+ import { fileContentHash, computeChunkHash, loadParseCacheChunk, persistParseCacheChunk, markParseCacheChunkStale, PARSE_CACHE_VERSION, packParseCacheChunks, } from '../../../storage/parse-cache.js';
20
+ import { clearParsedFileStore, persistParsedFileChunk, loadParsedFilesForPaths, getDurableParsedFileDir, loadDurableParsedFileIndex, prepareDurableParsedFileChunk, durableChunkHasShards, durableChunkHasStaleShards, } from '../../../storage/parsedfile-store.js';
21
21
  import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
22
22
  import { processRoutesFromExtracted, resolveRouteHandlerSymbols, buildExportedTypeMapFromGraph, } from '../call-processor.js';
23
23
  import { createSemanticModel } from '../model/index.js';
@@ -669,8 +669,12 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
669
669
  * Chunk hashes whose durable ParsedFile directory could not be reset. The
670
670
  * old generation's shards are still on disk, so a warm hit would union
671
671
  * stale shards with the new ones. Treated exactly like a quarantined chunk:
672
- * skip the parse-cache write so the next run re-dispatches into a clean
673
- * directory rather than trusting a generation we could not clear.
672
+ * skip the parse-cache write AND, when shards from that generation are
673
+ * still on disk, retire the hash (#3204): the old `.v8` is not carried
674
+ * forward and the directory is dropped from the durable index, so the next
675
+ * run re-dispatches. Kept as its own set rather than
676
+ * read back off `staleKeys`, which is a superset — it is what selects the
677
+ * warn below over the quarantine branch's dev-only log.
674
678
  */
675
679
  const durablePrepareFailures = new Set();
676
680
  const roundByteBudget = resolveParseRoundByteBudget(options);
@@ -809,7 +813,11 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
809
813
  // Persist raw results for this chunk hash (skipping when any chunk file
810
814
  // was worker-quarantined, so the narrower rawResults isn't cached under
811
815
  // the full-chunk key — see the original inline note / U20.U2).
812
- if (parseCache && p.chunkHash && rawResults.length > 0) {
816
+ // `rawResults.length > 0` guards the WRITE only. A quarantined chunk
817
+ // often returns nothing at all (the worker died on it), and that chunk
818
+ // still has to be retired — otherwise its pre-existing `.v8` is copied
819
+ // forward at save time (#3204).
820
+ if (parseCache && p.chunkHash) {
813
821
  const quarantineSet = new Set(workerPool?.getQuarantinedPaths?.() ?? []);
814
822
  const chunkHadQuarantine = p.chunkFiles.some((f) => quarantineSet.has(f.path));
815
823
  const durableGenerationStale = durablePrepareFailures.has(p.chunkHash);
@@ -818,6 +826,10 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
818
826
  'so its shards may be stale; next run will re-dispatch it');
819
827
  }
820
828
  else if (chunkHadQuarantine) {
829
+ // This chunk's durable directory now holds only this run's NARROWER
830
+ // shards, so a warm hit would replay the full-coverage `.v8` over
831
+ // partial ParsedFiles (#3204).
832
+ markParseCacheChunkStale(parseCache, p.chunkHash);
821
833
  if (isDev) {
822
834
  const quarantinedInChunk = p.chunkFiles.filter((f) => quarantineSet.has(f.path)).length;
823
835
  logger.info(`📦 parse-cache SKIP: chunk ${p.chunkIdx + 1}/${numChunks} ` +
@@ -825,7 +837,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
825
837
  `next run will rediscover (${p.chunkHash.slice(0, 8)})`);
826
838
  }
827
839
  }
828
- else {
840
+ else if (rawResults.length > 0) {
829
841
  await persistParseCacheChunk(parseCache, p.chunkHash, rawResults);
830
842
  if (isDev) {
831
843
  logger.info(`📦 parse-cache MISS+store: chunk ${p.chunkIdx + 1}/${numChunks} (${p.chunkFiles.length} files, ${p.chunkHash.slice(0, 8)})`);
@@ -871,6 +883,20 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
871
883
  // directory on write, so at worst the old generation lingers.
872
884
  // Caught per chunk so one failure cannot abort the others.
873
885
  durablePrepareFailures.add(miss.chunkHash);
886
+ // Retire ONLY when a generation nobody cleared is still on disk.
887
+ // `prepareDurableParsedFileChunk` is rm-then-mkdir: an rm failure
888
+ // leaves the old shards to be unioned into a warm hit, but an rm
889
+ // that succeeded before a failing mkdir leaves nothing — the
890
+ // workers recreate the directory and write a clean generation, so
891
+ // retiring there would discard a good `.v8` for no safety gain
892
+ // (and a correlated burst would discard the whole shared cache).
893
+ // Retire here rather than at finalize: that branch sits behind
894
+ // `rawResults.length > 0`, so a chunk whose worker round returns
895
+ // nothing would keep its stale entry.
896
+ if (parseCache &&
897
+ (await durableChunkHasStaleShards(durableParsedFileDir, miss.chunkHash))) {
898
+ markParseCacheChunkStale(parseCache, miss.chunkHash);
899
+ }
874
900
  logger.warn({ err, chunkHash: miss.chunkHash.slice(0, 8) }, 'parsedfile-cache: could not reset durable chunk generation; ' +
875
901
  'continuing without caching this chunk');
876
902
  }
@@ -3437,9 +3437,11 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
3437
3437
  // Prune the durable ParsedFile store to EXACTLY the parse cache's
3438
3438
  // surviving keys (#2038 warm-cache coverage), so the two content-addressed
3439
3439
  // stores stay coherent: a chunk is "cached" iff both its parse-cache shard
3440
- // and its durable shards exist. A quarantined chunk (in usedKeys but with
3441
- // no parse-cache shard) drops its durable subdir here and re-dispatches
3442
- // next run. Same try/catch — a durable-store write failure must never
3440
+ // and its durable shards exist. A retired chunk — worker-quarantined, or
3441
+ // one whose failed durable reset left an uncleared generation behind
3442
+ // (#3204) — is filtered out of `savedKeys`, so it drops out of the
3443
+ // durable index here and re-dispatches next run. Same try/catch — a
3444
+ // durable-store write must never
3443
3445
  // break an otherwise successful run (next run treats it as a miss).
3444
3446
  await mergeStagedDurableParsedFileStore(storagePath, parseCache.storagePath ?? storagePath, PARSE_CACHE_VERSION, new Set(savedKeys));
3445
3447
  }
@@ -68,6 +68,16 @@ export interface ParseCache {
68
68
  * Transient — never serialized to disk.
69
69
  */
70
70
  usedKeys: Set<string>;
71
+ /**
72
+ * Hashes this run decided it cannot vouch for — its durable generation could
73
+ * not be reset, or its chunk was worker-quarantined (#3204). `saveParseCache`
74
+ * refuses them, so neither a pre-existing `.v8` nor the chunk's durable
75
+ * directory survives into the next run. Kept separate from `usedKeys`
76
+ * because the orchestrator re-adds keys to that set after the parse phase
77
+ * (#2106 sibling fold), which would undo a deletion.
78
+ * Transient — never serialized to disk.
79
+ */
80
+ staleKeys?: Set<string>;
71
81
  /**
72
82
  * When set, chunk payloads are loaded from / flushed to sharded files on
73
83
  * demand instead of retaining every chunk in `entries` for the whole run
@@ -129,6 +139,18 @@ export declare const forgetCreatedParseCacheDir: (storagePath: string) => void;
129
139
  * run. Falls back to `cache.entries` when `storagePath` is unset (unit tests).
130
140
  */
131
141
  export declare const persistParseCacheChunk: (cache: ParseCache, chunkHash: string, chunkResults: readonly ParseWorkerResult[]) => Promise<void>;
142
+ /**
143
+ * Retire a chunk this run cannot vouch for — its durable ParsedFile generation
144
+ * could not be reset, or its chunk was worker-quarantined (#3204).
145
+ *
146
+ * `saveParseCache` refuses a stale key, so no pre-existing `.v8` is copied
147
+ * forward and the durable store — pruned to exactly the keys that save
148
+ * returns — drops the chunk in the same step. The two deletes matter because
149
+ * `loadParseCacheChunk` reads `entries` and `onDiskKeys` and does NOT consult
150
+ * `staleKeys`: without them a second lookup of the same hash inside this run
151
+ * would still serve the retired shard.
152
+ */
153
+ export declare const markParseCacheChunkStale: (cache: ParseCache, chunkHash: string) => void;
132
154
  /**
133
155
  * Load the parse cache. Returns an empty cache on any failure (missing
134
156
  * file, corrupt JSON, version mismatch). Never throws on a normal load.
@@ -1017,6 +1017,23 @@ export const persistParseCacheChunk = async (cache, chunkHash, chunkResults) =>
1017
1017
  }
1018
1018
  cache.entries.set(chunkHash, slim);
1019
1019
  };
1020
+ /**
1021
+ * Retire a chunk this run cannot vouch for — its durable ParsedFile generation
1022
+ * could not be reset, or its chunk was worker-quarantined (#3204).
1023
+ *
1024
+ * `saveParseCache` refuses a stale key, so no pre-existing `.v8` is copied
1025
+ * forward and the durable store — pruned to exactly the keys that save
1026
+ * returns — drops the chunk in the same step. The two deletes matter because
1027
+ * `loadParseCacheChunk` reads `entries` and `onDiskKeys` and does NOT consult
1028
+ * `staleKeys`: without them a second lookup of the same hash inside this run
1029
+ * would still serve the retired shard.
1030
+ */
1031
+ export const markParseCacheChunkStale = (cache, chunkHash) => {
1032
+ cache.staleKeys ??= new Set();
1033
+ cache.staleKeys.add(chunkHash);
1034
+ cache.entries.delete(chunkHash);
1035
+ cache.onDiskKeys?.delete(chunkHash);
1036
+ };
1020
1037
  const loadLegacyParseCache = async (storagePath) => {
1021
1038
  const cachePath = getLegacyCachePath(storagePath);
1022
1039
  try {
@@ -1096,7 +1113,14 @@ export const saveParseCache = async (storagePath, cache) => {
1096
1113
  const tmpDir = `${cacheDir}.tmp`;
1097
1114
  await fs.rm(tmpDir, { recursive: true, force: true });
1098
1115
  await fs.mkdir(tmpDir, { recursive: true });
1099
- const keys = [...cache.usedKeys].filter(isValidChunkCacheKey).sort();
1116
+ // A stale key is dropped here rather than at the failure site: the
1117
+ // orchestrator folds sibling-branch keys back into `usedKeys` after the parse
1118
+ // phase (#2106), so this is the last point that sees the final key set. The
1119
+ // exclusion also reaches the durable store, which prunes to the keys this
1120
+ // function returns — both stores drop the chunk together (#3204).
1121
+ const keys = [...cache.usedKeys]
1122
+ .filter((key) => isValidChunkCacheKey(key) && !cache.staleKeys?.has(key))
1123
+ .sort();
1100
1124
  // Track hashes whose shard was actually written/copied this save. A hash can
1101
1125
  // be in `usedKeys` without a backing shard — its in-memory serialize threw, or
1102
1126
  // its on-disk copy failed/was-absent (e.g. a worker-quarantined chunk added to
@@ -103,6 +103,18 @@ export declare const getDurableParsedFileDir: (storagePath: string) => string;
103
103
  * from accumulating across analyses.
104
104
  */
105
105
  export declare const prepareDurableParsedFileChunk: (durableDir: string, chunkHash: string) => Promise<void>;
106
+ /**
107
+ * Does this chunk still hold shards from a generation nobody cleared?
108
+ *
109
+ * Asked only after {@link prepareDurableParsedFileChunk} rejected, to tell its
110
+ * two failure modes apart (#3204). The `rm` failing leaves the previous
111
+ * generation in place, and a warm hit would union it with whatever this run's
112
+ * workers write — that chunk must be retired. The `rm` succeeding and the
113
+ * `mkdir` then failing leaves NO directory: the workers recreate it and write
114
+ * a clean generation, so retiring would throw away a good cache entry for
115
+ * nothing. Absent or empty ⇒ nothing to distrust.
116
+ */
117
+ export declare const durableChunkHasStaleShards: (durableDir: string, chunkHash: string) => Promise<boolean>;
106
118
  export declare const persistDurableParsedFileShardSync: (durableDir: string, chunkHash: string, threadId: number, shardSeq: number, parsedFiles: readonly ParsedFile[]) => boolean;
107
119
  /**
108
120
  * Validate and snapshot one durable chunk into the run store. Every envelope
@@ -116,8 +128,9 @@ export declare const loadDurableParsedFileIndex: (durableDir: string, expectedVe
116
128
  * Prune the durable store to `keepKeys` and rewrite its index. `keepKeys` must
117
129
  * be the parse cache's surviving on-disk keys (so the two stores stay coherent:
118
130
  * a chunk is "cached" iff BOTH its parse-cache shard and its durable shards
119
- * exist; a quarantined chunk — no parse-cache shard — drops its durable subdir
120
- * here and re-dispatches next run). Only chunks whose envelopes all validate
131
+ * exist; a chunk retired by `markParseCacheChunkStale` — worker-quarantined, or
132
+ * holding a durable generation that could not be reset — is absent from
133
+ * `keepKeys`, so it drops its durable subdir here and re-dispatches next run). Only chunks whose envelopes all validate
121
134
  * are indexed, together with their exact persisted path coverage (never vouch
122
135
  * for a missing/corrupt shard). The index write is tmp+rename atomic.
123
136
  */
@@ -512,6 +512,18 @@ export const prepareDurableParsedFileChunk = async (durableDir, chunkHash) => {
512
512
  await fs.rm(dir, { recursive: true, force: true });
513
513
  await fs.mkdir(dir, { recursive: true });
514
514
  };
515
+ /**
516
+ * Does this chunk still hold shards from a generation nobody cleared?
517
+ *
518
+ * Asked only after {@link prepareDurableParsedFileChunk} rejected, to tell its
519
+ * two failure modes apart (#3204). The `rm` failing leaves the previous
520
+ * generation in place, and a warm hit would union it with whatever this run's
521
+ * workers write — that chunk must be retired. The `rm` succeeding and the
522
+ * `mkdir` then failing leaves NO directory: the workers recreate it and write
523
+ * a clean generation, so retiring would throw away a good cache entry for
524
+ * nothing. Absent or empty ⇒ nothing to distrust.
525
+ */
526
+ export const durableChunkHasStaleShards = async (durableDir, chunkHash) => (await listV8Shards(durableChunkDir(durableDir, chunkHash))).length > 0;
515
527
  /**
516
528
  * Synchronous durable-shard writer for use INSIDE a parse worker, alongside
517
529
  * {@link persistParsedFileShardSync}. Writes the SAME bytes to a content-addressed
@@ -606,8 +618,9 @@ export const loadDurableParsedFileIndex = async (durableDir, expectedVersion) =>
606
618
  * Prune the durable store to `keepKeys` and rewrite its index. `keepKeys` must
607
619
  * be the parse cache's surviving on-disk keys (so the two stores stay coherent:
608
620
  * a chunk is "cached" iff BOTH its parse-cache shard and its durable shards
609
- * exist; a quarantined chunk — no parse-cache shard — drops its durable subdir
610
- * here and re-dispatches next run). Only chunks whose envelopes all validate
621
+ * exist; a chunk retired by `markParseCacheChunkStale` — worker-quarantined, or
622
+ * holding a durable generation that could not be reset — is absent from
623
+ * `keepKeys`, so it drops its durable subdir here and re-dispatches next run). Only chunks whose envelopes all validate
611
624
  * are indexed, together with their exact persisted path coverage (never vouch
612
625
  * for a missing/corrupt shard). The index write is tmp+rename atomic.
613
626
  */
@@ -620,6 +633,8 @@ export const pruneAndSaveDurableParsedFileStore = async (durableDir, version, ke
620
633
  return; // nothing written this run
621
634
  }
622
635
  const survivors = {};
636
+ const undeletable = [];
637
+ let firstRemoveError;
623
638
  for (const name of entries) {
624
639
  if (name === DURABLE_INDEX_FILENAME)
625
640
  continue;
@@ -649,7 +664,27 @@ export const pruneAndSaveDurableParsedFileStore = async (durableDir, version, ke
649
664
  /* not a readable dir → drop below */
650
665
  }
651
666
  }
652
- await fs.rm(full, { recursive: true, force: true });
667
+ // The causes that break `prepareDurableParsedFileChunk` — permissions, a
668
+ // locked file, a read-only mount — break this rm too (#3204). Dropping the
669
+ // entry from the index is what makes the chunk unreachable; losing the
670
+ // directory is a cleanup bonus. Never let one of them abort the loop and
671
+ // cost every remaining chunk its index entry.
672
+ try {
673
+ await fs.rm(full, { recursive: true, force: true });
674
+ }
675
+ catch (err) {
676
+ // `name` reached the drop branch precisely because it is not a live
677
+ // chunk key, so it may be any stray directory — report it as an entry.
678
+ undeletable.push(name);
679
+ firstRemoveError ??= err;
680
+ }
681
+ }
682
+ if (undeletable.length > 0) {
683
+ // One line per RUN, not per directory: a store-wide cause (read-only mount,
684
+ // wrong ownership) hits every non-survivor, and thousands of warns would
685
+ // bury the message that matters.
686
+ logger.warn({ err: firstRemoveError, count: undeletable.length, firstEntry: undeletable[0] }, 'parsedfile-cache: could not remove pruned durable chunk directories; ' +
687
+ 'they are excluded from the index and will be re-attempted next run');
653
688
  }
654
689
  const idx = { version, entries: survivors };
655
690
  const tmp = path.join(durableDir, `${DURABLE_INDEX_FILENAME}.tmp`);
@@ -684,7 +719,18 @@ export const mergeStagedDurableParsedFileStore = async (liveStoragePath, stagedS
684
719
  continue;
685
720
  const from = path.join(stagedDir, name);
686
721
  const to = path.join(liveDir, name);
687
- await replaceDurableChunkDir(from, to);
722
+ // Same reasoning as the prune's per-entry guard, on the loop that runs
723
+ // BEFORE it (#3204): this overlay targets the same live chunk directories,
724
+ // so the causes that break a reset break a replacement too. Letting one
725
+ // throw here would skip the prune entirely — the durable index would not be
726
+ // rewritten this run, and a retired chunk would keep its directory.
727
+ try {
728
+ await replaceDurableChunkDir(from, to);
729
+ }
730
+ catch (err) {
731
+ logger.warn({ err, entry: name }, 'parsedfile-cache: could not publish a staged durable chunk; ' +
732
+ 'it stays uncached and will re-dispatch next run');
733
+ }
688
734
  }
689
735
  await pruneAndSaveDurableParsedFileStore(liveDir, version, keepKeys);
690
736
  };
@@ -698,6 +744,12 @@ const replaceDurableChunkDir = async (from, to) => {
698
744
  /* dest exists, or the rename is cross-device */
699
745
  }
700
746
  const backup = `${to}.replacing`;
747
+ // Deliberately NOT best-effort. If a non-empty backup survives, the
748
+ // `fs.rename(to, backup)` below cannot overwrite it and is swallowed as
749
+ // "dest was missing", so the `fs.cp` fallback would merge the staged
750
+ // generation INTO the live directory — manufacturing exactly the old+new
751
+ // union this fix exists to prevent. Let it throw; the caller's per-entry
752
+ // guard keeps one such chunk from costing the others their prune.
701
753
  await fs.rm(backup, { recursive: true, force: true });
702
754
  let backedUp = false;
703
755
  try {
@@ -724,6 +776,8 @@ const replaceDurableChunkDir = async (from, to) => {
724
776
  throw err;
725
777
  }
726
778
  if (backedUp) {
727
- await fs.rm(backup, { recursive: true, force: true });
779
+ // The new generation is already in place; an undeletable backup is litter,
780
+ // not a failure. The next prune re-attempts it.
781
+ await fs.rm(backup, { recursive: true, force: true }).catch(() => { });
728
782
  }
729
783
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.12-rc.39",
3
+ "version": "1.6.12-rc.40",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -290,6 +290,11 @@ const NATIVE_ADDON_SMOKE = [
290
290
  // Filesystem behavior tests — exercise operations that vary across
291
291
  // platforms (CRLF, symlinks, permissions, temp dirs)
292
292
  const FILESYSTEM = [
293
+ // The durable ParsedFile store's prune tolerates a chunk directory it cannot
294
+ // delete (#3204). The failures that motivate it — held handles, read-only
295
+ // mounts — are Windows- and macOS-flavored, and the permission-based case
296
+ // skips itself where chmod cannot block a delete, so run it everywhere.
297
+ 'test/unit/parsedfile-store.test.ts',
293
298
  'test/integration/filesystem-walker.test.ts',
294
299
  'test/integration/watch-filesystem.test.ts',
295
300
  'test/integration/markdown-processor-crlf.test.ts',