pi-hashline-edit-pro 2.6.4 → 2.7.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/README.md +78 -28
- package/index.ts +10 -5
- package/package.json +2 -2
- package/prompts/grep-guidelines.md +6 -0
- package/prompts/grep-snippet.md +1 -0
- package/prompts/grep.md +1 -0
- package/prompts/insert-guidelines.md +6 -0
- package/prompts/insert-snippet.md +1 -0
- package/prompts/insert.md +1 -0
- package/prompts/read-guidelines.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -5
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -0
- package/prompts/undo-last-change-snippet.md +1 -0
- package/prompts/undo-last-change.md +1 -0
- package/src/commit.ts +107 -0
- package/src/constants.ts +1 -2
- package/src/file-kind.ts +1 -1
- package/src/file-reader.ts +5 -1
- package/src/grep.ts +323 -0
- package/src/hashline/apply.ts +1 -1
- package/src/hashline/hash.ts +1 -1
- package/src/hashline/index.ts +2 -0
- package/src/hashline/parse.ts +3 -3
- package/src/hashline/resolve.ts +55 -29
- package/src/insert.ts +222 -0
- package/src/read.ts +2 -2
- package/src/replace-diff.ts +47 -6
- package/src/replace-render.ts +117 -2
- package/src/replace-response.ts +12 -6
- package/src/replace-undo.ts +12 -12
- package/src/replace.ts +43 -245
- package/src/validation.ts +1 -1
- package/prompts/undo-last-replace-guidelines.md +0 -2
- package/prompts/undo-last-replace-snippet.md +0 -1
- package/prompts/undo-last-replace.md +0 -1
package/src/grep.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { readdir, stat } from "fs/promises";
|
|
4
|
+
import { dirname, join, relative } from "path";
|
|
5
|
+
import { loadFileKindAndText } from "./file-kind";
|
|
6
|
+
import { readNormFile } from "./file-reader";
|
|
7
|
+
import { MAX_HASH_LINES, HASH_SEP } from "./hashline";
|
|
8
|
+
import { toCwd } from "./paths";
|
|
9
|
+
import { loadP, loadGuide } from "./prompts";
|
|
10
|
+
import { normReq } from "./replace-normalize";
|
|
11
|
+
import { recordServedSafe } from "./served";
|
|
12
|
+
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, visLines } from "./utils";
|
|
13
|
+
|
|
14
|
+
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
15
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
|
|
16
|
+
const MAX_SCAN_FILES = 4000;
|
|
17
|
+
const MAX_SHOWN_ROWS = 2000;
|
|
18
|
+
|
|
19
|
+
export interface GrepReq {
|
|
20
|
+
pattern: string;
|
|
21
|
+
path?: string;
|
|
22
|
+
glob?: string;
|
|
23
|
+
context?: number;
|
|
24
|
+
ignoreCase?: boolean;
|
|
25
|
+
literal?: boolean;
|
|
26
|
+
limit?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertGrepReq(request: unknown): asserts request is GrepReq {
|
|
30
|
+
if (!isRec(request)) {
|
|
31
|
+
throw new Error("[E_BAD_SHAPE] Grep request must be an object.");
|
|
32
|
+
}
|
|
33
|
+
rejectUnknownFields(request, GREP_KS, "Grep request");
|
|
34
|
+
if (typeof request.pattern !== "string" || request.pattern.length === 0) {
|
|
35
|
+
throw new Error('[E_BAD_SHAPE] Grep request requires a non-empty "pattern" string.');
|
|
36
|
+
}
|
|
37
|
+
if (request.context !== undefined && (typeof request.context !== "number" || !Number.isInteger(request.context) || request.context < 0)) {
|
|
38
|
+
throw new Error('[E_BAD_SHAPE] Grep request field "context" must be a non-negative integer.');
|
|
39
|
+
}
|
|
40
|
+
if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isInteger(request.limit) || request.limit < 1)) {
|
|
41
|
+
throw new Error('[E_BAD_SHAPE] Grep request field "limit" must be a positive integer.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
|
|
46
|
+
const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
|
|
47
|
+
try {
|
|
48
|
+
return new RegExp(source, ignoreCase ? "ui" : "u");
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`[E_BAD_SHAPE] Invalid pattern: ${pattern}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function globToRegex(glob: string): RegExp {
|
|
55
|
+
let source = "";
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < glob.length) {
|
|
58
|
+
const ch = glob[i]!;
|
|
59
|
+
if (ch === "*") {
|
|
60
|
+
if (glob[i + 1] === "*") {
|
|
61
|
+
i += 2;
|
|
62
|
+
if (glob[i] === "/") {
|
|
63
|
+
i += 1;
|
|
64
|
+
source += "(?:.*\\/)?";
|
|
65
|
+
} else {
|
|
66
|
+
source += ".*";
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
source += ".*";
|
|
71
|
+
} else if (ch === "?") {
|
|
72
|
+
source += "[^/]";
|
|
73
|
+
} else {
|
|
74
|
+
source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
75
|
+
}
|
|
76
|
+
i += 1;
|
|
77
|
+
}
|
|
78
|
+
return new RegExp(`^${source}$`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isSkipableLoadError(error: unknown): boolean {
|
|
82
|
+
const code = errCode(error);
|
|
83
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT" || code === "ELOOP") return true;
|
|
84
|
+
return error instanceof Error && error.message.startsWith("[E_FILE_TOO_LARGE]");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface FileHit {
|
|
88
|
+
path: string;
|
|
89
|
+
displayPath: string;
|
|
90
|
+
fileHashes: string[];
|
|
91
|
+
rows: string[];
|
|
92
|
+
hashes: string[];
|
|
93
|
+
matchCount: number;
|
|
94
|
+
totalMatchCount: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ScanState {
|
|
98
|
+
scanned: number;
|
|
99
|
+
stopped: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function walkFiles(
|
|
103
|
+
root: string,
|
|
104
|
+
state: ScanState,
|
|
105
|
+
onFile: (absPath: string) => Promise<void>,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const queue: string[] = [root];
|
|
108
|
+
while (queue.length > 0 && !state.stopped) {
|
|
109
|
+
const dir = queue.pop()!;
|
|
110
|
+
let entries;
|
|
111
|
+
try {
|
|
112
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (state.stopped) break;
|
|
118
|
+
const full = join(dir, entry.name);
|
|
119
|
+
if (entry.isDirectory()) {
|
|
120
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
121
|
+
queue.push(full);
|
|
122
|
+
} else if (entry.isFile()) {
|
|
123
|
+
state.scanned += 1;
|
|
124
|
+
if (state.scanned > MAX_SCAN_FILES) {
|
|
125
|
+
state.stopped = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
await onFile(full);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function searchFile(
|
|
135
|
+
absPath: string,
|
|
136
|
+
globRoot: string,
|
|
137
|
+
cwd: string,
|
|
138
|
+
regex: RegExp,
|
|
139
|
+
globRegex: RegExp | undefined,
|
|
140
|
+
context: number,
|
|
141
|
+
maxMatches: number,
|
|
142
|
+
): Promise<FileHit | undefined> {
|
|
143
|
+
const displayPath = relative(cwd, absPath).replace(/\\/g, "/");
|
|
144
|
+
if (globRegex) {
|
|
145
|
+
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
146
|
+
if (!globRegex.test(globPath)) return undefined;
|
|
147
|
+
}
|
|
148
|
+
let file;
|
|
149
|
+
try {
|
|
150
|
+
file = await loadFileKindAndText(absPath, { maxLines: MAX_HASH_LINES, displayPath });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isSkipableLoadError(error)) return undefined;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
if (file.kind !== "text") return undefined;
|
|
156
|
+
let norm;
|
|
157
|
+
try {
|
|
158
|
+
norm = await readNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (isSkipableLoadError(error)) return undefined;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
const lines = visLines(norm.normalized);
|
|
164
|
+
const matchLines: number[] = [];
|
|
165
|
+
for (let i = 0; i < lines.length; i++) {
|
|
166
|
+
if (regex.test(lines[i]!)) matchLines.push(i);
|
|
167
|
+
}
|
|
168
|
+
if (matchLines.length === 0) return undefined;
|
|
169
|
+
const keptMatches = matchLines.length > maxMatches ? matchLines.slice(0, maxMatches) : matchLines;
|
|
170
|
+
const shown = new Set<number>();
|
|
171
|
+
for (const i of keptMatches) {
|
|
172
|
+
for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
|
|
173
|
+
}
|
|
174
|
+
const sorted = [...shown].sort((a, b) => a - b);
|
|
175
|
+
const rows: string[] = [];
|
|
176
|
+
const hashes: string[] = [];
|
|
177
|
+
for (const idx of sorted) {
|
|
178
|
+
rows.push(`${norm.fileHashes[idx]}${HASH_SEP}${lines[idx]}`);
|
|
179
|
+
hashes.push(norm.fileHashes[idx]!);
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
path: norm.absolutePath,
|
|
183
|
+
displayPath,
|
|
184
|
+
fileHashes: norm.fileHashes,
|
|
185
|
+
rows,
|
|
186
|
+
hashes,
|
|
187
|
+
matchCount: keptMatches.length,
|
|
188
|
+
totalMatchCount: matchLines.length,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const grepToolSchema = Type.Object(
|
|
193
|
+
{
|
|
194
|
+
pattern: Type.String({
|
|
195
|
+
description: "Search pattern (regex or literal string)",
|
|
196
|
+
}),
|
|
197
|
+
path: Type.Optional(
|
|
198
|
+
Type.String({
|
|
199
|
+
description: "Directory or file to search (default: current directory)",
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
glob: Type.Optional(
|
|
203
|
+
Type.String({
|
|
204
|
+
description: "Filter files by glob pattern; * matches across directories, e.g. '*.ts' or '**/*.spec.ts'",
|
|
205
|
+
}),
|
|
206
|
+
),
|
|
207
|
+
ignoreCase: Type.Optional(
|
|
208
|
+
Type.Boolean({
|
|
209
|
+
description: "Case-insensitive search (default: false)",
|
|
210
|
+
}),
|
|
211
|
+
),
|
|
212
|
+
literal: Type.Optional(
|
|
213
|
+
Type.Boolean({
|
|
214
|
+
description: "Treat pattern as literal string instead of regex (default: false)",
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
context: Type.Optional(
|
|
218
|
+
Type.Integer({
|
|
219
|
+
minimum: 0,
|
|
220
|
+
description: "Number of lines to show before and after each match (default: 0)",
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
223
|
+
limit: Type.Optional(
|
|
224
|
+
Type.Integer({
|
|
225
|
+
minimum: 1,
|
|
226
|
+
description: "Maximum number of matches to return (default: 100)",
|
|
227
|
+
}),
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
{ additionalProperties: false },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
export function regGrep(pi: ExtensionAPI): void {
|
|
234
|
+
pi.registerTool({
|
|
235
|
+
name: "grep",
|
|
236
|
+
label: "Grep",
|
|
237
|
+
description: loadP("../prompts/grep.md"),
|
|
238
|
+
promptSnippet: loadP("../prompts/grep-snippet.md"),
|
|
239
|
+
promptGuidelines: loadGuide("../prompts/grep-guidelines.md"),
|
|
240
|
+
prepareArguments: makePrepareArguments(),
|
|
241
|
+
parameters: grepToolSchema,
|
|
242
|
+
|
|
243
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
244
|
+
const canonical = normReq(params);
|
|
245
|
+
assertGrepReq(canonical);
|
|
246
|
+
const req = canonical;
|
|
247
|
+
const regex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
|
|
248
|
+
const context = req.context ?? 0;
|
|
249
|
+
const limit = req.limit ?? 100;
|
|
250
|
+
const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
|
|
251
|
+
const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
|
|
252
|
+
abortIf(signal);
|
|
253
|
+
let baseStat;
|
|
254
|
+
try {
|
|
255
|
+
baseStat = await stat(base);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (errCode(error) === "ENOENT") {
|
|
258
|
+
throw new Error(`[E_NOT_FOUND] File not found: ${req.path ?? ctx.cwd}`);
|
|
259
|
+
}
|
|
260
|
+
throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
|
|
261
|
+
}
|
|
262
|
+
const globRoot = baseStat.isFile() ? dirname(base) : base;
|
|
263
|
+
const state: ScanState = { scanned: 0, stopped: false };
|
|
264
|
+
const files: string[] = [];
|
|
265
|
+
if (baseStat.isFile()) {
|
|
266
|
+
files.push(base);
|
|
267
|
+
} else {
|
|
268
|
+
await walkFiles(base, state, async (absPath) => {
|
|
269
|
+
files.push(absPath);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const hits: FileHit[] = [];
|
|
273
|
+
let matches = 0;
|
|
274
|
+
let limitTruncated = false;
|
|
275
|
+
let rowTruncated = false;
|
|
276
|
+
let rowCount = 0;
|
|
277
|
+
for (const absPath of files) {
|
|
278
|
+
abortIf(signal);
|
|
279
|
+
const remaining = limit - matches;
|
|
280
|
+
if (remaining <= 0) {
|
|
281
|
+
limitTruncated = true;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining);
|
|
285
|
+
if (!hit) continue;
|
|
286
|
+
const rowBudget = MAX_SHOWN_ROWS - rowCount;
|
|
287
|
+
if (rowBudget <= 0) {
|
|
288
|
+
rowTruncated = true;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
const keptRows = hit.rows.slice(0, rowBudget);
|
|
292
|
+
const keptHashes = hit.hashes.slice(0, rowBudget);
|
|
293
|
+
rowCount += keptRows.length;
|
|
294
|
+
if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
|
|
295
|
+
if (keptRows.length < hit.rows.length) rowTruncated = true;
|
|
296
|
+
matches += hit.matchCount;
|
|
297
|
+
hits.push({ ...hit, rows: keptRows, hashes: keptHashes });
|
|
298
|
+
}
|
|
299
|
+
for (const hit of hits) {
|
|
300
|
+
await recordServedSafe(hit.path, hit.hashes, "grep", new Set(hit.fileHashes));
|
|
301
|
+
}
|
|
302
|
+
const blocks = hits
|
|
303
|
+
.map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
|
|
304
|
+
.join("\n");
|
|
305
|
+
const notes: string[] = [];
|
|
306
|
+
if (rowTruncated) notes.push(`[grep: output truncated at ${MAX_SHOWN_ROWS} rows; refine the pattern to see more.]`);
|
|
307
|
+
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
308
|
+
if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
|
|
309
|
+
const truncated = limitTruncated || rowTruncated;
|
|
310
|
+
const text = blocks.length > 0 ? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}` : "No matches found.";
|
|
311
|
+
return {
|
|
312
|
+
content: [{ type: "text", text }],
|
|
313
|
+
details: {
|
|
314
|
+
metrics: {
|
|
315
|
+
matches,
|
|
316
|
+
files: hits.length,
|
|
317
|
+
truncated: truncated || state.stopped,
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -55,7 +55,7 @@ type NoopSpan = {
|
|
|
55
55
|
function assertNotEmpty(originalContent: string, result: string): void {
|
|
56
56
|
if (originalContent.length > 0 && result.length === 0) {
|
|
57
57
|
throw new Error(
|
|
58
|
-
"[E_WOULD_EMPTY]
|
|
58
|
+
"[E_WOULD_EMPTY] A replace cannot empty a non-empty file. Use `write` to clear the file."
|
|
59
59
|
);
|
|
60
60
|
}
|
|
61
61
|
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -70,7 +70,7 @@ function nextZeroBit(bits: Uint32Array, start: number): number {
|
|
|
70
70
|
if (idx >= totalBits) idx -= totalBits;
|
|
71
71
|
}
|
|
72
72
|
throw new Error(
|
|
73
|
-
`[E_FILE_TOO_LARGE]
|
|
73
|
+
`[E_FILE_TOO_LARGE] File exceeds the ${HASH_SPACE}-line hashline limit; use write for very large files.`,
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/parse.ts
CHANGED
|
@@ -14,11 +14,11 @@ function diagRef(ref: string): string {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
if (/^\d+/.test(trimmed)) {
|
|
17
|
-
return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "aB3")
|
|
17
|
+
return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "aB3"): no line numbers or trailing content.`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if (trimmed.includes("│")) {
|
|
21
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}"
|
|
21
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}": use only the 3-char hash, drop everything from "│" onward.`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
|
|
@@ -52,7 +52,7 @@ export function parseText(edit: string[], warnings?: string[]): string[] {
|
|
|
52
52
|
}
|
|
53
53
|
if (split) {
|
|
54
54
|
warnings?.push(
|
|
55
|
-
"[E_BAD_SHAPE] Autocorrected: split
|
|
55
|
+
"[E_BAD_SHAPE] Autocorrected: split embedded newlines in replacement_lines into separate lines.",
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
58
|
return out;
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -90,7 +90,7 @@ export function fmtMismatchWithHashes(
|
|
|
90
90
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
91
91
|
if (notFound.length > 0) {
|
|
92
92
|
out.push(
|
|
93
|
-
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file
|
|
93
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file changed since read. Call read() for fresh anchors.`
|
|
94
94
|
);
|
|
95
95
|
for (const m of notFound) {
|
|
96
96
|
const ctx = m.context;
|
|
@@ -109,7 +109,7 @@ export function fmtMismatchWithHashes(
|
|
|
109
109
|
if (ambiguous.length > 0) {
|
|
110
110
|
if (out.length > 0) out.push("");
|
|
111
111
|
out.push(
|
|
112
|
-
`[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read()
|
|
112
|
+
`[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() for fresh anchors.`
|
|
113
113
|
);
|
|
114
114
|
for (const m of ambiguous) {
|
|
115
115
|
const sample = (m.candidates ?? []).slice(0, 5);
|
|
@@ -150,7 +150,7 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
152
|
if (!("replacement_lines" in edit)) {
|
|
153
|
-
throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines"
|
|
153
|
+
throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" array (use [] to delete).`);
|
|
154
154
|
}
|
|
155
155
|
if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
|
|
156
156
|
throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
|
|
@@ -164,26 +164,29 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
164
164
|
|
|
165
165
|
export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
|
|
166
166
|
|
|
167
|
+
export function stripAnchorRow(
|
|
168
|
+
trimmed: string,
|
|
169
|
+
entryLabel: string,
|
|
170
|
+
warnings?: string[],
|
|
171
|
+
): string {
|
|
172
|
+
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
173
|
+
if (!match) return trimmed;
|
|
174
|
+
const marker =
|
|
175
|
+
match[1] === "+"
|
|
176
|
+
? "diff-preview marker"
|
|
177
|
+
: match[1] === "-"
|
|
178
|
+
? 'leading "-" marker'
|
|
179
|
+
: '"HASH│" prefix';
|
|
180
|
+
warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${trimmed}".`);
|
|
181
|
+
return match[2]!;
|
|
182
|
+
}
|
|
183
|
+
|
|
167
184
|
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
168
185
|
assertItem(edit as Record<string, unknown>);
|
|
169
186
|
|
|
170
187
|
const replaceLines = parseText(edit.replacement_lines, warnings);
|
|
171
188
|
const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
|
|
172
|
-
|
|
173
|
-
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
174
|
-
if (match) {
|
|
175
|
-
let message: string;
|
|
176
|
-
if (match[1] === "+") {
|
|
177
|
-
message = `[E_BAD_REF] Autocorrected: stripped diff-preview marker copied from the diff preview in remove_from/remove_to entry "${trimmed}".`;
|
|
178
|
-
} else if (match[1] === "-") {
|
|
179
|
-
message = `[E_BAD_REF] Autocorrected: stripped leading "-" marker in remove_from/remove_to entry "${trimmed}".`;
|
|
180
|
-
} else {
|
|
181
|
-
message = `[E_BAD_REF] Autocorrected: stripped "HASH│" prefix copied from read output in remove_from/remove_to entry "${trimmed}".`;
|
|
182
|
-
}
|
|
183
|
-
warnings?.push(message);
|
|
184
|
-
return match[2]!;
|
|
185
|
-
}
|
|
186
|
-
return ref;
|
|
189
|
+
return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
|
|
187
190
|
}) as [string, string];
|
|
188
191
|
return {
|
|
189
192
|
content_lines: replaceLines,
|
|
@@ -197,7 +200,7 @@ function warnUnicodeEsc(
|
|
|
197
200
|
): void {
|
|
198
201
|
if (edit.content_lines.some((line) => /\\uDDDD/i.test(line))) {
|
|
199
202
|
warnings.push(
|
|
200
|
-
"Detected literal \\uDDDD in edit content; no autocorrection applied.
|
|
203
|
+
"Detected literal \\uDDDD in edit content; no autocorrection applied.",
|
|
201
204
|
);
|
|
202
205
|
}
|
|
203
206
|
}
|
|
@@ -220,16 +223,12 @@ export function stripBarePrefixes(
|
|
|
220
223
|
.map((s) => `replacement_lines line ${s.lineIndex + 1}`)
|
|
221
224
|
.join(", ");
|
|
222
225
|
const matchedCount = stripped.filter((s) => s.matched).length;
|
|
223
|
-
const evidence =
|
|
224
|
-
matchedCount === 0
|
|
225
|
-
? "none of the stripped hashes match current file lines"
|
|
226
|
-
: `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
|
|
227
226
|
const guidance =
|
|
228
227
|
matchedCount === 0
|
|
229
|
-
? " Verify
|
|
228
|
+
? " Verify it was pasted from read output."
|
|
230
229
|
: "";
|
|
231
230
|
warnings.push(
|
|
232
|
-
`[E_BARE_HASH_PREFIX]
|
|
231
|
+
`[E_BARE_HASH_PREFIX] Stripped "HASH│" prefix from ${locations}.${guidance}`
|
|
233
232
|
);
|
|
234
233
|
return { ...edit, content_lines: contentLines };
|
|
235
234
|
}
|
|
@@ -255,7 +254,7 @@ export function stripDiffPrefixes(
|
|
|
255
254
|
if (stripped.length === 0) return edit;
|
|
256
255
|
const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
|
|
257
256
|
warnings.push(
|
|
258
|
-
`[E_INVALID_PATCH]
|
|
257
|
+
`[E_INVALID_PATCH] Stripped diff-preview marker from ${locations}.`
|
|
259
258
|
);
|
|
260
259
|
return { ...edit, content_lines: contentLines };
|
|
261
260
|
}
|
|
@@ -280,7 +279,7 @@ export function swapReversedRanges(
|
|
|
280
279
|
return edit;
|
|
281
280
|
}
|
|
282
281
|
warnings.push(
|
|
283
|
-
`[E_BAD_OP] Autocorrected: remove_from
|
|
282
|
+
`[E_BAD_OP] Autocorrected: remove_from/remove_to were reversed; swapped them.`
|
|
284
283
|
);
|
|
285
284
|
return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
|
|
286
285
|
}
|
|
@@ -345,6 +344,7 @@ function firstNewAfterDups(
|
|
|
345
344
|
let runLen = 0;
|
|
346
345
|
while (
|
|
347
346
|
runLen < maxK &&
|
|
347
|
+
canonLines[endLine + runLen]!.length > 0 &&
|
|
348
348
|
canon(contentLines[firstNew.index + runLen]!) === canonLines[endLine + runLen]!
|
|
349
349
|
) {
|
|
350
350
|
runLen++;
|
|
@@ -369,6 +369,7 @@ function lastNewBeforeDups(
|
|
|
369
369
|
let runLen = 0;
|
|
370
370
|
while (
|
|
371
371
|
runLen < maxK &&
|
|
372
|
+
canonLines[startLine - 2 - runLen]!.length > 0 &&
|
|
372
373
|
canon(contentLines[lastNew.index - runLen]!) === canonLines[startLine - 2 - runLen]!
|
|
373
374
|
) {
|
|
374
375
|
runLen++;
|
|
@@ -402,8 +403,8 @@ export function findNewEdge(
|
|
|
402
403
|
const start = fromEnd ? contentLines.length - 1 : 0;
|
|
403
404
|
for (let i = start; i >= 0 && i < contentLines.length; i += step) {
|
|
404
405
|
const line = contentLines[i]!;
|
|
405
|
-
if (line.length === 0) continue;
|
|
406
406
|
const key = canon(line);
|
|
407
|
+
if (key.length === 0) continue;
|
|
407
408
|
const count = multiset.get(key) ?? 0;
|
|
408
409
|
if (count > 0) {
|
|
409
410
|
multiset.set(key, count - 1);
|
|
@@ -475,6 +476,31 @@ export function valEdit(
|
|
|
475
476
|
};
|
|
476
477
|
}
|
|
477
478
|
|
|
479
|
+
export function resolveAnchorLine(
|
|
480
|
+
ref: Anchor,
|
|
481
|
+
fileLines: string[],
|
|
482
|
+
fileHashes: string[],
|
|
483
|
+
filePath?: string,
|
|
484
|
+
): number {
|
|
485
|
+
const { resolved, mismatches } = valEdit(
|
|
486
|
+
{ hash_bounds: [ref, ref], content_lines: [] },
|
|
487
|
+
fileLines,
|
|
488
|
+
fileHashes,
|
|
489
|
+
[],
|
|
490
|
+
undefined,
|
|
491
|
+
);
|
|
492
|
+
if (mismatches.length > 0 || !resolved) {
|
|
493
|
+
const feedback = fmtMismatchWithHashes(
|
|
494
|
+
mismatches,
|
|
495
|
+
fileLines,
|
|
496
|
+
fileHashes,
|
|
497
|
+
filePath,
|
|
498
|
+
);
|
|
499
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes);
|
|
500
|
+
}
|
|
501
|
+
return resolved.hash_bounds[0].line;
|
|
502
|
+
}
|
|
503
|
+
|
|
478
504
|
export class RangeStaleError extends Error {
|
|
479
505
|
readonly firstMismatchLine: number;
|
|
480
506
|
readonly rangeHashes: string[];
|
|
@@ -531,7 +557,7 @@ export function assertRangeServed(
|
|
|
531
557
|
? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read() with offset=${startLine + shownLength} to see the rest.]`
|
|
532
558
|
: "";
|
|
533
559
|
const message =
|
|
534
|
-
`[E_RANGE_STALE] ${mismatchText} what was
|
|
560
|
+
`[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
|
|
535
561
|
throw new RangeStaleError(message, first, shownHashes);
|
|
536
562
|
}
|
|
537
563
|
|