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.
- package/CHANGELOG.md +11 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/package.json +1 -1
- package/plugins/sift-light/.claude-plugin/plugin.json +1 -1
- package/plugins/sift-light/.codex-plugin/plugin.json +1 -1
- package/plugins/sift-light/.mcp.json +1 -1
- package/plugins/sift-light/kimi.plugin.json +2 -2
- package/plugins/sift-light/omp-extension.mjs +126 -106
- package/plugins/sift-light/package.json +1 -1
- package/src/evidence-service.ts +1 -1
- package/src/evidence-validation.ts +16 -77
- package/src/inspect.ts +43 -14
- package/src/mcp-server.mjs +122 -102
- package/src/rg.ts +11 -1
- package/src/scan-revisions.ts +78 -1
- package/src/service.ts +1 -1
- package/src/snapshot-store.ts +1 -0
- package/src/source-document.ts +2 -5
- package/src/source-inspection.ts +1 -0
- package/src/types.ts +2 -0
package/src/mcp-server.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
6336
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
6271
6337
|
import { constants as constants3 } from "node:fs";
|
|
6272
|
-
import { lstat as lstat2, open as
|
|
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 =
|
|
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:
|
|
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
|
|
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:
|
|
6675
|
+
contentHash: createHash3("sha256").update(content).digest("hex")
|
|
6610
6676
|
},
|
|
6611
|
-
contentHash:
|
|
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
|
|
6857
|
-
import { open as
|
|
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
|
|
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;
|
|
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
|
|
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;
|
|
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 =
|
|
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:
|
|
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
|
|
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(
|
|
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 =
|
|
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 (
|
|
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
|
|
11467
|
-
if (!
|
|
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
|
|
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:
|
|
11548
|
+
key: sourceKey(reference),
|
|
11482
11549
|
path,
|
|
11483
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 {
|
|
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 &&
|
package/src/scan-revisions.ts
CHANGED
|
@@ -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.
|
|
218
|
+
.filter((match) => !snapshot.sourceReferences?.has(match.absolutePath))
|
|
219
219
|
.map((match) => match.absolutePath),
|
|
220
220
|
).size;
|
|
221
221
|
return {
|
package/src/snapshot-store.ts
CHANGED