codesift-mcp 0.8.10 → 0.8.12

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.
Files changed (78) hide show
  1. package/dist/cli/hooks.d.ts.map +1 -1
  2. package/dist/cli/hooks.js +2 -1
  3. package/dist/cli/hooks.js.map +1 -1
  4. package/dist/cli/setup.d.ts.map +1 -1
  5. package/dist/cli/setup.js +52 -9
  6. package/dist/cli/setup.js.map +1 -1
  7. package/dist/config.d.ts +1 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +1 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/register-tools.d.ts.map +1 -1
  12. package/dist/register-tools.js +126 -1
  13. package/dist/register-tools.js.map +1 -1
  14. package/dist/search/model2vec-tokenize.d.ts +22 -0
  15. package/dist/search/model2vec-tokenize.d.ts.map +1 -0
  16. package/dist/search/model2vec-tokenize.js +140 -0
  17. package/dist/search/model2vec-tokenize.js.map +1 -0
  18. package/dist/search/semantic.d.ts.map +1 -1
  19. package/dist/search/semantic.js +7 -0
  20. package/dist/search/semantic.js.map +1 -1
  21. package/dist/search/static-embedding-provider.d.ts +24 -0
  22. package/dist/search/static-embedding-provider.d.ts.map +1 -0
  23. package/dist/search/static-embedding-provider.js +157 -0
  24. package/dist/search/static-embedding-provider.js.map +1 -0
  25. package/dist/storage/_shared.d.ts.map +1 -1
  26. package/dist/storage/_shared.js +4 -1
  27. package/dist/storage/_shared.js.map +1 -1
  28. package/dist/storage/group-registry.d.ts +51 -0
  29. package/dist/storage/group-registry.d.ts.map +1 -0
  30. package/dist/storage/group-registry.js +263 -0
  31. package/dist/storage/group-registry.js.map +1 -0
  32. package/dist/storage/hash-snapshot.d.ts +36 -0
  33. package/dist/storage/hash-snapshot.d.ts.map +1 -0
  34. package/dist/storage/hash-snapshot.js +101 -0
  35. package/dist/storage/hash-snapshot.js.map +1 -0
  36. package/dist/storage/usage-stats.d.ts +8 -0
  37. package/dist/storage/usage-stats.d.ts.map +1 -1
  38. package/dist/storage/usage-stats.js +74 -24
  39. package/dist/storage/usage-stats.js.map +1 -1
  40. package/dist/storage/usage-tracker.d.ts +16 -0
  41. package/dist/storage/usage-tracker.d.ts.map +1 -1
  42. package/dist/storage/usage-tracker.js +23 -3
  43. package/dist/storage/usage-tracker.js.map +1 -1
  44. package/dist/tools/cross-repo-contract-tools.d.ts +137 -0
  45. package/dist/tools/cross-repo-contract-tools.d.ts.map +1 -0
  46. package/dist/tools/cross-repo-contract-tools.js +593 -0
  47. package/dist/tools/cross-repo-contract-tools.js.map +1 -0
  48. package/dist/tools/cross-repo-outbound-lexer.d.ts +35 -0
  49. package/dist/tools/cross-repo-outbound-lexer.d.ts.map +1 -0
  50. package/dist/tools/cross-repo-outbound-lexer.js +594 -0
  51. package/dist/tools/cross-repo-outbound-lexer.js.map +1 -0
  52. package/dist/tools/index-tools.d.ts +31 -2
  53. package/dist/tools/index-tools.d.ts.map +1 -1
  54. package/dist/tools/index-tools.js +463 -33
  55. package/dist/tools/index-tools.js.map +1 -1
  56. package/dist/tools/pg-introspect-tools.d.ts +147 -0
  57. package/dist/tools/pg-introspect-tools.d.ts.map +1 -0
  58. package/dist/tools/pg-introspect-tools.js +396 -0
  59. package/dist/tools/pg-introspect-tools.js.map +1 -0
  60. package/dist/types.d.ts +27 -0
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/utils/hf-download-stream.d.ts +21 -0
  63. package/dist/utils/hf-download-stream.d.ts.map +1 -0
  64. package/dist/utils/hf-download-stream.js +101 -0
  65. package/dist/utils/hf-download-stream.js.map +1 -0
  66. package/dist/utils/hf-hub-download.d.ts +8 -0
  67. package/dist/utils/hf-hub-download.d.ts.map +1 -0
  68. package/dist/utils/hf-hub-download.js +149 -0
  69. package/dist/utils/hf-hub-download.js.map +1 -0
  70. package/dist/utils/safetensors-loader.d.ts +9 -0
  71. package/dist/utils/safetensors-loader.d.ts.map +1 -0
  72. package/dist/utils/safetensors-loader.js +95 -0
  73. package/dist/utils/safetensors-loader.js.map +1 -0
  74. package/dist/utils/safetensors-meta-guard.d.ts +7 -0
  75. package/dist/utils/safetensors-meta-guard.d.ts.map +1 -0
  76. package/dist/utils/safetensors-meta-guard.js +50 -0
  77. package/dist/utils/safetensors-meta-guard.js.map +1 -0
  78. package/package.json +3 -1
@@ -1 +1 @@
1
- {"version":3,"file":"index-tools.d.ts","sourceRoot":"","sources":["../../src/tools/index-tools.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index-tools.d.ts","sourceRoot":"","sources":["../../src/tools/index-tools.ts"],"names":[],"mappings":"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"}
@@ -22,6 +22,7 @@ import { validateGitUrl, validateGitRef } from "../utils/git-validation.js";
22
22
  import { walkDirectory } from "../utils/walk.js";
23
23
  import { onFileChanged as scanOnChanged, onFileDeleted as scanOnDeleted, scanFileForSecrets } from "./secret-tools.js";
24
24
  import { getGraphPath } from "../storage/graph-store.js";
25
+ import { getSnapshotPath, loadHashSnapshot, saveHashSnapshot, HASH_SNAPSHOT_VERSION } from "../storage/hash-snapshot.js";
25
26
  const PARSE_CONCURRENCY = 8;
26
27
  const CHUNK_EMBEDDING_BATCH_SIZE = 96;
27
28
  const GIT_CLONE_TIMEOUT_MS = 120_000;
@@ -76,6 +77,13 @@ async function parseOneFile(filePath, repoRoot, repoName) {
76
77
  try {
77
78
  const stat = await import("node:fs/promises").then((fs) => fs.stat(filePath));
78
79
  const source = await readFile(filePath, "utf-8");
80
+ // CRITICAL-1 (TOCTOU parse↔hash): hash the EXACT source string we parse,
81
+ // here — never via a post-parse re-read. A re-read can observe a different
82
+ // on-disk version if the file is modified between parse and hash, pairing
83
+ // OLD symbols with a NEW sha so future runs permanently reuse mismatched
84
+ // symbols. The sha is NOT persisted inside FileEntry; callers thread it
85
+ // into the hash snapshot (and it saves one extra full read per parsed file).
86
+ const fileSha1 = createHash("sha1").update(source).digest("hex");
79
87
  const relPath = relative(repoRoot, filePath);
80
88
  const baseName = filePath.split("/").pop() ?? "";
81
89
  // Use full-path resolver so multi-dot suffixes like `.gradle.kts` beat
@@ -135,7 +143,7 @@ async function parseOneFile(filePath, repoRoot, repoName) {
135
143
  last_modified: Date.now(),
136
144
  mtime_ms: Math.round(stat.mtimeMs),
137
145
  };
138
- return { symbols, entry };
146
+ return { symbols, entry, sha1: fileSha1 };
139
147
  }
140
148
  catch (err) {
141
149
  const message = err instanceof Error ? err.message : String(err);
@@ -149,6 +157,9 @@ async function parseOneFile(filePath, repoRoot, repoName) {
149
157
  async function parseFiles(files, repoRoot, repoName) {
150
158
  const allSymbols = [];
151
159
  const fileEntries = [];
160
+ // CRITICAL-1: sha1 of the exact parsed source, keyed by relPath. Carried out
161
+ // of parseOneFile so the snapshot never re-reads (and never races) the file.
162
+ const shas = {};
152
163
  for (let i = 0; i < files.length; i += PARSE_CONCURRENCY) {
153
164
  const batch = files.slice(i, i + PARSE_CONCURRENCY);
154
165
  const results = await Promise.all(batch.map((filePath) => parseOneFile(filePath, repoRoot, repoName)));
@@ -156,10 +167,11 @@ async function parseFiles(files, repoRoot, repoName) {
156
167
  if (result) {
157
168
  allSymbols.push(...result.symbols);
158
169
  fileEntries.push(result.entry);
170
+ shas[result.entry.path] = result.sha1;
159
171
  }
160
172
  }
161
173
  }
162
- return { symbols: allSymbols, fileEntries };
174
+ return { symbols: allSymbols, fileEntries, shas };
163
175
  }
164
176
  // ---------------------------------------------------------------------------
165
177
  // Dirty propagation — mark caller files stale when a callee signature changes
@@ -307,6 +319,88 @@ async function embedChunks(fileEntries, rootPath, repoName, indexPath, config, s
307
319
  console.error(`[codesift] Chunk embedding failed for ${repoName}: ${message}`);
308
320
  }
309
321
  }
322
+ /**
323
+ * Decide whether a previously stored index no longer reflects the working
324
+ * tree. Samples up to 256 of its file paths (even stride) and stats them;
325
+ * when at least half are gone the old index is treated as stale. Used by the
326
+ * indexFolder sanity check to break the poisoned-baseline deadlock: an old
327
+ * index bloated with since-deleted trees (.worktrees/, vendored dirs) would
328
+ * otherwise reject every honest reindex as "truncated" forever.
329
+ */
330
+ const STALE_SAMPLE_LIMIT = 256;
331
+ const STALE_MISSING_FRACTION = 0.5;
332
+ async function isExistingIndexStale(existing, rootPath) {
333
+ const paths = existing.files.map((f) => f.path);
334
+ if (paths.length === 0)
335
+ return true;
336
+ const stride = Math.max(1, Math.floor(paths.length / STALE_SAMPLE_LIMIT));
337
+ const sampled = [];
338
+ for (let i = 0; i < paths.length && sampled.length < STALE_SAMPLE_LIMIT; i += stride) {
339
+ const p = paths[i];
340
+ if (p)
341
+ sampled.push(p);
342
+ }
343
+ let missing = 0;
344
+ await Promise.all(sampled.map(async (relPath) => {
345
+ try {
346
+ await stat(join(rootPath, relPath));
347
+ }
348
+ catch {
349
+ missing++;
350
+ }
351
+ }));
352
+ return missing >= sampled.length * STALE_MISSING_FRACTION;
353
+ }
354
+ /**
355
+ * Read a file and return the sha1 hex of its UTF-8 content, or null on read
356
+ * failure (deleted mid-walk, permission error). Code-sized files only — same
357
+ * assumption parseOneFile already makes. Non-throwing: callers treat null as
358
+ * "could not hash → fall through to re-parse".
359
+ */
360
+ async function sha1OfFile(absPath) {
361
+ try {
362
+ const content = await readFile(absPath, "utf-8");
363
+ return createHash("sha1").update(content).digest("hex");
364
+ }
365
+ catch {
366
+ return null;
367
+ }
368
+ }
369
+ /**
370
+ * Exported for unit testing only — not part of the public API.
371
+ *
372
+ * Drains a legacy-hash queue: hashes each file, then re-stats to confirm the
373
+ * mtime has not drifted since the decision-time stat. Entries whose mtime
374
+ * drifted (or whose stat fails) are omitted from the returned map so the next
375
+ * run re-parses them rather than reusing symbols against a mismatched sha.
376
+ *
377
+ * @param queue Items from the legacyHashQueue (relPath + filePath + decision-time mtimeMs).
378
+ * @param hashFn Injectable hash function (default: sha1OfFile). Tests inject a
379
+ * function that also modifies the file so they can trigger the
380
+ * TOCTOU drift-detection path without real concurrency.
381
+ * @param statFn Injectable stat function (default: fs.stat). Tests can stub this
382
+ * to return a post-modification mtime.
383
+ */
384
+ export async function drainLegacyHashQueue(queue, hashFn = sha1OfFile, statFn = (p) => import("node:fs/promises").then((m) => m.stat(p))) {
385
+ const result = {};
386
+ for (let i = 0; i < queue.length; i += PARSE_CONCURRENCY) {
387
+ const batch = queue.slice(i, i + PARSE_CONCURRENCY);
388
+ const shas = await Promise.all(batch.map((q) => hashFn(q.filePath)));
389
+ const stats = await Promise.all(batch.map((q) => statFn(q.filePath).then((st) => Math.round(st.mtimeMs), () => null)));
390
+ batch.forEach((q, j) => {
391
+ const currentMtime = stats[j];
392
+ if (currentMtime === null || currentMtime !== q.mtimeMs) {
393
+ // Mtime drifted or file gone — omit so next run re-parses.
394
+ return;
395
+ }
396
+ // Omit on null hash — never persist an empty-string sentinel that a
397
+ // snapshot reader could mistake for a valid sha1.
398
+ if (shas[j])
399
+ result[q.relPath] = shas[j];
400
+ });
401
+ }
402
+ return result;
403
+ }
310
404
  export async function indexFolder(folderPath, options) {
311
405
  if (!folderPath || typeof folderPath !== "string") {
312
406
  throw new Error("folderPath is required and must be a non-empty string");
@@ -379,16 +473,87 @@ export async function indexFolder(folderPath, options) {
379
473
  mtimeMap.set(f.path, f.mtime_ms);
380
474
  }
381
475
  }
476
+ // Persistent hash snapshot (Task 6): relPath → sha1 from the previous index.
477
+ // mtime stays the cheap pre-filter (unchanged mtime → reuse without hashing,
478
+ // the fastest path). When mtime *changed*, the snapshot sha1 lets us still
479
+ // reuse symbols for touch/checkout no-op rewrites that bumped mtime without
480
+ // changing content — something mtime-only logic could never catch.
481
+ // null when absent/corrupt/version-or-repo-mismatch → degrade to full parse.
482
+ const snapshotPath = getSnapshotPath(indexPath);
483
+ let oldSnapshot = existing
484
+ ? await loadHashSnapshot(snapshotPath, repoName)
485
+ : null;
486
+ // Staleness guard (Task 6, CRITICAL-2): an incremental saveIncremental /
487
+ // removeFileFromIndex advances index.updated_at WITHOUT touching the
488
+ // snapshot. If saveIndex landed but the subsequent snapshot save failed (or
489
+ // an incremental edit ran after the last full index), the on-disk snapshot
490
+ // is OLDER than the index and its SHAs may no longer match the indexed
491
+ // symbols — carrying them forward (fast path) or sha-matching against them
492
+ // (changed path) would produce wrong reuse on revert+touch sequences. When
493
+ // the snapshot predates the index, discard it: the legacy hash-now
494
+ // convergence path below repopulates a fresh, correct snapshot this run.
495
+ // Guard uses strict inequality (!==), not <. The fresh-write contract is
496
+ // snapshot.created_at === index.updated_at exactly (created_at is anchored to
497
+ // codeIndex.updated_at, not a fresh Date.now()). So ANY mismatch — older OR
498
+ // newer — means the snapshot is not the one paired with this index and must
499
+ // be discarded. A FUTURE created_at (e.g. a snapshot written against a later,
500
+ // since-rolled-back index, or clock skew) is just as untrustworthy as a stale
501
+ // one: its SHAs may not match the indexed symbols.
502
+ if (oldSnapshot && existing && oldSnapshot.created_at !== existing.updated_at) {
503
+ console.warn(`[codesift] hash-snapshot older than index — rebuilding (${repoName})`);
504
+ oldSnapshot = null;
505
+ }
382
506
  const filesToParse = [];
383
507
  const keptSymbols = [];
384
508
  const keptEntries = [];
509
+ // sha1 of every file in the NEW index, by relPath. Populated for reused files
510
+ // here (from the old snapshot when present, else hashed-now for convergence)
511
+ // and for parsed files after parseFiles resolves.
512
+ const newSnapshotFiles = {};
513
+ // CRITICAL-1: reused files whose sha1 must be (re)computed because the old
514
+ // snapshot lacks it (legacy snapshot-less index, or stale snapshot discarded
515
+ // above). Collected here and hashed AFTER the loop in PARSE_CONCURRENCY
516
+ // batches instead of one serial await per file inside the loop — on a first
517
+ // run after upgrade against a many-thousand-file repo the serial version cost
518
+ // thousands of sequential awaits. Behavior is identical, wall-clock is
519
+ // parallelized.
520
+ //
521
+ // mtimeMs: the mtime observed at decision time (the moment we confirmed
522
+ // mtime === prevMtime and placed the file in the queue). We re-stat after
523
+ // hashing to detect any concurrent modification that landed between the two
524
+ // operations. If the mtime drifted, we omit the file from newSnapshotFiles
525
+ // entirely — the missing sha causes the next cold run to re-parse, avoiding
526
+ // a snapshot that pairs new-content sha against old (reused) symbols.
527
+ const legacyHashQueue = [];
528
+ // PERF: pre-build per-file lookups ONCE before the reuse loop. Both reuse
529
+ // branches need (a) the existing index's symbols for a given relPath and (b)
530
+ // its FileEntry. Doing `existing.symbols.filter(s => s.file === relPath)` /
531
+ // `existing.files.find(f => f.path === relPath)` per file is O(files ×
532
+ // symbols) and O(files²) respectively — quadratic, and on a many-thousand
533
+ // file/symbol repo that dominated the reuse-heavy fast path. A single pass
534
+ // builds Map lookups each branch hits in O(1). Built only when there's an
535
+ // existing index to reuse from.
536
+ const symbolsByFile = new Map();
537
+ const fileEntryByPath = new Map();
538
+ if (existing) {
539
+ for (const sym of existing.symbols) {
540
+ const list = symbolsByFile.get(sym.file);
541
+ if (list)
542
+ list.push(sym);
543
+ else
544
+ symbolsByFile.set(sym.file, [sym]);
545
+ }
546
+ for (const fe of existing.files) {
547
+ fileEntryByPath.set(fe.path, fe);
548
+ }
549
+ }
385
550
  if (mtimeMap.size > 0) {
386
551
  const { stat } = await import("node:fs/promises");
387
552
  for (const filePath of files) {
388
553
  const relPath = relative(rootPath, filePath);
389
554
  const prevMtime = mtimeMap.get(relPath);
390
555
  if (prevMtime !== undefined) {
391
- const fileEntry = existing.files.find((f) => f.path === relPath);
556
+ const fileEntry = fileEntryByPath.get(relPath);
392
557
  // Force re-parse if file is marked stale (callee signature changed)
393
558
  if (fileEntry?.stale) {
394
559
  filesToParse.push(filePath);
@@ -397,14 +562,47 @@ export async function indexFolder(folderPath, options) {
397
562
  try {
398
563
  const st = await stat(filePath);
399
564
  if (Math.round(st.mtimeMs) === prevMtime) {
400
- // File unchanged — keep existing symbols
401
- const fileSymbols = existing.symbols.filter((s) => s.file === relPath);
565
+ // Fast path: mtime unchanged → reuse symbols without hashing.
566
+ const fileSymbols = symbolsByFile.get(relPath) ?? [];
402
567
  if (fileEntry) {
403
568
  keptSymbols.push(...fileSymbols);
404
569
  keptEntries.push(fileEntry);
570
+ // Carry the sha1 forward: reuse from old snapshot if present,
571
+ // else DEFER hashing so legacy (snapshot-less) indexes converge
572
+ // to a complete snapshot after one run — without paying a serial
573
+ // hash per file inside this loop.
574
+ const carried = oldSnapshot?.files[relPath];
575
+ if (carried !== undefined) {
576
+ newSnapshotFiles[relPath] = carried;
577
+ }
578
+ else {
579
+ legacyHashQueue.push({ relPath, filePath, mtimeMs: Math.round(st.mtimeMs) });
580
+ }
405
581
  continue;
406
582
  }
407
583
  }
584
+ else {
585
+ // mtime changed — hash decides reuse vs re-parse. This catches
586
+ // touch/checkout that bumped mtime without changing content.
587
+ const snapSha = oldSnapshot?.files[relPath];
588
+ if (snapSha !== undefined && fileEntry && !fileEntry.stale) {
589
+ const currentSha = await sha1OfFile(filePath);
590
+ if (currentSha !== null && currentSha === snapSha) {
591
+ const fileSymbols = symbolsByFile.get(relPath) ?? [];
592
+ keptSymbols.push(...fileSymbols);
593
+ // FIX: the file's mtime changed but content is identical (touch /
594
+ // checkout no-op rewrite). Reuse the symbols, but DON'T carry the
595
+ // stale FileEntry verbatim — its mtime_ms still holds the OLD
596
+ // mtime, so every future run would see mtime !== prevMtime and
597
+ // re-hash this file forever, permanently degrading it off the
598
+ // mtime fast path. Clone the entry with mtime_ms bumped to the
599
+ // CURRENT stat's mtime so the next run takes the cheap fast path.
600
+ keptEntries.push({ ...fileEntry, mtime_ms: Math.round(st.mtimeMs) });
601
+ newSnapshotFiles[relPath] = currentSha;
602
+ continue;
603
+ }
604
+ }
605
+ }
408
606
  }
409
607
  catch { /* file may have been deleted — reparse */ }
410
608
  }
@@ -414,10 +612,32 @@ export async function indexFolder(folderPath, options) {
414
612
  else {
415
613
  filesToParse.push(...files);
416
614
  }
615
+ // Drain the deferred legacy-hash queue (CRITICAL-1): files reused via the
616
+ // mtime fast path that had no carried sha1 (legacy snapshot-less index, or a
617
+ // stale snapshot discarded by the guard above). See drainLegacyHashQueue for
618
+ // the TOCTOU guard details — entries whose mtime drifted between decision
619
+ // time and hash time are omitted so the next run re-parses rather than
620
+ // reusing symbols against a mismatched sha.
621
+ if (legacyHashQueue.length > 0) {
622
+ const drained = await drainLegacyHashQueue(legacyHashQueue);
623
+ Object.assign(newSnapshotFiles, drained);
624
+ }
417
625
  // Parse only changed/new files
418
- const { symbols: parsedSymbols, fileEntries: parsedEntries } = await parseFiles(filesToParse, rootPath, repoName);
626
+ const { symbols: parsedSymbols, fileEntries: parsedEntries, shas: parsedShas } = await parseFiles(filesToParse, rootPath, repoName);
419
627
  const symbols = [...keptSymbols, ...parsedSymbols];
420
628
  const fileEntries = [...keptEntries, ...parsedEntries];
629
+ // Record sha1s for the files that were actually parsed (changed/new).
630
+ // CRITICAL-1 (TOCTOU): these hashes come straight from parseOneFile — they
631
+ // are the sha1 of the EXACT source string that produced the symbols, so the
632
+ // snapshot can never pair old symbols with a newer file's sha. Only entries
633
+ // that survived parseFiles (parseOneFile returned non-null) have a sha here,
634
+ // keeping the snapshot in lockstep with fileEntries. The previous post-parse
635
+ // double-read loop is gone — one fewer full read per parsed file.
636
+ for (const entry of parsedEntries) {
637
+ const sha = parsedShas[entry.path];
638
+ if (sha !== undefined)
639
+ newSnapshotFiles[entry.path] = sha;
640
+ }
421
641
  // Dirty propagation: detect signature changes and mark caller files stale
422
642
  if (existing && filesToParse.length > 0 && filesToParse.length < files.length) {
423
643
  const staleFiles = propagateDirtySignatures(existing.symbols, symbols, fileEntries);
@@ -425,25 +645,209 @@ export async function indexFolder(folderPath, options) {
425
645
  console.error(`[codesift] Dirty propagation: ${staleFiles.size} caller files marked stale`);
426
646
  }
427
647
  }
428
- // Build and cache BM25 index; invalidate code index cache
429
- const bm25 = buildBM25Index(symbols);
430
- bm25Indexes.set(repoName, bm25);
648
+ // Invalidate code index cache (BM25 is rebuilt below from the FINAL symbol
649
+ // set — possibly merged with out-of-scope existing symbols, see merge block).
431
650
  codeIndexes.delete(repoName);
432
651
  // Sanity check: don't overwrite a complete index with a partial one
433
- // (WASM crash or walk failure can produce truncated results)
652
+ // (WASM crash or walk failure can produce truncated results).
653
+ //
654
+ // IMPORTANT: skip the guard when the walk was explicitly narrowed — either
655
+ // max_files was hit (truncated at cap) or include_paths scoped the walk to a
656
+ // subdirectory. In both cases the small result count is EXPECTED and rejecting
657
+ // it would be a false positive (the "1139 vs 9512" bug class). For unrestricted
658
+ // walks the guard stays as-is, protecting against genuine silent truncations.
659
+ //
660
+ // CRITICAL (T7 correctness fix): skipping the guard is necessary but NOT
661
+ // sufficient. A scoped/capped walk only SEES a narrow slice of the repo; if we
662
+ // persisted that slice as the WHOLE index we would wipe every out-of-scope
663
+ // file's symbols from index+snapshot (worse than the guard's old reject,
664
+ // which at least preserved the prior index). So for scoped/capped walks with
665
+ // an existing index we MERGE: keep out-of-scope existing entries verbatim and
666
+ // overlay the walk's results. See the merge block below.
667
+ //
668
+ // "max_files hit" detection: files.length === effective maxFiles. This is the
669
+ // only signal walkDirectory exposes (it sets limitReached internally but does
670
+ // not surface it on the return value). A 1-in-a-million exact-count false
671
+ // positive (repo has exactly maxFiles parseable files) is accepted — the
672
+ // guard skip is conservative (allows write), not destructive.
434
673
  const DROP_THRESHOLD = 0.5; // Reject if new index has <50% of old file count
435
- if (existing && fileEntries.length < existing.file_count * DROP_THRESHOLD && existing.file_count > 50) {
436
- console.error(`[codesift] SANITY CHECK FAILED for ${repoName}: ` +
437
- `new index has ${fileEntries.length} files vs ${existing.file_count} previously. ` +
438
- `Keeping old index. Use invalidate_cache + index_folder to force reindex.`);
439
- return {
440
- repo: repoName,
441
- root: rootPath,
442
- file_count: existing.file_count,
443
- symbol_count: existing.symbol_count,
444
- duration_ms: Date.now() - startTime,
674
+ const walkExplicitlyCapped = hitFileLimit;
675
+ const walkExplicitlyScoped = options?.include_paths !== undefined && options.include_paths.length > 0;
676
+ // MIN_GUARD_FILES: the unrestricted guard only arms above this existing
677
+ // file_count (`existing.file_count > 50` below). The scoped-granularity guard
678
+ // mirrors that shape against the in-scope subset so a tiny scope can't be
679
+ // rejected on noise. Single source of truth so both guards stay in lockstep.
680
+ const MIN_GUARD_FILES = 50;
681
+ if (walkExplicitlyCapped || walkExplicitlyScoped) {
682
+ // ROUND-2 FIX (scoped-granularity guard): the unrestricted guard is skipped
683
+ // for scoped/capped walks because a small *overall* result is expected. But
684
+ // that skip was total — a scoped walk that aborts mid-enumeration (WASM
685
+ // crash, transient FS error, an over-broad exclude) silently truncates the
686
+ // IN-SCOPE slice, and the merge below treats every unwalked in-scope file as
687
+ // a deletion → wipes it from index+snapshot. So for a purely SCOPED (uncapped)
688
+ // walk we re-arm a guard against the IN-SCOPE subset: if the walk enumerated
689
+ // far fewer in-scope files than the existing index held in that same scope,
690
+ // AND those files are still on disk, the enumeration was truncated → reject
691
+ // before any merge/save, leaving the old index+snapshot intact.
692
+ //
693
+ // Capped walks are intentionally EXEMPT: a cap means unseen ≠ deleted (the
694
+ // merge preserves all unwalked files), so there is no truncation to detect —
695
+ // nothing in-scope is dropped. A walk that is BOTH scoped and capped also
696
+ // takes capped semantics (preserve everything unwalked), so the same
697
+ // exemption applies — no in-scope file can be lost.
698
+ if (walkExplicitlyScoped && !walkExplicitlyCapped && existing) {
699
+ const includePaths = options.include_paths;
700
+ const inScopeRel = (relPath) => includePaths.some((p) => relPath.startsWith(p)); // mirror walkDirectory
701
+ const existingInScope = existing.files.filter((fe) => inScopeRel(fe.path));
702
+ // All walked files are in scope by construction (walkDirectory honored
703
+ // includePaths), so walkedInScope is simply the walk's file count.
704
+ const walkedInScope = fileEntries.length;
705
+ if (existingInScope.length > MIN_GUARD_FILES &&
706
+ walkedInScope < existingInScope.length * DROP_THRESHOLD) {
707
+ // Auto-heal analog (in-scope): the shrink may be a genuine mass deletion
708
+ // within the scope, not a truncated walk. Sample the existing in-scope
709
+ // paths on disk (mirrors isExistingIndexStale, but restricted to the
710
+ // scope) — if most are gone, accept the merge.
711
+ const inScopePaths = existingInScope.map((fe) => fe.path);
712
+ const stride = Math.max(1, Math.floor(inScopePaths.length / STALE_SAMPLE_LIMIT));
713
+ const sampled = [];
714
+ for (let i = 0; i < inScopePaths.length && sampled.length < STALE_SAMPLE_LIMIT; i += stride) {
715
+ const p = inScopePaths[i];
716
+ if (p)
717
+ sampled.push(p);
718
+ }
719
+ let missing = 0;
720
+ await Promise.all(sampled.map(async (relPath) => {
721
+ try {
722
+ await stat(join(rootPath, relPath));
723
+ }
724
+ catch {
725
+ missing++;
726
+ }
727
+ }));
728
+ const mostGone = missing >= sampled.length * STALE_MISSING_FRACTION;
729
+ if (mostGone) {
730
+ console.error(`[codesift] Scoped sanity auto-heal for ${repoName}: walked ` +
731
+ `${walkedInScope} of ${existingInScope.length} in-scope files but ` +
732
+ `most sampled in-scope paths no longer exist on disk. Accepting ` +
733
+ `scoped merge (legit in-scope mass deletion).`);
734
+ }
735
+ else {
736
+ console.error(`[codesift] SCOPED SANITY CHECK FAILED for ${repoName}: scoped walk ` +
737
+ `under-enumerated — walked ${walkedInScope} of ${existingInScope.length} ` +
738
+ `in-scope files, which still exist on disk. Keeping old index.`);
739
+ return {
740
+ repo: repoName,
741
+ root: rootPath,
742
+ file_count: existing.file_count,
743
+ symbol_count: existing.symbol_count,
744
+ duration_ms: Date.now() - startTime,
745
+ status: "rejected_partial",
746
+ reason: `scoped walk under-enumerated: walked ${walkedInScope} of ${existingInScope.length} in-scope files (still on disk) — kept old index, nothing was re-registered`,
747
+ hint: "If the in-scope shrink is expected (deleted files, new excludes), run invalidate_cache then index_folder to rebuild from scratch.",
748
+ };
749
+ }
750
+ }
751
+ }
752
+ const detail = walkExplicitlyCapped
753
+ ? `max_files=${maxFiles} hit (${files.length} files returned)`
754
+ : `include_paths=[${options.include_paths.join(", ")}]`;
755
+ console.error(`[codesift] sanity guard skipped: walk explicitly capped/scoped (${detail})`);
756
+ }
757
+ else if (existing && fileEntries.length < existing.file_count * DROP_THRESHOLD && existing.file_count > MIN_GUARD_FILES) {
758
+ // The shrink can also mean the OLD index is the bogus one: an earlier
759
+ // walker may have swept since-deleted trees (.worktrees/, vendored dirs),
760
+ // permanently inflating the baseline so every honest reindex looks
761
+ // truncated and gets rejected forever. Disambiguate by sampling the old
762
+ // index's paths: if most of them no longer exist on disk, the old index
763
+ // is stale dead weight — accept the new result instead of keeping it.
764
+ if (await isExistingIndexStale(existing, rootPath)) {
765
+ console.error(`[codesift] Sanity check auto-heal for ${repoName}: old index has ` +
766
+ `${existing.file_count} files but most sampled paths no longer exist ` +
767
+ `on disk. Accepting new index (${fileEntries.length} files).`);
768
+ }
769
+ else {
770
+ console.error(`[codesift] SANITY CHECK FAILED for ${repoName}: ` +
771
+ `new index has ${fileEntries.length} files vs ${existing.file_count} previously. ` +
772
+ `Keeping old index. Use invalidate_cache + index_folder to force reindex.`);
773
+ return {
774
+ repo: repoName,
775
+ root: rootPath,
776
+ file_count: existing.file_count,
777
+ symbol_count: existing.symbol_count,
778
+ duration_ms: Date.now() - startTime,
779
+ status: "rejected_partial",
780
+ reason: `new walk found ${fileEntries.length} files, <50% of the ${existing.file_count} previously indexed — kept old index, nothing was re-registered`,
781
+ hint: "If the shrink is expected (deleted trees, new excludes), run invalidate_cache then index_folder to rebuild from scratch.",
782
+ };
783
+ }
784
+ }
785
+ // ── MERGE-persist for scoped/capped walks (T7 correctness fix) ────────────
786
+ // A scoped (include_paths) or capped (max_files-hit) walk only enumerated a
787
+ // slice of the repo. Persisting that slice verbatim would delete every
788
+ // out-of-scope file's symbols from index+snapshot. When an existing index is
789
+ // present we instead MERGE: preserve out-of-scope existing entries/symbols/
790
+ // shas and overlay the walk's results.
791
+ //
792
+ // - include_paths scoped (and NOT capped): "scope" = files whose relPath is
793
+ // under any include root (mirror walkDirectory's relPath.startsWith(p)
794
+ // test EXACTLY). Out-of-scope existing files are preserved verbatim;
795
+ // in-scope existing files NOT in the walk set W are dropped (genuine
796
+ // in-scope deletions — the walk fully enumerated the scope).
797
+ // - capped (max_files hit): scope is UNDEFINED — the cap means an unseen
798
+ // file is not necessarily deleted. Preserve ALL existing entries not in W,
799
+ // overlay W. (If a capped walk also passed include_paths, the cap makes the
800
+ // in-scope enumeration incomplete too, so we still only trust W and
801
+ // preserve everything else — capped semantics win.)
802
+ //
803
+ // First run (no existing index) with a scoped/capped walk → save what we have
804
+ // (current behavior, documented): there is nothing to preserve.
805
+ let mergedSymbols = symbols;
806
+ let mergedEntries = fileEntries;
807
+ let mergedSnapshotFiles = newSnapshotFiles;
808
+ if ((walkExplicitlyCapped || walkExplicitlyScoped) && existing) {
809
+ const walkedPaths = new Set(fileEntries.map((fe) => fe.path));
810
+ // A capped walk has undefined scope (unseen ≠ deleted), so it preserves
811
+ // everything not walked. A purely scoped (uncapped) walk additionally drops
812
+ // in-scope-but-unwalked files, since the walk fully enumerated the scope.
813
+ const includePaths = options?.include_paths;
814
+ const inScope = (relPath) => {
815
+ if (walkExplicitlyCapped)
816
+ return false; // cap → never treat as deletable
817
+ if (!includePaths || includePaths.length === 0)
818
+ return false;
819
+ // Mirror walkDirectory's include-path filter exactly.
820
+ return includePaths.some((p) => relPath.startsWith(p));
445
821
  };
822
+ const preservedEntries = [];
823
+ const preservedFilePaths = new Set();
824
+ for (const fe of existing.files) {
825
+ if (walkedPaths.has(fe.path))
826
+ continue; // walk result wins for these
827
+ if (inScope(fe.path))
828
+ continue; // in-scope + not walked = deleted-in-scope
829
+ preservedEntries.push(fe);
830
+ preservedFilePaths.add(fe.path);
831
+ }
832
+ const preservedSymbols = existing.symbols.filter((s) => preservedFilePaths.has(s.file));
833
+ mergedEntries = [...preservedEntries, ...fileEntries];
834
+ mergedSymbols = [...preservedSymbols, ...symbols];
835
+ // Snapshot: preserve out-of-scope shas, overlay walked ones.
836
+ mergedSnapshotFiles = {};
837
+ if (oldSnapshot) {
838
+ for (const relPath of preservedFilePaths) {
839
+ const sha = oldSnapshot.files[relPath];
840
+ if (sha !== undefined)
841
+ mergedSnapshotFiles[relPath] = sha;
842
+ }
843
+ }
844
+ Object.assign(mergedSnapshotFiles, newSnapshotFiles);
446
845
  }
846
+ // Build and cache BM25 index from the FINAL (possibly merged) symbol set.
847
+ // Built here (not before the guard) so a rejected_partial early-return leaves
848
+ // the previous in-memory BM25 index intact rather than swapping in a partial.
849
+ const bm25 = buildBM25Index(mergedSymbols);
850
+ bm25Indexes.set(repoName, bm25);
447
851
  // Resolve workspaces (Task 7) — runs before persistence so collectImportEdges
448
852
  // and other downstream consumers see the populated `workspaces` field.
449
853
  // Gated behind CODESIFT_DISABLE_MONOREPO=1 kill switch (spec D-FB).
@@ -460,24 +864,49 @@ export async function indexFolder(folderPath, options) {
460
864
  // mode is the safe fallback.
461
865
  }
462
866
  }
463
- // Build and save code index
867
+ // Build and save code index from the FINAL (possibly merged) sets.
464
868
  const codeIndex = {
465
869
  repo: repoName,
466
870
  root: rootPath,
467
- symbols,
468
- files: fileEntries,
871
+ symbols: mergedSymbols,
872
+ files: mergedEntries,
469
873
  created_at: Date.now(),
470
874
  updated_at: Date.now(),
471
- symbol_count: symbols.length,
472
- file_count: fileEntries.length,
875
+ symbol_count: mergedSymbols.length,
876
+ file_count: mergedEntries.length,
473
877
  extractor_version: { ...EXTRACTOR_VERSIONS },
474
878
  ...(workspaces ? { workspaces } : {}),
475
879
  };
476
880
  await saveIndex(indexPath, codeIndex);
881
+ // Persist the hash snapshot AFTER the index lands (mirrors registerRepo
882
+ // ordering) and only on the success path — the rejected_partial branch
883
+ // returned earlier, leaving the previous snapshot intact. Non-fatal: the
884
+ // snapshot is a reuse-optimization cache; a write failure just costs a full
885
+ // re-parse next run, so we warn and continue.
886
+ try {
887
+ const newSnapshot = {
888
+ version: HASH_SNAPSHOT_VERSION,
889
+ repo: repoName,
890
+ // CRITICAL-2 (created_at race): use the EXACT timestamp serialized into
891
+ // the index, not a fresh Date.now(). A watcher's saveIncremental that
892
+ // lands between saveIndex and this write would otherwise leave the
893
+ // snapshot OLDER than created_at, blinding the staleness guard above. By
894
+ // anchoring to codeIndex.updated_at, snapshot.created_at === the index's
895
+ // updated_at on a fresh write, so any later incremental strictly advances
896
+ // index.updated_at past it and the guard fires correctly.
897
+ created_at: codeIndex.updated_at,
898
+ files: mergedSnapshotFiles,
899
+ };
900
+ await saveHashSnapshot(snapshotPath, newSnapshot);
901
+ }
902
+ catch (err) {
903
+ const msg = err instanceof Error ? err.message : String(err);
904
+ console.warn(`[codesift] hash-snapshot save failed for ${repoName} (non-fatal): ${msg}`);
905
+ }
477
906
  // Embed symbols and chunks in background (non-fatal, don't block MCP response)
478
907
  // Large repos (71K symbols) can take minutes — fire-and-forget to prevent timeout
479
- embedSymbols(symbols, indexPath, repoName, config)
480
- .then(() => embedChunks(fileEntries, rootPath, repoName, indexPath, config, symbols))
908
+ embedSymbols(mergedSymbols, indexPath, repoName, config)
909
+ .then(() => embedChunks(mergedEntries, rootPath, repoName, indexPath, config, mergedSymbols))
481
910
  .catch((err) => {
482
911
  const msg = err instanceof Error ? err.message : String(err);
483
912
  console.error(`[codesift] Background embedding failed for ${repoName}: ${msg}`);
@@ -496,8 +925,8 @@ export async function indexFolder(folderPath, options) {
496
925
  name: repoName,
497
926
  root: rootPath,
498
927
  index_path: indexPath,
499
- symbol_count: symbols.length,
500
- file_count: fileEntries.length,
928
+ symbol_count: mergedSymbols.length,
929
+ file_count: mergedEntries.length,
501
930
  updated_at: Date.now(),
502
931
  };
503
932
  await registerRepo(config.registryPath, meta);
@@ -520,7 +949,7 @@ export async function indexFolder(folderPath, options) {
520
949
  try {
521
950
  const { detectFrameworks } = await import("../utils/framework-detect.js");
522
951
  const { enableFrameworkToolBundle } = await import("../register-tools.js");
523
- const tempIndex = { root: rootPath, files: fileEntries, symbols };
952
+ const tempIndex = { root: rootPath, files: mergedEntries, symbols: mergedSymbols };
524
953
  const frameworks = detectFrameworks(tempIndex);
525
954
  for (const fw of frameworks) {
526
955
  const enabled = enableFrameworkToolBundle(fw);
@@ -538,8 +967,8 @@ export async function indexFolder(folderPath, options) {
538
967
  return {
539
968
  repo: repoName,
540
969
  root: rootPath,
541
- file_count: fileEntries.length,
542
- symbol_count: symbols.length,
970
+ file_count: mergedEntries.length,
971
+ symbol_count: mergedSymbols.length,
543
972
  duration_ms: Date.now() - startTime,
544
973
  };
545
974
  }
@@ -778,7 +1207,8 @@ export async function invalidateCache(repoName) {
778
1207
  const chunkPath = getChunkPath(meta.index_path);
779
1208
  const chunkEmbeddingPath = getChunkEmbeddingPath(meta.index_path);
780
1209
  const graphStorePath = getGraphPath(meta.index_path);
781
- for (const fp of [meta.index_path, embeddingPath, embeddingMetaPath, chunkPath, chunkEmbeddingPath, graphStorePath]) {
1210
+ const snapshotPath = getSnapshotPath(meta.index_path);
1211
+ for (const fp of [meta.index_path, embeddingPath, embeddingMetaPath, chunkPath, chunkEmbeddingPath, graphStorePath, snapshotPath]) {
782
1212
  try {
783
1213
  await unlink(fp);
784
1214
  }