pi-hashline-edit-pro 2.8.0 → 2.8.2
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/README.md +9 -4
- package/index.ts +14 -8
- package/package.json +9 -9
- package/prompts/grep-guidelines.md +3 -3
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/src/commit.ts +8 -3
- package/src/constants.ts +1 -1
- package/src/edit-common.ts +45 -0
- package/src/file-kind.ts +6 -2
- package/src/file-reader.ts +9 -2
- package/src/fs-write.ts +42 -11
- package/src/grep.ts +284 -98
- package/src/hash-store/cache.ts +18 -0
- package/src/hash-store/retry.ts +48 -0
- package/src/hash-store/validation.ts +93 -0
- package/src/hash-store.ts +90 -147
- package/src/hashline/apply.ts +3 -3
- package/src/hashline/resolve.ts +52 -36
- package/src/insert.ts +7 -23
- package/src/normalize.ts +27 -0
- package/src/read.ts +5 -3
- package/src/replace-diff.ts +8 -30
- package/src/replace-undo.ts +22 -7
- package/src/replace.ts +14 -30
- package/src/served.ts +56 -27
- package/src/validation.ts +2 -2
- package/src/write-hook.ts +4 -4
package/src/grep.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import {
|
|
4
|
+
import { stat } from "fs/promises";
|
|
5
5
|
import { dirname, join, relative } from "path";
|
|
6
|
+
import { spawn, spawnSync } from "child_process";
|
|
7
|
+
import { createInterface } from "readline";
|
|
6
8
|
import { tryReadNormFile } from "./file-reader";
|
|
7
9
|
import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
|
|
8
10
|
import { MAX_GREP_LINE_BYTES } from "./constants";
|
|
9
11
|
import { toCwd } from "./paths";
|
|
10
12
|
import { loadP, loadGuide } from "./prompts";
|
|
11
13
|
import { normReq } from "./payload-contract";
|
|
12
|
-
import { recordServedSafe } from "./served";
|
|
14
|
+
import { recordServedSafe, buildServedMap } from "./served";
|
|
13
15
|
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
14
16
|
|
|
15
17
|
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
16
|
-
const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
|
|
17
|
-
const MAX_SCAN_FILES = 4000;
|
|
18
18
|
|
|
19
19
|
function cmp(a: string, b: string): number {
|
|
20
20
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
@@ -47,6 +47,7 @@ export function assertGrepReq(request: unknown): asserts request is GrepReq {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
|
|
50
|
+
if (!literal) assertSafeRegex(pattern);
|
|
50
51
|
const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
|
|
51
52
|
try {
|
|
52
53
|
return new RegExp(source, ignoreCase ? "ui" : "u");
|
|
@@ -55,6 +56,96 @@ function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): Reg
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
interface RegexGroupRisk {
|
|
60
|
+
hasQuantifier: boolean;
|
|
61
|
+
hasAlternation: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function unsafeRegex(pattern: string): never {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`[E_UNSAFE_REGEX] Refusing potentially exponential regex: ${pattern}. Use literal: true or simplify the expression.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertSafeRegex(pattern: string): void {
|
|
71
|
+
if (pattern.length > 4096) unsafeRegex(pattern);
|
|
72
|
+
const groups: RegexGroupRisk[] = [];
|
|
73
|
+
let inClass = false;
|
|
74
|
+
let escaped = false;
|
|
75
|
+
let variableQuantifiers = 0;
|
|
76
|
+
let lastAtom: { groupRisky: boolean; quantified: boolean } | undefined;
|
|
77
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
78
|
+
const ch = pattern[i]!;
|
|
79
|
+
if (escaped) {
|
|
80
|
+
if (!inClass && (/[1-9]/.test(ch) || (ch === "k" && pattern[i + 1] === "<"))) {
|
|
81
|
+
unsafeRegex(pattern);
|
|
82
|
+
}
|
|
83
|
+
escaped = false;
|
|
84
|
+
lastAtom = { groupRisky: false, quantified: false };
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === "\\") {
|
|
88
|
+
escaped = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (inClass) {
|
|
92
|
+
if (ch === "]") {
|
|
93
|
+
inClass = false;
|
|
94
|
+
lastAtom = { groupRisky: false, quantified: false };
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (ch === "[") {
|
|
99
|
+
inClass = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ch === "(") {
|
|
103
|
+
groups.push({ hasQuantifier: false, hasAlternation: false });
|
|
104
|
+
lastAtom = undefined;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (ch === ")") {
|
|
108
|
+
const group = groups.pop();
|
|
109
|
+
if (group) {
|
|
110
|
+
lastAtom = {
|
|
111
|
+
groupRisky: group.hasQuantifier || group.hasAlternation,
|
|
112
|
+
quantified: false,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (ch === "|") {
|
|
118
|
+
const group = groups.at(-1);
|
|
119
|
+
if (group) group.hasAlternation = true;
|
|
120
|
+
lastAtom = undefined;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
let quantifierLength = 0;
|
|
124
|
+
if (ch === "*" || ch === "+" || ch === "?") {
|
|
125
|
+
quantifierLength = 1;
|
|
126
|
+
} else if (ch === "{") {
|
|
127
|
+
quantifierLength = /^\{\d+(?:,\d*)?\}/.exec(pattern.slice(i))?.[0].length ?? 0;
|
|
128
|
+
}
|
|
129
|
+
if (ch === "{" && quantifierLength > 0) {
|
|
130
|
+
const quant = pattern.slice(i, i + quantifierLength);
|
|
131
|
+
const m = /^\{(\d+)/.exec(quant);
|
|
132
|
+
if (m && Number(m[1]) > 1000) unsafeRegex(pattern);
|
|
133
|
+
}
|
|
134
|
+
if (quantifierLength > 0 && lastAtom) {
|
|
135
|
+
if (ch === "?" && lastAtom.quantified) continue;
|
|
136
|
+
const variable = ch !== "{" || pattern.slice(i, i + quantifierLength).includes(",");
|
|
137
|
+
if (variable && ++variableQuantifiers > 1) unsafeRegex(pattern);
|
|
138
|
+
if (lastAtom.groupRisky) unsafeRegex(pattern);
|
|
139
|
+
const group = groups.at(-1);
|
|
140
|
+
if (group) group.hasQuantifier = true;
|
|
141
|
+
lastAtom.quantified = true;
|
|
142
|
+
i += quantifierLength - 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
lastAtom = { groupRisky: false, quantified: false };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
58
149
|
function globToRegex(glob: string): RegExp {
|
|
59
150
|
if (glob.startsWith("/")) glob = glob.slice(1);
|
|
60
151
|
let source = "";
|
|
@@ -87,8 +178,10 @@ interface FileHit {
|
|
|
87
178
|
path: string;
|
|
88
179
|
displayPath: string;
|
|
89
180
|
fileHashes: string[];
|
|
181
|
+
fileLines: string[];
|
|
90
182
|
rows: string[];
|
|
91
183
|
hashes: string[];
|
|
184
|
+
lineNumbers: number[];
|
|
92
185
|
matchCount: number;
|
|
93
186
|
totalMatchCount: number;
|
|
94
187
|
fragmented: boolean[];
|
|
@@ -129,89 +222,41 @@ function grepHeadFragment(line: string): string {
|
|
|
129
222
|
return head.length < line.length ? `${head}...` : head;
|
|
130
223
|
}
|
|
131
224
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
async function walkFiles(
|
|
138
|
-
root: string,
|
|
139
|
-
state: ScanState,
|
|
140
|
-
onFile: (absPath: string) => Promise<void>,
|
|
141
|
-
): Promise<void> {
|
|
142
|
-
const queue: string[] = [root];
|
|
143
|
-
let head = 0;
|
|
144
|
-
while (head < queue.length && !state.stopped) {
|
|
145
|
-
const dir = queue[head++]!;
|
|
146
|
-
let entries;
|
|
147
|
-
try {
|
|
148
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
149
|
-
} catch {
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
entries.sort((a, b) => cmp(a.name, b.name));
|
|
153
|
-
for (const entry of entries) {
|
|
154
|
-
if (state.stopped) break;
|
|
155
|
-
const full = join(dir, entry.name);
|
|
156
|
-
if (entry.isDirectory()) {
|
|
157
|
-
if (SKIP_DIRS.has(entry.name)) continue;
|
|
158
|
-
queue.push(full);
|
|
159
|
-
} else if (entry.isFile()) {
|
|
160
|
-
state.scanned += 1;
|
|
161
|
-
if (state.scanned > MAX_SCAN_FILES) {
|
|
162
|
-
state.stopped = true;
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
|
-
await onFile(full);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async function searchFile(
|
|
172
|
-
absPath: string,
|
|
173
|
-
globRoot: string,
|
|
174
|
-
cwd: string,
|
|
175
|
-
regex: RegExp,
|
|
176
|
-
globRegex: RegExp | undefined,
|
|
225
|
+
function makeHitFromIndices(
|
|
226
|
+
norm: { normalized: string; fileHashes: string[]; absolutePath: string },
|
|
227
|
+
displayPath: string,
|
|
228
|
+
matchIndices: number[],
|
|
177
229
|
context: number,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
183
|
-
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) return undefined;
|
|
184
|
-
}
|
|
185
|
-
const norm = await tryReadNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, noPersist: true });
|
|
186
|
-
if (!norm) return undefined;
|
|
230
|
+
regex: RegExp | undefined,
|
|
231
|
+
totalMatchCount: number,
|
|
232
|
+
keptMatchCount: number,
|
|
233
|
+
): FileHit {
|
|
187
234
|
const lines = visLines(norm.normalized);
|
|
188
|
-
const matchLines: number[] = [];
|
|
189
|
-
for (let i = 0; i < lines.length; i++) {
|
|
190
|
-
if (regex.test(lines[i]!)) matchLines.push(i);
|
|
191
|
-
}
|
|
192
|
-
if (matchLines.length === 0) return undefined;
|
|
193
|
-
const keptMatches = matchLines.length > maxMatches ? matchLines.slice(0, maxMatches) : matchLines;
|
|
194
235
|
const shown = new Set<number>();
|
|
195
|
-
|
|
236
|
+
const kept = matchIndices.slice(0, keptMatchCount);
|
|
237
|
+
for (const i of kept) {
|
|
196
238
|
for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
|
|
197
239
|
}
|
|
198
240
|
const sorted = [...shown].sort((a, b) => a - b);
|
|
199
|
-
const matchSet = new Set(
|
|
241
|
+
const matchSet = new Set(matchIndices);
|
|
200
242
|
const rows: string[] = [];
|
|
201
243
|
const hashes: string[] = [];
|
|
244
|
+
const lineNumbers: number[] = [];
|
|
202
245
|
const fragmented: boolean[] = [];
|
|
203
246
|
for (const idx of sorted) {
|
|
204
247
|
const hash = norm.fileHashes[idx]!;
|
|
205
248
|
const line = lines[idx]!;
|
|
206
249
|
const row = fmtRow(hash, line);
|
|
207
250
|
if (Buffer.byteLength(row, "utf-8") > MAX_GREP_LINE_BYTES) {
|
|
208
|
-
const content = matchSet.has(idx) ? grepMatchFragment(line, regex) : grepHeadFragment(line);
|
|
251
|
+
const content = matchSet.has(idx) && regex ? grepMatchFragment(line, regex) : grepHeadFragment(line);
|
|
209
252
|
rows.push(fmtRow(hash, content));
|
|
210
253
|
hashes.push(hash);
|
|
254
|
+
lineNumbers.push(idx + 1);
|
|
211
255
|
fragmented.push(true);
|
|
212
256
|
} else {
|
|
213
257
|
rows.push(row);
|
|
214
258
|
hashes.push(hash);
|
|
259
|
+
lineNumbers.push(idx + 1);
|
|
215
260
|
fragmented.push(false);
|
|
216
261
|
}
|
|
217
262
|
}
|
|
@@ -219,14 +264,142 @@ async function searchFile(
|
|
|
219
264
|
path: norm.absolutePath,
|
|
220
265
|
displayPath,
|
|
221
266
|
fileHashes: norm.fileHashes,
|
|
267
|
+
fileLines: lines,
|
|
222
268
|
rows,
|
|
223
269
|
hashes,
|
|
224
|
-
|
|
225
|
-
|
|
270
|
+
lineNumbers,
|
|
271
|
+
matchCount: kept.length,
|
|
272
|
+
totalMatchCount,
|
|
226
273
|
fragmented,
|
|
227
274
|
};
|
|
228
275
|
}
|
|
229
276
|
|
|
277
|
+
async function resolveRgPath(): Promise<string> {
|
|
278
|
+
try {
|
|
279
|
+
const r = spawnSync("rg", ["--version"], { stdio: "pipe" });
|
|
280
|
+
if (!r.error && r.status === 0) return "rg";
|
|
281
|
+
} catch {}
|
|
282
|
+
try {
|
|
283
|
+
const { homedir } = await import("os");
|
|
284
|
+
const { existsSync } = await import("fs");
|
|
285
|
+
const home = process.env.HOME ?? homedir();
|
|
286
|
+
const base = process.env.PI_CODING_AGENT_DIR ?? join(home, ".pi", "agent");
|
|
287
|
+
const bin = join(base, "bin", process.platform === "win32" ? "rg.exe" : "rg");
|
|
288
|
+
if (existsSync(bin)) {
|
|
289
|
+
const r = spawnSync(bin, ["--version"], { stdio: "pipe" });
|
|
290
|
+
if (!r.error && r.status === 0) return bin;
|
|
291
|
+
}
|
|
292
|
+
} catch {}
|
|
293
|
+
try {
|
|
294
|
+
const { createRequire } = await import("module");
|
|
295
|
+
const require = createRequire(import.meta.url);
|
|
296
|
+
const pkgPath = require.resolve("@earendil-works/pi-coding-agent/package.json");
|
|
297
|
+
const { dirname } = await import("path");
|
|
298
|
+
const piDir = dirname(pkgPath);
|
|
299
|
+
const toolsManagerPath = join(piDir, "dist/utils/tools-manager.js");
|
|
300
|
+
const mod = await import("file://" + toolsManagerPath);
|
|
301
|
+
if (mod.ensureTool) {
|
|
302
|
+
const p = await mod.ensureTool("rg", true);
|
|
303
|
+
if (p) return p;
|
|
304
|
+
}
|
|
305
|
+
} catch {}
|
|
306
|
+
throw new Error("[E_ACCESS] ripgrep (rg) is required for grep but was not found. Install ripgrep or ensure pi can download it to ~/.pi/agent/bin.");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function collectRgMatches(
|
|
310
|
+
rgPath: string,
|
|
311
|
+
pattern: string,
|
|
312
|
+
searchPath: string,
|
|
313
|
+
req: GrepReq,
|
|
314
|
+
signal?: AbortSignal,
|
|
315
|
+
): Promise<Map<string, number[]>> {
|
|
316
|
+
const args = ["--json", "--line-number", "--color=never", "--hidden"];
|
|
317
|
+
if (req.ignoreCase) args.push("--ignore-case");
|
|
318
|
+
if (req.literal) args.push("--fixed-strings");
|
|
319
|
+
args.push("--", pattern, searchPath);
|
|
320
|
+
const result = new Map<string, number[]>();
|
|
321
|
+
return await new Promise<Map<string, number[]>>((resolve, reject) => {
|
|
322
|
+
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
323
|
+
const rl = createInterface({ input: child.stdout });
|
|
324
|
+
let stderr = "";
|
|
325
|
+
let timedOut = false;
|
|
326
|
+
const rgTimeout = setTimeout(() => {
|
|
327
|
+
timedOut = true;
|
|
328
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
329
|
+
reject(new Error("rg timeout"));
|
|
330
|
+
}, 10000);
|
|
331
|
+
child.stderr?.on("data", (chunk) => {
|
|
332
|
+
stderr += chunk.toString();
|
|
333
|
+
});
|
|
334
|
+
const onAbort = () => {
|
|
335
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
336
|
+
};
|
|
337
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
338
|
+
const cleanup = () => {
|
|
339
|
+
clearTimeout(rgTimeout);
|
|
340
|
+
rl.close();
|
|
341
|
+
signal?.removeEventListener("abort", onAbort);
|
|
342
|
+
};
|
|
343
|
+
rl.on("line", (line) => {
|
|
344
|
+
if (!line.trim()) return;
|
|
345
|
+
let event: { type?: string; data?: { path?: { text?: string }; line_number?: number } };
|
|
346
|
+
try {
|
|
347
|
+
event = JSON.parse(line);
|
|
348
|
+
} catch {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (event.type === "match") {
|
|
352
|
+
const filePath = event.data?.path?.text;
|
|
353
|
+
const lineNumber = event.data?.line_number;
|
|
354
|
+
if (typeof filePath === "string" && typeof lineNumber === "number") {
|
|
355
|
+
let abs: string;
|
|
356
|
+
try {
|
|
357
|
+
abs = filePath.startsWith("/") || /^[A-Za-z]:\\/.test(filePath) ? filePath : join(searchPath, filePath);
|
|
358
|
+
} catch {
|
|
359
|
+
abs = filePath;
|
|
360
|
+
}
|
|
361
|
+
const list = result.get(abs) ?? [];
|
|
362
|
+
list.push(lineNumber);
|
|
363
|
+
result.set(abs, list);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
child.on("error", (error) => {
|
|
368
|
+
cleanup();
|
|
369
|
+
reject(error);
|
|
370
|
+
});
|
|
371
|
+
child.on("close", (code) => {
|
|
372
|
+
cleanup();
|
|
373
|
+
if (timedOut) return;
|
|
374
|
+
if (signal?.aborted) {
|
|
375
|
+
reject(new Error("Operation aborted"));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (code !== 0 && code !== 1) {
|
|
379
|
+
const msg = stderr.trim() || `ripgrep exited with code ${code}`;
|
|
380
|
+
reject(new Error(msg));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
resolve(result);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function gutterWidthFor(numbers: number[]): number {
|
|
389
|
+
let max = 0;
|
|
390
|
+
for (const n of numbers) if (n > max) max = n;
|
|
391
|
+
return String(max || 1).length;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function displayRowsForHit(hit: FileHit): string[] {
|
|
395
|
+
const width = gutterWidthFor(hit.lineNumbers);
|
|
396
|
+
return hit.rows.map((row, i) => {
|
|
397
|
+
const n = hit.lineNumbers[i]!;
|
|
398
|
+
const padded = String(n).padStart(width, " ");
|
|
399
|
+
return `${padded} │ ${row}`;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
230
403
|
const grepToolSchema = Type.Object(
|
|
231
404
|
{
|
|
232
405
|
pattern: Type.String({
|
|
@@ -283,10 +456,8 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
283
456
|
const canonical = normReq(params);
|
|
284
457
|
assertGrepReq(canonical);
|
|
285
458
|
const req = canonical;
|
|
286
|
-
const regex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
|
|
287
459
|
const context = req.context ?? 0;
|
|
288
460
|
const limit = req.limit ?? 100;
|
|
289
|
-
const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
|
|
290
461
|
const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
|
|
291
462
|
abortIf(signal);
|
|
292
463
|
let baseStat;
|
|
@@ -299,16 +470,9 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
299
470
|
throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
|
|
300
471
|
}
|
|
301
472
|
const globRoot = baseStat.isFile() ? dirname(base) : base;
|
|
302
|
-
const
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
files.push(base);
|
|
306
|
-
} else {
|
|
307
|
-
await walkFiles(base, state, async (absPath) => {
|
|
308
|
-
files.push(absPath);
|
|
309
|
-
});
|
|
310
|
-
files.sort(cmp);
|
|
311
|
-
}
|
|
473
|
+
const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
|
|
474
|
+
const validatedRegex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
|
|
475
|
+
const rgPath = await resolveRgPath();
|
|
312
476
|
const hits: FileHit[] = [];
|
|
313
477
|
let matches = 0;
|
|
314
478
|
let limitTruncated = false;
|
|
@@ -320,17 +484,26 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
320
484
|
let truncatedBy: "lines" | "bytes" | null = null;
|
|
321
485
|
let linesReplaced = 0;
|
|
322
486
|
let countOnly = false;
|
|
323
|
-
|
|
487
|
+
const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal);
|
|
488
|
+
const sortedFiles = [...rgMatches.keys()].sort(cmp);
|
|
489
|
+
for (let f = 0; f < sortedFiles.length; f++) {
|
|
324
490
|
abortIf(signal);
|
|
325
|
-
const absPath =
|
|
491
|
+
const absPath = sortedFiles[f]!;
|
|
492
|
+
const allNums = rgMatches.get(absPath) ?? [];
|
|
493
|
+
const totalForFile = allNums.length;
|
|
494
|
+
const sortedNums = [...allNums].sort((a, b) => a - b);
|
|
495
|
+
const indices = sortedNums.map((n) => n - 1).filter((n) => n >= 0);
|
|
326
496
|
if (countOnly) {
|
|
327
|
-
const
|
|
328
|
-
if (!
|
|
329
|
-
|
|
330
|
-
|
|
497
|
+
const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
|
|
498
|
+
if (!norm) continue;
|
|
499
|
+
const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, indices.length);
|
|
500
|
+
const display = displayRowsForHit(hit);
|
|
501
|
+
totalRows += display.length;
|
|
502
|
+
for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
|
|
331
503
|
const remaining = limit - matches;
|
|
332
504
|
if (remaining > 0) {
|
|
333
|
-
|
|
505
|
+
const add = Math.min(hit.matchCount, remaining);
|
|
506
|
+
matches += add;
|
|
334
507
|
if (hit.matchCount > remaining) limitTruncated = true;
|
|
335
508
|
} else {
|
|
336
509
|
limitTruncated = true;
|
|
@@ -342,24 +515,36 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
342
515
|
limitTruncated = true;
|
|
343
516
|
break;
|
|
344
517
|
}
|
|
345
|
-
const
|
|
518
|
+
const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
|
|
519
|
+
if (!norm) continue;
|
|
520
|
+
if (globRegex) {
|
|
521
|
+
const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
|
|
522
|
+
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
523
|
+
if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
|
|
524
|
+
}
|
|
525
|
+
const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
|
|
346
526
|
if (!hit) continue;
|
|
527
|
+
const display = displayRowsForHit(hit);
|
|
347
528
|
const keptRows: string[] = [];
|
|
348
529
|
const keptHashes: string[] = [];
|
|
349
|
-
|
|
350
|
-
|
|
530
|
+
const keptLineNumbers: number[] = [];
|
|
531
|
+
const keptFragmented: boolean[] = [];
|
|
532
|
+
for (let i = 0; i < display.length; i++) {
|
|
533
|
+
const row = display[i]!;
|
|
351
534
|
const rowBytes = Buffer.byteLength(row, "utf-8") + 1;
|
|
352
535
|
if (rowCount >= DEFAULT_MAX_LINES || byteCount + rowBytes > DEFAULT_MAX_BYTES) {
|
|
353
536
|
rowTruncated = true;
|
|
354
537
|
if (truncatedBy === null) truncatedBy = byteCount + rowBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines";
|
|
355
|
-
for (let j = i; j <
|
|
538
|
+
for (let j = i; j < display.length; j++) {
|
|
356
539
|
totalRows += 1;
|
|
357
|
-
totalBytes += Buffer.byteLength(
|
|
540
|
+
totalBytes += Buffer.byteLength(display[j]!, "utf-8") + 1;
|
|
358
541
|
}
|
|
359
542
|
break;
|
|
360
543
|
}
|
|
361
544
|
keptRows.push(row);
|
|
362
|
-
keptHashes.push(hit.hashes[i]);
|
|
545
|
+
keptHashes.push(hit.hashes[i]!);
|
|
546
|
+
keptLineNumbers.push(hit.lineNumbers[i]!);
|
|
547
|
+
keptFragmented.push(hit.fragmented[i]!);
|
|
363
548
|
if (hit.fragmented[i]) linesReplaced += 1;
|
|
364
549
|
rowCount += 1;
|
|
365
550
|
byteCount += rowBytes;
|
|
@@ -368,12 +553,14 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
368
553
|
}
|
|
369
554
|
if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
|
|
370
555
|
matches += hit.matchCount;
|
|
371
|
-
|
|
556
|
+
const displayHit: FileHit = { ...hit, rows: keptRows, hashes: keptHashes, lineNumbers: keptLineNumbers, fragmented: keptFragmented };
|
|
557
|
+
hits.push(displayHit);
|
|
372
558
|
if (rowTruncated) countOnly = true;
|
|
373
559
|
}
|
|
374
560
|
hits.sort((a, b) => cmp(a.displayPath, b.displayPath));
|
|
375
561
|
for (const hit of hits) {
|
|
376
|
-
|
|
562
|
+
const servedMap = buildServedMap(hit.fileHashes, hit.fileLines, hit.hashes);
|
|
563
|
+
await recordServedSafe(hit.path, servedMap, "grep", new Set(hit.fileHashes));
|
|
377
564
|
}
|
|
378
565
|
const blocks = hits
|
|
379
566
|
.map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
|
|
@@ -381,7 +568,6 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
381
568
|
const notes: string[] = [];
|
|
382
569
|
if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
|
|
383
570
|
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
384
|
-
if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
|
|
385
571
|
if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
|
|
386
572
|
const truncated = limitTruncated || rowTruncated;
|
|
387
573
|
const truncation: TruncationResult | undefined = rowTruncated
|
|
@@ -408,7 +594,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
408
594
|
metrics: {
|
|
409
595
|
matches,
|
|
410
596
|
files: hits.length,
|
|
411
|
-
truncated
|
|
597
|
+
truncated,
|
|
412
598
|
},
|
|
413
599
|
},
|
|
414
600
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface SnapshotCacheEntry {
|
|
2
|
+
checksum: string;
|
|
3
|
+
lineCount: number;
|
|
4
|
+
hashes: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const SNAPSHOT_CACHE_LIMIT = 256;
|
|
8
|
+
|
|
9
|
+
export const snapshotCache = new Map<string, SnapshotCacheEntry>();
|
|
10
|
+
|
|
11
|
+
export function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
|
|
12
|
+
snapshotCache.delete(path);
|
|
13
|
+
snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
|
|
14
|
+
if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
|
|
15
|
+
const oldest = snapshotCache.keys().next().value;
|
|
16
|
+
if (oldest !== undefined) snapshotCache.delete(oldest);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isBusyError } from "./validation";
|
|
2
|
+
|
|
3
|
+
const sleepSab = new Int32Array(new SharedArrayBuffer(4));
|
|
4
|
+
|
|
5
|
+
function sleepSync(ms: number): void {
|
|
6
|
+
Atomics.wait(sleepSab, 0, 0, ms);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const BUSY_RETRIES = 3;
|
|
10
|
+
const BUSY_RETRY_DELAY_MS = 50;
|
|
11
|
+
|
|
12
|
+
export function withBusyRetry<T>(fn: () => T): T {
|
|
13
|
+
let lastError: unknown;
|
|
14
|
+
for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
|
|
15
|
+
try {
|
|
16
|
+
return fn();
|
|
17
|
+
} catch (error) {
|
|
18
|
+
lastError = error;
|
|
19
|
+
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
20
|
+
sleepSync(BUSY_RETRY_DELAY_MS * (1 << attempt));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw lastError;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
|
|
27
|
+
let lastError: unknown;
|
|
28
|
+
for (let attempt = 0; attempt <= BUSY_RETRIES; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
return fn();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
lastError = error;
|
|
33
|
+
if (!isBusyError(error) || attempt === BUSY_RETRIES) throw error;
|
|
34
|
+
await new Promise<void>((r) => setTimeout(r, BUSY_RETRY_DELAY_MS * (1 << attempt)));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw lastError;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function retriedWrite(stmt: { run(...params: (string | number)[]): unknown }): (...params: (string | number)[]) => void {
|
|
41
|
+
return (...params) => {
|
|
42
|
+
withBusyRetry(() => { stmt.run(...params); });
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function openDbWithBusyRetryAsync<T>(fn: () => T): Promise<T> {
|
|
47
|
+
return withBusyRetryAsync(fn);
|
|
48
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { HASH_RE } from "../hashline/alphabet";
|
|
2
|
+
|
|
3
|
+
export function isValidHashList(value: unknown): value is string[] {
|
|
4
|
+
if (!Array.isArray(value)) return false;
|
|
5
|
+
for (const hash of value) {
|
|
6
|
+
if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
|
|
7
|
+
}
|
|
8
|
+
if (new Set(value).size !== value.length) return false;
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isValidServedMap(value: unknown): value is Record<string, string> {
|
|
13
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
14
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
15
|
+
if (typeof k !== "string" || !HASH_RE.test(k)) return false;
|
|
16
|
+
if (typeof v !== "string") return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseHashList(raw: string, onInvalid: () => void, context?: string): string[] | undefined {
|
|
22
|
+
let parsed: unknown;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(raw);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error(`[parseHashList]${context ? ` ${context}:` : ""} failed to parse stored hashes JSON:`, error);
|
|
27
|
+
onInvalid();
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
if (!isValidHashList(parsed)) {
|
|
31
|
+
console.error(`[parseHashList]${context ? ` ${context}:` : ""} stored hashes did not pass validation:`, Array.isArray(parsed) ? `length=${parsed.length} sample=${JSON.stringify(parsed.slice(0, 3))}` : (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
|
|
32
|
+
onInvalid();
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseServedMap(raw: string, onInvalid: () => void, context?: string): Map<string, string> | undefined {
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(raw);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error(`[parseServedMap]${context ? ` ${context}:` : ""} failed to parse stored served JSON:`, error);
|
|
44
|
+
onInvalid();
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (!isValidServedMap(parsed)) {
|
|
48
|
+
console.error(`[parseServedMap]${context ? ` ${context}:` : ""} stored served did not pass validation:`, (() => { try { return JSON.stringify(parsed)?.slice(0, 500) ?? String(parsed).slice(0, 500); } catch { return String(parsed).slice(0, 500); } })());
|
|
49
|
+
onInvalid();
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return new Map(Object.entries(parsed as Record<string, string>));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseStoredHashes(row: Record<string, unknown> | undefined, onInvalid: () => void): string[] | undefined {
|
|
56
|
+
if (!row) return undefined;
|
|
57
|
+
return parseHashList(row.hashes as string, onInvalid);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseStoredServed(row: Record<string, unknown> | undefined, onInvalid: () => void): Map<string, string> | undefined {
|
|
61
|
+
if (!row) return undefined;
|
|
62
|
+
return parseServedMap(row.hashes as string, onInvalid);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isValidSnapshot(value: unknown): value is { content: string; hashes: string[] } {
|
|
66
|
+
if (typeof value !== "object" || value === null) return false;
|
|
67
|
+
const v = value as Record<string, unknown>;
|
|
68
|
+
if (typeof v.content !== "string") return false;
|
|
69
|
+
return isValidHashList(v.hashes);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isCorruptionError(error: unknown): boolean {
|
|
73
|
+
if (error && typeof error === "object") {
|
|
74
|
+
const errcode = (error as { errcode?: unknown }).errcode;
|
|
75
|
+
if (typeof errcode === "number") {
|
|
76
|
+
return errcode === 11 || errcode === 24 || errcode === 26;
|
|
77
|
+
}
|
|
78
|
+
const code = (error as { code?: unknown }).code;
|
|
79
|
+
if (typeof code === "string" && /NOTADB|CORRUPT/.test(code)) return true;
|
|
80
|
+
}
|
|
81
|
+
return (
|
|
82
|
+
error instanceof Error &&
|
|
83
|
+
/corrupt|not a database|malformed|database disk image/i.test(error.message)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isBusyError(error: unknown): boolean {
|
|
88
|
+
if (error && typeof error === "object") {
|
|
89
|
+
const errcode = (error as { errcode?: unknown }).errcode;
|
|
90
|
+
if (typeof errcode === "number") return errcode === 5 || errcode === 6;
|
|
91
|
+
}
|
|
92
|
+
return error instanceof Error && /busy|locked/i.test(error.message);
|
|
93
|
+
}
|