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,112 @@
1
+ import { SiftLightError } from "./errors.js";
2
+ import { SourceDocument, type ByteRange, type SourcePosition } from "./source-document.js";
3
+
4
+ export interface SourceFragment extends ByteRange {
5
+ text: string;
6
+ startPosition: SourcePosition;
7
+ endPosition: SourcePosition;
8
+ }
9
+
10
+ export interface SourcePage {
11
+ fragment: SourceFragment;
12
+ remaining: ByteRange[];
13
+ text: string;
14
+ }
15
+
16
+ /** Half-open ranges, combined before allocating the shared response budget. */
17
+ export function mergeByteRanges(ranges: readonly ByteRange[]): ByteRange[] {
18
+ const merged: ByteRange[] = [];
19
+ for (const range of ranges.toSorted((a, b) => a.start - b.start || a.end - b.end)) {
20
+ if (
21
+ !Number.isSafeInteger(range.start) ||
22
+ !Number.isSafeInteger(range.end) ||
23
+ range.start < 0 ||
24
+ range.end < range.start
25
+ ) {
26
+ throw new SiftLightError("Invalid source range");
27
+ }
28
+ const previous = merged.at(-1);
29
+ if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end);
30
+ else merged.push({ start: range.start, end: range.end });
31
+ }
32
+ return merged;
33
+ }
34
+
35
+ export function subtractByteRange(ranges: readonly ByteRange[], returned: ByteRange): ByteRange[] {
36
+ const remaining: ByteRange[] = [];
37
+ for (const range of ranges) {
38
+ if (returned.end <= range.start || returned.start >= range.end) {
39
+ remaining.push({ start: range.start, end: range.end });
40
+ continue;
41
+ }
42
+ if (range.start < returned.start) remaining.push({ start: range.start, end: returned.start });
43
+ if (returned.end < range.end) remaining.push({ start: returned.end, end: range.end });
44
+ }
45
+ return remaining;
46
+ }
47
+
48
+ function utf8Boundary(document: SourceDocument, offset: number, direction: -1 | 1): number {
49
+ let byte = Math.max(0, Math.min(document.bytes.length, offset));
50
+ while (byte > 0 && byte < document.bytes.length) {
51
+ const value = document.bytes[byte];
52
+ if (value === undefined || (value & 0xc0) !== 0x80) break;
53
+ byte += direction;
54
+ }
55
+ return byte;
56
+ }
57
+
58
+ export function renderSourceFragment(fragment: SourceFragment): string {
59
+ const header = `[source bytes ${String(fragment.start)}..${String(fragment.end)}; ${String(fragment.startPosition.line)}:${String(fragment.startPosition.column)}–${String(fragment.endPosition.line)}:${String(fragment.endPosition.column)}; UTF-8, end exclusive]`;
60
+ const lines = fragment.text.split("\n");
61
+ return [
62
+ header,
63
+ ...lines.map((text, index) => `${String(fragment.startPosition.line + index)}: ${text}`),
64
+ ].join("\n");
65
+ }
66
+
67
+ /** Keep actual source characters; only the explicitly described range is paged. */
68
+ export function sourcePage(
69
+ document: SourceDocument,
70
+ ranges: readonly ByteRange[],
71
+ maxBytes: number,
72
+ focus?: number,
73
+ ): SourcePage {
74
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 256) {
75
+ throw new SiftLightError("Source page budget must allow at least 256 bytes");
76
+ }
77
+ const gaps = mergeByteRanges(ranges);
78
+ const range =
79
+ gaps.find((item) => focus !== undefined && item.start <= focus && focus < item.end) ?? gaps[0];
80
+ if (!range) throw new SiftLightError("Source range is already complete");
81
+ document.checkRange(range);
82
+ document.toCharacterOffset(range.start);
83
+ document.toCharacterOffset(range.end);
84
+ const target = Math.max(range.start, Math.min(range.end, focus ?? range.start));
85
+ let available = Math.max(4, maxBytes - 160);
86
+ for (;;) {
87
+ let start = range.start;
88
+ if (range.end - range.start > available && target - range.start > available / 2) {
89
+ start = utf8Boundary(document, Math.floor(target - available / 2), 1);
90
+ }
91
+ start = Math.max(range.start, start);
92
+ let end = utf8Boundary(document, Math.min(range.end, start + available), -1);
93
+ if (end <= start && range.end > range.start) end = utf8Boundary(document, start + 1, 1);
94
+ const fragment: SourceFragment = {
95
+ start,
96
+ end,
97
+ text: document.slice({ start, end }),
98
+ startPosition: document.positionAt(start),
99
+ endPosition: document.positionAt(end),
100
+ };
101
+ const text = renderSourceFragment(fragment);
102
+ if (Buffer.byteLength(text) <= maxBytes) {
103
+ return {
104
+ fragment,
105
+ remaining: subtractByteRange(gaps, fragment).filter((gap) => gap.start < gap.end),
106
+ text,
107
+ };
108
+ }
109
+ if (available <= 4) throw new SiftLightError("Source metadata exceeds the page budget");
110
+ available = Math.max(4, Math.floor(available * 0.75));
111
+ }
112
+ }
package/src/source.ts ADDED
@@ -0,0 +1,286 @@
1
+ import { readFile, realpath, stat } from "node:fs/promises";
2
+ import type { Stats } from "node:fs";
3
+ import { abortError, SiftLightError } from "./errors.js";
4
+ import { excerptText } from "./excerpt.js";
5
+ import { isPathInsideCwd, SearchPathPolicy } from "./path-policy.js";
6
+ import {
7
+ MAX_RESULT_BYTES,
8
+ MAX_SOURCE_FILE_BYTES,
9
+ type MatchOccurrence,
10
+ type SourceRevision,
11
+ } from "./types.js";
12
+
13
+ export { isPathInsideCwd } from "./path-policy.js";
14
+
15
+ const SOURCE_RANGE_METADATA_RESERVE_BYTES = 1024;
16
+ const MAX_SOURCE_RANGE_BYTES = MAX_RESULT_BYTES - SOURCE_RANGE_METADATA_RESERVE_BYTES;
17
+
18
+ export async function getSourceRevision(
19
+ path: string,
20
+ onError?: (error: unknown) => void,
21
+ ): Promise<SourceRevision | undefined> {
22
+ try {
23
+ const metadata = await stat(path);
24
+ return sourceRevisionFromStats(metadata);
25
+ } catch (error) {
26
+ onError?.(error);
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ export function sourceRevisionFromStats(metadata: Stats): SourceRevision {
32
+ return {
33
+ size: metadata.size,
34
+ mtimeMs: metadata.mtimeMs,
35
+ ctimeMs: metadata.ctimeMs,
36
+ ...(metadata.ino !== 0 ? { inode: metadata.ino } : {}),
37
+ ...(metadata.dev !== 0 ? { device: metadata.dev } : {}),
38
+ };
39
+ }
40
+
41
+ export function sameSourceRevision(left: SourceRevision, right: SourceRevision): boolean {
42
+ return (
43
+ left.size === right.size &&
44
+ left.mtimeMs === right.mtimeMs &&
45
+ (left.ctimeMs === undefined || right.ctimeMs === undefined || left.ctimeMs === right.ctimeMs) &&
46
+ left.inode === right.inode &&
47
+ left.device === right.device
48
+ );
49
+ }
50
+
51
+ /** Worktree modification-time bounds are inclusive at the lower edge and exclusive at the upper edge. */
52
+ export function matchesModificationTime(
53
+ revision: SourceRevision,
54
+ modifiedAfterMs?: number,
55
+ modifiedBeforeMs?: number,
56
+ ): boolean {
57
+ return (
58
+ (modifiedAfterMs === undefined || revision.mtimeMs >= modifiedAfterMs) &&
59
+ (modifiedBeforeMs === undefined || revision.mtimeMs < modifiedBeforeMs)
60
+ );
61
+ }
62
+
63
+ function modificationTimeDisplay(value: number): string {
64
+ const date = new Date(value);
65
+ return Number.isNaN(date.getTime())
66
+ ? `${String(value)} Unix ms`
67
+ : JSON.stringify(date.toISOString());
68
+ }
69
+
70
+ export function modificationTimeBoundsText(
71
+ modifiedAfterMs?: number,
72
+ modifiedBeforeMs?: number,
73
+ ): string {
74
+ if (modifiedAfterMs === undefined && modifiedBeforeMs === undefined) return "";
75
+ const bounds = [
76
+ modifiedAfterMs === undefined
77
+ ? undefined
78
+ : `mtime >= ${modificationTimeDisplay(modifiedAfterMs)}`,
79
+ modifiedBeforeMs === undefined
80
+ ? undefined
81
+ : `mtime < ${modificationTimeDisplay(modifiedBeforeMs)}`,
82
+ ].filter((value): value is string => value !== undefined);
83
+ return ` [Modification-time filter: ${bounds.join("; ")}.]`;
84
+ }
85
+
86
+ export async function assertExistingSearchPath(path: string, cwd: string): Promise<void> {
87
+ await new SearchPathPolicy(cwd).assertExistingPath(path);
88
+ }
89
+
90
+ export async function assertExistingPathInsideCwd(path: string, cwd: string): Promise<void> {
91
+ if (!isPathInsideCwd(path, cwd)) {
92
+ throw new SiftLightError("Path must stay within the working directory");
93
+ }
94
+ const canonical = await new SearchPathPolicy(cwd).resolveExistingPath(path);
95
+ if (canonical && !isPathInsideCwd(canonical, await realpath(cwd))) {
96
+ throw new SiftLightError("Path must stay within the working directory");
97
+ }
98
+ }
99
+
100
+ export class SourceTooLargeError extends SiftLightError {
101
+ constructor(message: string) {
102
+ super(message);
103
+ this.name = "SourceTooLargeError";
104
+ }
105
+ }
106
+
107
+ export class SourceBudgetTooSmallError extends SiftLightError {
108
+ constructor() {
109
+ super("Source target line exceeds the available byte budget");
110
+ this.name = "SourceBudgetTooSmallError";
111
+ }
112
+ }
113
+
114
+ export class SourceLineUnavailableError extends SiftLightError {
115
+ constructor(line: number) {
116
+ super(`Source line ${String(line)} is beyond the end of the file`);
117
+ this.name = "SourceLineUnavailableError";
118
+ }
119
+ }
120
+
121
+ export interface SourceRangeRead {
122
+ text: string;
123
+ lines: SourceExcerptLine[];
124
+ startLine: number;
125
+ endLine: number;
126
+ truncated: boolean;
127
+ omittedBefore: number;
128
+ omittedAfter: number;
129
+ truncatedLines: number[];
130
+ }
131
+
132
+ export interface SourceExcerptLine {
133
+ line: number;
134
+ text: string;
135
+ truncated: boolean;
136
+ }
137
+
138
+ export interface SourceRangeOptions {
139
+ maxBytes?: number;
140
+ focus?: MatchOccurrence;
141
+ }
142
+
143
+ interface SelectedSourceWindow {
144
+ lines: SourceExcerptLine[];
145
+ startIndex: number;
146
+ endIndex: number;
147
+ }
148
+
149
+ function sourceLineBytes(line: SourceExcerptLine): number {
150
+ return Buffer.byteLength(`${String(line.line)}: ${line.text}`, "utf8");
151
+ }
152
+
153
+ function selectSourceWindow(
154
+ rendered: SourceExcerptLine[],
155
+ targetIndex: number,
156
+ maxBytes: number,
157
+ ): SelectedSourceWindow {
158
+ let startIndex = targetIndex;
159
+ let endIndex = targetIndex;
160
+ const target = rendered[targetIndex];
161
+ if (!target) throw new Error("Source target line is unavailable");
162
+ let bytes = sourceLineBytes(target);
163
+ if (bytes > maxBytes) throw new SourceBudgetTooSmallError();
164
+ let canGrowBefore = true;
165
+ let canGrowAfter = true;
166
+
167
+ while (canGrowBefore || canGrowAfter) {
168
+ let grew = false;
169
+ if (canGrowBefore) {
170
+ const candidate = rendered[startIndex - 1];
171
+ if (candidate === undefined) {
172
+ canGrowBefore = false;
173
+ } else if (bytes + 1 + sourceLineBytes(candidate) <= maxBytes) {
174
+ startIndex -= 1;
175
+ bytes += 1 + sourceLineBytes(candidate);
176
+ grew = true;
177
+ } else {
178
+ canGrowBefore = false;
179
+ }
180
+ }
181
+
182
+ if (canGrowAfter) {
183
+ const candidate = rendered[endIndex + 1];
184
+ if (candidate === undefined) {
185
+ canGrowAfter = false;
186
+ } else if (bytes + 1 + sourceLineBytes(candidate) <= maxBytes) {
187
+ endIndex += 1;
188
+ bytes += 1 + sourceLineBytes(candidate);
189
+ grew = true;
190
+ } else {
191
+ canGrowAfter = false;
192
+ }
193
+ }
194
+
195
+ if (!grew && !canGrowBefore && !canGrowAfter) break;
196
+ }
197
+
198
+ return { lines: rendered.slice(startIndex, endIndex + 1), startIndex, endIndex };
199
+ }
200
+
201
+ export async function readSourceRange(
202
+ absolutePath: string,
203
+ startLine: number,
204
+ endLine: number,
205
+ signal?: AbortSignal,
206
+ targetLine = startLine,
207
+ options: SourceRangeOptions = {},
208
+ ): Promise<SourceRangeRead> {
209
+ if (signal?.aborted) throw abortError();
210
+ const maxBytes = options.maxBytes ?? MAX_SOURCE_RANGE_BYTES;
211
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > MAX_SOURCE_RANGE_BYTES) {
212
+ throw new Error("Source range byte budget must be within the result body limit");
213
+ }
214
+ const metadata = await stat(absolutePath, { bigint: false });
215
+ if (metadata.size > MAX_SOURCE_FILE_BYTES) {
216
+ throw new SourceTooLargeError(
217
+ `Source file exceeds the ${String(MAX_SOURCE_FILE_BYTES)}-byte source limit`,
218
+ );
219
+ }
220
+ const content = await readFile(absolutePath, { signal });
221
+ if (content.byteLength > MAX_SOURCE_FILE_BYTES) {
222
+ throw new SourceTooLargeError(
223
+ `Source file exceeds the ${String(MAX_SOURCE_FILE_BYTES)}-byte source limit`,
224
+ );
225
+ }
226
+ return sourceRangeFromBytes(content, startLine, endLine, targetLine, options);
227
+ }
228
+
229
+ export function sourceRangeFromBytes(
230
+ content: Buffer,
231
+ startLine: number,
232
+ endLine: number,
233
+ targetLine = startLine,
234
+ options: SourceRangeOptions = {},
235
+ ): SourceRangeRead {
236
+ const maxBytes = options.maxBytes ?? MAX_SOURCE_RANGE_BYTES;
237
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > MAX_SOURCE_RANGE_BYTES)
238
+ throw new Error("Source range byte budget must be within the result body limit");
239
+ // ripgrep counts LF-delimited lines. Keep original bytes until byte-based
240
+ // occurrence offsets have been decoded; re-encoding replacement characters
241
+ // would move the focus in files containing invalid UTF-8.
242
+ const lines: Buffer[] = [];
243
+ let lineStart = 0;
244
+ for (let newline = content.indexOf(10); newline >= 0; newline = content.indexOf(10, lineStart)) {
245
+ lines.push(content.subarray(lineStart, newline));
246
+ lineStart = newline + 1;
247
+ }
248
+ lines.push(content.subarray(lineStart));
249
+ const boundedStart = Math.max(1, startLine);
250
+ if (boundedStart > lines.length) {
251
+ throw new SourceLineUnavailableError(targetLine);
252
+ }
253
+ const boundedEnd = Math.min(lines.length, Math.max(boundedStart, endLine));
254
+ if (targetLine < 1 || targetLine > lines.length) {
255
+ throw new SourceLineUnavailableError(targetLine);
256
+ }
257
+ const boundedTarget = Math.min(boundedEnd, Math.max(boundedStart, targetLine));
258
+ const rendered = Array.from({ length: boundedEnd - boundedStart + 1 }, (_, index) => {
259
+ const lineNumber = boundedStart + index;
260
+ const raw = lines[lineNumber - 1];
261
+ if (!raw) throw new Error("Source line is unavailable");
262
+ const focus = lineNumber === targetLine ? options.focus : undefined;
263
+ const start = focus?.range.start.character ?? 0;
264
+ const end = focus?.range.end.character ?? start;
265
+ const bytes = focus?.range.encoding === "utf-8" ? raw : undefined;
266
+ const excerpt = excerptText(
267
+ raw.toString("utf8").replaceAll("\r", ""),
268
+ bytes ? bytes.subarray(0, start).toString("utf8").replaceAll("\r", "").length : start,
269
+ bytes ? bytes.subarray(0, end).toString("utf8").replaceAll("\r", "").length : end,
270
+ );
271
+ return { line: lineNumber, text: excerpt.text, truncated: excerpt.truncated };
272
+ });
273
+ const selected = selectSourceWindow(rendered, boundedTarget - boundedStart, maxBytes);
274
+ const omittedBefore = selected.startIndex;
275
+ const omittedAfter = rendered.length - selected.endIndex - 1;
276
+ return {
277
+ text: selected.lines.map((line) => `${String(line.line)}: ${line.text}`).join("\n"),
278
+ lines: selected.lines,
279
+ startLine: boundedStart + selected.startIndex,
280
+ endLine: boundedStart + selected.endIndex,
281
+ truncated: omittedBefore > 0 || omittedAfter > 0,
282
+ omittedBefore,
283
+ omittedAfter,
284
+ truncatedLines: selected.lines.filter((line) => line.truncated).map((line) => line.line),
285
+ };
286
+ }
@@ -0,0 +1,125 @@
1
+ import { rangeEvidence } from "./analysis-evidence.js";
2
+ import type { AnalysisResultSet } from "./analysis-types.js";
3
+ import { MAX_ANALYSIS_RESULTS, MAX_ANALYSIS_STORAGE_BYTES } from "./analysis-limits.js";
4
+ import { SiftLightError } from "./errors.js";
5
+ import type { SiftLightInput } from "./service.js";
6
+ import { SourceAccess, SourceBudgetError } from "./source-access.js";
7
+ import { SourceDocumentError } from "./source-document.js";
8
+ import { syntaxLanguage } from "./syntax.js";
9
+ import { normalizeRequest } from "./request.js";
10
+ import { listWorkspaceFiles } from "./workspace-files.js";
11
+
12
+ export async function structuralSearch(
13
+ input: SiftLightInput,
14
+ access: SourceAccess,
15
+ ): Promise<AnalysisResultSet> {
16
+ if (
17
+ !input.pattern?.trim() ||
18
+ Buffer.byteLength(input.pattern) > 4_096 ||
19
+ !input.pattern.isWellFormed()
20
+ )
21
+ throw new SiftLightError(
22
+ "Structural pattern must be nonempty, well-formed and at most 4 KiB; ast-grep $NAME/$$$ARGS metavariables are supported",
23
+ );
24
+ const request = normalizeRequest({ ...input, pattern: "" });
25
+ const files = await listWorkspaceFiles(access.cwd, access.signal, {
26
+ ...(request.path ? { path: request.path } : {}),
27
+ glob: request.glob,
28
+ exclude: request.exclude,
29
+ hidden: request.hidden,
30
+ });
31
+ const result: AnalysisResultSet = {
32
+ kind: "structure",
33
+ unit: "occurrences",
34
+ items: [],
35
+ partial: files.partial,
36
+ reasons: [...files.reasons],
37
+ redact: input.redact ?? false,
38
+ };
39
+ let retainedBytes = 0;
40
+ const supported = files.paths.filter((path) => syntaxLanguage(path));
41
+ if (files.paths.length && !supported.length)
42
+ throw new SiftLightError(
43
+ "Structural patterns require admitted JS/TS/TSX/Go source; no supported source files were found",
44
+ );
45
+ result.stats = {
46
+ filesEnumerated: files.paths.length,
47
+ filesSkipped: files.paths.length - supported.length,
48
+ };
49
+ for (const path of files.paths) {
50
+ if (!syntaxLanguage(path)) continue;
51
+ try {
52
+ // oxlint-disable-next-line no-await-in-loop -- sequential parser admission shares the source and output budgets.
53
+ const document = await access.load(path);
54
+ // oxlint-disable-next-line no-await-in-loop -- parser execution is owned by the serialized syntax queue.
55
+ const syntax = await access.pattern(document, input.pattern);
56
+ if (syntax.status !== "ok") {
57
+ result.partial = true;
58
+ result.reasons.push(`${path}: syntax ${syntax.status}; structural matches withheld`);
59
+ continue;
60
+ }
61
+ for (const match of syntax.patternMatches ?? []) {
62
+ const range = {
63
+ start: document.toByteOffset(match.start),
64
+ end: document.toByteOffset(match.end),
65
+ };
66
+ const evidence = rangeEvidence(document, range);
67
+ const item = {
68
+ path: document.path,
69
+ line: document.lineAt(range.start),
70
+ source: document.reference,
71
+ range,
72
+ label: "AST pattern match",
73
+ excerpt: evidence.excerpt,
74
+ details: {
75
+ kind: "structural-match",
76
+ certainty: "syntax",
77
+ score: 90,
78
+ rankingReason: "AST structure and repeated metavariable equality",
79
+ excerptRange: evidence.excerptRange,
80
+ excerptTruncated: evidence.excerptTruncated,
81
+ },
82
+ };
83
+ const bytes = Buffer.byteLength(JSON.stringify(item));
84
+ if (
85
+ result.items.length >= MAX_ANALYSIS_RESULTS ||
86
+ retainedBytes + bytes > MAX_ANALYSIS_STORAGE_BYTES - 65_536
87
+ ) {
88
+ result.partial = true;
89
+ result.reasons.push("Structural evidence storage limit reached: 50,000 items / 32 MiB");
90
+ break;
91
+ }
92
+ result.items.push(item);
93
+ retainedBytes += bytes;
94
+ }
95
+ if (
96
+ result.items.length >= MAX_ANALYSIS_RESULTS ||
97
+ result.reasons.at(-1)?.startsWith("Structural evidence storage")
98
+ )
99
+ break;
100
+ } catch (error) {
101
+ if (error instanceof SourceBudgetError) {
102
+ result.partial = true;
103
+ result.reasons.push(error.message);
104
+ break;
105
+ }
106
+ if (!(error instanceof SourceDocumentError)) throw error;
107
+ result.partial = true;
108
+ result.reasons.push(`${path}: ${error.message}`);
109
+ }
110
+ }
111
+ result.filesRead = access.filesRead;
112
+ result.bytesRead = access.bytesRead;
113
+ result.coverage = { astPatterns: result.partial ? "partial" : "complete" };
114
+ result.scope = {
115
+ path: request.path ?? ".",
116
+ requestedPath: request.path ?? ".",
117
+ glob: request.glob,
118
+ exclude: request.exclude,
119
+ hidden: request.hidden,
120
+ ignorePolicy: request.ignorePolicy ?? "respect",
121
+ expandedToProjectRoot: false,
122
+ assertion: request.path && request.path !== "." ? "requested-scope" : "project-wide",
123
+ };
124
+ return result;
125
+ }