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,466 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { AnalysisStore } from "./analysis-store.js";
4
+ import { abortError, CursorError, SiftLightError } from "./errors.js";
5
+ import { evidenceRecheck } from "./evidence-validity.js";
6
+ import { SearchPathPolicy } from "./path-policy.js";
7
+ import { sameSourceRevision, sourceRevisionFromStats } from "./source.js";
8
+ import { SourceAccess, SourceBudgetError, SyntaxQueue } from "./source-access.js";
9
+ import { SourceDocumentError, type SourceReference } from "./source-document.js";
10
+ import { SnapshotStore } from "./snapshot-store.js";
11
+ import type { AnalysisItem } from "./analysis-types.js";
12
+ import type { SearchRequest, SearchScopeDetails, SearchSnapshot, SourceRevision } from "./types.js";
13
+ import type {
14
+ EvidenceCoverageStatus,
15
+ EvidenceComparisonTarget,
16
+ EvidenceRecheck,
17
+ EvidenceSourceScope,
18
+ EvidenceSourceStatus,
19
+ } from "./validation-types.js";
20
+ import { MAX_STRUCTURE_FILES } from "./analysis-limits.js";
21
+
22
+ export type { EvidenceComparisonTarget } from "./validation-types.js";
23
+
24
+ export interface SavedEvidenceValidationOptions {
25
+ cursor: string;
26
+ cwd: string;
27
+ signal?: AbortSignal;
28
+ matchIndex?: number;
29
+ analyses: AnalysisStore;
30
+ snapshots: SnapshotStore;
31
+ queue: SyntaxQueue;
32
+ maxFiles?: number;
33
+ }
34
+
35
+ export interface SavedEvidenceValidationResult {
36
+ scope: EvidenceSourceScope;
37
+ sources: readonly EvidenceSourceStatus[];
38
+ coverage: EvidenceCoverageStatus;
39
+ storedPartial: boolean;
40
+ reasons: readonly string[];
41
+ comparisonTarget: EvidenceComparisonTarget;
42
+ recheck: EvidenceRecheck;
43
+ }
44
+
45
+ interface ValidationTarget {
46
+ key: string;
47
+ path: string;
48
+ expected?: SourceReference;
49
+ revision?: SourceRevision;
50
+ }
51
+
52
+ interface ClassifiedFailure {
53
+ status: EvidenceSourceStatus["status"];
54
+ reason: string;
55
+ budgetReached?: boolean;
56
+ }
57
+
58
+ interface ValidationStatus extends EvidenceSourceStatus {
59
+ budgetReached?: boolean;
60
+ }
61
+
62
+ function isAbort(error: unknown, signal?: AbortSignal): boolean {
63
+ return signal?.aborted === true || (error instanceof Error && error.name === "AbortError");
64
+ }
65
+
66
+ function systemErrorCode(error: unknown): string | undefined {
67
+ if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
68
+ const code = error.code;
69
+ return typeof code === "string" ? code : undefined;
70
+ }
71
+
72
+ function policyFailure(error: unknown): boolean {
73
+ return (
74
+ error instanceof SiftLightError &&
75
+ (error.message.startsWith("Path is inside a protected credential or system area:") ||
76
+ error.message === "Git internals are excluded from search" ||
77
+ error.message === "Path must stay within the working directory")
78
+ );
79
+ }
80
+
81
+ async function confirmWorktreeState(
82
+ path: string,
83
+ cwd: string,
84
+ signal?: AbortSignal,
85
+ ): Promise<ClassifiedFailure> {
86
+ const absolute = resolve(cwd, path);
87
+ const policy = new SearchPathPolicy(cwd);
88
+ try {
89
+ policy.assertPath(absolute);
90
+ const before = await stat(absolute);
91
+ const beforeCanonical = await realpath(absolute);
92
+ const after = await stat(absolute);
93
+ const afterCanonical = await realpath(absolute);
94
+ if (!before.isFile() || !after.isFile()) {
95
+ return { status: "stale", reason: "Source is no longer a regular file" };
96
+ }
97
+ if (
98
+ beforeCanonical !== afterCanonical ||
99
+ !sameSourceRevision(sourceRevisionFromStats(before), sourceRevisionFromStats(after))
100
+ ) {
101
+ return { status: "stale", reason: "Source changed while checking its availability" };
102
+ }
103
+ return { status: "unknown", reason: "Source could not be read despite a stable path" };
104
+ } catch (error) {
105
+ if (isAbort(error, signal)) throw error;
106
+ const code = systemErrorCode(error);
107
+ if (code === "ENOENT") return { status: "stale", reason: "Source is unavailable" };
108
+ if (code && ["EACCES", "EPERM", "ELOOP", "ENOTDIR"].includes(code)) {
109
+ return { status: "unknown", reason: `Source could not be checked (${code})` };
110
+ }
111
+ if (policyFailure(error)) {
112
+ return {
113
+ status: "unknown",
114
+ reason: error instanceof Error ? error.message : "Path policy denied source",
115
+ };
116
+ }
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ async function failure(
122
+ error: unknown,
123
+ expected: SourceReference | undefined,
124
+ path: string,
125
+ cwd: string,
126
+ signal?: AbortSignal,
127
+ ): Promise<ClassifiedFailure> {
128
+ if (error instanceof SourceDocumentError) {
129
+ if (error.reason === "source-changed") {
130
+ return { status: "stale", reason: error.message };
131
+ }
132
+ if (error.reason === "source-unavailable" && expected?.origin.kind === "worktree") {
133
+ return confirmWorktreeState(path, cwd, signal);
134
+ }
135
+ return { status: "unknown", reason: error.message };
136
+ }
137
+ if (error instanceof SourceBudgetError)
138
+ return { status: "unknown", reason: error.message, budgetReached: true };
139
+ const code = systemErrorCode(error);
140
+ if (code === "ENOENT" && expected?.origin.kind === "worktree") {
141
+ return confirmWorktreeState(path, cwd, signal);
142
+ }
143
+ if (code && ["EACCES", "EPERM", "ELOOP", "ENOTDIR"].includes(code)) {
144
+ return { status: "unknown", reason: `Source could not be checked (${code})` };
145
+ }
146
+ if (policyFailure(error)) {
147
+ return {
148
+ status: "unknown",
149
+ reason: error instanceof Error ? error.message : "Path policy denied source",
150
+ };
151
+ }
152
+ throw error;
153
+ }
154
+
155
+ function sourceKey(reference: SourceReference): string {
156
+ return JSON.stringify([reference.path, reference.origin]);
157
+ }
158
+
159
+ function revisionKey(path: string, revision: SourceRevision): string {
160
+ return JSON.stringify([path, revision]);
161
+ }
162
+
163
+ function selectedIndex(
164
+ index: number | undefined,
165
+ count: number,
166
+ label: string,
167
+ ): number | undefined {
168
+ if (index === undefined) return undefined;
169
+ if (!Number.isSafeInteger(index) || index < 1 || index > count) {
170
+ throw new CursorError(
171
+ `matchIndex must select a retained ${label} from 1 through ${String(count)}`,
172
+ "E_CURSOR_OFFSET_INVALID",
173
+ );
174
+ }
175
+ return index;
176
+ }
177
+
178
+ function addUniqueTarget(
179
+ targets: ValidationTarget[],
180
+ seen: Set<string>,
181
+ target: ValidationTarget,
182
+ ): void {
183
+ if (seen.has(target.key)) return;
184
+ seen.add(target.key);
185
+ targets.push(target);
186
+ }
187
+
188
+ function analysisTargets(
189
+ items: readonly AnalysisItem[],
190
+ matchIndex: number | undefined,
191
+ ): { targets: ValidationTarget[]; missing: EvidenceSourceStatus[] } {
192
+ const selected = selectedIndex(matchIndex, items.length, "analysis item");
193
+ const selectedItems = selected === undefined ? items : [items[selected - 1]!];
194
+ const targets: ValidationTarget[] = [];
195
+ const missing: EvidenceSourceStatus[] = [];
196
+ const seen = new Set<string>();
197
+ for (const item of selectedItems) {
198
+ const reference = item.source;
199
+ if (!reference) {
200
+ const key = JSON.stringify(["missing", item.path]);
201
+ if (!seen.has(key)) {
202
+ seen.add(key);
203
+ missing.push({
204
+ path: item.path,
205
+ role: "source",
206
+ status: "unknown",
207
+ reason: "Saved analysis item has no source reference",
208
+ });
209
+ }
210
+ continue;
211
+ }
212
+ addUniqueTarget(targets, seen, {
213
+ key: sourceKey(reference),
214
+ path: reference.path,
215
+ expected: reference,
216
+ });
217
+ }
218
+ return { targets, missing };
219
+ }
220
+
221
+ function snapshotTargets(
222
+ snapshot: SearchSnapshot,
223
+ matchIndex: number | undefined,
224
+ ): { targets: ValidationTarget[]; missing: EvidenceSourceStatus[] } {
225
+ const selected = selectedIndex(matchIndex, snapshot.matches.length, "search match");
226
+ const selectedPaths =
227
+ selected === undefined
228
+ ? [...snapshot.sourceRevisions.keys()]
229
+ : [snapshot.matches[selected - 1]!.absolutePath];
230
+ const targets: ValidationTarget[] = [];
231
+ const missing: EvidenceSourceStatus[] = [];
232
+ const seen = new Set<string>();
233
+ for (const path of selectedPaths) {
234
+ const revision = snapshot.sourceRevisions.get(path);
235
+ if (!revision) {
236
+ const key = JSON.stringify(["missing", path]);
237
+ if (!seen.has(key)) {
238
+ seen.add(key);
239
+ missing.push({
240
+ path,
241
+ role: "source",
242
+ status: "unknown",
243
+ reason: "Saved search has no source revision",
244
+ });
245
+ }
246
+ continue;
247
+ }
248
+ addUniqueTarget(targets, seen, {
249
+ key: revisionKey(path, revision),
250
+ path,
251
+ revision,
252
+ });
253
+ }
254
+ return { targets, missing };
255
+ }
256
+
257
+ async function validateSnapshotTarget(
258
+ target: ValidationTarget,
259
+ cwd: string,
260
+ policy: SearchPathPolicy,
261
+ signal?: AbortSignal,
262
+ ): Promise<EvidenceSourceStatus> {
263
+ if (!target.revision) throw new Error("Snapshot validation target omitted its revision");
264
+ try {
265
+ const absolute = resolve(cwd, target.path);
266
+ const canonical = await policy.resolveExistingPath(absolute);
267
+ if (!canonical) {
268
+ const result = await confirmWorktreeState(target.path, cwd, signal);
269
+ return { path: target.path, role: "source", ...result };
270
+ }
271
+ const before = await stat(absolute);
272
+ const beforeCanonical = canonical;
273
+ const after = await stat(absolute);
274
+ const afterCanonical = await realpath(absolute);
275
+ if (!before.isFile() || !after.isFile()) {
276
+ return {
277
+ path: target.path,
278
+ role: "source",
279
+ status: "stale",
280
+ reason: "Source is no longer a regular file",
281
+ };
282
+ }
283
+ if (
284
+ beforeCanonical !== afterCanonical ||
285
+ !sameSourceRevision(sourceRevisionFromStats(before), sourceRevisionFromStats(after))
286
+ ) {
287
+ return {
288
+ path: target.path,
289
+ role: "source",
290
+ status: "stale",
291
+ reason: "Source changed while checking its revision",
292
+ };
293
+ }
294
+ const current = sourceRevisionFromStats(after);
295
+ const unchanged = sameSourceRevision(target.revision, current);
296
+ return {
297
+ path: target.path,
298
+ role: "source",
299
+ status: unchanged ? "current" : "stale",
300
+ ...(unchanged ? {} : { reason: "Source revision changed" }),
301
+ };
302
+ } catch (error) {
303
+ if (isAbort(error, signal)) throw error;
304
+ const code = systemErrorCode(error);
305
+ if (code === "ENOENT") {
306
+ const classified = await confirmWorktreeState(target.path, cwd, signal);
307
+ return { path: target.path, role: "source", ...classified };
308
+ }
309
+ const classified = await failure(error, undefined, target.path, cwd, signal);
310
+ return {
311
+ path: target.path,
312
+ role: "source",
313
+ status: classified.status,
314
+ reason: classified.reason,
315
+ };
316
+ }
317
+ }
318
+
319
+ async function validateAnalysisTarget(
320
+ target: ValidationTarget,
321
+ access: SourceAccess,
322
+ cwd: string,
323
+ signal?: AbortSignal,
324
+ ): Promise<ValidationStatus> {
325
+ if (!target.expected) throw new Error("Analysis validation target omitted its source reference");
326
+ try {
327
+ const current = await access.refresh(target.path, target.expected);
328
+ return {
329
+ path: target.path,
330
+ role: "source",
331
+ status: "current",
332
+ ...(target.expected ? { expected: target.expected } : {}),
333
+ current: current.reference,
334
+ };
335
+ } catch (error) {
336
+ if (isAbort(error, signal)) throw error;
337
+ const classified = await failure(error, target.expected, target.path, cwd, signal);
338
+ return {
339
+ path: target.path,
340
+ role: "source",
341
+ status: classified.status,
342
+ ...(target.expected ? { expected: target.expected } : {}),
343
+ reason: classified.reason,
344
+ ...(classified.budgetReached ? { budgetReached: true } : {}),
345
+ };
346
+ }
347
+ }
348
+
349
+ function comparisonTarget(
350
+ sources: readonly EvidenceSourceStatus[],
351
+ hasUnscopedUnknown: boolean,
352
+ ): EvidenceComparisonTarget {
353
+ const hasGit = sources.some((source) => source.expected?.origin.kind === "git");
354
+ const hasWorktree = sources.some((source) => source.expected?.origin.kind === "worktree");
355
+ if ((hasGit && hasWorktree) || (hasGit && hasUnscopedUnknown)) return "mixed";
356
+ if (hasGit) return "recorded-git";
357
+ return "current-worktree";
358
+ }
359
+
360
+ function evidenceScope(
361
+ cwd: string,
362
+ scope: SearchScopeDetails | undefined,
363
+ request: SearchRequest | undefined,
364
+ ): EvidenceSourceScope {
365
+ const path = scope?.path ?? request?.expandedFromPath ?? request?.path ?? ".";
366
+ const include = scope?.glob ?? request?.glob;
367
+ const exclude = scope?.exclude ?? request?.exclude;
368
+ const hidden = scope?.hidden ?? request?.hidden;
369
+ return {
370
+ root: resolve(cwd, path),
371
+ ...(include && include.length > 0 ? { include: [...include] } : {}),
372
+ ...(exclude && exclude.length > 0 ? { exclude: [...exclude] } : {}),
373
+ ...(hidden === undefined ? {} : { hidden }),
374
+ };
375
+ }
376
+
377
+ /* oxlint-disable no-await-in-loop -- one validation owner shares a bounded source budget. */
378
+ export async function validateSavedEvidence(
379
+ options: SavedEvidenceValidationOptions,
380
+ ): Promise<SavedEvidenceValidationResult> {
381
+ let scope: EvidenceSourceScope = { root: resolve(options.cwd) };
382
+ const reasons: string[] = [];
383
+ let storedPartial = false;
384
+ let targets: ValidationTarget[];
385
+ let sources: EvidenceSourceStatus[];
386
+ const missing: EvidenceSourceStatus[] = [];
387
+ const isAnalysis = options.cursor.includes(".analysis.");
388
+
389
+ if (isAnalysis) {
390
+ const { stored } = options.analyses.resolve(options.cursor);
391
+ scope = evidenceScope(options.cwd, stored.result.scope, undefined);
392
+ storedPartial = stored.result.partial;
393
+ reasons.push(...stored.result.reasons);
394
+ const selected = analysisTargets(stored.result.items, options.matchIndex);
395
+ targets = selected.targets;
396
+ missing.push(...selected.missing);
397
+ const access = new SourceAccess(options.cwd, options.queue, options.signal, {
398
+ maxFiles: options.maxFiles ?? MAX_STRUCTURE_FILES,
399
+ });
400
+ sources = [...missing];
401
+ let budgetExhausted = false;
402
+ let admittedTargets = 0;
403
+ for (const target of targets) {
404
+ if (options.signal?.aborted) throw abortError();
405
+ if (budgetExhausted || admittedTargets >= access.maxFiles) {
406
+ sources.push({
407
+ path: target.path,
408
+ role: "source",
409
+ status: "unknown",
410
+ ...(target.expected ? { expected: target.expected } : {}),
411
+ reason: "Saved evidence validation source budget is exhausted",
412
+ });
413
+ continue;
414
+ }
415
+ admittedTargets += 1;
416
+ const status = await validateAnalysisTarget(target, access, options.cwd, options.signal);
417
+ const { budgetReached, ...sourceStatus } = status;
418
+ sources.push(sourceStatus);
419
+ if (budgetReached) budgetExhausted = true;
420
+ }
421
+ } else {
422
+ const { snapshot } = options.snapshots.resolve(options.cursor);
423
+ scope = evidenceScope(options.cwd, undefined, snapshot.request);
424
+ storedPartial = !snapshot.snapshotComplete;
425
+ reasons.push(...(snapshot.retention?.reasons ?? []));
426
+ const selected = snapshotTargets(snapshot, options.matchIndex);
427
+ targets = selected.targets;
428
+ missing.push(...selected.missing);
429
+ const policy = new SearchPathPolicy(options.cwd);
430
+ sources = [...missing];
431
+ let checked = 0;
432
+ const maxFiles = options.maxFiles ?? MAX_STRUCTURE_FILES;
433
+ for (const target of targets) {
434
+ if (options.signal?.aborted) throw abortError();
435
+ if (checked >= maxFiles) {
436
+ sources.push({
437
+ path: target.path,
438
+ role: "source",
439
+ status: "unknown",
440
+ reason: "Saved evidence validation source budget is exhausted",
441
+ });
442
+ continue;
443
+ }
444
+ checked += 1;
445
+ sources.push(await validateSnapshotTarget(target, options.cwd, policy, options.signal));
446
+ }
447
+ }
448
+
449
+ if (sources.length === 0) reasons.push("No retained source evidence could be validated");
450
+ const uniqueReasons = [...new Set(reasons)];
451
+ const coverage: EvidenceCoverageStatus =
452
+ storedPartial || sources.length === 0 || sources.some((source) => source.status === "unknown")
453
+ ? "partial"
454
+ : "complete";
455
+ const recheck = evidenceRecheck(sources, uniqueReasons, coverage);
456
+ return {
457
+ scope,
458
+ sources,
459
+ coverage,
460
+ storedPartial,
461
+ reasons: uniqueReasons,
462
+ comparisonTarget: comparisonTarget(sources, missing.length > 0),
463
+ recheck,
464
+ };
465
+ }
466
+ /* oxlint-enable no-await-in-loop */
@@ -0,0 +1,33 @@
1
+ import type {
2
+ EvidenceCoverageStatus,
3
+ EvidenceRecheck,
4
+ EvidenceSourceStatus,
5
+ EvidenceValidity,
6
+ } from "./validation-types.js";
7
+
8
+ /**
9
+ * Combine independent source checks without making missing evidence look current.
10
+ * Stale evidence has the highest user-visible priority, while unknown is retained
11
+ * whenever any dependency could not be verified.
12
+ */
13
+ export function aggregateEvidenceValidity(
14
+ sources: readonly EvidenceSourceStatus[],
15
+ ): EvidenceValidity {
16
+ if (sources.some((source) => source.status === "stale")) return "stale";
17
+ if (sources.length === 0 || sources.some((source) => source.status === "unknown"))
18
+ return "unknown";
19
+ return "current";
20
+ }
21
+
22
+ export function evidenceRecheck(
23
+ sources: readonly EvidenceSourceStatus[],
24
+ reasons: readonly string[] = [],
25
+ coverage: EvidenceCoverageStatus = sources.length > 0 ? "complete" : "not-applicable",
26
+ ): EvidenceRecheck {
27
+ return {
28
+ validity: aggregateEvidenceValidity(sources),
29
+ coverage,
30
+ sources: [...sources],
31
+ reasons: [...new Set(reasons)],
32
+ };
33
+ }
package/src/excerpt.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { MAX_LINE_CHARACTERS } from "./types.js";
2
+
3
+ export interface TextExcerpt {
4
+ text: string;
5
+ truncated: boolean;
6
+ startCharacter: number;
7
+ endCharacter: number;
8
+ }
9
+
10
+ function boundedCharacter(value: number, maximum: number): number {
11
+ if (!Number.isFinite(value)) return 0;
12
+ return Math.min(maximum, Math.max(0, Math.floor(value)));
13
+ }
14
+
15
+ export function excerptText(
16
+ text: string,
17
+ focusStart = 0,
18
+ focusEnd = focusStart,
19
+ maximumCharacters = MAX_LINE_CHARACTERS,
20
+ ): TextExcerpt {
21
+ if (!Number.isSafeInteger(maximumCharacters) || maximumCharacters <= 0) {
22
+ throw new Error("Excerpt size must be a positive safe integer");
23
+ }
24
+ if (text.length <= maximumCharacters) {
25
+ return {
26
+ text,
27
+ truncated: false,
28
+ startCharacter: 0,
29
+ endCharacter: text.length,
30
+ };
31
+ }
32
+
33
+ const boundedStart = boundedCharacter(focusStart, text.length);
34
+ const boundedEnd = Math.max(boundedStart, boundedCharacter(focusEnd, text.length));
35
+ const focusLength = boundedEnd - boundedStart;
36
+ const startCharacter =
37
+ focusLength >= maximumCharacters
38
+ ? Math.min(boundedStart, text.length - maximumCharacters)
39
+ : Math.min(
40
+ Math.max(0, boundedStart - Math.floor((maximumCharacters - focusLength) / 2)),
41
+ text.length - maximumCharacters,
42
+ );
43
+ const endCharacter = startCharacter + maximumCharacters;
44
+ const prefix = startCharacter > 0 ? "…" : "";
45
+ const suffix = endCharacter < text.length ? "…" : "";
46
+
47
+ return {
48
+ text: `${prefix}${text.slice(startCharacter, endCharacter)}${suffix}`,
49
+ truncated: true,
50
+ startCharacter,
51
+ endCharacter,
52
+ };
53
+ }
@@ -0,0 +1,150 @@
1
+ import { basename as platformBasename, posix, relative, resolve, sep } from "node:path";
2
+ import type { AnalysisResultSet } from "./analysis-types.js";
3
+ import { SiftLightError } from "./errors.js";
4
+ import { SearchPathPolicy } from "./path-policy.js";
5
+ import { normalizeRequest, type RawSearchInput } from "./request.js";
6
+ import { listWorkspaceFiles } from "./workspace-files.js";
7
+ import { filterPathsByModificationTime } from "./file-metadata-filter.js";
8
+
9
+ interface FileScore {
10
+ score: number;
11
+ reason: string;
12
+ }
13
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
14
+ /** Bracket classes remain valid query text, so dynamic route names stay searchable. */
15
+ const FILE_QUERY_GLOB_WILDCARD = /[*?]/u;
16
+
17
+ function subsequenceScore(text: string, query: string): number | undefined {
18
+ const characters = Array.from(graphemes.segment(text), (item) => item.segment);
19
+ const queryCharacters = Array.from(graphemes.segment(query), (item) => item.segment);
20
+ let next = 0;
21
+ let first = -1;
22
+ let last = 0;
23
+ for (const character of queryCharacters) {
24
+ const index = characters.indexOf(character, next);
25
+ if (index < 0) return undefined;
26
+ if (first < 0) first = index;
27
+ last = index;
28
+ next = index + 1;
29
+ }
30
+ return Math.round((40 * queryCharacters.length) / Math.max(1, last - first + 1));
31
+ }
32
+
33
+ export function scoreFilePath(path: string, query: string): FileScore | undefined {
34
+ if (!query) return { score: 0, reason: "all admitted files" };
35
+ const normalized = path.toLowerCase();
36
+ const needle = query.toLowerCase();
37
+ const basename = posix.basename(normalized);
38
+ if (basename === needle) return { score: 100, reason: "exact filename" };
39
+ if (basename.slice(0, basename.length - posix.extname(basename).length) === needle)
40
+ return { score: 95, reason: "exact filename stem" };
41
+ if (basename.includes(needle)) return { score: 85, reason: "filename substring" };
42
+ if (normalized.includes(needle)) return { score: 70, reason: "path substring" };
43
+ const terms = needle.trim().split(/\s+/);
44
+ // Several words describe path components, not independent fuzzy character
45
+ // walks through an arbitrarily long build-artifact path.
46
+ if (terms.length > 1) {
47
+ return terms.every((term) => normalized.includes(term))
48
+ ? { score: 60, reason: "all query words occur literally in the path" }
49
+ : undefined;
50
+ }
51
+ const scores = terms.map((term) => subsequenceScore(normalized, term));
52
+ if (scores.some((score) => score === undefined)) return undefined;
53
+ return {
54
+ score: Math.min(...scores.map((score) => score ?? 0)),
55
+ reason: "ordered fuzzy path characters; candidate, not an exact filename",
56
+ };
57
+ }
58
+
59
+ function pathRelativeToDiscoveryRoot(cwd: string, root: string, path: string): string {
60
+ const absoluteRoot = resolve(cwd, root);
61
+ const absolutePath = resolve(cwd, path);
62
+ const scoped = relative(absoluteRoot, absolutePath).split(sep).join("/");
63
+ // A file can itself be the requested root; its basename is the only
64
+ // candidate path and must remain searchable without scoring the root name.
65
+ return scoped || platformBasename(absolutePath);
66
+ }
67
+
68
+ export async function discoverFiles(
69
+ input: RawSearchInput & { query?: string },
70
+ cwd: string,
71
+ signal?: AbortSignal,
72
+ ): Promise<AnalysisResultSet> {
73
+ const query = input.query ?? "";
74
+ if (query.length > 256 || !query.isWellFormed() || /[\r\n\0]/.test(query))
75
+ throw new SiftLightError(
76
+ "File query must be well-formed single-line text of at most 256 characters",
77
+ );
78
+ // A wildcard query can never match filename text, so scoring it would discard
79
+ // every enumerated file and report an empty result as complete.
80
+ if (FILE_QUERY_GLOB_WILDCARD.test(query))
81
+ throw new SiftLightError(
82
+ `File query ${JSON.stringify(query)} uses glob wildcards; mode=files matches filename and path text, not glob patterns. Omit query to retain every file under path, or use glob to filter by name pattern.`,
83
+ );
84
+ const request = normalizeRequest({ ...input, pattern: "" });
85
+ const policy = new SearchPathPolicy(cwd);
86
+ const discoveryRoot = request.path ?? ".";
87
+ const scoringRoot = await policy.resolveSearchTarget(discoveryRoot);
88
+ const files = await listWorkspaceFiles(cwd, signal, {
89
+ path: scoringRoot,
90
+ glob: request.glob,
91
+ exclude: request.exclude,
92
+ hidden: request.hidden,
93
+ });
94
+ const filtered = await filterPathsByModificationTime(
95
+ cwd,
96
+ files.paths,
97
+ request.modifiedAfterMs,
98
+ request.modifiedBeforeMs,
99
+ signal,
100
+ );
101
+ const selected = filtered.paths
102
+ .flatMap((path) => {
103
+ const rank = scoreFilePath(pathRelativeToDiscoveryRoot(cwd, scoringRoot, path), query);
104
+ return rank ? [{ path, ...rank }] : [];
105
+ })
106
+ .toSorted((left, right) => right.score - left.score || left.path.localeCompare(right.path));
107
+ for (let offset = 0; offset < selected.length; offset += 16) {
108
+ // oxlint-disable-next-line no-await-in-loop -- bounded canonical-path checks enforce the same protected-path policy.
109
+ await Promise.all(
110
+ selected.slice(offset, offset + 16).map((item) => policy.assertExistingPath(item.path)),
111
+ );
112
+ signal?.throwIfAborted();
113
+ }
114
+ return {
115
+ kind: "files",
116
+ unit: "files",
117
+ partial: files.partial || filtered.partial,
118
+ reasons: [...new Set([...files.reasons, ...filtered.reasons])],
119
+ items: selected.map((item) => ({
120
+ path: item.path,
121
+ line: 1,
122
+ label: `File candidate (${item.reason})`,
123
+ details: {
124
+ kind: "file",
125
+ score: item.score,
126
+ rankingReason: item.reason,
127
+ inspect: { mode: "inspect", path: item.path, line: 1 },
128
+ },
129
+ })),
130
+ coverage: { fileEnumeration: files.partial || filtered.partial ? "partial" : "complete" },
131
+ stats: { filesEnumerated: files.paths.length },
132
+ scope: {
133
+ path: request.path ?? ".",
134
+ requestedPath: request.path ?? ".",
135
+ glob: request.glob,
136
+ exclude: request.exclude,
137
+ hidden: request.hidden,
138
+ ignorePolicy: request.ignorePolicy ?? "respect",
139
+ expandedToProjectRoot: false,
140
+ assertion: request.path && request.path !== "." ? "requested-scope" : "project-wide",
141
+ ...(request.modifiedAfterMs !== undefined
142
+ ? { modifiedAfterMs: request.modifiedAfterMs }
143
+ : {}),
144
+ ...(request.modifiedBeforeMs !== undefined
145
+ ? { modifiedBeforeMs: request.modifiedBeforeMs }
146
+ : {}),
147
+ },
148
+ redact: input.redact ?? false,
149
+ };
150
+ }