pi-hashline-edit-pro 2.1.0 → 2.1.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 +2 -2
- package/package.json +3 -2
- package/prompts/replace-guidelines.md +1 -1
- package/prompts/undo-last-replace-guidelines.md +1 -1
- package/src/hashline/apply.ts +11 -18
- package/src/hashline/hash.ts +1 -1
- package/src/hashline/index.ts +3 -1
- package/src/hashline/resolve.ts +112 -32
- package/src/read.ts +7 -1
- package/src/replace-diff.ts +9 -1
- package/src/replace-normalize.ts +1 -8
- package/src/replace-render.ts +1 -55
- package/src/replace-response.ts +2 -3
- package/src/replace-undo.ts +11 -4
- package/src/replace.ts +13 -11
- package/src/utils.ts +5 -2
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ One edit per call, with `hash_bounds` and `new_content` at the top level:
|
|
|
91
91
|
Notes:
|
|
92
92
|
|
|
93
93
|
- The request is checked before any file I/O, so a bad request never touches the file.
|
|
94
|
-
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix
|
|
94
|
+
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `new_content` or `hash_bounds`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. A new line that duplicates a unique line adjacent to the range is stripped automatically. `file_path` works as an alias for `path` in all three tools.
|
|
95
95
|
- An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
96
96
|
- After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
|
|
97
97
|
|
|
@@ -110,7 +110,7 @@ Notes:
|
|
|
110
110
|
Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so you get fresh `HASH│content` anchors without a separate `read` call.
|
|
111
111
|
|
|
112
112
|
- After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. Call `read` when you want the full file's anchors.
|
|
113
|
-
- After `
|
|
113
|
+
- After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. The `-HASH│` rows show removed lines with their old hashes, so you can see exactly which anchors were deleted (those hashes are stale after the edit). Call `read` when you want the full file's anchors.
|
|
114
114
|
- Auto-read keeps a 50KB display budget. Lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
|
|
115
115
|
- Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
|
|
116
116
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"test:watch": "vitest",
|
|
50
50
|
"test:coverage": "vitest run --coverage --coverage.thresholds.lines=90 --coverage.thresholds.statements=90 --coverage.thresholds.functions=85 --coverage.thresholds.branches=80",
|
|
51
51
|
"lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
|
|
52
|
-
"typecheck": "tsc --noEmit"
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"prepublishOnly": "npm run typecheck && npm run lint && npm test"
|
|
53
54
|
},
|
|
54
55
|
"devDependencies": {
|
|
55
56
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
- `replace`: to replace a single line, repeat its hash in both positions of hash_bounds: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
|
|
4
4
|
- `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
5
5
|
- `replace`: to add a blank line, end new_content with an explicit empty line (e.g. ending with \n\n).
|
|
6
|
-
- `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading.
|
|
6
|
+
- `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading. `-HASH│` rows show removed lines with their old hashes; those hashes are stale after the edit.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
- `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
|
|
2
|
-
- `undo_last_replace`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading.
|
|
2
|
+
- `undo_last_replace`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading. `-HASH│` rows show removed lines with their old hashes; those hashes are stale after the undo.
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf, splitLines
|
|
1
|
+
import { abortIf, splitLines } from "../utils";
|
|
2
2
|
import { _lineHashesPure, HASH_SEP } from "./hash";
|
|
3
3
|
import {
|
|
4
4
|
valEdit,
|
|
@@ -49,7 +49,6 @@ type NoopSpan = {
|
|
|
49
49
|
loc: string;
|
|
50
50
|
currentContent: string;
|
|
51
51
|
};
|
|
52
|
-
|
|
53
52
|
function assertNotEmpty(originalContent: string, result: string): void {
|
|
54
53
|
if (originalContent.length > 0 && result.length === 0) {
|
|
55
54
|
throw new Error(
|
|
@@ -164,7 +163,7 @@ export function applyEdit(
|
|
|
164
163
|
warnings,
|
|
165
164
|
);
|
|
166
165
|
|
|
167
|
-
const { resolved: initialResolved, mismatches,
|
|
166
|
+
const { resolved: initialResolved, mismatches, boundaryDups } = valEdit(
|
|
168
167
|
prefixFixed,
|
|
169
168
|
lineIndex.fileLines,
|
|
170
169
|
fileHashes,
|
|
@@ -181,26 +180,20 @@ export function applyEdit(
|
|
|
181
180
|
|
|
182
181
|
let resolved = initialResolved;
|
|
183
182
|
let autoFixes: AutoFix[] | undefined;
|
|
184
|
-
if (
|
|
183
|
+
if (boundaryDups.length > 0) {
|
|
185
184
|
autoFixes = [];
|
|
186
185
|
const correctedEdit: HEdit = {
|
|
187
186
|
...prefixFixed,
|
|
188
187
|
content_lines: [...prefixFixed.content_lines],
|
|
189
188
|
};
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const idx = firstNonEmptyIndex(correctedEdit.content_lines);
|
|
199
|
-
if (idx >= 0) {
|
|
200
|
-
const removed = correctedEdit.content_lines.splice(idx, 1)[0];
|
|
201
|
-
autoFixes.push({ kind: "leading", removedLine: removed });
|
|
202
|
-
}
|
|
203
|
-
}
|
|
189
|
+
const dupsByIndex = [...boundaryDups].sort(
|
|
190
|
+
(a, b) => b.replacementLineIndex - a.replacementLineIndex,
|
|
191
|
+
);
|
|
192
|
+
for (const dup of dupsByIndex) {
|
|
193
|
+
const idx = dup.replacementLineIndex;
|
|
194
|
+
if (idx < 0 || idx >= correctedEdit.content_lines.length) continue;
|
|
195
|
+
const removed = correctedEdit.content_lines.splice(idx, 1)[0];
|
|
196
|
+
autoFixes.push({ kind: dup.kind, removedLine: removed, removedLineIndex: idx });
|
|
204
197
|
}
|
|
205
198
|
const correctedResult = valEdit(
|
|
206
199
|
correctedEdit,
|
package/src/hashline/hash.ts
CHANGED
|
@@ -47,7 +47,7 @@ export const HL_PREFIX_MINUS_RE = new RegExp(
|
|
|
47
47
|
|
|
48
48
|
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
|
|
49
49
|
|
|
50
|
-
function canon(line: string): string {
|
|
50
|
+
export function canon(line: string): string {
|
|
51
51
|
return line.replace(/\r/g, "").trimEnd();
|
|
52
52
|
}
|
|
53
53
|
|
package/src/hashline/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export {
|
|
|
12
12
|
lineHashes,
|
|
13
13
|
_lineHashesPure,
|
|
14
14
|
initHasher,
|
|
15
|
+
canon,
|
|
15
16
|
} from "./hash";
|
|
16
17
|
|
|
17
18
|
export {
|
|
@@ -26,7 +27,7 @@ export {
|
|
|
26
27
|
type RHEdit,
|
|
27
28
|
type HTEdit,
|
|
28
29
|
type NEdit,
|
|
29
|
-
type
|
|
30
|
+
type BDup,
|
|
30
31
|
type AutoFix,
|
|
31
32
|
resEdit,
|
|
32
33
|
valEdit,
|
|
@@ -34,6 +35,7 @@ export {
|
|
|
34
35
|
stripDiffPrefixes,
|
|
35
36
|
swapReversedRanges,
|
|
36
37
|
fmtMismatch,
|
|
38
|
+
findNewEdge,
|
|
37
39
|
} from "./resolve";
|
|
38
40
|
|
|
39
41
|
export {
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
|
|
2
|
-
import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hash";
|
|
1
|
+
import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
2
|
+
import { HASH_CLASS, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
4
|
import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
|
|
5
5
|
|
|
@@ -22,16 +22,15 @@ interface HMismatch {
|
|
|
22
22
|
context?: RAnchor;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export interface
|
|
26
|
-
kind: "trailing" | "leading";
|
|
27
|
-
|
|
28
|
-
survivingLineIndex: number;
|
|
29
|
-
replacementLineContent: string;
|
|
25
|
+
export interface BDup {
|
|
26
|
+
kind: "trailing" | "leading" | "first-new-after" | "last-new-before";
|
|
27
|
+
replacementLineIndex: number;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
export interface AutoFix {
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
kind: "trailing" | "leading" | "first-new-after" | "last-new-before";
|
|
32
|
+
removedLine: string;
|
|
33
|
+
removedLineIndex: number;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
export interface NEdit {
|
|
@@ -161,13 +160,32 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
161
160
|
}
|
|
162
161
|
}
|
|
163
162
|
|
|
164
|
-
|
|
163
|
+
const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_CLASS})│`);
|
|
164
|
+
|
|
165
|
+
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
165
166
|
assertItem(edit as Record<string, unknown>);
|
|
166
167
|
|
|
167
168
|
const replaceLines = parseText(edit.new_content);
|
|
169
|
+
const bounds = edit.hash_bounds.map((ref) => {
|
|
170
|
+
const trimmed = ref.trim();
|
|
171
|
+
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
172
|
+
if (match) {
|
|
173
|
+
let message: string;
|
|
174
|
+
if (match[1] === "+") {
|
|
175
|
+
message = `[E_BAD_REF] Autocorrected: stripped diff-preview marker copied from the diff preview in hash_bounds entry "${trimmed}".`;
|
|
176
|
+
} else if (match[1] === "-") {
|
|
177
|
+
message = `[E_BAD_REF] Autocorrected: stripped leading "-" marker in hash_bounds entry "${trimmed}".`;
|
|
178
|
+
} else {
|
|
179
|
+
message = `[E_BAD_REF] Autocorrected: stripped "HASH│" prefix copied from read output in hash_bounds entry "${trimmed}".`;
|
|
180
|
+
}
|
|
181
|
+
warnings?.push(message);
|
|
182
|
+
return match[2]!;
|
|
183
|
+
}
|
|
184
|
+
return ref;
|
|
185
|
+
}) as [string, string];
|
|
168
186
|
return {
|
|
169
187
|
content_lines: replaceLines,
|
|
170
|
-
hash_bounds: [parseHashRef(
|
|
188
|
+
hash_bounds: [parseHashRef(bounds[0]), parseHashRef(bounds[1])],
|
|
171
189
|
};
|
|
172
190
|
}
|
|
173
191
|
|
|
@@ -265,24 +283,79 @@ export function swapReversedRanges(
|
|
|
265
283
|
return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
|
|
266
284
|
}
|
|
267
285
|
|
|
286
|
+
function edgeRef(
|
|
287
|
+
line: string | undefined,
|
|
288
|
+
index: number,
|
|
289
|
+
): { index: number; line: string } | undefined {
|
|
290
|
+
return line === undefined ? undefined : { index, line };
|
|
291
|
+
}
|
|
292
|
+
|
|
268
293
|
function checkBoundaryDup(
|
|
269
294
|
adjacentLine: string | undefined,
|
|
270
|
-
replacementEdge: string | undefined,
|
|
271
|
-
kind: "
|
|
272
|
-
|
|
273
|
-
|
|
295
|
+
replacementEdge: { index: number; line: string } | undefined,
|
|
296
|
+
kind: BDup["kind"],
|
|
297
|
+
canonical = false,
|
|
298
|
+
uniqueInFile?: Map<string, number>,
|
|
299
|
+
): BDup | null {
|
|
274
300
|
if (
|
|
275
301
|
adjacentLine === undefined ||
|
|
276
302
|
replacementEdge === undefined ||
|
|
277
|
-
replacementEdge.length === 0
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
303
|
+
replacementEdge.line.length === 0
|
|
304
|
+
) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
const matches = canonical
|
|
308
|
+
? canon(adjacentLine) === canon(replacementEdge.line)
|
|
309
|
+
: adjacentLine === replacementEdge.line;
|
|
310
|
+
if (!matches) return null;
|
|
311
|
+
if (uniqueInFile && (uniqueInFile.get(canon(adjacentLine)) ?? 0) !== 1) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
kind,
|
|
316
|
+
replacementLineIndex: replacementEdge.index,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function canonCounts(lines: string[]): Map<string, number> {
|
|
321
|
+
const counts = new Map<string, number>();
|
|
322
|
+
for (const line of lines) {
|
|
323
|
+
const key = canon(line);
|
|
324
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
325
|
+
}
|
|
326
|
+
return counts;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function findNewEdge(
|
|
330
|
+
contentLines: string[],
|
|
331
|
+
rangeLines: string[],
|
|
332
|
+
fromEnd: boolean,
|
|
333
|
+
): { index: number; line: string } | undefined {
|
|
334
|
+
const multiset = canonCounts(rangeLines);
|
|
335
|
+
const step = fromEnd ? -1 : 1;
|
|
336
|
+
const start = fromEnd ? contentLines.length - 1 : 0;
|
|
337
|
+
for (let i = start; i >= 0 && i < contentLines.length; i += step) {
|
|
338
|
+
const line = contentLines[i]!;
|
|
339
|
+
if (line.length === 0) continue;
|
|
340
|
+
const key = canon(line);
|
|
341
|
+
const count = multiset.get(key) ?? 0;
|
|
342
|
+
if (count > 0) {
|
|
343
|
+
multiset.set(key, count - 1);
|
|
344
|
+
} else {
|
|
345
|
+
return { index: i, line };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function newEdgeLines(
|
|
352
|
+
contentLines: string[],
|
|
353
|
+
rangeLines: string[],
|
|
354
|
+
): { firstNew: { index: number; line: string } | undefined; lastNew: { index: number; line: string } | undefined } {
|
|
355
|
+
return {
|
|
356
|
+
firstNew: findNewEdge(contentLines, rangeLines, false),
|
|
357
|
+
lastNew: findNewEdge(contentLines, rangeLines, true),
|
|
358
|
+
};
|
|
286
359
|
}
|
|
287
360
|
|
|
288
361
|
export function valEdit(
|
|
@@ -291,10 +364,10 @@ export function valEdit(
|
|
|
291
364
|
fileHashes: string[],
|
|
292
365
|
warnings: string[],
|
|
293
366
|
signal: AbortSignal | undefined,
|
|
294
|
-
): { resolved: RHEdit | undefined; mismatches: HMismatch[];
|
|
367
|
+
): { resolved: RHEdit | undefined; mismatches: HMismatch[]; boundaryDups: BDup[] } {
|
|
295
368
|
assertAligned(fileLines, fileHashes, "valEdit");
|
|
296
369
|
const mismatches: HMismatch[] = [];
|
|
297
|
-
const
|
|
370
|
+
const boundaryDups: BDup[] = [];
|
|
298
371
|
|
|
299
372
|
const hashIndex = new Map<string, number[]>();
|
|
300
373
|
for (let i = 0; i < fileHashes.length; i++) {
|
|
@@ -324,7 +397,7 @@ export function valEdit(
|
|
|
324
397
|
const endMismatch = mismatches.findLast((m) => m.ref === edit.hash_bounds[1]);
|
|
325
398
|
if (endMismatch && endMismatch.kind === "not_found") endMismatch.context = startResolved;
|
|
326
399
|
}
|
|
327
|
-
return { resolved: undefined, mismatches,
|
|
400
|
+
return { resolved: undefined, mismatches, boundaryDups };
|
|
328
401
|
}
|
|
329
402
|
if (startResolved.line > endResolved.line) {
|
|
330
403
|
throw new Error(
|
|
@@ -334,12 +407,19 @@ export function valEdit(
|
|
|
334
407
|
const endLine = endResolved.line;
|
|
335
408
|
const nextLine = fileLines[endLine];
|
|
336
409
|
const replacementLastLine = lastNonEmpty(edit.content_lines);
|
|
337
|
-
const trailing = checkBoundaryDup(nextLine, replacementLastLine, "trailing"
|
|
338
|
-
if (trailing)
|
|
410
|
+
const trailing = checkBoundaryDup(nextLine, edgeRef(replacementLastLine, lastNonEmptyIndex(edit.content_lines)), "trailing");
|
|
411
|
+
if (trailing) boundaryDups.push(trailing);
|
|
339
412
|
const prevLine = fileLines[startResolved.line - 2];
|
|
340
413
|
const replacementFirstLine = firstNonEmpty(edit.content_lines);
|
|
341
|
-
const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading"
|
|
342
|
-
if (leading)
|
|
414
|
+
const leading = checkBoundaryDup(prevLine, edgeRef(replacementFirstLine, firstNonEmptyIndex(edit.content_lines)), "leading");
|
|
415
|
+
if (leading) boundaryDups.push(leading);
|
|
416
|
+
const rangeLines = fileLines.slice(startResolved.line - 1, endLine);
|
|
417
|
+
const { firstNew, lastNew } = newEdgeLines(edit.content_lines, rangeLines);
|
|
418
|
+
const fileCounts = canonCounts(fileLines);
|
|
419
|
+
const firstNewAfter = checkBoundaryDup(nextLine, firstNew, "first-new-after", true, fileCounts);
|
|
420
|
+
if (firstNewAfter) boundaryDups.push(firstNewAfter);
|
|
421
|
+
const lastNewBefore = checkBoundaryDup(prevLine, lastNew, "last-new-before", true, fileCounts);
|
|
422
|
+
if (lastNewBefore) boundaryDups.push(lastNewBefore);
|
|
343
423
|
|
|
344
424
|
return {
|
|
345
425
|
resolved: {
|
|
@@ -347,7 +427,7 @@ export function valEdit(
|
|
|
347
427
|
hash_bounds: [startResolved, endResolved],
|
|
348
428
|
},
|
|
349
429
|
mismatches,
|
|
350
|
-
|
|
430
|
+
boundaryDups,
|
|
351
431
|
};
|
|
352
432
|
}
|
|
353
433
|
|
package/src/read.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { loadFileKindAndText } from "./file-kind";
|
|
|
12
12
|
import { readNormFile } from "./file-reader";
|
|
13
13
|
import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
14
14
|
import { toCwd } from "./paths";
|
|
15
|
-
import { abortIf } from "./utils";
|
|
15
|
+
import { abortIf, isRec, normalizeFilePath } from "./utils";
|
|
16
16
|
import { fileSnap } from "./file-reader";
|
|
17
17
|
import { visLines } from "./utils";
|
|
18
18
|
import { loadP, loadGuide } from "./prompts";
|
|
@@ -154,6 +154,12 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
154
154
|
description: R_DESC,
|
|
155
155
|
promptSnippet: R_SNIPPET,
|
|
156
156
|
promptGuidelines: readGuide(),
|
|
157
|
+
prepareArguments: (args: unknown) => {
|
|
158
|
+
if (!isRec(args)) return args as any;
|
|
159
|
+
const record = { ...args };
|
|
160
|
+
normalizeFilePath(record);
|
|
161
|
+
return record;
|
|
162
|
+
},
|
|
157
163
|
parameters: Type.Object({
|
|
158
164
|
path: Type.String({
|
|
159
165
|
description: "Path to the file to read (relative or absolute)",
|
package/src/replace-diff.ts
CHANGED
|
@@ -56,12 +56,14 @@ export function genDiff(
|
|
|
56
56
|
newContent: string,
|
|
57
57
|
contextLines = 2,
|
|
58
58
|
newContentHashes?: string[],
|
|
59
|
+
oldContentHashes?: string[],
|
|
59
60
|
): { diff: string; firstChangedLine: number | undefined } {
|
|
60
61
|
const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
|
|
61
62
|
|
|
62
63
|
const parts = Diff.diffLines(oldContent, newContent);
|
|
63
64
|
const output: string[] = [];
|
|
64
65
|
let newLineNum = 1;
|
|
66
|
+
let oldLineNum = 1;
|
|
65
67
|
let lastWasChange = false;
|
|
66
68
|
let firstChangedLine: number | undefined;
|
|
67
69
|
|
|
@@ -79,7 +81,9 @@ export function genDiff(
|
|
|
79
81
|
output.push(fmtDiffLine("+", displayLines[k]!, hash));
|
|
80
82
|
newLineNum++;
|
|
81
83
|
} else {
|
|
82
|
-
|
|
84
|
+
const hash = oldContentHashes?.[oldLineNum - 1];
|
|
85
|
+
output.push(fmtDiffLine("-", displayLines[k]!, hash));
|
|
86
|
+
oldLineNum++;
|
|
83
87
|
}
|
|
84
88
|
}
|
|
85
89
|
lastWasChange = true;
|
|
@@ -107,19 +111,23 @@ export function genDiff(
|
|
|
107
111
|
if (skipStart > 0) {
|
|
108
112
|
output.push(" ...");
|
|
109
113
|
newLineNum += skipStart;
|
|
114
|
+
oldLineNum += skipStart;
|
|
110
115
|
}
|
|
111
116
|
for (const line of linesToShow) {
|
|
112
117
|
if (isEllipsisMarker(line)) {
|
|
113
118
|
output.push(" ...");
|
|
114
119
|
newLineNum += skipMiddle;
|
|
120
|
+
oldLineNum += skipMiddle;
|
|
115
121
|
continue;
|
|
116
122
|
}
|
|
117
123
|
const hash = effectiveNewHashes[newLineNum - 1];
|
|
118
124
|
output.push(fmtDiffLine(" ", line, hash));
|
|
119
125
|
newLineNum++;
|
|
126
|
+
oldLineNum++;
|
|
120
127
|
}
|
|
121
128
|
} else {
|
|
122
129
|
newLineNum += displayLines.length;
|
|
130
|
+
oldLineNum += displayLines.length;
|
|
123
131
|
}
|
|
124
132
|
lastWasChange = false;
|
|
125
133
|
}
|
package/src/replace-normalize.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import { isRec } from "./utils";
|
|
2
|
-
|
|
3
|
-
export function normalizeFilePath(record: Record<string, unknown>): void {
|
|
4
|
-
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
5
|
-
record.path = record.file_path;
|
|
6
|
-
delete record.file_path;
|
|
7
|
-
}
|
|
8
|
-
}
|
|
1
|
+
import { isRec, normalizeFilePath } from "./utils";
|
|
9
2
|
|
|
10
3
|
export function normReq(input: unknown): unknown {
|
|
11
4
|
if (!isRec(input)) {
|
package/src/replace-render.ts
CHANGED
|
@@ -163,62 +163,8 @@ function trimEmpty(lines: string[]): string[] {
|
|
|
163
163
|
return lines.slice(start, end);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
function isSectionBoundary(line: string): boolean {
|
|
167
|
-
return (
|
|
168
|
-
line === "--- Anchors ---" ||
|
|
169
|
-
line === "Warnings:" ||
|
|
170
|
-
line === "Structure outline:" ||
|
|
171
|
-
/^--- Range \d+ ---$/.test(line)
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
166
|
export function fmtResultMd(text: string): string {
|
|
176
|
-
|
|
177
|
-
const sections: string[] = [];
|
|
178
|
-
let plainLines: string[] = [];
|
|
179
|
-
|
|
180
|
-
const flush = () => {
|
|
181
|
-
const trimmed = trimEmpty(plainLines);
|
|
182
|
-
if (trimmed.length > 0) {
|
|
183
|
-
sections.push(trimmed.join("\n"));
|
|
184
|
-
}
|
|
185
|
-
plainLines = [];
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
let index = 0;
|
|
189
|
-
while (index < lines.length) {
|
|
190
|
-
const line = lines[index]!;
|
|
191
|
-
|
|
192
|
-
if (line.startsWith("--- Anchors ")) {
|
|
193
|
-
flush();
|
|
194
|
-
const title = line.replace(/^---\s*/, "").replace(/\s*---$/, "");
|
|
195
|
-
index++;
|
|
196
|
-
const bodyLines: string[] = [];
|
|
197
|
-
while (
|
|
198
|
-
index < lines.length &&
|
|
199
|
-
!isSectionBoundary(lines[index]!)
|
|
200
|
-
) {
|
|
201
|
-
bodyLines.push(lines[index]!);
|
|
202
|
-
index++;
|
|
203
|
-
}
|
|
204
|
-
sections.push(
|
|
205
|
-
[
|
|
206
|
-
`#### ${title}`,
|
|
207
|
-
"```text",
|
|
208
|
-
...trimEmpty(bodyLines),
|
|
209
|
-
"```",
|
|
210
|
-
].join("\n"),
|
|
211
|
-
);
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
plainLines.push(line);
|
|
216
|
-
index++;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
flush();
|
|
220
|
-
|
|
221
|
-
return sections.join("\n\n");
|
|
167
|
+
return trimEmpty(text.split("\n")).join("\n");
|
|
222
168
|
}
|
|
223
169
|
|
|
224
170
|
export function mkMdTheme(theme: MdTheme) {
|
package/src/replace-response.ts
CHANGED
|
@@ -123,10 +123,9 @@ export function buildNoop(input: NoopInput): TResult {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export function buildChanged(input: SuccessInput): TResult {
|
|
126
|
-
const { path, result, warnings, snapshotId, originalNormalized, editMeta, resultHashes } = input;
|
|
127
|
-
|
|
126
|
+
const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes } = input;
|
|
128
127
|
const resultLines = visLines(result);
|
|
129
|
-
const diffResult = genDiff(originalNormalized, result, 1, resultHashes);
|
|
128
|
+
const diffResult = genDiff(originalNormalized, result, 1, resultHashes, originalHashes);
|
|
130
129
|
const addedLines = editMeta.addedLines;
|
|
131
130
|
const removedLines = editMeta.removedLines;
|
|
132
131
|
const warningsBlock = warnBlock(warnings);
|
package/src/replace-undo.ts
CHANGED
|
@@ -7,10 +7,10 @@ import { contentChecksum } from "./hashline/hasher";
|
|
|
7
7
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
8
|
import { toCwd } from "./paths";
|
|
9
9
|
import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./replace-diff";
|
|
10
|
-
import { cntDiff, splitLines, errCode } from "./utils";
|
|
10
|
+
import { cntDiff, splitLines, errCode, isRec, normalizeFilePath } from "./utils";
|
|
11
11
|
import { loadP, loadGuide } from "./prompts";
|
|
12
12
|
import { buildMetrics } from "./replace-response";
|
|
13
|
-
import { changedRange } from "./hashline";
|
|
13
|
+
import { changedRange, lineHashes } from "./hashline";
|
|
14
14
|
export interface UndoEntry {
|
|
15
15
|
content: string;
|
|
16
16
|
bom: string;
|
|
@@ -91,6 +91,12 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
91
91
|
description: loadP("../prompts/undo-last-replace.md"),
|
|
92
92
|
promptSnippet: loadP("../prompts/undo-last-replace-snippet.md"),
|
|
93
93
|
promptGuidelines: loadGuide("../prompts/undo-last-replace-guidelines.md"),
|
|
94
|
+
prepareArguments: (args: unknown) => {
|
|
95
|
+
if (!isRec(args)) return args as any;
|
|
96
|
+
const record = { ...args };
|
|
97
|
+
normalizeFilePath(record);
|
|
98
|
+
return record;
|
|
99
|
+
},
|
|
94
100
|
parameters: Type.Object({
|
|
95
101
|
path: Type.String({
|
|
96
102
|
description: "Path to the file to undo",
|
|
@@ -153,11 +159,12 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
153
159
|
|
|
154
160
|
const { text: currentStripped } = stripBOM(currentRaw);
|
|
155
161
|
const currentNormalized = toLF(currentStripped);
|
|
156
|
-
const
|
|
162
|
+
const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
|
|
163
|
+
const diffResult = genDiff(undo.content, currentNormalized, 0, undefined, undo.hashes);
|
|
157
164
|
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
158
165
|
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
|
159
166
|
const restoredRange = changedRange(currentNormalized, undo.content);
|
|
160
|
-
const undoDiff = genDiff(currentNormalized, undo.content, 1, undo.hashes).diff;
|
|
167
|
+
const undoDiff = genDiff(currentNormalized, undo.content, 1, undo.hashes, currentHashes).diff;
|
|
161
168
|
|
|
162
169
|
await writeAtomic(
|
|
163
170
|
mutationTargetPath,
|
package/src/replace.ts
CHANGED
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
type LineEnding,
|
|
13
13
|
} from "./replace-diff";
|
|
14
14
|
import { readNormFile } from "./file-reader";
|
|
15
|
-
import { normReq
|
|
16
|
-
import { isRec, rejectUnknownFields, abortIf } from "./utils";
|
|
15
|
+
import { normReq } from "./replace-normalize";
|
|
16
|
+
import { isRec, rejectUnknownFields, abortIf, normalizeFilePath } from "./utils";
|
|
17
17
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
18
18
|
import { applyEdit,
|
|
19
19
|
lineHashes,
|
|
@@ -175,10 +175,14 @@ export async function execPipeline(
|
|
|
175
175
|
|
|
176
176
|
const path = params.path;
|
|
177
177
|
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
const editWarnings: string[] = [];
|
|
179
|
+
const edit = resEdit(
|
|
180
|
+
{
|
|
181
|
+
hash_bounds: params.hash_bounds,
|
|
182
|
+
new_content: params.new_content,
|
|
183
|
+
},
|
|
184
|
+
editWarnings,
|
|
185
|
+
);
|
|
182
186
|
|
|
183
187
|
const hashStore = options?.store ?? await loadHashStore();
|
|
184
188
|
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
|
|
@@ -207,8 +211,7 @@ export async function execPipeline(
|
|
|
207
211
|
hashes: originalHashes,
|
|
208
212
|
removedHashes,
|
|
209
213
|
}, hashStore, noPersist !== true);
|
|
210
|
-
const warnings = [...(anchorResult.warnings ?? [])];
|
|
211
|
-
|
|
214
|
+
const warnings = [...editWarnings, ...(anchorResult.warnings ?? [])];
|
|
212
215
|
const { totalAddedLines, totalRemovedLines } = countLineChanges(
|
|
213
216
|
edit, originalHashes, isNoop, anchorResult.autoFixes?.length ?? 0,
|
|
214
217
|
);
|
|
@@ -238,19 +241,18 @@ export async function compPreview(
|
|
|
238
241
|
try {
|
|
239
242
|
const normalized = normReq(request);
|
|
240
243
|
assertReq(normalized);
|
|
241
|
-
const { path, originalNormalized, result, resultHashes } = await execPipeline(
|
|
244
|
+
const { path, originalNormalized, result, resultHashes, originalHashes } = await execPipeline(
|
|
242
245
|
normalized,
|
|
243
246
|
cwd,
|
|
244
247
|
{ accessMode: constants.R_OK, noPersist: true },
|
|
245
248
|
);
|
|
246
|
-
|
|
247
249
|
if (originalNormalized === result) {
|
|
248
250
|
return {
|
|
249
251
|
error: `No changes made to ${path}. The edit produced identical content.`,
|
|
250
252
|
};
|
|
251
253
|
}
|
|
252
254
|
|
|
253
|
-
return { diff: genDiff(originalNormalized, result, 4, resultHashes).diff };
|
|
255
|
+
return { diff: genDiff(originalNormalized, result, 4, resultHashes, originalHashes).diff };
|
|
254
256
|
} catch (error: unknown) {
|
|
255
257
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
256
258
|
}
|
package/src/utils.ts
CHANGED
|
@@ -2,8 +2,11 @@ export function isRec(value: unknown): value is Record<string, unknown> {
|
|
|
2
2
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
export function
|
|
6
|
-
|
|
5
|
+
export function normalizeFilePath(record: Record<string, unknown>): void {
|
|
6
|
+
if (typeof record.path !== "string" && typeof record.file_path === "string") {
|
|
7
|
+
record.path = record.file_path;
|
|
8
|
+
delete record.file_path;
|
|
9
|
+
}
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
export function splitLines(text: string): string[] {
|