sift-light 1.0.0

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 (145) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/CONTRIBUTING.md +19 -0
  3. package/LICENSE +661 -0
  4. package/README.md +177 -0
  5. package/README.zh-CN.md +177 -0
  6. package/SECURITY.md +25 -0
  7. package/package.json +142 -0
  8. package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
  9. package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
  10. package/plugins/sift-light/.mcp.json +24 -0
  11. package/plugins/sift-light/LICENSE +661 -0
  12. package/plugins/sift-light/concept-worker.mjs +567 -0
  13. package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
  14. package/plugins/sift-light/hooks/hooks.json +16 -0
  15. package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
  16. package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
  17. package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
  18. package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
  19. package/plugins/sift-light/kimi.plugin.json +36 -0
  20. package/plugins/sift-light/mcp-server.mjs +1 -0
  21. package/plugins/sift-light/omp-extension.mjs +20914 -0
  22. package/plugins/sift-light/package.json +11 -0
  23. package/plugins/sift-light/syntax-worker.mjs +280 -0
  24. package/plugins/sift-light/syntax-worker.toml +2 -0
  25. package/src/analysis-evidence.ts +43 -0
  26. package/src/analysis-limits.ts +27 -0
  27. package/src/analysis-store.ts +559 -0
  28. package/src/analysis-term-pages.ts +73 -0
  29. package/src/analysis-types.ts +162 -0
  30. package/src/audit-search.ts +391 -0
  31. package/src/capped-lines.ts +112 -0
  32. package/src/concept-embedding-cache.ts +186 -0
  33. package/src/concept-inference.ts +218 -0
  34. package/src/concept-model.ts +96 -0
  35. package/src/concept-search.ts +577 -0
  36. package/src/concept-setup.ts +75 -0
  37. package/src/concept-source-generation.ts +268 -0
  38. package/src/concept-worker.mjs +567 -0
  39. package/src/concept-worker.ts +210 -0
  40. package/src/config-reader.ts +283 -0
  41. package/src/config.ts +37 -0
  42. package/src/context-budget.ts +26 -0
  43. package/src/discovery-errors.ts +2 -0
  44. package/src/errors.ts +95 -0
  45. package/src/evidence-candidates.ts +344 -0
  46. package/src/evidence-ranking.ts +48 -0
  47. package/src/evidence-service.ts +1047 -0
  48. package/src/evidence-validation.ts +466 -0
  49. package/src/evidence-validity.ts +33 -0
  50. package/src/excerpt.ts +53 -0
  51. package/src/file-discovery.ts +150 -0
  52. package/src/file-metadata-filter.ts +46 -0
  53. package/src/format.ts +407 -0
  54. package/src/git-diff.ts +195 -0
  55. package/src/git-process.ts +189 -0
  56. package/src/git-repository.ts +367 -0
  57. package/src/git-source.ts +385 -0
  58. package/src/historical-paths.ts +205 -0
  59. package/src/hybrid-search.ts +340 -0
  60. package/src/import-model.ts +611 -0
  61. package/src/import-navigation.ts +100 -0
  62. package/src/import-resolution.ts +240 -0
  63. package/src/index.ts +97 -0
  64. package/src/inspect-batch.ts +265 -0
  65. package/src/inspect.ts +271 -0
  66. package/src/language-capabilities.ts +157 -0
  67. package/src/language-capability-definitions.ts +204 -0
  68. package/src/literal-search.ts +25 -0
  69. package/src/mcp-cli.ts +21 -0
  70. package/src/mcp-model-output.ts +120 -0
  71. package/src/mcp-output.ts +9 -0
  72. package/src/mcp-semantic-judge.ts +39 -0
  73. package/src/mcp-server.mjs +14064 -0
  74. package/src/mcp-server.ts +157 -0
  75. package/src/mcp-stdio.ts +114 -0
  76. package/src/mcp.ts +631 -0
  77. package/src/model-error.ts +120 -0
  78. package/src/multi-term-search.ts +122 -0
  79. package/src/omp-index.ts +277 -0
  80. package/src/omp-marketplace-entry.ts +7 -0
  81. package/src/operation-lifecycle.ts +413 -0
  82. package/src/operation-output.ts +100 -0
  83. package/src/owned-parallel.ts +22 -0
  84. package/src/owned-process.ts +102 -0
  85. package/src/owned-task-queue.ts +38 -0
  86. package/src/package-version.ts +3 -0
  87. package/src/path-policy.ts +203 -0
  88. package/src/pi-search-policy.ts +33 -0
  89. package/src/prompt-guidelines.ts +54 -0
  90. package/src/python-outline.ts +231 -0
  91. package/src/record-value.ts +3 -0
  92. package/src/redaction.ts +95 -0
  93. package/src/request-contract-catalog.ts +215 -0
  94. package/src/request-contract-error.ts +96 -0
  95. package/src/request-contract.ts +481 -0
  96. package/src/request.ts +132 -0
  97. package/src/result-statistics.ts +178 -0
  98. package/src/rg.ts +574 -0
  99. package/src/ripgrep-diagnostics.ts +122 -0
  100. package/src/ripgrep-executable.ts +43 -0
  101. package/src/runtime.ts +53 -0
  102. package/src/scan-revisions.ts +148 -0
  103. package/src/script-runtime.ts +11 -0
  104. package/src/search-policy-commands.ts +170 -0
  105. package/src/search-policy-hook.ts +61 -0
  106. package/src/search-policy-recovery.ts +388 -0
  107. package/src/search-policy-shell.ts +280 -0
  108. package/src/search-policy.ts +102 -0
  109. package/src/search-retention.ts +77 -0
  110. package/src/semantic-judge-batches.ts +135 -0
  111. package/src/semantic-judge.ts +519 -0
  112. package/src/service.ts +945 -0
  113. package/src/session-summary.ts +100 -0
  114. package/src/snapshot-store.ts +203 -0
  115. package/src/source-access.ts +288 -0
  116. package/src/source-continuations.ts +168 -0
  117. package/src/source-document.ts +277 -0
  118. package/src/source-inspection.ts +731 -0
  119. package/src/source-pages.ts +112 -0
  120. package/src/source.ts +286 -0
  121. package/src/structural-search.ts +125 -0
  122. package/src/structure.ts +259 -0
  123. package/src/summary-previews.ts +96 -0
  124. package/src/summary.ts +79 -0
  125. package/src/syntax-facts.ts +464 -0
  126. package/src/syntax-fields.ts +161 -0
  127. package/src/syntax-search.ts +361 -0
  128. package/src/syntax-tree.ts +30 -0
  129. package/src/syntax-types.ts +72 -0
  130. package/src/syntax-worker.mjs +280 -0
  131. package/src/syntax-worker.toml +2 -0
  132. package/src/syntax-worker.ts +143 -0
  133. package/src/syntax.ts +259 -0
  134. package/src/test-navigation-facts.ts +261 -0
  135. package/src/test-navigation.ts +543 -0
  136. package/src/tool-schema.ts +321 -0
  137. package/src/tui/dashboard.ts +107 -0
  138. package/src/tui/layout.ts +291 -0
  139. package/src/tui/palette.ts +73 -0
  140. package/src/tui/presentation.ts +240 -0
  141. package/src/tui/renderers.ts +106 -0
  142. package/src/types.ts +368 -0
  143. package/src/validation-output.ts +148 -0
  144. package/src/validation-types.ts +48 -0
  145. package/src/workspace-files.ts +142 -0
@@ -0,0 +1,385 @@
1
+ import { MAX_GIT_DIFF_WORK, MAX_STRUCTURE_BYTES, MAX_STRUCTURE_FILES } from "./analysis-limits.js";
2
+ import { abortError, SiftLightError } from "./errors.js";
3
+ import { filterHistoricalPaths } from "./historical-paths.js";
4
+ import {
5
+ changedLineRanges,
6
+ GitDiffBudget,
7
+ GitDiffLimitError,
8
+ sourceLineCount,
9
+ sourceSimilarity,
10
+ type GitLineRange,
11
+ } from "./git-diff.js";
12
+ import {
13
+ gitPath,
14
+ readGitBlob,
15
+ readGitTree,
16
+ readWorktreeSource,
17
+ resolveGitCommit,
18
+ visibleGitPaths,
19
+ verifyWorktreeRevision,
20
+ worktreeNames,
21
+ type GitRawSource,
22
+ type GitReadBudget,
23
+ type GitSourceOrigin,
24
+ type GitTreeEntry,
25
+ } from "./git-repository.js";
26
+ import { setImmediate } from "node:timers/promises";
27
+
28
+ export type { GitSourceOrigin } from "./git-repository.js";
29
+ export type { GitLineRange } from "./git-diff.js";
30
+
31
+ export interface GitChangeRequest {
32
+ base?: string;
33
+ target?: string;
34
+ scope: "files" | "lines";
35
+ side: "new" | "old";
36
+ }
37
+
38
+ export interface GitSourceOptions {
39
+ filterPaths?: (paths: string[]) => Promise<{ paths: string[]; bytesRead?: number }>;
40
+ includePath?: (path: string) => boolean | Promise<boolean>;
41
+ maxFiles?: number;
42
+ maxBytes?: number;
43
+ maxDiffWork?: number;
44
+ }
45
+
46
+ export interface GitSourceFile {
47
+ path: string;
48
+ oldPath?: string;
49
+ newPath?: string;
50
+ change: "added" | "modified" | "deleted" | "renamed" | "unknown";
51
+ sourceStatus: GitRawSource["sourceStatus"];
52
+ content?: Buffer;
53
+ contentHash?: string;
54
+ origin?: GitSourceOrigin;
55
+ changedRanges: GitLineRange[];
56
+ ranges: GitLineRange[];
57
+ reason?: string;
58
+ rename?: { method: "identical-content" | "line-similarity"; similarity: number };
59
+ }
60
+
61
+ export interface GitChangeResult {
62
+ base: string;
63
+ target: string;
64
+ scope: "files" | "lines";
65
+ side: "new" | "old";
66
+ files: GitSourceFile[];
67
+ partial: boolean;
68
+ reasons: string[];
69
+ filesRead: number;
70
+ bytesRead: number;
71
+ diffWork: number;
72
+ omittedFiles: number;
73
+ }
74
+
75
+ interface SourcePair {
76
+ old: GitRawSource;
77
+ new: GitRawSource;
78
+ rename?: GitSourceFile["rename"];
79
+ }
80
+
81
+ function absent(path: string): GitRawSource {
82
+ return { path, mode: "000000", sourceStatus: "absent" };
83
+ }
84
+
85
+ function sameContents(left: GitRawSource, right: GitRawSource): boolean {
86
+ return left.contentHash !== undefined && left.contentHash === right.contentHash;
87
+ }
88
+
89
+ function wholeFile(content: Buffer): GitLineRange[] {
90
+ const lines = sourceLineCount(content);
91
+ return lines === 0 ? [] : [{ startLine: 1, endLine: lines }];
92
+ }
93
+
94
+ function validateLimit(value: number, label: string): number {
95
+ if (!Number.isSafeInteger(value) || value < 1)
96
+ throw new SiftLightError(`${label} must be a positive integer`);
97
+ return value;
98
+ }
99
+
100
+ function rememberBest(
101
+ best: Map<SourcePair, { score: number; count: number }>,
102
+ pair: SourcePair,
103
+ score: number,
104
+ ) {
105
+ const previous = best.get(pair);
106
+ if (!previous || score > previous.score) best.set(pair, { score, count: 1 });
107
+ else if (score === previous.score) previous.count += 1;
108
+ }
109
+
110
+ /* oxlint-disable no-await-in-loop -- rename pairs consume one cumulative work budget. */
111
+ async function pairRenames(
112
+ pairs: SourcePair[],
113
+ budget: GitDiffBudget,
114
+ reasons: Set<string>,
115
+ ): Promise<SourcePair[]> {
116
+ const removed = pairs.filter((pair) => pair.new.sourceStatus === "absent" && pair.old.content);
117
+ const added = pairs.filter((pair) => pair.old.sourceStatus === "absent" && pair.new.content);
118
+ const scores: { oldPair: SourcePair; newPair: SourcePair; score: number }[] = [];
119
+ const bestOld = new Map<SourcePair, { score: number; count: number }>();
120
+ const bestNew = new Map<SourcePair, { score: number; count: number }>();
121
+
122
+ try {
123
+ for (const oldPair of removed) {
124
+ for (const newPair of added) {
125
+ if (!oldPair.old.content || !newPair.new.content) continue;
126
+ if (budget.tick()) await setImmediate();
127
+ const score = sameContents(oldPair.old, newPair.new)
128
+ ? 100
129
+ : await sourceSimilarity(oldPair.old.content, newPair.new.content, budget);
130
+ if (score >= 50) {
131
+ scores.push({ oldPair, newPair, score });
132
+ rememberBest(bestOld, oldPair, score);
133
+ rememberBest(bestNew, newPair, score);
134
+ }
135
+ }
136
+ }
137
+ } catch (error) {
138
+ if (!(error instanceof GitDiffLimitError)) throw error;
139
+ reasons.add(error.message);
140
+ reasons.add("Rename comparison is incomplete; unpaired additions/deletions remain explicit");
141
+ return pairs;
142
+ }
143
+ const consumed = new Set<SourcePair>();
144
+ const renamed: SourcePair[] = [];
145
+ for (const entry of scores) {
146
+ const oldBest = bestOld.get(entry.oldPair);
147
+ const newBest = bestNew.get(entry.newPair);
148
+ if (
149
+ oldBest?.score !== entry.score ||
150
+ newBest?.score !== entry.score ||
151
+ oldBest.count !== 1 ||
152
+ newBest.count !== 1
153
+ )
154
+ continue;
155
+ consumed.add(entry.oldPair);
156
+ consumed.add(entry.newPair);
157
+ renamed.push({
158
+ old: entry.oldPair.old,
159
+ new: entry.newPair.new,
160
+ rename: {
161
+ method: entry.score === 100 ? "identical-content" : "line-similarity",
162
+ similarity: entry.score,
163
+ },
164
+ });
165
+ }
166
+ if (scores.some((entry) => !consumed.has(entry.oldPair) && !consumed.has(entry.newPair))) {
167
+ reasons.add("Ambiguous rename candidates remain separate additions/deletions");
168
+ }
169
+ return [...pairs.filter((pair) => !consumed.has(pair)), ...renamed];
170
+ }
171
+
172
+ /* oxlint-enable no-await-in-loop */
173
+
174
+ async function renderPair(
175
+ pair: SourcePair,
176
+ request: GitChangeRequest,
177
+ budget: GitDiffBudget,
178
+ reasons: Set<string>,
179
+ ): Promise<GitSourceFile> {
180
+ const selected = request.side === "old" ? pair.old : pair.new;
181
+ const oldExists = pair.old.sourceStatus !== "absent";
182
+ const newExists = pair.new.sourceStatus !== "absent";
183
+ let changedRanges: GitLineRange[] = [];
184
+ let rangeReason: string | undefined;
185
+ if (selected.content) {
186
+ try {
187
+ if (!oldExists || !newExists) changedRanges = wholeFile(selected.content);
188
+ else if (pair.old.content && pair.new.content) {
189
+ const diff = await changedLineRanges(pair.old.content, pair.new.content, budget);
190
+ changedRanges = request.side === "old" ? diff.oldRanges : diff.newRanges;
191
+ } else {
192
+ rangeReason =
193
+ "Changed lines unavailable because the opposite source cannot be compared as raw text";
194
+ }
195
+ } catch (error) {
196
+ if (!(error instanceof GitDiffLimitError)) throw error;
197
+ rangeReason = error.message;
198
+ }
199
+ }
200
+ if (rangeReason) reasons.add(rangeReason);
201
+ for (const source of [pair.old, pair.new]) {
202
+ if (source.sourceStatus === "unavailable") reasons.add(source.reason ?? "Source unavailable");
203
+ }
204
+ const unsupported = [pair.old, pair.new].some((source) =>
205
+ ["unavailable", "symlink", "submodule"].includes(source.sourceStatus),
206
+ );
207
+ const change = pair.rename
208
+ ? "renamed"
209
+ : !oldExists
210
+ ? "added"
211
+ : !newExists
212
+ ? "deleted"
213
+ : unsupported
214
+ ? "unknown"
215
+ : "modified";
216
+ const reason = selected.reason ?? rangeReason;
217
+ return {
218
+ path:
219
+ selected.sourceStatus === "absent"
220
+ ? request.side === "old"
221
+ ? pair.new.path
222
+ : pair.old.path
223
+ : selected.path,
224
+ ...(oldExists ? { oldPath: pair.old.path } : {}),
225
+ ...(newExists ? { newPath: pair.new.path } : {}),
226
+ change,
227
+ sourceStatus: selected.sourceStatus,
228
+ ...(selected.content ? { content: selected.content } : {}),
229
+ ...(selected.contentHash ? { contentHash: selected.contentHash } : {}),
230
+ ...(selected.origin ? { origin: selected.origin } : {}),
231
+ changedRanges,
232
+ ranges:
233
+ request.scope === "files" && selected.content ? wholeFile(selected.content) : changedRanges,
234
+ ...(reason ? { reason } : {}),
235
+ ...(pair.rename ? { rename: pair.rename } : {}),
236
+ };
237
+ }
238
+
239
+ /** Compare fixed commit trees or a fixed base with final raw disk contents; never read the index as source. */
240
+ /* oxlint-disable no-await-in-loop -- reads and comparisons share request budgets; revisions are checked after all comparisons. */
241
+ export async function readGitChanges(
242
+ cwd: string,
243
+ request: GitChangeRequest,
244
+ signal?: AbortSignal,
245
+ options: GitSourceOptions = {},
246
+ ): Promise<GitChangeResult> {
247
+ if (!["files", "lines"].includes(request.scope) || !["new", "old"].includes(request.side))
248
+ throw new SiftLightError("Invalid Git scope or side");
249
+ if (request.target !== undefined && request.base === undefined)
250
+ throw new SiftLightError("Git commit comparison requires an explicit base and target");
251
+ const maxFiles = validateLimit(options.maxFiles ?? MAX_STRUCTURE_FILES, "Git file limit");
252
+ const maxBytes = validateLimit(options.maxBytes ?? MAX_STRUCTURE_BYTES, "Git byte limit");
253
+ const maxDiffWork = validateLimit(
254
+ options.maxDiffWork ?? MAX_GIT_DIFF_WORK,
255
+ "Git diff work limit",
256
+ );
257
+ const base = await resolveGitCommit(cwd, request.base ?? "HEAD", signal);
258
+ const target =
259
+ request.target === undefined ? undefined : await resolveGitCommit(cwd, request.target, signal);
260
+ const oldTree = await readGitTree(cwd, base, signal);
261
+ const newTree = target ? await readGitTree(cwd, target, signal) : undefined;
262
+ const diskNames = target ? undefined : await worktreeNames(cwd, signal);
263
+ const reasons = new Set<string>();
264
+ if (oldTree.limited || newTree?.limited || diskNames?.limited)
265
+ reasons.add("Git candidate metadata limit reached");
266
+ const candidates = [
267
+ ...new Set([...oldTree.entries.keys(), ...(newTree?.entries.keys() ?? diskNames?.paths ?? [])]),
268
+ ]
269
+ .filter((path) => {
270
+ if (!target) return true;
271
+ const oldEntry = oldTree.entries.get(path);
272
+ const newEntry = newTree?.entries.get(path);
273
+ return (
274
+ !oldEntry || !newEntry || oldEntry.blob !== newEntry.blob || oldEntry.mode !== newEntry.mode
275
+ );
276
+ })
277
+ .toSorted();
278
+ let visible = await visibleGitPaths(cwd, candidates, signal, options.includePath);
279
+ let filterBytes = 0;
280
+ if (options.filterPaths) {
281
+ const allowed = new Set(visible);
282
+ const filtered = await options.filterPaths(visible);
283
+ visible = filtered.paths;
284
+ filterBytes = filtered.bytesRead ?? 0;
285
+ if (!Number.isSafeInteger(filterBytes) || filterBytes < 0 || filterBytes > maxBytes)
286
+ throw new SiftLightError("Git path filtering exceeded its shared source read budget");
287
+ if (visible.some((path) => !allowed.has(path)))
288
+ throw new SiftLightError("Git path filter expanded the authorized candidate set");
289
+ visible = [...new Set(visible)];
290
+ }
291
+ const readBudget: GitReadBudget = { bytes: filterBytes, maxBytes };
292
+ const diffBudget = new GitDiffBudget(maxDiffWork, signal);
293
+ const pairs: SourcePair[] = [];
294
+ let filesRead = 0;
295
+ let omittedFiles = 0;
296
+ for (const path of visible) {
297
+ if (signal?.aborted) throw abortError();
298
+ const oldEntry = oldTree.entries.get(path);
299
+ const newEntry = newTree?.entries.get(path);
300
+ if (filesRead >= maxFiles || readBudget.bytes >= maxBytes) {
301
+ omittedFiles += 1;
302
+ continue;
303
+ }
304
+ filesRead += 1;
305
+ const oldSource = oldEntry
306
+ ? await readGitBlob(cwd, base, oldEntry, readBudget, signal)
307
+ : absent(path);
308
+ const newSource = target
309
+ ? newEntry
310
+ ? await readGitBlob(cwd, target, newEntry, readBudget, signal)
311
+ : absent(path)
312
+ : await readWorktreeSource(cwd, path, readBudget, signal);
313
+ if (oldSource.sourceStatus === "absent" && newSource.sourceStatus === "absent") continue;
314
+ if (
315
+ sameContents(oldSource, newSource) &&
316
+ (process.platform === "win32" || oldSource.mode === newSource.mode)
317
+ )
318
+ continue;
319
+ pairs.push({ old: oldSource, new: newSource });
320
+ }
321
+ if (omittedFiles > 0)
322
+ reasons.add(
323
+ `Git read limits omitted ${String(omittedFiles)} candidate files (${String(maxFiles)} files / ${String(maxBytes)} bytes)`,
324
+ );
325
+ const paired = await pairRenames(pairs, diffBudget, reasons);
326
+ const files: GitSourceFile[] = [];
327
+ for (const pair of paired) files.push(await renderPair(pair, request, diffBudget, reasons));
328
+ // Range calculation and rename work must not outlive the disk revision used to discover them.
329
+ for (const pair of paired) {
330
+ if (pair.new.origin?.kind !== "worktree") continue;
331
+ await verifyWorktreeRevision(cwd, pair.new.path, pair.new.origin.revision);
332
+ }
333
+ return {
334
+ base,
335
+ target: target ?? "worktree",
336
+ scope: request.scope,
337
+ side: request.side,
338
+ files: files.toSorted((left, right) =>
339
+ left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
340
+ ),
341
+ partial: reasons.size > 0,
342
+ reasons: [...reasons],
343
+ filesRead,
344
+ bytesRead: readBudget.bytes,
345
+ diffWork: diffBudget.work,
346
+ omittedFiles,
347
+ };
348
+ }
349
+
350
+ /* oxlint-enable no-await-in-loop */
351
+
352
+ /** Revalidate path membership and today's privacy rules before returning immutable historical source. */
353
+ export async function readGitSource(
354
+ cwd: string,
355
+ identity: { commit: string; path: string; blob?: string },
356
+ signal?: AbortSignal,
357
+ options: Pick<GitSourceOptions, "includePath" | "maxBytes"> = {},
358
+ ): Promise<GitRawSource> {
359
+ const path = gitPath(cwd, identity.path);
360
+ if (!(await visibleGitPaths(cwd, [path], signal, options.includePath)).includes(path))
361
+ throw new SiftLightError("Git source is excluded by current workspace privacy or path rules");
362
+ const selected = await filterHistoricalPaths(
363
+ cwd,
364
+ [path],
365
+ { glob: [], exclude: [], hidden: true },
366
+ signal,
367
+ );
368
+ if (selected.partial || !selected.paths.includes(path))
369
+ throw new SiftLightError(
370
+ "Git source is excluded or unverified by current .ignore/.rgignore rules",
371
+ );
372
+ const commit = await resolveGitCommit(cwd, identity.commit, signal);
373
+ const tree = await readGitTree(cwd, commit, signal, path);
374
+ const entry: GitTreeEntry | undefined = tree.entries.get(path);
375
+ if (!entry) throw new SiftLightError("Git source path does not exist in the requested commit");
376
+ if (identity.blob !== undefined && identity.blob !== entry.blob)
377
+ throw new SiftLightError("Git source blob does not match its commit and path");
378
+ return readGitBlob(
379
+ cwd,
380
+ commit,
381
+ entry,
382
+ { bytes: selected.ignoreBytesRead, maxBytes: options.maxBytes ?? MAX_STRUCTURE_BYTES },
383
+ signal,
384
+ );
385
+ }
@@ -0,0 +1,205 @@
1
+ import { lstat, mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, parse, relative, resolve } from "node:path";
5
+ import { MAX_STRUCTURE_BYTES, MAX_STRUCTURE_FILES } from "./analysis-limits.js";
6
+ import { abortError, SiftLightError } from "./errors.js";
7
+ import {
8
+ assertExistingPathInsideCwd,
9
+ isPathInsideCwd,
10
+ sameSourceRevision,
11
+ sourceRevisionFromStats,
12
+ } from "./source.js";
13
+ import { MAX_SOURCE_FILE_BYTES, type SearchRequest } from "./types.js";
14
+ import { listWorkspaceFiles, workspaceRelativePath } from "./workspace-files.js";
15
+
16
+ interface HistoricalPathSelection {
17
+ paths: string[];
18
+ partial: boolean;
19
+ reasons: string[];
20
+ ignoreBytesRead: number;
21
+ }
22
+
23
+ function partitionPaths(paths: string[]): string[][] {
24
+ const groups: string[][] = [];
25
+ for (const path of paths) {
26
+ const group = groups.find(
27
+ (candidate) =>
28
+ !candidate.some((other) => path.startsWith(`${other}/`) || other.startsWith(`${path}/`)),
29
+ );
30
+ if (group) group.push(path);
31
+ else groups.push([path]);
32
+ }
33
+ return groups;
34
+ }
35
+
36
+ function relevantDirectories(cwd: string, paths: string[]): string[] {
37
+ const directories = new Set<string>();
38
+ for (const path of [cwd, ...paths.map((sourcePath) => dirname(resolve(cwd, sourcePath)))]) {
39
+ let current = path;
40
+ for (;;) {
41
+ directories.add(current);
42
+ const parent = dirname(current);
43
+ if (current === parent) break;
44
+ current = parent;
45
+ }
46
+ }
47
+ return [...directories];
48
+ }
49
+
50
+ /** Empty metadata trees let ripgrep itself decide globs and current .ignore/.rgignore precedence. */
51
+ /* oxlint-disable no-await-in-loop -- owned temporary trees are built and checked within one cumulative read budget. */
52
+ export async function filterHistoricalPaths(
53
+ cwd: string,
54
+ paths: string[],
55
+ request: Pick<SearchRequest, "path" | "glob" | "exclude" | "hidden">,
56
+ signal?: AbortSignal,
57
+ ): Promise<HistoricalPathSelection> {
58
+ if (!isPathInsideCwd(resolve(cwd, request.path ?? "."), cwd)) {
59
+ throw new SiftLightError("Historical path filtering requires a path inside cwd");
60
+ }
61
+ const selectedPath = workspaceRelativePath(cwd, request.path ?? ".");
62
+ const candidates = paths.filter(
63
+ (path) =>
64
+ selectedPath.length === 0 || path === selectedPath || path.startsWith(`${selectedPath}/`),
65
+ );
66
+ const reasons = new Set<string>();
67
+ if (candidates.length > MAX_STRUCTURE_FILES)
68
+ reasons.add(
69
+ `Historical path filtering reached the ${String(MAX_STRUCTURE_FILES)} candidate limit`,
70
+ );
71
+ const bounded = candidates.slice(0, MAX_STRUCTURE_FILES);
72
+ if (bounded.length === 0)
73
+ return { paths: [], partial: reasons.size > 0, reasons: [...reasons], ignoreBytesRead: 0 };
74
+ const root = await mkdtemp(join(tmpdir(), "sift-light-paths-"));
75
+ const absoluteCwd = resolve(cwd);
76
+ const volumeRoot = parse(absoluteCwd).root;
77
+ const ignoreFiles: { local: string; bytes: Buffer }[] = [];
78
+ let ignoreBytesRead = 0;
79
+ try {
80
+ for (const directory of relevantDirectories(absoluteCwd, bounded)) {
81
+ if (isPathInsideCwd(directory, absoluteCwd))
82
+ await assertExistingPathInsideCwd(directory, absoluteCwd);
83
+ for (const name of [".ignore", ".rgignore"]) {
84
+ if (signal?.aborted) throw abortError();
85
+ const path = join(directory, name);
86
+ let discovered = false;
87
+ try {
88
+ const before = await lstat(path);
89
+ discovered = true;
90
+ if (!before.isFile())
91
+ throw new SiftLightError(
92
+ "Current ignore rules are not regular files; historical path filtering is unavailable",
93
+ );
94
+ if (
95
+ before.size > MAX_SOURCE_FILE_BYTES ||
96
+ ignoreBytesRead + before.size > MAX_STRUCTURE_BYTES
97
+ )
98
+ throw new SiftLightError("Current ignore rules exceed the source read budget");
99
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
100
+ let bytes: Buffer;
101
+ try {
102
+ if (
103
+ !sameSourceRevision(
104
+ sourceRevisionFromStats(before),
105
+ sourceRevisionFromStats(await handle.stat()),
106
+ )
107
+ )
108
+ throw new SiftLightError("Current ignore rules changed before reading");
109
+ const buffer = Buffer.alloc(before.size + 1);
110
+ let used = 0;
111
+ while (used < buffer.length) {
112
+ if (signal?.aborted) throw abortError();
113
+ const chunk = await handle.read(
114
+ buffer,
115
+ used,
116
+ Math.min(64 * 1024, buffer.length - used),
117
+ null,
118
+ );
119
+ if (chunk.bytesRead === 0) break;
120
+ used += chunk.bytesRead;
121
+ }
122
+ bytes = buffer.subarray(0, used);
123
+ const after = await lstat(path);
124
+ if (
125
+ used !== before.size ||
126
+ !sameSourceRevision(
127
+ sourceRevisionFromStats(before),
128
+ sourceRevisionFromStats(after),
129
+ ) ||
130
+ !sameSourceRevision(
131
+ sourceRevisionFromStats(before),
132
+ sourceRevisionFromStats(await handle.stat()),
133
+ )
134
+ )
135
+ throw new SiftLightError(
136
+ "Current ignore rules changed during historical path filtering",
137
+ );
138
+ } finally {
139
+ await handle.close();
140
+ }
141
+ ignoreBytesRead += bytes.length;
142
+ ignoreFiles.push({ local: relative(volumeRoot, path), bytes });
143
+ } catch (error) {
144
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
145
+ if (discovered)
146
+ throw new SiftLightError(
147
+ "Current ignore rules disappeared during historical path filtering",
148
+ );
149
+ }
150
+ }
151
+ }
152
+ if (
153
+ ignoreFiles.length === 0 &&
154
+ request.glob.length === 0 &&
155
+ request.exclude.length === 0 &&
156
+ request.hidden
157
+ ) {
158
+ return { paths: bounded, partial: reasons.size > 0, reasons: [...reasons], ignoreBytesRead };
159
+ }
160
+ const visible = new Set<string>();
161
+ for (const [index, group] of partitionPaths(bounded).entries()) {
162
+ const tree = join(root, String(index));
163
+ const target = join(tree, relative(volumeRoot, absoluteCwd));
164
+ await mkdir(target, { recursive: true });
165
+ for (const path of group) {
166
+ const safe = workspaceRelativePath(absoluteCwd, path);
167
+ const placeholder = resolve(target, safe);
168
+ await mkdir(dirname(placeholder), { recursive: true });
169
+ await writeFile(placeholder, "");
170
+ }
171
+ for (const ignore of ignoreFiles) {
172
+ const destination = join(tree, ignore.local);
173
+ await mkdir(dirname(destination), { recursive: true });
174
+ await writeFile(destination, ignore.bytes);
175
+ }
176
+ // Privacy filtering happens before explicit globs, which must not revive ignored history.
177
+ const privacy = await listWorkspaceFiles(tree, signal, { ignoreParents: false });
178
+ const prefix = `${relative(tree, target).split("\\").join("/")}/`;
179
+ const allowed = new Set(
180
+ privacy.paths
181
+ .filter((path) => path.startsWith(prefix))
182
+ .map((path) => path.slice(prefix.length)),
183
+ );
184
+ const scoped = await listWorkspaceFiles(target, signal, {
185
+ glob: request.glob,
186
+ exclude: request.exclude,
187
+ hidden: request.hidden,
188
+ ignore: false,
189
+ });
190
+ for (const reason of [...privacy.reasons, ...scoped.reasons]) reasons.add(reason);
191
+ const included = new Set(group);
192
+ for (const path of scoped.paths)
193
+ if (included.has(path) && allowed.has(path)) visible.add(path);
194
+ }
195
+ return {
196
+ paths: [...visible].toSorted(),
197
+ partial: reasons.size > 0,
198
+ reasons: [...reasons],
199
+ ignoreBytesRead,
200
+ };
201
+ } finally {
202
+ await rm(root, { recursive: true, force: true });
203
+ }
204
+ }
205
+ /* oxlint-enable no-await-in-loop */