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.
- package/CHANGELOG.md +0 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +661 -0
- package/README.md +177 -0
- package/README.zh-CN.md +177 -0
- package/SECURITY.md +25 -0
- package/package.json +142 -0
- package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
- package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
- package/plugins/sift-light/.mcp.json +24 -0
- package/plugins/sift-light/LICENSE +661 -0
- package/plugins/sift-light/concept-worker.mjs +567 -0
- package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
- package/plugins/sift-light/hooks/hooks.json +16 -0
- package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
- package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
- package/plugins/sift-light/kimi.plugin.json +36 -0
- package/plugins/sift-light/mcp-server.mjs +1 -0
- package/plugins/sift-light/omp-extension.mjs +20914 -0
- package/plugins/sift-light/package.json +11 -0
- package/plugins/sift-light/syntax-worker.mjs +280 -0
- package/plugins/sift-light/syntax-worker.toml +2 -0
- package/src/analysis-evidence.ts +43 -0
- package/src/analysis-limits.ts +27 -0
- package/src/analysis-store.ts +559 -0
- package/src/analysis-term-pages.ts +73 -0
- package/src/analysis-types.ts +162 -0
- package/src/audit-search.ts +391 -0
- package/src/capped-lines.ts +112 -0
- package/src/concept-embedding-cache.ts +186 -0
- package/src/concept-inference.ts +218 -0
- package/src/concept-model.ts +96 -0
- package/src/concept-search.ts +577 -0
- package/src/concept-setup.ts +75 -0
- package/src/concept-source-generation.ts +268 -0
- package/src/concept-worker.mjs +567 -0
- package/src/concept-worker.ts +210 -0
- package/src/config-reader.ts +283 -0
- package/src/config.ts +37 -0
- package/src/context-budget.ts +26 -0
- package/src/discovery-errors.ts +2 -0
- package/src/errors.ts +95 -0
- package/src/evidence-candidates.ts +344 -0
- package/src/evidence-ranking.ts +48 -0
- package/src/evidence-service.ts +1047 -0
- package/src/evidence-validation.ts +466 -0
- package/src/evidence-validity.ts +33 -0
- package/src/excerpt.ts +53 -0
- package/src/file-discovery.ts +150 -0
- package/src/file-metadata-filter.ts +46 -0
- package/src/format.ts +407 -0
- package/src/git-diff.ts +195 -0
- package/src/git-process.ts +189 -0
- package/src/git-repository.ts +367 -0
- package/src/git-source.ts +385 -0
- package/src/historical-paths.ts +205 -0
- package/src/hybrid-search.ts +340 -0
- package/src/import-model.ts +611 -0
- package/src/import-navigation.ts +100 -0
- package/src/import-resolution.ts +240 -0
- package/src/index.ts +97 -0
- package/src/inspect-batch.ts +265 -0
- package/src/inspect.ts +271 -0
- package/src/language-capabilities.ts +157 -0
- package/src/language-capability-definitions.ts +204 -0
- package/src/literal-search.ts +25 -0
- package/src/mcp-cli.ts +21 -0
- package/src/mcp-model-output.ts +120 -0
- package/src/mcp-output.ts +9 -0
- package/src/mcp-semantic-judge.ts +39 -0
- package/src/mcp-server.mjs +14064 -0
- package/src/mcp-server.ts +157 -0
- package/src/mcp-stdio.ts +114 -0
- package/src/mcp.ts +631 -0
- package/src/model-error.ts +120 -0
- package/src/multi-term-search.ts +122 -0
- package/src/omp-index.ts +277 -0
- package/src/omp-marketplace-entry.ts +7 -0
- package/src/operation-lifecycle.ts +413 -0
- package/src/operation-output.ts +100 -0
- package/src/owned-parallel.ts +22 -0
- package/src/owned-process.ts +102 -0
- package/src/owned-task-queue.ts +38 -0
- package/src/package-version.ts +3 -0
- package/src/path-policy.ts +203 -0
- package/src/pi-search-policy.ts +33 -0
- package/src/prompt-guidelines.ts +54 -0
- package/src/python-outline.ts +231 -0
- package/src/record-value.ts +3 -0
- package/src/redaction.ts +95 -0
- package/src/request-contract-catalog.ts +215 -0
- package/src/request-contract-error.ts +96 -0
- package/src/request-contract.ts +481 -0
- package/src/request.ts +132 -0
- package/src/result-statistics.ts +178 -0
- package/src/rg.ts +574 -0
- package/src/ripgrep-diagnostics.ts +122 -0
- package/src/ripgrep-executable.ts +43 -0
- package/src/runtime.ts +53 -0
- package/src/scan-revisions.ts +148 -0
- package/src/script-runtime.ts +11 -0
- package/src/search-policy-commands.ts +170 -0
- package/src/search-policy-hook.ts +61 -0
- package/src/search-policy-recovery.ts +388 -0
- package/src/search-policy-shell.ts +280 -0
- package/src/search-policy.ts +102 -0
- package/src/search-retention.ts +77 -0
- package/src/semantic-judge-batches.ts +135 -0
- package/src/semantic-judge.ts +519 -0
- package/src/service.ts +945 -0
- package/src/session-summary.ts +100 -0
- package/src/snapshot-store.ts +203 -0
- package/src/source-access.ts +288 -0
- package/src/source-continuations.ts +168 -0
- package/src/source-document.ts +277 -0
- package/src/source-inspection.ts +731 -0
- package/src/source-pages.ts +112 -0
- package/src/source.ts +286 -0
- package/src/structural-search.ts +125 -0
- package/src/structure.ts +259 -0
- package/src/summary-previews.ts +96 -0
- package/src/summary.ts +79 -0
- package/src/syntax-facts.ts +464 -0
- package/src/syntax-fields.ts +161 -0
- package/src/syntax-search.ts +361 -0
- package/src/syntax-tree.ts +30 -0
- package/src/syntax-types.ts +72 -0
- package/src/syntax-worker.mjs +280 -0
- package/src/syntax-worker.toml +2 -0
- package/src/syntax-worker.ts +143 -0
- package/src/syntax.ts +259 -0
- package/src/test-navigation-facts.ts +261 -0
- package/src/test-navigation.ts +543 -0
- package/src/tool-schema.ts +321 -0
- package/src/tui/dashboard.ts +107 -0
- package/src/tui/layout.ts +291 -0
- package/src/tui/palette.ts +73 -0
- package/src/tui/presentation.ts +240 -0
- package/src/tui/renderers.ts +106 -0
- package/src/types.ts +368 -0
- package/src/validation-output.ts +148 -0
- package/src/validation-types.ts +48 -0
- package/src/workspace-files.ts +142 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { abortError } from "./errors.js";
|
|
3
|
+
import { getSourceRevision, matchesModificationTime } from "./source.js";
|
|
4
|
+
import { MAX_SOURCE_REVISION_CONCURRENCY, type SourceRevision } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface ModificationTimeFilterResult {
|
|
7
|
+
paths: string[];
|
|
8
|
+
partial: boolean;
|
|
9
|
+
reasons: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Apply a worktree mtime filter to an already enumerated path set with bounded metadata reads. */
|
|
13
|
+
export async function filterPathsByModificationTime(
|
|
14
|
+
cwd: string,
|
|
15
|
+
paths: readonly string[],
|
|
16
|
+
modifiedAfterMs?: number,
|
|
17
|
+
modifiedBeforeMs?: number,
|
|
18
|
+
signal?: AbortSignal,
|
|
19
|
+
): Promise<ModificationTimeFilterResult> {
|
|
20
|
+
if (modifiedAfterMs === undefined && modifiedBeforeMs === undefined)
|
|
21
|
+
return { paths: [...paths], partial: false, reasons: [] };
|
|
22
|
+
|
|
23
|
+
const retained: string[] = [];
|
|
24
|
+
const reasons = new Set<string>();
|
|
25
|
+
for (let offset = 0; offset < paths.length; offset += MAX_SOURCE_REVISION_CONCURRENCY) {
|
|
26
|
+
if (signal?.aborted) throw abortError();
|
|
27
|
+
const batch = paths.slice(offset, offset + MAX_SOURCE_REVISION_CONCURRENCY);
|
|
28
|
+
// oxlint-disable-next-line no-await-in-loop -- batches keep filesystem metadata concurrency and memory bounded.
|
|
29
|
+
const revisions = await Promise.all(
|
|
30
|
+
batch.map(async (path): Promise<{ path: string; revision?: SourceRevision }> => {
|
|
31
|
+
const revision = await getSourceRevision(resolve(cwd, path));
|
|
32
|
+
return revision ? { path, revision } : { path };
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
for (const { path, revision } of revisions) {
|
|
36
|
+
if (!revision) {
|
|
37
|
+
reasons.add(
|
|
38
|
+
`Modification time unavailable for ${path}; it was excluded from the filtered set`,
|
|
39
|
+
);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (matchesModificationTime(revision, modifiedAfterMs, modifiedBeforeMs)) retained.push(path);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { paths: retained, partial: reasons.size > 0, reasons: [...reasons] };
|
|
46
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { abortError } from "./errors.js";
|
|
3
|
+
import { excerptText } from "./excerpt.js";
|
|
4
|
+
import { getSourceRevision, sameSourceRevision } from "./source.js";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_RESULT_TOKEN_BUDGET,
|
|
7
|
+
ESTIMATED_CHARACTERS_PER_TOKEN,
|
|
8
|
+
MAX_CONTEXT_LINES,
|
|
9
|
+
MAX_DISPLAYED_OCCURRENCES,
|
|
10
|
+
MAX_RESULT_BYTES,
|
|
11
|
+
MAX_SOURCE_FILE_BYTES,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
import type { MatchRecord, SearchSnapshot, SourceRevision } from "./types.js";
|
|
14
|
+
|
|
15
|
+
const RESULT_METADATA_RESERVE_BYTES = 1024;
|
|
16
|
+
const RESULT_METADATA_RESERVE_CHARACTERS = 512;
|
|
17
|
+
|
|
18
|
+
export class MatchPageSoftLimitError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super("A single match exceeds the estimated-token detail target");
|
|
21
|
+
this.name = "MatchPageSoftLimitError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pageBodyCharacterLimit(resultTokenBudget = DEFAULT_RESULT_TOKEN_BUDGET): number {
|
|
26
|
+
if (!Number.isSafeInteger(resultTokenBudget) || resultTokenBudget <= 0) {
|
|
27
|
+
throw new Error("Result token budget must be a positive safe integer");
|
|
28
|
+
}
|
|
29
|
+
const limit =
|
|
30
|
+
resultTokenBudget * ESTIMATED_CHARACTERS_PER_TOKEN - RESULT_METADATA_RESERVE_CHARACTERS;
|
|
31
|
+
if (limit <= 0) {
|
|
32
|
+
throw new Error("Result token budget cannot fit reserved response metadata");
|
|
33
|
+
}
|
|
34
|
+
return limit;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FormattedPage {
|
|
38
|
+
body: string;
|
|
39
|
+
returnedMatches: number;
|
|
40
|
+
nextOffset: number;
|
|
41
|
+
hasNext: boolean;
|
|
42
|
+
hasMatchRanges: boolean;
|
|
43
|
+
hasByteRanges: boolean;
|
|
44
|
+
occurrenceRangesOmitted: number;
|
|
45
|
+
occurrenceMatchesTruncated: number;
|
|
46
|
+
firstMatchIndex?: number;
|
|
47
|
+
lastMatchIndex?: number;
|
|
48
|
+
contextOmittedFiles: string[];
|
|
49
|
+
contextChangedFiles: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatMetadataMatchLine(match: MatchRecord, matchIndex: number): string {
|
|
53
|
+
const occurrences = match.occurrences.length;
|
|
54
|
+
const occurrenceLabel = occurrences === 1 ? "occurrence" : "occurrences";
|
|
55
|
+
return ` ${match.lineNumber}: ${String(occurrences)} ${occurrenceLabel} {match #${String(matchIndex)}}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Returns location metadata only; source text is intentionally never part of public results. */
|
|
59
|
+
export async function formatMatchMetadataPage(
|
|
60
|
+
snapshot: SearchSnapshot,
|
|
61
|
+
offset: number,
|
|
62
|
+
signal?: AbortSignal,
|
|
63
|
+
options: MatchPageOptions = {},
|
|
64
|
+
): Promise<FormattedPage> {
|
|
65
|
+
const maxPageBodyBytes =
|
|
66
|
+
MAX_RESULT_BYTES - Math.max(RESULT_METADATA_RESERVE_BYTES, options.metadataReserveBytes ?? 0);
|
|
67
|
+
if (maxPageBodyBytes <= 0)
|
|
68
|
+
throw new Error("Continuation metadata exceeds the response byte budget; select fewer paths");
|
|
69
|
+
const maxPageBodyCharacters = pageBodyCharacterLimit(options.resultTokenBudget);
|
|
70
|
+
const output: string[] = [];
|
|
71
|
+
let returnedMatches = 0;
|
|
72
|
+
let nextOffset = offset;
|
|
73
|
+
let currentFile: string | undefined;
|
|
74
|
+
let outputBytes = 0;
|
|
75
|
+
let outputCharacters = 0;
|
|
76
|
+
let firstMatchIndex: number | undefined;
|
|
77
|
+
let lastMatchIndex: number | undefined;
|
|
78
|
+
let hasMatchRanges = false;
|
|
79
|
+
while (nextOffset < snapshot.matches.length && returnedMatches < snapshot.request.pageSize) {
|
|
80
|
+
if (signal?.aborted) throw abortError();
|
|
81
|
+
const matchIndex = nextOffset;
|
|
82
|
+
const match = snapshot.matches[matchIndex];
|
|
83
|
+
if (!match) break;
|
|
84
|
+
nextOffset += 1;
|
|
85
|
+
if (options.include && !options.include(match, matchIndex)) continue;
|
|
86
|
+
const fileHeader = match.displayPath === currentFile ? "" : `${match.displayPath}\n`;
|
|
87
|
+
const separator = output.length === 0 ? "" : fileHeader.length === 0 ? "\n" : "\n\n";
|
|
88
|
+
const addition = `${separator}${fileHeader}${formatMetadataMatchLine(match, matchIndex + 1)}`;
|
|
89
|
+
const additionBytes = Buffer.byteLength(addition);
|
|
90
|
+
const additionCharacters = addition.length;
|
|
91
|
+
if (
|
|
92
|
+
outputBytes + additionBytes > maxPageBodyBytes ||
|
|
93
|
+
outputCharacters + additionCharacters > maxPageBodyCharacters
|
|
94
|
+
) {
|
|
95
|
+
if (returnedMatches > 0) {
|
|
96
|
+
nextOffset = matchIndex;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
if (additionBytes > maxPageBodyBytes)
|
|
100
|
+
throw new Error("A single match exceeds the reserved result budget");
|
|
101
|
+
throw new MatchPageSoftLimitError();
|
|
102
|
+
}
|
|
103
|
+
output.push(addition);
|
|
104
|
+
outputBytes += additionBytes;
|
|
105
|
+
outputCharacters += additionCharacters;
|
|
106
|
+
currentFile = match.displayPath;
|
|
107
|
+
returnedMatches += 1;
|
|
108
|
+
hasMatchRanges ||= match.occurrences.length > 0;
|
|
109
|
+
firstMatchIndex ??= matchIndex;
|
|
110
|
+
lastMatchIndex = matchIndex;
|
|
111
|
+
}
|
|
112
|
+
const hasNext = snapshot.matches
|
|
113
|
+
.slice(nextOffset)
|
|
114
|
+
.some((match, index) => !options.include || options.include(match, nextOffset + index));
|
|
115
|
+
const page: FormattedPage = {
|
|
116
|
+
body: output.join(""),
|
|
117
|
+
returnedMatches,
|
|
118
|
+
nextOffset,
|
|
119
|
+
hasNext,
|
|
120
|
+
hasMatchRanges,
|
|
121
|
+
hasByteRanges: false,
|
|
122
|
+
occurrenceRangesOmitted: 0,
|
|
123
|
+
occurrenceMatchesTruncated: 0,
|
|
124
|
+
contextOmittedFiles: [],
|
|
125
|
+
contextChangedFiles: [],
|
|
126
|
+
};
|
|
127
|
+
if (firstMatchIndex !== undefined) page.firstMatchIndex = firstMatchIndex;
|
|
128
|
+
if (lastMatchIndex !== undefined) page.lastMatchIndex = lastMatchIndex;
|
|
129
|
+
return page;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface MatchPageOptions {
|
|
133
|
+
resultTokenBudget?: number;
|
|
134
|
+
metadataReserveBytes?: number;
|
|
135
|
+
include?: (match: MatchRecord, index: number) => boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type ContextLoad = { status: "available"; lines: string[] } | { status: "changed" | "unavailable" };
|
|
139
|
+
|
|
140
|
+
type ContextCache = Map<string, ContextLoad>;
|
|
141
|
+
|
|
142
|
+
function compactLine(line: string): string {
|
|
143
|
+
const clean = line.replaceAll("\r", "").trimEnd();
|
|
144
|
+
return excerptText(clean).text;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function matchLocationSuffix(match: MatchRecord): string {
|
|
148
|
+
if (match.occurrences.length === 0) return "";
|
|
149
|
+
const displayed = match.occurrences.slice(0, MAX_DISPLAYED_OCCURRENCES);
|
|
150
|
+
const ranges = displayed.map(({ range }) => {
|
|
151
|
+
const start = range.start.character + 1;
|
|
152
|
+
const end = Math.max(start, range.end.character);
|
|
153
|
+
const suffix = range.encoding === "utf-8" ? "b" : "";
|
|
154
|
+
return `${start}-${end}${suffix}`;
|
|
155
|
+
});
|
|
156
|
+
const omitted = match.occurrences.length - displayed.length;
|
|
157
|
+
const notice =
|
|
158
|
+
omitted > 0
|
|
159
|
+
? ` [ranges: ${String(displayed.length)} of ${String(match.occurrences.length)} shown; ${String(omitted)} omitted; mode=inspect with this path/line for source]`
|
|
160
|
+
: "";
|
|
161
|
+
return ` [${ranges.join(",")}]${notice}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatMatchLine(match: MatchRecord, matchIndex: number): string {
|
|
165
|
+
return ` ${match.lineNumber}: ${match.lineContent}${matchLocationSuffix(match)} {match #${String(matchIndex)}}${match.lineTruncated ? " [line excerpt truncated]" : ""}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function loadContextLines(
|
|
169
|
+
match: MatchRecord,
|
|
170
|
+
expectedRevision: SourceRevision | undefined,
|
|
171
|
+
cache: ContextCache,
|
|
172
|
+
signal?: AbortSignal,
|
|
173
|
+
): Promise<ContextLoad> {
|
|
174
|
+
const cached = cache.get(match.absolutePath);
|
|
175
|
+
if (cached) return cached;
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
if (signal?.aborted) throw abortError();
|
|
179
|
+
if (!expectedRevision || expectedRevision.size > MAX_SOURCE_FILE_BYTES) {
|
|
180
|
+
const unavailable = { status: "unavailable" as const };
|
|
181
|
+
cache.set(match.absolutePath, unavailable);
|
|
182
|
+
return unavailable;
|
|
183
|
+
}
|
|
184
|
+
const beforeRevision = await getSourceRevision(match.absolutePath);
|
|
185
|
+
if (!beforeRevision || !sameSourceRevision(expectedRevision, beforeRevision)) {
|
|
186
|
+
const changed = { status: "changed" as const };
|
|
187
|
+
cache.set(match.absolutePath, changed);
|
|
188
|
+
return changed;
|
|
189
|
+
}
|
|
190
|
+
const content = await readFile(match.absolutePath, { encoding: "utf8", signal });
|
|
191
|
+
const afterRevision = await getSourceRevision(match.absolutePath);
|
|
192
|
+
if (!afterRevision || !sameSourceRevision(expectedRevision, afterRevision)) {
|
|
193
|
+
const changed = { status: "changed" as const };
|
|
194
|
+
cache.set(match.absolutePath, changed);
|
|
195
|
+
return changed;
|
|
196
|
+
}
|
|
197
|
+
const available = {
|
|
198
|
+
status: "available" as const,
|
|
199
|
+
lines: content.replaceAll("\r", "").split("\n"),
|
|
200
|
+
};
|
|
201
|
+
cache.set(match.absolutePath, available);
|
|
202
|
+
return available;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) {
|
|
205
|
+
throw abortError();
|
|
206
|
+
}
|
|
207
|
+
const unavailable = { status: "unavailable" as const };
|
|
208
|
+
cache.set(match.absolutePath, unavailable);
|
|
209
|
+
return unavailable;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface MatchContextWindow {
|
|
214
|
+
startLine: number;
|
|
215
|
+
endLine: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface FormattedMatchBlock {
|
|
219
|
+
text: string;
|
|
220
|
+
contextStatus: "available" | "changed" | "unavailable" | "none";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function matchContextWindows(
|
|
224
|
+
snapshot: SearchSnapshot,
|
|
225
|
+
include: MatchPageOptions["include"],
|
|
226
|
+
): Map<MatchRecord, MatchContextWindow> {
|
|
227
|
+
const windows = new Map<MatchRecord, MatchContextWindow>();
|
|
228
|
+
const context = Math.min(Math.max(0, snapshot.request.context), MAX_CONTEXT_LINES);
|
|
229
|
+
if (context === 0) return windows;
|
|
230
|
+
|
|
231
|
+
const selectedFiles = new Map<string, MatchRecord[]>();
|
|
232
|
+
for (const [index, match] of snapshot.matches.entries()) {
|
|
233
|
+
if (include && !include(match, index)) continue;
|
|
234
|
+
const matches = selectedFiles.get(match.absolutePath) ?? [];
|
|
235
|
+
matches.push(match);
|
|
236
|
+
selectedFiles.set(match.absolutePath, matches);
|
|
237
|
+
}
|
|
238
|
+
for (const matches of selectedFiles.values()) {
|
|
239
|
+
const ordered = matches.toSorted((left, right) => left.lineNumber - right.lineNumber);
|
|
240
|
+
for (const [index, match] of ordered.entries()) {
|
|
241
|
+
const previous = ordered[index - 1];
|
|
242
|
+
const next = ordered[index + 1];
|
|
243
|
+
// Each context line belongs to its nearest selected match; ties belong to the earlier one.
|
|
244
|
+
// This partitions overlapping windows without guessing what a previous cursor page emitted.
|
|
245
|
+
windows.set(match, {
|
|
246
|
+
startLine: Math.max(
|
|
247
|
+
1,
|
|
248
|
+
match.lineNumber - context,
|
|
249
|
+
previous ? Math.floor((previous.lineNumber + match.lineNumber) / 2) + 1 : 1,
|
|
250
|
+
),
|
|
251
|
+
endLine: Math.min(
|
|
252
|
+
match.lineNumber + context,
|
|
253
|
+
next ? Math.floor((match.lineNumber + next.lineNumber) / 2) : Number.MAX_SAFE_INTEGER,
|
|
254
|
+
),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return windows;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function formatBlock(
|
|
262
|
+
match: MatchRecord,
|
|
263
|
+
matchIndex: number,
|
|
264
|
+
expectedRevision: SourceRevision | undefined,
|
|
265
|
+
window: MatchContextWindow | undefined,
|
|
266
|
+
cache: ContextCache,
|
|
267
|
+
allMatchLines: Map<string, Set<number>>,
|
|
268
|
+
signal?: AbortSignal,
|
|
269
|
+
): Promise<FormattedMatchBlock> {
|
|
270
|
+
const matchingLine = formatMatchLine(match, matchIndex);
|
|
271
|
+
if (!window) return { text: matchingLine, contextStatus: "none" };
|
|
272
|
+
|
|
273
|
+
const contextLoad = await loadContextLines(match, expectedRevision, cache, signal);
|
|
274
|
+
if (contextLoad.status !== "available") {
|
|
275
|
+
return { text: matchingLine, contextStatus: contextLoad.status };
|
|
276
|
+
}
|
|
277
|
+
const { lines } = contextLoad;
|
|
278
|
+
const output: string[] = [];
|
|
279
|
+
for (let lineNumber = window.startLine; lineNumber <= window.endLine; lineNumber += 1) {
|
|
280
|
+
if (lineNumber === match.lineNumber) {
|
|
281
|
+
output.push(matchingLine);
|
|
282
|
+
} else if (
|
|
283
|
+
lineNumber <= lines.length &&
|
|
284
|
+
!allMatchLines.get(match.absolutePath)?.has(lineNumber)
|
|
285
|
+
) {
|
|
286
|
+
output.push(` ${lineNumber}- ${compactLine(lines[lineNumber - 1] ?? "")}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { text: output.join("\n"), contextStatus: "available" };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function formatMatchPage(
|
|
293
|
+
snapshot: SearchSnapshot,
|
|
294
|
+
offset: number,
|
|
295
|
+
signal?: AbortSignal,
|
|
296
|
+
options: MatchPageOptions = {},
|
|
297
|
+
): Promise<FormattedPage> {
|
|
298
|
+
const maxPageBodyBytes =
|
|
299
|
+
MAX_RESULT_BYTES - Math.max(RESULT_METADATA_RESERVE_BYTES, options.metadataReserveBytes ?? 0);
|
|
300
|
+
if (maxPageBodyBytes <= 0)
|
|
301
|
+
throw new Error("Continuation metadata exceeds the response byte budget; select fewer paths");
|
|
302
|
+
const maxPageBodyCharacters = pageBodyCharacterLimit(options.resultTokenBudget);
|
|
303
|
+
const cache: ContextCache = new Map();
|
|
304
|
+
const omittedFiles = new Set<string>();
|
|
305
|
+
const changedFiles = new Set<string>();
|
|
306
|
+
const output: string[] = [];
|
|
307
|
+
let returnedMatches = 0;
|
|
308
|
+
let nextOffset = offset;
|
|
309
|
+
let currentFile: string | undefined;
|
|
310
|
+
let outputBytes = 0;
|
|
311
|
+
let outputCharacters = 0;
|
|
312
|
+
let firstMatchIndex: number | undefined;
|
|
313
|
+
let lastMatchIndex: number | undefined;
|
|
314
|
+
let hasMatchRanges = false;
|
|
315
|
+
let hasByteRanges = false;
|
|
316
|
+
let occurrenceRangesOmitted = 0;
|
|
317
|
+
let occurrenceMatchesTruncated = 0;
|
|
318
|
+
const contextWindows = matchContextWindows(snapshot, options.include);
|
|
319
|
+
const allMatchLines = new Map<string, Set<number>>();
|
|
320
|
+
if (contextWindows.size > 0) {
|
|
321
|
+
for (const match of snapshot.matches) {
|
|
322
|
+
const lines = allMatchLines.get(match.absolutePath) ?? new Set<number>();
|
|
323
|
+
lines.add(match.lineNumber);
|
|
324
|
+
allMatchLines.set(match.absolutePath, lines);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
while (nextOffset < snapshot.matches.length && returnedMatches < snapshot.request.pageSize) {
|
|
329
|
+
if (signal?.aborted) throw abortError();
|
|
330
|
+
const matchIndex = nextOffset;
|
|
331
|
+
const match = snapshot.matches[matchIndex];
|
|
332
|
+
if (!match) break;
|
|
333
|
+
nextOffset += 1;
|
|
334
|
+
if (options.include && !options.include(match, matchIndex)) continue;
|
|
335
|
+
// Formatting is sequential because each block consumes the remaining shared byte budget.
|
|
336
|
+
// oxlint-disable-next-line no-await-in-loop -- the shared output budget is consumed in order.
|
|
337
|
+
let block = await formatBlock(
|
|
338
|
+
match,
|
|
339
|
+
matchIndex + 1,
|
|
340
|
+
snapshot.sourceRevisions.get(match.absolutePath),
|
|
341
|
+
contextWindows.get(match),
|
|
342
|
+
cache,
|
|
343
|
+
allMatchLines,
|
|
344
|
+
signal,
|
|
345
|
+
);
|
|
346
|
+
const fileHeader = match.displayPath === currentFile ? "" : `${match.displayPath}\n`;
|
|
347
|
+
const separator = output.length === 0 ? "" : fileHeader.length === 0 ? "\n" : "\n\n";
|
|
348
|
+
let addition = `${separator}${fileHeader}${block.text}`;
|
|
349
|
+
let additionBytes = Buffer.byteLength(addition);
|
|
350
|
+
let additionCharacters = addition.length;
|
|
351
|
+
const exceedsBudget = () =>
|
|
352
|
+
outputBytes + additionBytes > maxPageBodyBytes ||
|
|
353
|
+
outputCharacters + additionCharacters > maxPageBodyCharacters;
|
|
354
|
+
|
|
355
|
+
if (exceedsBudget()) {
|
|
356
|
+
if (returnedMatches > 0) {
|
|
357
|
+
nextOffset = matchIndex;
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
block = { text: formatMatchLine(match, matchIndex + 1), contextStatus: "unavailable" };
|
|
361
|
+
addition = `${fileHeader}${block.text}`;
|
|
362
|
+
additionBytes = Buffer.byteLength(addition);
|
|
363
|
+
additionCharacters = addition.length;
|
|
364
|
+
}
|
|
365
|
+
if (additionBytes > maxPageBodyBytes) {
|
|
366
|
+
throw new Error("A single match exceeds the reserved result budget");
|
|
367
|
+
}
|
|
368
|
+
if (additionCharacters > maxPageBodyCharacters) throw new MatchPageSoftLimitError();
|
|
369
|
+
|
|
370
|
+
output.push(addition);
|
|
371
|
+
outputBytes += additionBytes;
|
|
372
|
+
outputCharacters += additionCharacters;
|
|
373
|
+
currentFile = match.displayPath;
|
|
374
|
+
returnedMatches += 1;
|
|
375
|
+
hasMatchRanges ||= match.occurrences.length > 0;
|
|
376
|
+
hasByteRanges ||= match.occurrences
|
|
377
|
+
.slice(0, MAX_DISPLAYED_OCCURRENCES)
|
|
378
|
+
.some(({ range }) => range.encoding === "utf-8");
|
|
379
|
+
const omittedRanges = Math.max(0, match.occurrences.length - MAX_DISPLAYED_OCCURRENCES);
|
|
380
|
+
occurrenceRangesOmitted += omittedRanges;
|
|
381
|
+
if (omittedRanges > 0) occurrenceMatchesTruncated += 1;
|
|
382
|
+
if (block.contextStatus === "changed") changedFiles.add(match.displayPath);
|
|
383
|
+
if (block.contextStatus === "unavailable") omittedFiles.add(match.displayPath);
|
|
384
|
+
firstMatchIndex ??= matchIndex;
|
|
385
|
+
lastMatchIndex = matchIndex;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const hasNext = snapshot.matches
|
|
389
|
+
.slice(nextOffset)
|
|
390
|
+
.some((match, index) => !options.include || options.include(match, nextOffset + index));
|
|
391
|
+
|
|
392
|
+
const page: FormattedPage = {
|
|
393
|
+
body: output.join(""),
|
|
394
|
+
returnedMatches,
|
|
395
|
+
nextOffset,
|
|
396
|
+
hasNext,
|
|
397
|
+
hasMatchRanges,
|
|
398
|
+
hasByteRanges,
|
|
399
|
+
occurrenceRangesOmitted,
|
|
400
|
+
occurrenceMatchesTruncated,
|
|
401
|
+
contextOmittedFiles: [...omittedFiles].toSorted((left, right) => left.localeCompare(right)),
|
|
402
|
+
contextChangedFiles: [...changedFiles].toSorted((left, right) => left.localeCompare(right)),
|
|
403
|
+
};
|
|
404
|
+
if (firstMatchIndex !== undefined) page.firstMatchIndex = firstMatchIndex;
|
|
405
|
+
if (lastMatchIndex !== undefined) page.lastMatchIndex = lastMatchIndex;
|
|
406
|
+
return page;
|
|
407
|
+
}
|
package/src/git-diff.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { setImmediate } from "node:timers/promises";
|
|
2
|
+
import { MAX_GIT_DIFF_WORK } from "./analysis-limits.js";
|
|
3
|
+
import { abortError, SiftLightError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
export interface GitLineRange {
|
|
6
|
+
startLine: number;
|
|
7
|
+
endLine: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class GitDiffLimitError extends SiftLightError {}
|
|
11
|
+
|
|
12
|
+
/** One work counter is shared by every file and rename comparison in a request. */
|
|
13
|
+
export class GitDiffBudget {
|
|
14
|
+
work = 0;
|
|
15
|
+
|
|
16
|
+
readonly maxWork: number;
|
|
17
|
+
readonly signal: AbortSignal | undefined;
|
|
18
|
+
|
|
19
|
+
constructor(maxWork = MAX_GIT_DIFF_WORK, signal?: AbortSignal) {
|
|
20
|
+
this.maxWork = maxWork;
|
|
21
|
+
this.signal = signal;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
tick(): boolean {
|
|
25
|
+
if (this.signal?.aborted) throw abortError();
|
|
26
|
+
this.work += 1;
|
|
27
|
+
if (this.work > this.maxWork) {
|
|
28
|
+
throw new GitDiffLimitError(
|
|
29
|
+
`Git line comparison exceeds the ${String(this.maxWork)} step limit`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return this.work % 4096 === 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* oxlint-disable no-await-in-loop -- sequential CPU work yields regularly for cancellation. */
|
|
37
|
+
async function sourceLines(content: Buffer, budget: GitDiffBudget): Promise<string[]> {
|
|
38
|
+
const lines: string[] = [];
|
|
39
|
+
for (let start = 0; start < content.length;) {
|
|
40
|
+
if (budget.tick()) await setImmediate();
|
|
41
|
+
const newline = content.indexOf(10, start);
|
|
42
|
+
const end = newline === -1 ? content.length : newline + 1;
|
|
43
|
+
// Latin-1 is a reversible byte mapping, so malformed UTF-8 and CRLF remain distinct.
|
|
44
|
+
lines.push(content.toString("latin1", start, end));
|
|
45
|
+
start = end;
|
|
46
|
+
}
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* oxlint-enable no-await-in-loop */
|
|
51
|
+
|
|
52
|
+
export function sourceLineCount(content: Buffer): number {
|
|
53
|
+
if (content.length === 0) return 0;
|
|
54
|
+
let count = content[content.length - 1] === 10 ? 0 : 1;
|
|
55
|
+
for (const byte of content) if (byte === 10) count += 1;
|
|
56
|
+
return count;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function diagonal(vector: Int32Array, distance: number, k: number): number {
|
|
60
|
+
return vector[k + distance + 1] ?? -1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function prependLine(ranges: GitLineRange[], line: number): void {
|
|
64
|
+
const last = ranges.at(-1);
|
|
65
|
+
if (last && last.startLine === line + 1) last.startLine = line;
|
|
66
|
+
else ranges.push({ startLine: line, endLine: line });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function reconstruct(
|
|
70
|
+
trace: Int32Array[],
|
|
71
|
+
oldLength: number,
|
|
72
|
+
newLength: number,
|
|
73
|
+
prefix: number,
|
|
74
|
+
): { oldRanges: GitLineRange[]; newRanges: GitLineRange[] } {
|
|
75
|
+
let x = oldLength;
|
|
76
|
+
let y = newLength;
|
|
77
|
+
const oldRanges: GitLineRange[] = [];
|
|
78
|
+
const newRanges: GitLineRange[] = [];
|
|
79
|
+
for (let distance = trace.length - 1; distance > 0; distance -= 1) {
|
|
80
|
+
const previous = trace[distance - 1];
|
|
81
|
+
if (!previous) throw new Error("Missing Git line comparison trace");
|
|
82
|
+
const k = x - y;
|
|
83
|
+
const previousK =
|
|
84
|
+
k === -distance ||
|
|
85
|
+
(k !== distance &&
|
|
86
|
+
diagonal(previous, distance - 1, k - 1) < diagonal(previous, distance - 1, k + 1))
|
|
87
|
+
? k + 1
|
|
88
|
+
: k - 1;
|
|
89
|
+
const previousX = diagonal(previous, distance - 1, previousK);
|
|
90
|
+
const previousY = previousX - previousK;
|
|
91
|
+
while (x > previousX && y > previousY) {
|
|
92
|
+
x -= 1;
|
|
93
|
+
y -= 1;
|
|
94
|
+
}
|
|
95
|
+
if (x === previousX) {
|
|
96
|
+
prependLine(newRanges, prefix + y);
|
|
97
|
+
y -= 1;
|
|
98
|
+
} else {
|
|
99
|
+
prependLine(oldRanges, prefix + x);
|
|
100
|
+
x -= 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { oldRanges: oldRanges.toReversed(), newRanges: newRanges.toReversed() };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Myers shortest edit script over raw LF-delimited lines, with bounded trace and cancellation. */
|
|
107
|
+
/* oxlint-disable no-await-in-loop -- each frontier depends on its predecessor; yields allow cancellation. */
|
|
108
|
+
export async function changedLineRanges(
|
|
109
|
+
oldContent: Buffer,
|
|
110
|
+
newContent: Buffer,
|
|
111
|
+
budget = new GitDiffBudget(),
|
|
112
|
+
): Promise<{ oldRanges: GitLineRange[]; newRanges: GitLineRange[] }> {
|
|
113
|
+
if (budget.signal?.aborted) throw abortError();
|
|
114
|
+
if (oldContent.equals(newContent)) return { oldRanges: [], newRanges: [] };
|
|
115
|
+
const oldLines = await sourceLines(oldContent, budget);
|
|
116
|
+
const newLines = await sourceLines(newContent, budget);
|
|
117
|
+
let prefix = 0;
|
|
118
|
+
while (
|
|
119
|
+
prefix < oldLines.length &&
|
|
120
|
+
prefix < newLines.length &&
|
|
121
|
+
oldLines[prefix] === newLines[prefix]
|
|
122
|
+
) {
|
|
123
|
+
if (budget.tick()) await setImmediate();
|
|
124
|
+
prefix += 1;
|
|
125
|
+
}
|
|
126
|
+
let oldEnd = oldLines.length;
|
|
127
|
+
let newEnd = newLines.length;
|
|
128
|
+
while (oldEnd > prefix && newEnd > prefix && oldLines[oldEnd - 1] === newLines[newEnd - 1]) {
|
|
129
|
+
if (budget.tick()) await setImmediate();
|
|
130
|
+
oldEnd -= 1;
|
|
131
|
+
newEnd -= 1;
|
|
132
|
+
}
|
|
133
|
+
const n = oldEnd - prefix;
|
|
134
|
+
const m = newEnd - prefix;
|
|
135
|
+
if (n === 0 || m === 0) {
|
|
136
|
+
return {
|
|
137
|
+
oldRanges: n === 0 ? [] : [{ startLine: prefix + 1, endLine: oldEnd }],
|
|
138
|
+
newRanges: m === 0 ? [] : [{ startLine: prefix + 1, endLine: newEnd }],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const trace: Int32Array[] = [];
|
|
142
|
+
for (let distance = 0; distance <= n + m; distance += 1) {
|
|
143
|
+
const current = new Int32Array(2 * distance + 3).fill(-1);
|
|
144
|
+
const previous = trace[distance - 1];
|
|
145
|
+
for (let k = -distance; k <= distance; k += 2) {
|
|
146
|
+
if (budget.tick()) await setImmediate();
|
|
147
|
+
let x = 0;
|
|
148
|
+
if (previous) {
|
|
149
|
+
x =
|
|
150
|
+
k === -distance ||
|
|
151
|
+
(k !== distance &&
|
|
152
|
+
diagonal(previous, distance - 1, k - 1) < diagonal(previous, distance - 1, k + 1))
|
|
153
|
+
? diagonal(previous, distance - 1, k + 1)
|
|
154
|
+
: diagonal(previous, distance - 1, k - 1) + 1;
|
|
155
|
+
}
|
|
156
|
+
let y = x - k;
|
|
157
|
+
while (x < n && y < m && oldLines[prefix + x] === newLines[prefix + y]) {
|
|
158
|
+
if (budget.tick()) await setImmediate();
|
|
159
|
+
x += 1;
|
|
160
|
+
y += 1;
|
|
161
|
+
}
|
|
162
|
+
current[k + distance + 1] = x;
|
|
163
|
+
if (x >= n && y >= m) {
|
|
164
|
+
trace.push(current);
|
|
165
|
+
return reconstruct(trace, n, m, prefix);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
trace.push(current);
|
|
169
|
+
}
|
|
170
|
+
throw new Error("Git line comparison did not produce an edit script");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* oxlint-enable no-await-in-loop */
|
|
174
|
+
|
|
175
|
+
/** A bounded byte-weighted line multiset score; this is explicitly heuristic, not rename history. */
|
|
176
|
+
export async function sourceSimilarity(
|
|
177
|
+
oldContent: Buffer,
|
|
178
|
+
newContent: Buffer,
|
|
179
|
+
budget: GitDiffBudget,
|
|
180
|
+
): Promise<number> {
|
|
181
|
+
if (oldContent.equals(newContent)) return 100;
|
|
182
|
+
const maximum = Math.max(oldContent.length, newContent.length);
|
|
183
|
+
if (maximum === 0 || Math.min(oldContent.length, newContent.length) / maximum < 0.5) return 0;
|
|
184
|
+
const counts = new Map<string, number>();
|
|
185
|
+
for (const line of await sourceLines(oldContent, budget))
|
|
186
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
187
|
+
let commonBytes = 0;
|
|
188
|
+
for (const line of await sourceLines(newContent, budget)) {
|
|
189
|
+
const remaining = counts.get(line) ?? 0;
|
|
190
|
+
if (remaining === 0) continue;
|
|
191
|
+
counts.set(line, remaining - 1);
|
|
192
|
+
commonBytes += line.length;
|
|
193
|
+
}
|
|
194
|
+
return Math.min(99, Math.floor((100 * commonBytes) / maximum));
|
|
195
|
+
}
|