sift-light 1.1.0 → 1.1.1-2

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.
@@ -334,7 +334,7 @@ class OperationLifecycle {
334
334
  }
335
335
 
336
336
  // src/media-search.ts
337
- import { createHash, randomUUID as randomUUID4 } from "node:crypto";
337
+ import { createHash as createHash2, randomUUID as randomUUID4 } from "node:crypto";
338
338
  import { mkdtemp, mkdir, readFile as readFile3, realpath as realpath3, rm, stat as stat5, writeFile } from "node:fs/promises";
339
339
  import { tmpdir } from "node:os";
340
340
  import { basename as basename2, dirname as dirname2, extname as extname2, join as join2, resolve as resolve7 } from "node:path";
@@ -1842,7 +1842,10 @@ function sourceRangeFromBytes(content, startLine, endLine, targetLine = startLin
1842
1842
  }
1843
1843
 
1844
1844
  // src/scan-revisions.ts
1845
+ import { createHash } from "node:crypto";
1846
+ import { open } from "node:fs/promises";
1845
1847
  import { resolve as resolve4 } from "node:path";
1848
+ var SOURCE_HASH_CONCURRENCY = 4;
1846
1849
  async function captureRevision(path, revisions, onRevisionError) {
1847
1850
  let failed = false;
1848
1851
  let failure;
@@ -1938,6 +1941,67 @@ async function retainStableSourceRevisions(paths, before, signal) {
1938
1941
  return initial !== undefined && sameSourceRevision(initial, revision);
1939
1942
  }));
1940
1943
  }
1944
+ async function captureStableSourceReference(path, expected, signal) {
1945
+ if (signal?.aborted)
1946
+ throw abortError();
1947
+ const before = await getSourceRevision(path);
1948
+ if (!before || !sameSourceRevision(expected, before))
1949
+ return;
1950
+ const handle = await open(path, "r");
1951
+ try {
1952
+ const metadata = await handle.stat();
1953
+ if (!metadata.isFile() || !sameSourceRevision(before, sourceRevisionFromStats(metadata))) {
1954
+ return;
1955
+ }
1956
+ const hash = createHash("sha256");
1957
+ const buffer = Buffer.allocUnsafe(64 * 1024);
1958
+ let offset = 0;
1959
+ while (offset < before.size) {
1960
+ if (signal?.aborted)
1961
+ throw abortError();
1962
+ const read = await handle.read(buffer, 0, Math.min(buffer.length, before.size - offset), offset);
1963
+ if (read.bytesRead === 0)
1964
+ return;
1965
+ hash.update(buffer.subarray(0, read.bytesRead));
1966
+ offset += read.bytesRead;
1967
+ }
1968
+ const after = await getSourceRevision(path);
1969
+ if (!after || !sameSourceRevision(before, after))
1970
+ return;
1971
+ return {
1972
+ path,
1973
+ origin: { kind: "worktree", revision: after, contentHash: hash.digest("hex") }
1974
+ };
1975
+ } finally {
1976
+ await handle.close();
1977
+ }
1978
+ }
1979
+ async function captureStableSourceReferences(paths, before, signal) {
1980
+ const candidates = [...paths].filter((path) => before.has(path));
1981
+ const references = new Map;
1982
+ for (let offset = 0;offset < candidates.length; offset += SOURCE_HASH_CONCURRENCY) {
1983
+ if (signal?.aborted)
1984
+ throw abortError();
1985
+ const batch = candidates.slice(offset, offset + SOURCE_HASH_CONCURRENCY);
1986
+ const captured = await Promise.all(batch.map(async (path) => {
1987
+ const expected = before.get(path);
1988
+ if (!expected)
1989
+ return;
1990
+ try {
1991
+ return await captureStableSourceReference(path, expected, signal);
1992
+ } catch (error) {
1993
+ if (signal?.aborted)
1994
+ throw error;
1995
+ return;
1996
+ }
1997
+ }));
1998
+ for (const reference of captured) {
1999
+ if (reference)
2000
+ references.set(reference.path, reference);
2001
+ }
2002
+ }
2003
+ return references;
2004
+ }
1941
2005
 
1942
2006
  // src/rg.ts
1943
2007
  function isRecord(value) {
@@ -2339,6 +2403,7 @@ function createRipgrepRunner(options = {}) {
2339
2403
  const retainedPaths = new Set(matches.map((match) => match.absolutePath).filter((path) => !lossyPaths.has(path)));
2340
2404
  await assertRetainedPathsAllowed(policy, [...retainedPaths], signal);
2341
2405
  const sourceRevisions = await retainStableSourceRevisions(retainedPaths, before.revisions, signal);
2406
+ const sourceReferences = await captureStableSourceReferences(retainedPaths, sourceRevisions, signal);
2342
2407
  if (signal?.aborted)
2343
2408
  throw abortError();
2344
2409
  return {
@@ -2347,6 +2412,7 @@ function createRipgrepRunner(options = {}) {
2347
2412
  totalMatches,
2348
2413
  fileCounts,
2349
2414
  sourceRevisions,
2415
+ sourceReferences,
2350
2416
  snapshotComplete: matches.length === totalMatches && !modificationTimeFilterIncomplete && retention.details.reasons.length === 0,
2351
2417
  filesystemCoverage,
2352
2418
  filesystemErrorSamples: [...filesystemErrorSamples],
@@ -2520,7 +2586,7 @@ var extensions = new Set([
2520
2586
  var maxFileBytes = 20 * 1024 * 1024;
2521
2587
  var maxCorpusBytes = 256 * 1024 * 1024;
2522
2588
  function hash(bytes) {
2523
- return createHash("sha256").update(bytes).digest("hex");
2589
+ return createHash2("sha256").update(bytes).digest("hex");
2524
2590
  }
2525
2591
  async function sourceBytes(cwd, path, signal) {
2526
2592
  const policy = new SearchPathPolicy(cwd);
@@ -2665,7 +2731,7 @@ class MediaSearch {
2665
2731
  } else if (this.#remoteConfig) {
2666
2732
  this.#remote ??= new RemoteMediaClient(this.#remoteConfig);
2667
2733
  const remoteFiles = [...admitted.values()].map((entry) => ({
2668
- id: createHash("sha256").update(entry.staged).digest("hex").slice(0, 32),
2734
+ id: createHash2("sha256").update(entry.staged).digest("hex").slice(0, 32),
2669
2735
  path: entry.staged
2670
2736
  }));
2671
2737
  response = await this.#remote.search({
@@ -2837,7 +2903,7 @@ import { URL as URL2 } from "node:url";
2837
2903
  // package.json
2838
2904
  var package_default = {
2839
2905
  name: "sift-light",
2840
- version: "1.1.0",
2906
+ version: "1.1.1-2",
2841
2907
  description: "Context-efficient local search for files, documents, notes and logs across Pi, OMP and MCP clients",
2842
2908
  keywords: [
2843
2909
  "ai-agent",
@@ -4434,7 +4500,7 @@ function parseDataSources(value) {
4434
4500
  }
4435
4501
 
4436
4502
  // src/data-source-http.ts
4437
- import { open } from "node:fs/promises";
4503
+ import { open as open2 } from "node:fs/promises";
4438
4504
  class DataSourceError extends Error {
4439
4505
  code;
4440
4506
  constructor(code) {
@@ -4449,7 +4515,7 @@ async function credential(config) {
4449
4515
  value = process.env[config.tokenEnv];
4450
4516
  if (config.tokenFile) {
4451
4517
  try {
4452
- const handle = await open(config.tokenFile, "r");
4518
+ const handle = await open2(config.tokenFile, "r");
4453
4519
  try {
4454
4520
  const info = await handle.stat();
4455
4521
  if (!info.isFile() || info.size > 16384)
@@ -4744,7 +4810,7 @@ class DataSources {
4744
4810
  }
4745
4811
 
4746
4812
  // src/service.ts
4747
- import { createHash as createHash7 } from "node:crypto";
4813
+ import { createHash as createHash8 } from "node:crypto";
4748
4814
 
4749
4815
  // src/concept-worker-client.ts
4750
4816
  import { randomUUID as randomUUID5 } from "node:crypto";
@@ -5288,7 +5354,7 @@ function rangeEvidence(document, range) {
5288
5354
 
5289
5355
  // src/concept-search.ts
5290
5356
  import { resolve as resolve14 } from "node:path";
5291
- import { createHash as createHash5 } from "node:crypto";
5357
+ import { createHash as createHash6 } from "node:crypto";
5292
5358
 
5293
5359
  // src/request.ts
5294
5360
  function list(value) {
@@ -5962,13 +6028,13 @@ async function parseSyntax(path, text, signal, pattern) {
5962
6028
  }
5963
6029
 
5964
6030
  // src/concept-source-generation.ts
5965
- import { createHash as createHash4 } from "node:crypto";
6031
+ import { createHash as createHash5 } from "node:crypto";
5966
6032
 
5967
6033
  // src/source-access.ts
5968
6034
  import { extname as extname4, resolve as resolve13 } from "node:path";
5969
6035
 
5970
6036
  // src/historical-paths.ts
5971
- import { lstat, mkdir as mkdir3, mkdtemp as mkdtemp2, open as open2, rm as rm3, writeFile as writeFile2 } from "node:fs/promises";
6037
+ import { lstat, mkdir as mkdir3, mkdtemp as mkdtemp2, open as open3, rm as rm3, writeFile as writeFile2 } from "node:fs/promises";
5972
6038
  import { constants as constants2 } from "node:fs";
5973
6039
  import { tmpdir as tmpdir2 } from "node:os";
5974
6040
  import { dirname as dirname5, join as join5, parse, relative as relative4, resolve as resolve10 } from "node:path";
@@ -6030,7 +6096,7 @@ async function filterHistoricalPaths(cwd, paths, request, signal) {
6030
6096
  throw new SiftLightError("Current ignore rules are not regular files; historical path filtering is unavailable");
6031
6097
  if (before.size > MAX_SOURCE_FILE_BYTES || ignoreBytesRead + before.size > MAX_STRUCTURE_BYTES)
6032
6098
  throw new SiftLightError("Current ignore rules exceed the source read budget");
6033
- const handle = await open2(path, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
6099
+ const handle = await open3(path, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
6034
6100
  let bytes;
6035
6101
  try {
6036
6102
  if (!sameSourceRevision(sourceRevisionFromStats(before), sourceRevisionFromStats(await handle.stat())))
@@ -6267,9 +6333,9 @@ async function sourceSimilarity(oldContent, newContent, budget) {
6267
6333
  }
6268
6334
 
6269
6335
  // src/git-repository.ts
6270
- import { createHash as createHash2 } from "node:crypto";
6336
+ import { createHash as createHash3 } from "node:crypto";
6271
6337
  import { constants as constants3 } from "node:fs";
6272
- import { lstat as lstat2, open as open3 } from "node:fs/promises";
6338
+ import { lstat as lstat2, open as open4 } from "node:fs/promises";
6273
6339
  import { isAbsolute as isAbsolute6, relative as relative5, resolve as resolve11, sep as sep3 } from "node:path";
6274
6340
 
6275
6341
  // src/git-process.ts
@@ -6537,7 +6603,7 @@ async function readGitBlob(cwd, commit, entry, budget, signal) {
6537
6603
  budget.bytes += output.length;
6538
6604
  if (output.length !== size)
6539
6605
  throw new SiftLightError("Git blob size does not match its immutable tree entry");
6540
- const verifiedBlob = createHash2(blob.length === 40 ? "sha1" : "sha256").update(`blob ${String(output.length)}\x00`).update(output).digest("hex");
6606
+ const verifiedBlob = createHash3(blob.length === 40 ? "sha1" : "sha256").update(`blob ${String(output.length)}\x00`).update(output).digest("hex");
6541
6607
  if (verifiedBlob !== blob)
6542
6608
  throw new SiftLightError("Git blob bytes do not match their immutable object identity");
6543
6609
  return {
@@ -6546,7 +6612,7 @@ async function readGitBlob(cwd, commit, entry, budget, signal) {
6546
6612
  sourceStatus: output.includes(0) ? "binary" : "available",
6547
6613
  ...output.includes(0) ? { reason: "Binary source contains NUL bytes" } : { content: output },
6548
6614
  origin: { kind: "git", commit, blob },
6549
- contentHash: createHash2("sha256").update(output).digest("hex")
6615
+ contentHash: createHash3("sha256").update(output).digest("hex")
6550
6616
  };
6551
6617
  }
6552
6618
  async function readWorktreeSource(cwd, path, budget, signal) {
@@ -6577,7 +6643,7 @@ async function readWorktreeSource(cwd, path, budget, signal) {
6577
6643
  if (budget.bytes + before.size > budget.maxBytes)
6578
6644
  return limitedSource(path, mode, `Source reads exceed the ${String(budget.maxBytes)} byte request limit`);
6579
6645
  await assertExistingPathInsideCwd(absolute, cwd);
6580
- const handle = await open3(absolute, constants3.O_RDONLY | (constants3.O_NOFOLLOW ?? 0));
6646
+ const handle = await open4(absolute, constants3.O_RDONLY | (constants3.O_NOFOLLOW ?? 0));
6581
6647
  try {
6582
6648
  if (!sameSourceRevision(sourceRevisionFromStats(before), sourceRevisionFromStats(await handle.stat())))
6583
6649
  throw new SiftLightError("Working source changed before reading");
@@ -6606,9 +6672,9 @@ async function readWorktreeSource(cwd, path, budget, signal) {
6606
6672
  origin: {
6607
6673
  kind: "worktree",
6608
6674
  revision: sourceRevisionFromStats(after),
6609
- contentHash: createHash2("sha256").update(content).digest("hex")
6675
+ contentHash: createHash3("sha256").update(content).digest("hex")
6610
6676
  },
6611
- contentHash: createHash2("sha256").update(content).digest("hex")
6677
+ contentHash: createHash3("sha256").update(content).digest("hex")
6612
6678
  };
6613
6679
  } finally {
6614
6680
  await handle.close();
@@ -6853,8 +6919,8 @@ async function readGitSource(cwd, identity, signal, options = {}) {
6853
6919
 
6854
6920
  // src/source-document.ts
6855
6921
  import { isUtf8 } from "node:buffer";
6856
- import { createHash as createHash3 } from "node:crypto";
6857
- import { open as open4, realpath as realpath4 } from "node:fs/promises";
6922
+ import { createHash as createHash4 } from "node:crypto";
6923
+ import { open as open5, realpath as realpath4 } from "node:fs/promises";
6858
6924
  import { relative as relative6, resolve as resolve12 } from "node:path";
6859
6925
  class SourceDocumentError extends SiftLightError {
6860
6926
  reason;
@@ -6865,7 +6931,7 @@ class SourceDocumentError extends SiftLightError {
6865
6931
  }
6866
6932
  }
6867
6933
  function contentHash(bytes) {
6868
- return createHash3("sha256").update(bytes).digest("hex");
6934
+ return createHash4("sha256").update(bytes).digest("hex");
6869
6935
  }
6870
6936
 
6871
6937
  class SourceDocument {
@@ -7020,14 +7086,14 @@ async function readWorkspaceDocument(path, cwd, signal, expected, readBudget = M
7020
7086
  if (!before)
7021
7087
  throw new SourceDocumentError("source-unavailable", "Source is unavailable");
7022
7088
  if (expected && !sameSourceRevision(before, expected.revision)) {
7023
- throw new SourceDocumentError("source-changed", "Source changed; start a new inspection");
7089
+ throw new SourceDocumentError("source-changed", "Source changed; refresh the search");
7024
7090
  }
7025
7091
  if (before.size > Math.min(MAX_SOURCE_FILE_BYTES, readBudget)) {
7026
7092
  throw new SourceDocumentError("file-too-large", "Source exceeds the 5 MiB file limit");
7027
7093
  }
7028
7094
  if (signal?.aborted)
7029
7095
  throw abortError();
7030
- const handle = await open4(canonical, "r");
7096
+ const handle = await open5(canonical, "r");
7031
7097
  let bytes;
7032
7098
  try {
7033
7099
  const metadata = await handle.stat();
@@ -7062,7 +7128,7 @@ async function readWorkspaceDocument(path, cwd, signal, expected, readBudget = M
7062
7128
  throw abortError();
7063
7129
  const hash = contentHash(bytes);
7064
7130
  if (expected && expected.contentHash !== hash) {
7065
- throw new SourceDocumentError("source-changed", "Source content changed; start a new inspection");
7131
+ throw new SourceDocumentError("source-changed", "Source content changed; refresh the search");
7066
7132
  }
7067
7133
  return new SourceDocument({
7068
7134
  path: isPathInsideCwd(canonical, canonicalCwd) ? relative6(canonicalCwd, canonical).replaceAll("\\", "/") : canonical.replaceAll("\\", "/"),
@@ -7357,7 +7423,7 @@ async function createConceptSourceGeneration(access, filters, providedFiles) {
7357
7423
  reasons.push(`${path}: ${error.message}`);
7358
7424
  }
7359
7425
  }
7360
- const inventoryHash = createHash4("sha256").update(JSON.stringify(inventory)).digest("hex").slice(0, 32);
7426
+ const inventoryHash = createHash5("sha256").update(JSON.stringify(inventory)).digest("hex").slice(0, 32);
7361
7427
  return {
7362
7428
  cwd: access.cwd,
7363
7429
  filters: {
@@ -7696,7 +7762,7 @@ async function runConceptSearch(input, access, infer, onProgress, options = {})
7696
7762
  batchFileLimit: MAX_STRUCTURE_FILES,
7697
7763
  fileLimit: access.maxFiles,
7698
7764
  startedAt: generationStartedAt,
7699
- inventoryHash: createHash5("sha256").update(JSON.stringify(inventory)).digest("hex").slice(0, 32)
7765
+ inventoryHash: createHash6("sha256").update(JSON.stringify(inventory)).digest("hex").slice(0, 32)
7700
7766
  };
7701
7767
  result.counts = {
7702
7768
  ...lexical ? { relevanceRanking: 1 } : {},
@@ -7876,7 +7942,7 @@ async function structuralSearch(input, access) {
7876
7942
  }
7877
7943
 
7878
7944
  // src/native-text-search.ts
7879
- import { createHash as createHash6 } from "node:crypto";
7945
+ import { createHash as createHash7 } from "node:crypto";
7880
7946
  import { mkdtemp as mkdtemp3, mkdir as mkdir4, readFile as readFile4, realpath as realpath5, rm as rm4, writeFile as writeFile3 } from "node:fs/promises";
7881
7947
  import { tmpdir as tmpdir3 } from "node:os";
7882
7948
  import { basename as basename3, dirname as dirname6, isAbsolute as isAbsolute7, join as join6, resolve as resolve15 } from "node:path";
@@ -7953,11 +8019,11 @@ function createNativeTextSearch(config, search) {
7953
8019
  if (access.signal?.aborted)
7954
8020
  throw abortError();
7955
8021
  const path = document.path;
7956
- const relative = isAbsolute7(path) ? join6(createHash6("sha256").update(dirname6(path)).digest("hex"), basename3(path)) : path;
8022
+ const relative = isAbsolute7(path) ? join6(createHash7("sha256").update(dirname6(path)).digest("hex"), basename3(path)) : path;
7957
8023
  const staged = resolve15(corpus, relative);
7958
8024
  if (!staged.startsWith(corpus + "/"))
7959
8025
  throw new SiftLightError("Native staging path escaped its corpus");
7960
- const hash = createHash6("sha256").update(document.bytes).digest("hex");
8026
+ const hash = createHash7("sha256").update(document.bytes).digest("hex");
7961
8027
  const previous = known.get(path);
7962
8028
  if (!previous || previous.hash !== hash) {
7963
8029
  await mkdir4(dirname6(staged), { recursive: true });
@@ -8003,7 +8069,7 @@ function createNativeTextSearch(config, search) {
8003
8069
  throw new SiftLightError("Native text returned an unknown or duplicate source");
8004
8070
  seen.add(entry.staged);
8005
8071
  const content = await readFile4(entry.staged);
8006
- if (createHash6("sha256").update(content).digest("hex") !== entry.hash)
8072
+ if (createHash7("sha256").update(content).digest("hex") !== entry.hash)
8007
8073
  throw new SiftLightError("Native staged source changed");
8008
8074
  const document = new SourceDocument(entry.source, content);
8009
8075
  const range = document.lineRange(hit.line);
@@ -8714,6 +8780,7 @@ function resolveInspectionTarget(input, cwd, snapshots) {
8714
8780
  const absolutePath = retainedMatch?.absolutePath ?? resolve16(cwd, path);
8715
8781
  new SearchPathPolicy(cwd).assertPath(absolutePath);
8716
8782
  let expectedRevision;
8783
+ let expectedReference;
8717
8784
  if (input.cursor) {
8718
8785
  const { snapshot } = snapshots.resolve(input.cursor);
8719
8786
  retainedMatch ??= snapshot.matches.find((match) => match.absolutePath === absolutePath && match.lineNumber === line);
@@ -8721,13 +8788,15 @@ function resolveInspectionTarget(input, cwd, snapshots) {
8721
8788
  throw new CursorError("The requested line is not a retained match in this snapshot.");
8722
8789
  }
8723
8790
  expectedRevision = snapshot.sourceRevisions.get(absolutePath);
8791
+ expectedReference = snapshot.sourceReferences?.get(absolutePath);
8724
8792
  }
8725
8793
  return {
8726
8794
  path,
8727
8795
  absolutePath,
8728
8796
  line,
8729
- unverified: input.cursor !== undefined && expectedRevision === undefined,
8797
+ unverified: input.cursor !== undefined && (expectedReference === undefined || expectedRevision === undefined),
8730
8798
  ...retainedMatch ? { retainedMatch } : {},
8799
+ ...expectedReference ? { expectedReference } : {},
8731
8800
  ...expectedRevision ? { expectedRevision } : {}
8732
8801
  };
8733
8802
  }
@@ -10825,6 +10894,7 @@ function matchInspectionTarget(target) {
10825
10894
  path: target.path,
10826
10895
  line: target.line,
10827
10896
  unverified: target.unverified,
10897
+ ...target.expectedReference ? { reference: target.expectedReference } : {},
10828
10898
  ...target.expectedRevision ? { expectedRevision: target.expectedRevision } : {},
10829
10899
  ...target.retainedMatch?.occurrences[0] ? { focus: target.retainedMatch.occurrences[0].byteStart } : {}
10830
10900
  };
@@ -11410,9 +11480,6 @@ async function failure(error, expected, path, cwd, signal) {
11410
11480
  function sourceKey(reference) {
11411
11481
  return JSON.stringify([reference.path, reference.origin]);
11412
11482
  }
11413
- function revisionKey(path, revision) {
11414
- return JSON.stringify([path, revision]);
11415
- }
11416
11483
  function selectedIndex(index, count, label) {
11417
11484
  if (index === undefined)
11418
11485
  return;
@@ -11463,8 +11530,8 @@ function snapshotTargets(snapshot, matchIndex) {
11463
11530
  const missing = [];
11464
11531
  const seen = new Set;
11465
11532
  for (const path of selectedPaths) {
11466
- const revision = snapshot.sourceRevisions.get(path);
11467
- if (!revision) {
11533
+ const reference = snapshot.sourceReferences?.get(path);
11534
+ if (!reference) {
11468
11535
  const key = JSON.stringify(["missing", path]);
11469
11536
  if (!seen.has(key)) {
11470
11537
  seen.add(key);
@@ -11472,74 +11539,19 @@ function snapshotTargets(snapshot, matchIndex) {
11472
11539
  path,
11473
11540
  role: "source",
11474
11541
  status: "unknown",
11475
- reason: "Saved search has no source revision"
11542
+ reason: "Saved search has no verified source content identity"
11476
11543
  });
11477
11544
  }
11478
11545
  continue;
11479
11546
  }
11480
11547
  addUniqueTarget(targets, seen, {
11481
- key: revisionKey(path, revision),
11548
+ key: sourceKey(reference),
11482
11549
  path,
11483
- revision
11550
+ expected: reference
11484
11551
  });
11485
11552
  }
11486
11553
  return { targets, missing };
11487
11554
  }
11488
- async function validateSnapshotTarget(target, cwd, policy, signal) {
11489
- if (!target.revision)
11490
- throw new Error("Snapshot validation target omitted its revision");
11491
- try {
11492
- const absolute = resolve22(cwd, target.path);
11493
- const canonical = await policy.resolveExistingPath(absolute);
11494
- if (!canonical) {
11495
- const result = await confirmWorktreeState(target.path, cwd, signal);
11496
- return { path: target.path, role: "source", ...result };
11497
- }
11498
- const before = await stat7(absolute);
11499
- const beforeCanonical = canonical;
11500
- const after = await stat7(absolute);
11501
- const afterCanonical = await realpath7(absolute);
11502
- if (!before.isFile() || !after.isFile()) {
11503
- return {
11504
- path: target.path,
11505
- role: "source",
11506
- status: "stale",
11507
- reason: "Source is no longer a regular file"
11508
- };
11509
- }
11510
- if (beforeCanonical !== afterCanonical || !sameSourceRevision(sourceRevisionFromStats(before), sourceRevisionFromStats(after))) {
11511
- return {
11512
- path: target.path,
11513
- role: "source",
11514
- status: "stale",
11515
- reason: "Source changed while checking its revision"
11516
- };
11517
- }
11518
- const current = sourceRevisionFromStats(after);
11519
- const unchanged = sameSourceRevision(target.revision, current);
11520
- return {
11521
- path: target.path,
11522
- role: "source",
11523
- status: unchanged ? "current" : "stale",
11524
- ...unchanged ? {} : { reason: "Source revision changed" }
11525
- };
11526
- } catch (error) {
11527
- if (isAbort(error, signal))
11528
- throw error;
11529
- const code = systemErrorCode(error);
11530
- if (code === "ENOENT") {
11531
- const classified = await confirmWorktreeState(target.path, cwd, signal);
11532
- return { path: target.path, role: "source", ...classified };
11533
- }
11534
- const classified = await failure(error, undefined, target.path, cwd, signal);
11535
- return {
11536
- path: target.path,
11537
- role: "source",
11538
- status: classified.status,
11539
- reason: classified.reason
11540
- };
11541
- }
11542
- }
11543
11555
  async function validateAnalysisTarget(target, access, cwd, signal) {
11544
11556
  if (!target.expected)
11545
11557
  throw new Error("Analysis validation target omitted its source reference");
@@ -11637,14 +11649,17 @@ async function validateSavedEvidence(options) {
11637
11649
  const selected = snapshotTargets(snapshot, options.matchIndex);
11638
11650
  targets = selected.targets;
11639
11651
  missing.push(...selected.missing);
11640
- const policy = new SearchPathPolicy(options.cwd);
11641
11652
  sources = [...missing];
11642
- let checked = 0;
11643
11653
  const maxFiles = options.maxFiles ?? MAX_STRUCTURE_FILES;
11654
+ const access = new SourceAccess(options.cwd, options.queue, options.signal, {
11655
+ maxFiles
11656
+ });
11657
+ let checked = 0;
11658
+ let budgetExhausted = false;
11644
11659
  for (const target of targets) {
11645
11660
  if (options.signal?.aborted)
11646
11661
  throw abortError();
11647
- if (checked >= maxFiles) {
11662
+ if (budgetExhausted || checked >= maxFiles) {
11648
11663
  sources.push({
11649
11664
  path: target.path,
11650
11665
  role: "source",
@@ -11654,7 +11669,11 @@ async function validateSavedEvidence(options) {
11654
11669
  continue;
11655
11670
  }
11656
11671
  checked += 1;
11657
- sources.push(await validateSnapshotTarget(target, options.cwd, policy, options.signal));
11672
+ const status = await validateAnalysisTarget(target, access, options.cwd, options.signal);
11673
+ const { budgetReached, ...sourceStatus } = status;
11674
+ sources.push(sourceStatus);
11675
+ if (budgetReached)
11676
+ budgetExhausted = true;
11658
11677
  }
11659
11678
  }
11660
11679
  if (sources.length === 0)
@@ -14146,7 +14165,7 @@ class EvidenceService {
14146
14165
  if (selected.unverified)
14147
14166
  throw new SiftLightError("Snapshot source revision is unverified; refresh the search");
14148
14167
  if (selected.expectedRevision) {
14149
- const doc = await access.load(path);
14168
+ const doc = await access.load(path, selected.reference);
14150
14169
  if (doc.reference.origin.kind !== "worktree" || !sameSourceRevision(selected.expectedRevision, doc.reference.origin.revision))
14151
14170
  throw new SiftLightError("Source changed; refresh the search");
14152
14171
  reference = doc.reference;
@@ -14931,7 +14950,8 @@ class SnapshotStore {
14931
14950
  return scan.retention?.accountedBytes ?? Buffer.byteLength(JSON.stringify({
14932
14951
  matches: scan.matches,
14933
14952
  fileCounts: [...scan.fileCounts],
14934
- sourceRevisions: [...scan.sourceRevisions]
14953
+ sourceRevisions: [...scan.sourceRevisions],
14954
+ sourceReferences: scan.sourceReferences ? [...scan.sourceReferences] : []
14935
14955
  }));
14936
14956
  }
14937
14957
  #scanOccurrences(scan) {
@@ -15460,11 +15480,11 @@ function cursorPathSelection(input, cwd) {
15460
15480
  absolutePaths.add(absolutePath);
15461
15481
  labels.push(label);
15462
15482
  }
15463
- const key = createHash7("sha256").update([...absolutePaths].toSorted((left, right) => left.localeCompare(right)).join("\x00")).digest("hex").slice(0, 16);
15483
+ const key = createHash8("sha256").update([...absolutePaths].toSorted((left, right) => left.localeCompare(right)).join("\x00")).digest("hex").slice(0, 16);
15464
15484
  return { labels, absolutePaths, key };
15465
15485
  }
15466
15486
  function baseDetails2(snapshot, mode) {
15467
- const sourceUnverifiedFileCount = new Set(snapshot.matches.filter((match) => !snapshot.sourceRevisions.has(match.absolutePath)).map((match) => match.absolutePath)).size;
15487
+ const sourceUnverifiedFileCount = new Set(snapshot.matches.filter((match) => !snapshot.sourceReferences?.has(match.absolutePath)).map((match) => match.absolutePath)).size;
15468
15488
  return {
15469
15489
  version: 1,
15470
15490
  mode,
package/src/rg.ts CHANGED
@@ -14,7 +14,11 @@ import {
14
14
  hasRequestedRootUnreadable,
15
15
  } from "./ripgrep-diagnostics.js";
16
16
  import { matchesModificationTime } from "./source.js";
17
- import { captureCandidateRevisions, retainStableSourceRevisions } from "./scan-revisions.js";
17
+ import {
18
+ captureCandidateRevisions,
19
+ captureStableSourceReferences,
20
+ retainStableSourceRevisions,
21
+ } from "./scan-revisions.js";
18
22
  import {
19
23
  MAX_SOURCE_REVISION_CONCURRENCY,
20
24
  MAX_PROTOCOL_LINE_BYTES,
@@ -564,6 +568,11 @@ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
564
568
  before.revisions,
565
569
  signal,
566
570
  );
571
+ const sourceReferences = await captureStableSourceReferences(
572
+ retainedPaths,
573
+ sourceRevisions,
574
+ signal,
575
+ );
567
576
  if (signal?.aborted) throw abortError();
568
577
  return {
569
578
  request,
@@ -571,6 +580,7 @@ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
571
580
  totalMatches,
572
581
  fileCounts,
573
582
  sourceRevisions,
583
+ sourceReferences,
574
584
  snapshotComplete:
575
585
  matches.length === totalMatches &&
576
586
  !modificationTimeFilterIncomplete &&
@@ -1,3 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+ import { open } from "node:fs/promises";
1
3
  import { resolve } from "node:path";
2
4
  import { abortError, SiftLightError } from "./errors.js";
3
5
  import { runOwnedProcess } from "./owned-process.js";
@@ -8,12 +10,15 @@ import {
8
10
  type RipgrepRecoveryRequest,
9
11
  type RipgrepUnreadableDiagnostic,
10
12
  } from "./ripgrep-diagnostics.js";
11
- import { getSourceRevision, sameSourceRevision } from "./source.js";
13
+ import { getSourceRevision, sameSourceRevision, sourceRevisionFromStats } from "./source.js";
12
14
  import {
13
15
  MAX_PROTOCOL_LINE_BYTES,
14
16
  MAX_SOURCE_REVISION_CONCURRENCY,
15
17
  type SourceRevision,
16
18
  } from "./types.js";
19
+ import type { SourceReference } from "./source-document.js";
20
+
21
+ const SOURCE_HASH_CONCURRENCY = 4;
17
22
 
18
23
  async function captureRevision(
19
24
  path: string,
@@ -148,3 +153,75 @@ export async function retainStableSourceRevisions(
148
153
  }),
149
154
  );
150
155
  }
156
+
157
+ async function captureStableSourceReference(
158
+ path: string,
159
+ expected: SourceRevision,
160
+ signal?: AbortSignal,
161
+ ): Promise<SourceReference | undefined> {
162
+ if (signal?.aborted) throw abortError();
163
+ const before = await getSourceRevision(path);
164
+ if (!before || !sameSourceRevision(expected, before)) return undefined;
165
+ const handle = await open(path, "r");
166
+ try {
167
+ const metadata = await handle.stat();
168
+ if (!metadata.isFile() || !sameSourceRevision(before, sourceRevisionFromStats(metadata))) {
169
+ return undefined;
170
+ }
171
+ const hash = createHash("sha256");
172
+ const buffer = Buffer.allocUnsafe(64 * 1024);
173
+ let offset = 0;
174
+ while (offset < before.size) {
175
+ if (signal?.aborted) throw abortError();
176
+ // oxlint-disable-next-line no-await-in-loop -- one bounded file stream owns the hash.
177
+ const read = await handle.read(
178
+ buffer,
179
+ 0,
180
+ Math.min(buffer.length, before.size - offset),
181
+ offset,
182
+ );
183
+ if (read.bytesRead === 0) return undefined;
184
+ hash.update(buffer.subarray(0, read.bytesRead));
185
+ offset += read.bytesRead;
186
+ }
187
+ const after = await getSourceRevision(path);
188
+ if (!after || !sameSourceRevision(before, after)) return undefined;
189
+ return {
190
+ path,
191
+ origin: { kind: "worktree", revision: after, contentHash: hash.digest("hex") },
192
+ };
193
+ } finally {
194
+ await handle.close();
195
+ }
196
+ }
197
+
198
+ /** Capture content identities for retained matches without buffering whole files in memory. */
199
+ export async function captureStableSourceReferences(
200
+ paths: ReadonlySet<string>,
201
+ before: ReadonlyMap<string, SourceRevision>,
202
+ signal?: AbortSignal,
203
+ ): Promise<Map<string, SourceReference>> {
204
+ const candidates = [...paths].filter((path) => before.has(path));
205
+ const references = new Map<string, SourceReference>();
206
+ for (let offset = 0; offset < candidates.length; offset += SOURCE_HASH_CONCURRENCY) {
207
+ if (signal?.aborted) throw abortError();
208
+ const batch = candidates.slice(offset, offset + SOURCE_HASH_CONCURRENCY);
209
+ // oxlint-disable-next-line no-await-in-loop -- bounded content hashing avoids unbounded I/O.
210
+ const captured = await Promise.all(
211
+ batch.map(async (path) => {
212
+ const expected = before.get(path);
213
+ if (!expected) return undefined;
214
+ try {
215
+ return await captureStableSourceReference(path, expected, signal);
216
+ } catch (error) {
217
+ if (signal?.aborted) throw error;
218
+ return undefined;
219
+ }
220
+ }),
221
+ );
222
+ for (const reference of captured) {
223
+ if (reference) references.set(reference.path, reference);
224
+ }
225
+ }
226
+ return references;
227
+ }
package/src/service.ts CHANGED
@@ -215,7 +215,7 @@ function cursorPathSelection(input: SiftLightInput, cwd: string): PathSelection
215
215
  function baseDetails(snapshot: SearchSnapshot, mode: SearchMode): SiftLightDetails {
216
216
  const sourceUnverifiedFileCount = new Set(
217
217
  snapshot.matches
218
- .filter((match) => !snapshot.sourceRevisions.has(match.absolutePath))
218
+ .filter((match) => !snapshot.sourceReferences?.has(match.absolutePath))
219
219
  .map((match) => match.absolutePath),
220
220
  ).size;
221
221
  return {
@@ -180,6 +180,7 @@ export class SnapshotStore {
180
180
  matches: scan.matches,
181
181
  fileCounts: [...scan.fileCounts],
182
182
  sourceRevisions: [...scan.sourceRevisions],
183
+ sourceReferences: scan.sourceReferences ? [...scan.sourceReferences] : [],
183
184
  }),
184
185
  )
185
186
  );