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
package/src/rg.ts ADDED
@@ -0,0 +1,574 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+ import { abortError, RipgrepInputError, SiftLightError } from "./errors.js";
3
+ import { excerptText } from "./excerpt.js";
4
+ import { SearchRetention } from "./search-retention.js";
5
+ import { consumeCappedLines } from "./capped-lines.js";
6
+ import { isPathInsideCwd, SearchPathPolicy } from "./path-policy.js";
7
+ import { runOwnedProcess } from "./owned-process.js";
8
+ import { resolveRipgrepExecutable } from "./ripgrep-executable.js";
9
+ import {
10
+ boundedRipgrepDiagnostic,
11
+ classifyRipgrepDiagnostics,
12
+ createRipgrepInputError,
13
+ describeUnreadableDiagnostics,
14
+ hasRequestedRootUnreadable,
15
+ } from "./ripgrep-diagnostics.js";
16
+ import { matchesModificationTime } from "./source.js";
17
+ import { captureCandidateRevisions, retainStableSourceRevisions } from "./scan-revisions.js";
18
+ import {
19
+ MAX_SOURCE_REVISION_CONCURRENCY,
20
+ MAX_PROTOCOL_LINE_BYTES,
21
+ MAX_SOURCE_REVISION_FILES,
22
+ MAX_STORED_MATCHES,
23
+ type MatchOccurrence,
24
+ type MatchRecord,
25
+ type SearchRequest,
26
+ type SearchScan,
27
+ type TextRange,
28
+ } from "./types.js";
29
+
30
+ interface RgText {
31
+ text?: string;
32
+ bytes?: string;
33
+ }
34
+
35
+ interface RgSubmatch {
36
+ match: RgText;
37
+ start: number;
38
+ end: number;
39
+ }
40
+
41
+ interface RgMatchEvent {
42
+ type: "match";
43
+ data: {
44
+ path: RgText;
45
+ lines: RgText;
46
+ line_number: number;
47
+ submatches?: RgSubmatch[];
48
+ };
49
+ }
50
+
51
+ export interface RipgrepRunnerOptions {
52
+ executable?: string;
53
+ maxStoredMatches?: number;
54
+ maxEventBytes?: number;
55
+ maxSourceRevisionFiles?: number;
56
+ maxStoredBytes?: number;
57
+ maxStoredOccurrences?: number;
58
+ }
59
+
60
+ function isRecord(value: unknown): value is Record<string, unknown> {
61
+ return typeof value === "object" && value !== null;
62
+ }
63
+
64
+ function isRgText(value: unknown): value is RgText {
65
+ return isRecord(value) && (typeof value.text === "string" || typeof value.bytes === "string");
66
+ }
67
+
68
+ function isRgSubmatch(value: unknown): value is RgSubmatch {
69
+ if (!isRecord(value)) return false;
70
+ return (
71
+ isRgText(value.match) &&
72
+ typeof value.start === "number" &&
73
+ Number.isSafeInteger(value.start) &&
74
+ typeof value.end === "number" &&
75
+ Number.isSafeInteger(value.end) &&
76
+ value.start >= 0 &&
77
+ value.end >= value.start
78
+ );
79
+ }
80
+
81
+ function isRgMatchEvent(value: unknown): value is RgMatchEvent {
82
+ if (!isRecord(value) || value.type !== "match" || !isRecord(value.data)) return false;
83
+ const submatches = value.data.submatches;
84
+ return (
85
+ isRgText(value.data.path) &&
86
+ isRgText(value.data.lines) &&
87
+ typeof value.data.line_number === "number" &&
88
+ Number.isSafeInteger(value.data.line_number) &&
89
+ value.data.line_number > 0 &&
90
+ (submatches === undefined || (Array.isArray(submatches) && submatches.every(isRgSubmatch)))
91
+ );
92
+ }
93
+
94
+ interface DecodedRgText {
95
+ text: string;
96
+ bytes: Buffer;
97
+ encoding: "utf-8" | "utf-16";
98
+ }
99
+
100
+ function decodeRgText(value: RgText, field: string): DecodedRgText {
101
+ if (typeof value.text === "string") {
102
+ return { text: value.text, bytes: Buffer.from(value.text, "utf8"), encoding: "utf-16" };
103
+ }
104
+ if (typeof value.bytes === "string") {
105
+ const bytes = Buffer.from(value.bytes, "base64");
106
+ return { text: bytes.toString("utf8"), bytes, encoding: "utf-8" };
107
+ }
108
+ throw new SiftLightError(`ripgrep JSON event omitted ${field}`);
109
+ }
110
+
111
+ function displayPath(rawPath: string, cwd: string): { absolutePath: string; displayPath: string } {
112
+ const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
113
+ const localPath = relative(cwd, absolutePath).replaceAll("\\", "/");
114
+ const isInsideCwd = localPath !== ".." && !localPath.startsWith("../") && !isAbsolute(localPath);
115
+ return {
116
+ absolutePath,
117
+ displayPath: isInsideCwd && localPath.length > 0 ? localPath : absolutePath,
118
+ };
119
+ }
120
+ function jsonObjectEnd(value: string, start: number, end: number): number | undefined {
121
+ let depth = 0;
122
+ let inString = false;
123
+ let escaped = false;
124
+ for (let index = start; index < end; index += 1) {
125
+ const character = value[index];
126
+ if (inString) {
127
+ if (escaped) escaped = false;
128
+ else if (character === "\\") escaped = true;
129
+ else if (character === '"') inString = false;
130
+ continue;
131
+ }
132
+ if (character === '"') {
133
+ inString = true;
134
+ continue;
135
+ }
136
+ if (character === "{") {
137
+ depth += 1;
138
+ continue;
139
+ }
140
+ if (character !== "}") continue;
141
+ depth -= 1;
142
+ if (depth === 0) return index + 1;
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ function jsonObjectStringProperty(
148
+ value: string,
149
+ property: string,
150
+ pathStart: number,
151
+ ): string | undefined {
152
+ let offset = pathStart + '"path"'.length;
153
+ while (/\s/.test(value[offset] ?? "")) offset += 1;
154
+ if (value[offset] !== ":") return undefined;
155
+ offset += 1;
156
+ while (/\s/.test(value[offset] ?? "")) offset += 1;
157
+ if (value[offset] !== "{") return undefined;
158
+ const objectEnd = jsonObjectEnd(value, offset, value.length);
159
+ if (objectEnd === undefined) return undefined;
160
+ try {
161
+ const parsed: unknown = JSON.parse(value.slice(offset, objectEnd));
162
+ if (!isRecord(parsed)) return undefined;
163
+ const candidate = parsed[property];
164
+ return typeof candidate === "string" ? candidate : undefined;
165
+ } catch {
166
+ return undefined;
167
+ }
168
+ }
169
+
170
+ function oversizedMatchPath(
171
+ prefix: string,
172
+ cwd: string,
173
+ ): { absolutePath: string; displayPath: string } | undefined {
174
+ const pathStart = prefix.indexOf('"path"');
175
+ if (pathStart < 0) return undefined;
176
+ const text = jsonObjectStringProperty(prefix, "text", pathStart);
177
+ if (text !== undefined) return displayPath(text, cwd);
178
+ const encoded = jsonObjectStringProperty(prefix, "bytes", pathStart);
179
+ if (encoded === undefined) return undefined;
180
+ const bytes = Buffer.from(encoded, "base64");
181
+ const decoded = bytes.toString("utf8");
182
+ return Buffer.from(decoded, "utf8").equals(bytes) ? displayPath(decoded, cwd) : undefined;
183
+ }
184
+
185
+ async function assertSearchTargetIdentity(
186
+ policy: SearchPathPolicy,
187
+ path: string,
188
+ expectedCanonical: string | undefined,
189
+ ): Promise<void> {
190
+ const currentCanonical = await policy.resolveExistingPath(path);
191
+ if (currentCanonical !== expectedCanonical) {
192
+ throw new SiftLightError("Search target changed during validation; retry the search");
193
+ }
194
+ }
195
+
196
+ async function assertRetainedPathsAllowed(
197
+ policy: SearchPathPolicy,
198
+ paths: readonly string[],
199
+ signal?: AbortSignal,
200
+ ): Promise<void> {
201
+ for (let offset = 0; offset < paths.length; offset += MAX_SOURCE_REVISION_CONCURRENCY) {
202
+ if (signal?.aborted) throw abortError();
203
+ const batch = paths.slice(offset, offset + MAX_SOURCE_REVISION_CONCURRENCY);
204
+ // oxlint-disable-next-line no-await-in-loop -- bounded batches avoid unbounded filesystem work.
205
+ await Promise.all(batch.map((path) => policy.resolveExistingPath(path)));
206
+ }
207
+ }
208
+
209
+ function utf16Length(value: string): number {
210
+ return value.length;
211
+ }
212
+
213
+ function byteOffsetToCharacter(
214
+ bytes: Buffer,
215
+ byteOffset: number,
216
+ encoding: "utf-8" | "utf-16",
217
+ ): number {
218
+ if (byteOffset < 0 || byteOffset > bytes.length) {
219
+ throw new SiftLightError("ripgrep emitted a submatch outside its matching line");
220
+ }
221
+ if (encoding === "utf-8") return byteOffset;
222
+ const prefix = bytes.subarray(0, byteOffset).toString("utf8").replaceAll("\r", "");
223
+ return utf16Length(prefix);
224
+ }
225
+
226
+ function createOccurrences(
227
+ lineNumber: number,
228
+ decodedLine: DecodedRgText,
229
+ submatches: RgSubmatch[],
230
+ ): MatchOccurrence[] {
231
+ const range: TextRange = {
232
+ start: { line: lineNumber - 1, character: 0 },
233
+ end: { line: lineNumber - 1, character: 0 },
234
+ encoding: decodedLine.encoding,
235
+ };
236
+ const occurrences: MatchOccurrence[] = [];
237
+ for (const submatch of submatches) {
238
+ if (submatch.end > decodedLine.bytes.length) {
239
+ throw new SiftLightError("ripgrep emitted a submatch outside its matching line");
240
+ }
241
+ occurrences.push({
242
+ byteStart: submatch.start,
243
+ byteEnd: submatch.end,
244
+ range: {
245
+ start: {
246
+ ...range.start,
247
+ character: byteOffsetToCharacter(decodedLine.bytes, submatch.start, range.encoding),
248
+ },
249
+ end: {
250
+ ...range.end,
251
+ character: byteOffsetToCharacter(decodedLine.bytes, submatch.end, range.encoding),
252
+ },
253
+ encoding: range.encoding,
254
+ },
255
+ });
256
+ }
257
+ return occurrences;
258
+ }
259
+
260
+ export function fileScopeArguments(
261
+ request: Pick<SearchRequest, "hidden" | "glob" | "exclude"> & {
262
+ ignorePolicy?: "respect" | "include";
263
+ },
264
+ ): string[] {
265
+ const args: string[] = [];
266
+ if (request.ignorePolicy === "include") args.push("--no-ignore", "--no-ignore-parent");
267
+ if (request.hidden) args.push("--hidden");
268
+ for (const glob of request.glob) args.push("--glob", glob);
269
+ for (const excluded of request.exclude) {
270
+ const normalized = excluded.startsWith("!") ? excluded : `!${excluded}`;
271
+ args.push("--glob", normalized);
272
+ }
273
+ // Ripgrep gives the last matching glob priority, so this invariant must come last.
274
+ args.push("--iglob", "!.git", "--iglob", "!.git/**", "--iglob", "!**/.git/**");
275
+ return args;
276
+ }
277
+
278
+ export function buildRipgrepArguments(
279
+ request: SearchRequest,
280
+ cwd: string,
281
+ validatedSearchPath?: string,
282
+ ): string[] {
283
+ const searchPath = validatedSearchPath ?? resolve(cwd, request.path ?? ".");
284
+ const policy = new SearchPathPolicy(cwd);
285
+ policy.assertPath(searchPath);
286
+ const args = [
287
+ "--no-config",
288
+ "--json",
289
+ "--line-number",
290
+ "--color=never",
291
+ "--no-heading",
292
+ ...(request.binaryAsText ? ["--text"] : []),
293
+ ...fileScopeArguments(request),
294
+ ...policy.ripgrepGlobArguments(searchPath),
295
+ ];
296
+
297
+ args.push(...patternArguments(request));
298
+
299
+ const searchTarget = isPathInsideCwd(searchPath, cwd)
300
+ ? relative(resolve(cwd), searchPath) || "."
301
+ : searchPath;
302
+ args.push("--", request.pattern, searchTarget);
303
+ return args;
304
+ }
305
+
306
+ export function patternArguments(
307
+ request: Pick<SearchRequest, "literal" | "ignoreCase" | "wholeWord">,
308
+ ): string[] {
309
+ return [
310
+ ...(request.wholeWord ? ["--word-regexp"] : []),
311
+ ...(request.literal ? ["--fixed-strings"] : []),
312
+ request.ignoreCase === true
313
+ ? "--ignore-case"
314
+ : request.ignoreCase === false
315
+ ? "--case-sensitive"
316
+ : "--smart-case",
317
+ ];
318
+ }
319
+
320
+ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
321
+ const maxStoredMatches = options.maxStoredMatches ?? MAX_STORED_MATCHES;
322
+ const maxEventBytes = options.maxEventBytes ?? MAX_PROTOCOL_LINE_BYTES;
323
+ const maxSourceRevisionFiles = options.maxSourceRevisionFiles ?? MAX_SOURCE_REVISION_FILES;
324
+
325
+ return async function runRipgrep(
326
+ request: SearchRequest,
327
+ cwd: string,
328
+ signal?: AbortSignal,
329
+ ): Promise<SearchScan> {
330
+ if (signal?.aborted) throw abortError();
331
+ const executable = options.executable ?? (await resolveRipgrepExecutable());
332
+ const searchPath = resolve(cwd, request.path ?? ".");
333
+ const policy = new SearchPathPolicy(cwd);
334
+ const validatedSearchPath = await policy.resolveSearchTarget(searchPath);
335
+ const expectedSearchTarget = await policy.resolveExistingPath(validatedSearchPath);
336
+ const searchTarget = isPathInsideCwd(validatedSearchPath, cwd)
337
+ ? relative(resolve(cwd), validatedSearchPath) || "."
338
+ : validatedSearchPath;
339
+ const args = buildRipgrepArguments(request, cwd, validatedSearchPath);
340
+ if (signal?.aborted) throw abortError();
341
+
342
+ const matches: MatchRecord[] = [];
343
+ const retention = new SearchRetention(options.maxStoredBytes, options.maxStoredOccurrences);
344
+ const fileCounts = new Map<string, number>();
345
+ const lossyPaths = new Set<string>();
346
+ const oversizedPathReasons = new Set<string>();
347
+ let totalMatches = 0;
348
+ let truncatedLines = 0;
349
+ let modificationTimeFilterIncomplete = false;
350
+ let candidateRevisions = new Map<string, import("./types.js").SourceRevision>();
351
+
352
+ const onLine = (line: string) => {
353
+ if (line.length === 0) return;
354
+ let event: unknown;
355
+ try {
356
+ event = JSON.parse(line);
357
+ } catch (error) {
358
+ throw new SiftLightError("Failed to parse ripgrep JSON output", { cause: error });
359
+ }
360
+ if (!isRecord(event)) return;
361
+ if (event.type !== "match") return;
362
+ if (!isRgMatchEvent(event)) {
363
+ throw new SiftLightError("ripgrep emitted an invalid match event");
364
+ }
365
+
366
+ const rawPath = decodeRgText(event.data.path, "path");
367
+ const rawContent = decodeRgText(event.data.lines, "line content");
368
+ const normalizedContent = rawContent.text.replaceAll("\r", "").replace(/\n$/, "");
369
+ const path = displayPath(rawPath.text, cwd);
370
+ if (request.modifiedAfterMs !== undefined || request.modifiedBeforeMs !== undefined) {
371
+ const revision = candidateRevisions.get(path.absolutePath);
372
+ if (!revision) {
373
+ modificationTimeFilterIncomplete = true;
374
+ retention.noteLimit(
375
+ `Modification time could not be verified for ${path.displayPath}; matching evidence was retained`,
376
+ );
377
+ } else if (
378
+ !matchesModificationTime(revision, request.modifiedAfterMs, request.modifiedBeforeMs)
379
+ ) {
380
+ return;
381
+ }
382
+ }
383
+ if (rawPath.encoding === "utf-8") lossyPaths.add(path.absolutePath);
384
+ const submatches = event.data.submatches ?? [];
385
+ if (submatches.some((match) => match.end > rawContent.bytes.length))
386
+ throw new SiftLightError("ripgrep emitted a submatch outside its matching line");
387
+ const primaryOccurrence = submatches[0];
388
+ let focusStart = 0;
389
+ let focusEnd = 0;
390
+ if (primaryOccurrence) {
391
+ focusStart = byteOffsetToCharacter(rawContent.bytes, primaryOccurrence.start, "utf-16");
392
+ focusEnd = byteOffsetToCharacter(rawContent.bytes, primaryOccurrence.end, "utf-16");
393
+ }
394
+ const excerpt = excerptText(normalizedContent, focusStart, focusEnd);
395
+ const { text: lineContent, truncated: lineTruncated } = excerpt;
396
+
397
+ totalMatches += 1;
398
+ if (!fileCounts.has(path.displayPath)) {
399
+ if (retention.file(path.displayPath, path.absolutePath))
400
+ fileCounts.set(path.displayPath, 0);
401
+ }
402
+ if (fileCounts.has(path.displayPath))
403
+ fileCounts.set(path.displayPath, (fileCounts.get(path.displayPath) ?? 0) + 1);
404
+ if (lineTruncated) truncatedLines += 1;
405
+ if (matches.length >= maxStoredMatches)
406
+ retention.noteLimit(
407
+ `Matching-line retention reached the ${String(maxStoredMatches)} limit`,
408
+ );
409
+ if (matches.length < maxStoredMatches && retention.canRetainOccurrences(submatches.length)) {
410
+ const match = {
411
+ ...path,
412
+ lineNumber: event.data.line_number,
413
+ lineContent,
414
+ lineTruncated,
415
+ occurrences: createOccurrences(event.data.line_number, rawContent, submatches),
416
+ };
417
+ if (retention.retain(match)) matches.push(match);
418
+ }
419
+ };
420
+
421
+ try {
422
+ const before = await captureCandidateRevisions(
423
+ executable,
424
+ [
425
+ "--no-config",
426
+ "--files",
427
+ "--null",
428
+ ...fileScopeArguments(request),
429
+ ...policy.ripgrepGlobArguments(validatedSearchPath),
430
+ "--",
431
+ searchTarget,
432
+ ],
433
+ cwd,
434
+ maxSourceRevisionFiles,
435
+ signal,
436
+ request.redact,
437
+ );
438
+ let ignoredFileCount = 0;
439
+ let ignoredFileSamples: string[] = [];
440
+ let filesystemCoverage: SearchScan["filesystemCoverage"] = "complete";
441
+ const filesystemCoverageReasons = new Set<string>();
442
+ if (before.enumerationTruncated) {
443
+ filesystemCoverage = "partial";
444
+ filesystemCoverageReasons.add(
445
+ `Filesystem coverage comparison reached the ${String(maxSourceRevisionFiles)} file metadata limit`,
446
+ );
447
+ }
448
+ if (before.invalidPathCount > 0) {
449
+ filesystemCoverage = "partial";
450
+ filesystemCoverageReasons.add(
451
+ `${String(before.invalidPathCount)} candidate path(s) were not valid UTF-8`,
452
+ );
453
+ }
454
+ if (request.ignorePolicy !== "include") {
455
+ const allCandidates = await captureCandidateRevisions(
456
+ executable,
457
+ [
458
+ "--no-config",
459
+ "--files",
460
+ "--null",
461
+ ...fileScopeArguments({ ...request, ignorePolicy: "include" }),
462
+ ...policy.ripgrepGlobArguments(validatedSearchPath),
463
+ "--",
464
+ searchTarget,
465
+ ],
466
+ cwd,
467
+ maxSourceRevisionFiles,
468
+ signal,
469
+ request.redact,
470
+ );
471
+ const ignored = [...allCandidates.revisions.keys()].filter(
472
+ (path) => !before.revisions.has(path),
473
+ );
474
+ ignoredFileCount = ignored.length;
475
+ ignoredFileSamples = ignored.slice(0, 20).map((path) => displayPath(path, cwd).displayPath);
476
+ if (ignoredFileCount > 0) filesystemCoverage = "policy-filtered";
477
+ if (allCandidates.unreadable.length > 0) {
478
+ filesystemCoverage = "partial";
479
+ retention.noteLimit(describeUnreadableDiagnostics(allCandidates.unreadable));
480
+ }
481
+ if (allCandidates.enumerationTruncated) {
482
+ filesystemCoverage = "partial";
483
+ filesystemCoverageReasons.add(
484
+ `Include-ignored coverage comparison reached the ${String(maxSourceRevisionFiles)} file metadata limit`,
485
+ );
486
+ }
487
+ if (allCandidates.invalidPathCount > 0) {
488
+ filesystemCoverage = "partial";
489
+ filesystemCoverageReasons.add(
490
+ `${String(allCandidates.invalidPathCount)} include-ignored candidate path(s) were not valid UTF-8`,
491
+ );
492
+ }
493
+ }
494
+ if (hasRequestedRootUnreadable(before.unreadable, cwd, validatedSearchPath))
495
+ throw new SiftLightError(describeUnreadableDiagnostics(before.unreadable));
496
+ candidateRevisions = before.revisions;
497
+ if (before.unreadable.length > 0)
498
+ retention.noteLimit(describeUnreadableDiagnostics(before.unreadable));
499
+ if (before.unreadable.length > 0) filesystemCoverage = "partial";
500
+ if (before.unreadable.length > 0)
501
+ filesystemCoverageReasons.add(describeUnreadableDiagnostics(before.unreadable));
502
+ await assertSearchTargetIdentity(policy, validatedSearchPath, expectedSearchTarget);
503
+ const { code, stderr } = await runOwnedProcess(
504
+ { executable, args, cwd, ...(signal ? { signal } : {}) },
505
+ (stdout) =>
506
+ consumeCappedLines(stdout, onLine, {
507
+ maxLineBytes: maxEventBytes,
508
+ onLineTooLong: ({ prefix, observedBytes }) => {
509
+ const source = oversizedMatchPath(prefix, cwd);
510
+ const label = source?.displayPath ?? "unknown source";
511
+ if (oversizedPathReasons.has(label)) return;
512
+ oversizedPathReasons.add(label);
513
+ retention.noteLimit(
514
+ `Skipped oversized ripgrep match line in ${JSON.stringify(label)}; observed at least ${String(observedBytes)} bytes, limit is ${String(maxEventBytes)} bytes`,
515
+ );
516
+ },
517
+ }),
518
+ );
519
+ const diagnostics = classifyRipgrepDiagnostics(stderr);
520
+ const inputError = createRipgrepInputError(stderr, request.redact);
521
+ if (inputError) throw inputError;
522
+ if (hasRequestedRootUnreadable(diagnostics.unreadable, cwd, validatedSearchPath))
523
+ throw new SiftLightError(describeUnreadableDiagnostics(diagnostics.unreadable));
524
+ if (diagnostics.unreadable.length > 0)
525
+ retention.noteLimit(describeUnreadableDiagnostics(diagnostics.unreadable));
526
+ if (diagnostics.unreadable.length > 0) filesystemCoverage = "partial";
527
+ if (diagnostics.unreadable.length > 0)
528
+ filesystemCoverageReasons.add(describeUnreadableDiagnostics(diagnostics.unreadable));
529
+ if (code === 2 && (diagnostics.other.length > 0 || diagnostics.unreadable.length === 0)) {
530
+ throw new SiftLightError(stderr.trim() || `ripgrep exited with status ${String(code)}`);
531
+ }
532
+ await assertSearchTargetIdentity(policy, validatedSearchPath, expectedSearchTarget);
533
+ const retainedPaths = new Set(
534
+ matches.map((match) => match.absolutePath).filter((path) => !lossyPaths.has(path)),
535
+ );
536
+ await assertRetainedPathsAllowed(policy, [...retainedPaths], signal);
537
+ const sourceRevisions = await retainStableSourceRevisions(
538
+ retainedPaths,
539
+ before.revisions,
540
+ signal,
541
+ );
542
+ if (signal?.aborted) throw abortError();
543
+ return {
544
+ request,
545
+ matches,
546
+ totalMatches,
547
+ fileCounts,
548
+ sourceRevisions,
549
+ snapshotComplete:
550
+ matches.length === totalMatches &&
551
+ !modificationTimeFilterIncomplete &&
552
+ retention.details.reasons.length === 0,
553
+ filesystemCoverage,
554
+ filesystemCoverageReasons: [...filesystemCoverageReasons],
555
+ ignoredFileCount,
556
+ ignoredFileSamples,
557
+ searchedFileCount: before.revisions.size,
558
+ truncatedLines,
559
+ retention: retention.details,
560
+ };
561
+ } catch (error) {
562
+ if (error instanceof Error && error.name === "AbortError") throw abortError();
563
+ if (error instanceof RipgrepInputError) throw error;
564
+ const cause = error instanceof Error ? error : new Error(String(error));
565
+ const executableMissing = "code" in cause && cause.code === "ENOENT";
566
+ const message = executableMissing
567
+ ? `ripgrep executable not found: ${boundedRipgrepDiagnostic(executable, request.redact)}`
568
+ : boundedRipgrepDiagnostic(cause.message, request.redact);
569
+ throw new SiftLightError(message, { cause });
570
+ }
571
+ };
572
+ }
573
+
574
+ export type RipgrepRunner = ReturnType<typeof createRipgrepRunner>;
@@ -0,0 +1,122 @@
1
+ import { resolve } from "node:path";
2
+ import { RipgrepInputError, type RipgrepInputErrorCode } from "./errors.js";
3
+ import { redactDiagnosticText } from "./redaction.js";
4
+
5
+ export interface RipgrepUnreadableDiagnostic {
6
+ message: string;
7
+ path?: string;
8
+ }
9
+
10
+ export interface RipgrepDiagnostics {
11
+ unreadable: RipgrepUnreadableDiagnostic[];
12
+ other: string[];
13
+ }
14
+
15
+ const UNREADABLE_SUFFIX = /:\s+Permission denied(?:\s+\(os error 13\))?\s*$/iu;
16
+ const UNREADABLE_CODE = /\(os error 13\)\s*$/iu;
17
+ const INVALID_REGEX_DIAGNOSTIC = /(?:^|\n)\s*(?:rg:\s*)?regex parse error:/iu;
18
+ const MISSING_PATH_DIAGNOSTIC =
19
+ /(?:IO error for operation on .+?:\s*)?No such file or directory \(os error 2\)\s*$/iu;
20
+ const MAX_RIPGREP_DIAGNOSTIC_CHARACTERS = 4_096;
21
+
22
+ function diagnosticLines(stderr: string): string[] {
23
+ return stderr
24
+ .split(/\r?\n/u)
25
+ .map((line) => line.trim())
26
+ .filter((line) => line.length > 0);
27
+ }
28
+
29
+ export function boundedRipgrepDiagnostic(value: string, redact = false): string {
30
+ const safe = redact ? redactDiagnosticText(value) : value;
31
+ const normalized = safe.toWellFormed();
32
+ return normalized.length <= MAX_RIPGREP_DIAGNOSTIC_CHARACTERS
33
+ ? normalized
34
+ : `${normalized.slice(0, MAX_RIPGREP_DIAGNOSTIC_CHARACTERS - 1)}…`;
35
+ }
36
+
37
+ /** Classify only stable ripgrep input failures; unknown stderr remains a normal runtime error. */
38
+ export function classifyRipgrepInputFailure(stderr: string): RipgrepInputErrorCode | undefined {
39
+ if (INVALID_REGEX_DIAGNOSTIC.test(stderr)) return "E_REGEX_INVALID";
40
+ if (diagnosticLines(stderr).some((line) => MISSING_PATH_DIAGNOSTIC.test(line)))
41
+ return "E_SEARCH_PATH_NOT_FOUND";
42
+ return undefined;
43
+ }
44
+
45
+ function missingPathFromDiagnostics(stderr: string): string | undefined {
46
+ for (const line of diagnosticLines(stderr)) {
47
+ const operation =
48
+ /IO error for operation on (.+?):\s*No such file or directory \(os error 2\)\s*$/iu.exec(
49
+ line,
50
+ );
51
+ if (operation?.[1]) return operation[1].trim();
52
+ const direct = /^rg:\s*(.+?):\s*No such file or directory \(os error 2\)\s*$/iu.exec(line);
53
+ if (direct?.[1]) return direct[1].trim();
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ export function createRipgrepInputError(
59
+ stderr: string,
60
+ redact = false,
61
+ ): RipgrepInputError | undefined {
62
+ const code = classifyRipgrepInputFailure(stderr);
63
+ if (code === "E_REGEX_INVALID") {
64
+ return new RipgrepInputError(
65
+ code,
66
+ "ripgrep regex parse error: pattern is not a valid regular expression.",
67
+ "Correct pattern or set literal=true for source text; no automatic literal conversion was attempted.",
68
+ );
69
+ }
70
+ if (code === "E_SEARCH_PATH_NOT_FOUND") {
71
+ const path = missingPathFromDiagnostics(stderr);
72
+ const location = path ? ` (${JSON.stringify(boundedRipgrepDiagnostic(path, redact))})` : "";
73
+ return new RipgrepInputError(
74
+ code,
75
+ `A searched path${location} does not exist or became unavailable.`,
76
+ "Provide an existing exact path, or use mode=files with query to discover an unknown filename; no retry or scope expansion was attempted.",
77
+ );
78
+ }
79
+ // Known input failures above intentionally omit ripgrep's raw pattern and path echo.
80
+ return undefined;
81
+ }
82
+
83
+ function unreadablePath(line: string, suffix: RegExp): string | undefined {
84
+ const match = suffix.exec(line);
85
+ if (!match || match.index === undefined) return undefined;
86
+ const prefix = line.slice(0, match.index).trim();
87
+ const separator = prefix.indexOf(": ");
88
+ const path = (separator < 0 ? prefix : prefix.slice(separator + 2)).trim();
89
+ return path.replace(/^['"]|['"]$/gu, "") || undefined;
90
+ }
91
+
92
+ export function classifyRipgrepDiagnostics(stderr: string): RipgrepDiagnostics {
93
+ const unreadable: RipgrepUnreadableDiagnostic[] = [];
94
+ const other: string[] = [];
95
+ for (const line of diagnosticLines(stderr)) {
96
+ const path = unreadablePath(line, UNREADABLE_SUFFIX);
97
+ if (path !== undefined || UNREADABLE_CODE.test(line)) {
98
+ unreadable.push({ message: line, ...(path ? { path } : {}) });
99
+ } else {
100
+ other.push(line);
101
+ }
102
+ }
103
+ return { unreadable, other };
104
+ }
105
+
106
+ export function hasRequestedRootUnreadable(
107
+ diagnostics: readonly RipgrepUnreadableDiagnostic[],
108
+ cwd: string,
109
+ searchPath: string,
110
+ ): boolean {
111
+ const expected = resolve(cwd, searchPath);
112
+ return diagnostics.some(
113
+ (diagnostic) => diagnostic.path === undefined || resolve(cwd, diagnostic.path) === expected,
114
+ );
115
+ }
116
+
117
+ export function describeUnreadableDiagnostics(
118
+ diagnostics: readonly RipgrepUnreadableDiagnostic[],
119
+ ): string {
120
+ const messages = [...new Set(diagnostics.map((diagnostic) => diagnostic.message))];
121
+ return `Ripgrep skipped ${String(messages.length)} unreadable path(s); search coverage is partial: ${messages.join("; ")}`;
122
+ }