pi-hashline-edit-pro 2.6.2 → 2.6.4
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 +4 -3
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/boundary-bypass.ts +53 -0
- package/src/file-kind.ts +8 -0
- package/src/fs-write.ts +1 -1
- package/src/hashline/alphabet.ts +2 -0
- package/src/hashline/apply.ts +12 -2
- package/src/hashline/hash.ts +5 -5
- package/src/hashline/index.ts +1 -0
- package/src/hashline/resolve.ts +2 -2
- package/src/prompts.ts +4 -16
- package/src/replace-render.ts +14 -12
- package/src/replace.ts +16 -1
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Lines up to 200KB are shown in full. Larger lines are replaced by a marker with
|
|
|
66
66
|
Edge cases:
|
|
67
67
|
|
|
68
68
|
- Images (JPEG, PNG, GIF, WebP, BMP) come back as visual attachments. Other image formats (for example AVIF, HEIC/HEIF, TIFF, ICO, JPEG 2000, JPEG XL, PSD, APNG) are rejected as binary, since the built-in renderer cannot attach them.
|
|
69
|
-
- Binary files and directories are rejected with a descriptive error. A magic-signature match is ignored when the sampled bytes contain no NUL bytes and decode as UTF-8, so a text file whose first bytes happen to match a binary or image signature (for example starting with `BM` or `8BPS`) is still read as text.
|
|
69
|
+
- Binary files and directories are rejected with a descriptive error. A magic-signature match is ignored when the sampled bytes contain no NUL bytes and decode as UTF-8, so a text file whose first bytes happen to match a binary or image signature (for example starting with `BM` or `8BPS`) is still read as text. The NUL-byte check covers the whole file, not just the sampled bytes: a file with a NUL byte anywhere is rejected as binary.
|
|
70
70
|
- UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
|
|
71
71
|
- Empty files come back as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
|
|
72
72
|
- BOMs are stripped for display. Non-UTF-8 bytes are shown as `U+FFFD`; editing such a file rewrites it as UTF-8, with a warning.
|
|
@@ -96,8 +96,8 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
|
|
|
96
96
|
Notes:
|
|
97
97
|
|
|
98
98
|
- The request is checked before any file I/O, so a bad request never touches the file.
|
|
99
|
-
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
|
|
100
|
-
- An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
99
|
+
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix (including a truncated or expanded prefix of up to 6 characters, e.g. `L3│` or `ab12│`) in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
|
|
100
|
+
- An edit that produces identical content reports `No changes made` and leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the exact same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally — the duplicated lines are kept, and the result carries a `[E_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to that payload; copied `HASH│` prefixes, diff markers, and stray whitespace in the resend are normalized before matching, so a copy-paste resend still hits it. Any applied edit clears it, and a successful `write` also clears it.
|
|
101
101
|
- Every line in the removed range must match what was last shown to you. The extension records the `HASH│content` rows it serves — `read` output, the auto-read block after `write`, the `+HASH│`/` HASH│` rows of post-edit diffs (replace and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback — and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
|
|
102
102
|
- After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
|
|
103
103
|
- Do not issue multiple replace calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
|
|
@@ -181,6 +181,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
181
181
|
| `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
|
|
182
182
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
|
|
183
183
|
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
|
|
184
|
+
| `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
|
|
184
185
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
|
|
185
186
|
|
|
186
187
|
## Troubleshooting
|
package/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "./src/config";
|
|
15
15
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
16
16
|
import { recordServedSafe, clearServed } from "./src/served";
|
|
17
|
+
import { clearBoundaryBypass } from "./src/boundary-bypass";
|
|
17
18
|
import { readNormFile } from "./src/file-reader";
|
|
18
19
|
import { loadFileKindAndText } from "./src/file-kind";
|
|
19
20
|
import { toCwd } from "./src/paths";
|
|
@@ -64,6 +65,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
64
65
|
try {
|
|
65
66
|
const target = await resolveTarget(toCwd(writtenPath, ctx.cwd));
|
|
66
67
|
await clearUndo(target);
|
|
68
|
+
clearBoundaryBypass(target);
|
|
67
69
|
const store = await loadHashStore();
|
|
68
70
|
clearServed(store, target);
|
|
69
71
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/undo tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { parseText } from "./hashline/parse";
|
|
2
|
+
import { ANCHOR_ROW_RE } from "./hashline/resolve";
|
|
3
|
+
import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hashline/hash";
|
|
4
|
+
|
|
5
|
+
function canonRef(ref: string): string {
|
|
6
|
+
const trimmed = ref.trim();
|
|
7
|
+
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
8
|
+
return match ? match[2]! : trimmed;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function canonLines(lines: string[]): string[] {
|
|
12
|
+
return parseText(lines).map((line) => {
|
|
13
|
+
const bare = line.match(HL_BARE_PREFIX_RE);
|
|
14
|
+
if (bare) return line.slice(bare[0].length);
|
|
15
|
+
const plus = line.match(HL_PREFIX_PLUS_RE);
|
|
16
|
+
if (plus) return line.slice(plus[0].length);
|
|
17
|
+
const minus = line.match(HL_PREFIX_MINUS_RE);
|
|
18
|
+
if (minus) return line.slice(minus[0].length);
|
|
19
|
+
return line;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const boundaryBypassTracker = new Map<string, string>();
|
|
24
|
+
|
|
25
|
+
export function noopPayloadKey(
|
|
26
|
+
absolutePath: string,
|
|
27
|
+
removeFrom: string,
|
|
28
|
+
removeTo: string,
|
|
29
|
+
replacementLines: string[],
|
|
30
|
+
): string {
|
|
31
|
+
return JSON.stringify([
|
|
32
|
+
absolutePath,
|
|
33
|
+
canonRef(removeFrom),
|
|
34
|
+
canonRef(removeTo),
|
|
35
|
+
canonLines(replacementLines),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function markBoundaryNoop(absolutePath: string, payload: string): void {
|
|
40
|
+
boundaryBypassTracker.set(absolutePath, payload);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function consumeBoundaryBypass(absolutePath: string, payload: string): boolean {
|
|
44
|
+
if (boundaryBypassTracker.get(absolutePath) === payload) {
|
|
45
|
+
boundaryBypassTracker.delete(absolutePath);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function clearBoundaryBypass(absolutePath: string): void {
|
|
52
|
+
boundaryBypassTracker.delete(absolutePath);
|
|
53
|
+
}
|
package/src/file-kind.ts
CHANGED
|
@@ -125,6 +125,7 @@ export async function loadFileKindAndText(
|
|
|
125
125
|
|
|
126
126
|
const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
|
|
127
127
|
let hadUtf8DecodeErrors = false;
|
|
128
|
+
let containsNul = false;
|
|
128
129
|
let newlineCount = 0;
|
|
129
130
|
const parts: string[] = [];
|
|
130
131
|
|
|
@@ -133,6 +134,9 @@ export async function loadFileKindAndText(
|
|
|
133
134
|
if (!hadUtf8DecodeErrors && decoded.includes("\uFFFD")) {
|
|
134
135
|
hadUtf8DecodeErrors = true;
|
|
135
136
|
}
|
|
137
|
+
if (!containsNul && decoded.includes("\0")) {
|
|
138
|
+
containsNul = true;
|
|
139
|
+
}
|
|
136
140
|
if (options?.maxLines !== undefined) {
|
|
137
141
|
for (let i = 0; i < decoded.length; i++) {
|
|
138
142
|
if (decoded.charCodeAt(i) === 10) newlineCount++;
|
|
@@ -166,6 +170,10 @@ export async function loadFileKindAndText(
|
|
|
166
170
|
}
|
|
167
171
|
parts.push(decodeChunk(new Uint8Array(0), false));
|
|
168
172
|
|
|
173
|
+
if (containsNul) {
|
|
174
|
+
return { kind: "binary", description: "contains NUL bytes" };
|
|
175
|
+
}
|
|
176
|
+
|
|
169
177
|
return {
|
|
170
178
|
kind: "text",
|
|
171
179
|
text: parts.join(""),
|
package/src/fs-write.ts
CHANGED
|
@@ -142,7 +142,7 @@ export async function writeAtomic(
|
|
|
142
142
|
}
|
|
143
143
|
await tempHandle.sync();
|
|
144
144
|
} catch (error: unknown) {
|
|
145
|
-
await tempHandle.close();
|
|
145
|
+
try { await tempHandle.close(); } catch {}
|
|
146
146
|
try { await rm(tempPath, { force: true }); } catch {}
|
|
147
147
|
throw error;
|
|
148
148
|
}
|
package/src/hashline/alphabet.ts
CHANGED
package/src/hashline/apply.ts
CHANGED
|
@@ -84,11 +84,19 @@ function resToSpan(
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
if (edit.content_lines.length > 0) {
|
|
87
|
+
const lastReplacementLine = edit.content_lines[edit.content_lines.length - 1]!;
|
|
88
|
+
const endsWithBlank = lastReplacementLine.length === 0;
|
|
89
|
+
const endsAtEofWithoutNewline =
|
|
90
|
+
endLine === fileLines.length && !content.endsWith("\n");
|
|
91
|
+
const replacement = edit.content_lines.join("\n");
|
|
87
92
|
return {
|
|
88
93
|
kind: "replace",
|
|
89
94
|
start: lineStarts[startLine - 1]!,
|
|
90
95
|
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
91
|
-
replacement:
|
|
96
|
+
replacement:
|
|
97
|
+
endsAtEofWithoutNewline && endsWithBlank
|
|
98
|
+
? `${replacement}\n`
|
|
99
|
+
: replacement,
|
|
92
100
|
};
|
|
93
101
|
}
|
|
94
102
|
|
|
@@ -147,6 +155,7 @@ export function applyEdit(
|
|
|
147
155
|
precomputedHashes?: string[],
|
|
148
156
|
filePath?: string,
|
|
149
157
|
servedHashes?: ReadonlySet<string>,
|
|
158
|
+
skipBoundaryDedup?: boolean,
|
|
150
159
|
): {
|
|
151
160
|
content: string;
|
|
152
161
|
firstChangedLine: number | undefined;
|
|
@@ -188,7 +197,7 @@ export function applyEdit(
|
|
|
188
197
|
|
|
189
198
|
let resolved = initialResolved;
|
|
190
199
|
let autoFixes: AutoFix[] | undefined;
|
|
191
|
-
if (boundaryDups.length > 0) {
|
|
200
|
+
if (boundaryDups.length > 0 && !skipBoundaryDedup) {
|
|
192
201
|
autoFixes = [];
|
|
193
202
|
const correctedEdit: HEdit = {
|
|
194
203
|
...prefixFixed,
|
|
@@ -241,6 +250,7 @@ export function applyEdit(
|
|
|
241
250
|
firstChangedLine: undefined,
|
|
242
251
|
lastChangedLine: undefined,
|
|
243
252
|
...(warnings.length ? { warnings } : {}),
|
|
253
|
+
...(autoFixes ? { autoFixes } : {}),
|
|
244
254
|
noopEdit: { loc: spanResult.loc, currentContent: spanResult.currentContent },
|
|
245
255
|
};
|
|
246
256
|
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
upsertSnapshot,
|
|
7
7
|
} from "../hash-store";
|
|
8
8
|
import { xxh32, contentChecksum, initHasher } from "./hasher";
|
|
9
|
-
import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS } from "./alphabet";
|
|
10
|
-
export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS };
|
|
9
|
+
import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS, HASH_RUN } from "./alphabet";
|
|
10
|
+
export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS, HASH_RUN };
|
|
11
11
|
|
|
12
12
|
export const ANCHOR_LEN = HASH_LEN;
|
|
13
13
|
|
|
@@ -39,13 +39,13 @@ function hashAt(idx: number): string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
42
|
-
`^\\+${
|
|
42
|
+
`^\\+${HASH_RUN}│`,
|
|
43
43
|
);
|
|
44
44
|
export const HL_PREFIX_MINUS_RE = new RegExp(
|
|
45
|
-
`^-(?:${
|
|
45
|
+
`^-(?:${HASH_RUN}│| {${ANCHOR_LEN}}│)`,
|
|
46
46
|
);
|
|
47
47
|
|
|
48
|
-
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${
|
|
48
|
+
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_RUN})│`);
|
|
49
49
|
|
|
50
50
|
export function canon(line: string): string {
|
|
51
51
|
return line.replace(/\r/g, "").trimEnd();
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
2
|
-
import {
|
|
2
|
+
import { HASH_SEP, HASH_RUN, 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_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
5
|
|
|
@@ -162,7 +162,7 @@ function assertItem(edit: Record<string, unknown>): void {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${
|
|
165
|
+
export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
|
|
166
166
|
|
|
167
167
|
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
168
168
|
assertItem(edit as Record<string, unknown>);
|
package/src/prompts.ts
CHANGED
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
|
|
3
|
-
export function loadP(relativePath: string
|
|
4
|
-
|
|
5
|
-
if (replacements) {
|
|
6
|
-
for (const [key, value] of Object.entries(replacements)) {
|
|
7
|
-
content = content.split(`{{${key}}}`).join(value);
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
return content;
|
|
3
|
+
export function loadP(relativePath: string): string {
|
|
4
|
+
return readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
|
|
11
5
|
}
|
|
12
6
|
|
|
13
|
-
export function loadGuide(relativePath: string
|
|
14
|
-
|
|
15
|
-
if (replacements) {
|
|
16
|
-
for (const [key, value] of Object.entries(replacements)) {
|
|
17
|
-
content = content.split(`{{${key}}}`).join(value);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return content
|
|
7
|
+
export function loadGuide(relativePath: string): string[] {
|
|
8
|
+
return readFileSync(new URL(relativePath, import.meta.url), "utf-8")
|
|
21
9
|
.split("\n")
|
|
22
10
|
.map((line) => line.trim())
|
|
23
11
|
.filter((line) => line.startsWith("- "))
|
package/src/replace-render.ts
CHANGED
|
@@ -50,14 +50,19 @@ export function getPreviewInput(
|
|
|
50
50
|
return request;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
type DiffRowKind = "added" | "removed" | "context";
|
|
54
|
+
|
|
55
|
+
function diffRowKind(line: string): DiffRowKind {
|
|
56
|
+
if (line.startsWith("+") && !line.startsWith("+++")) return "added";
|
|
57
|
+
if (line.startsWith("-") && !line.startsWith("---")) return "removed";
|
|
58
|
+
return "context";
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
export function colorLines(lines: string[], theme: FgT): string[] {
|
|
54
62
|
return lines.map((line) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
59
|
-
return theme.fg("error", line);
|
|
60
|
-
}
|
|
63
|
+
const kind = diffRowKind(line);
|
|
64
|
+
if (kind === "added") return theme.fg("success", line);
|
|
65
|
+
if (kind === "removed") return theme.fg("error", line);
|
|
61
66
|
return theme.fg("dim", line);
|
|
62
67
|
});
|
|
63
68
|
}
|
|
@@ -194,12 +199,9 @@ export function mkMdTheme(theme: MdTheme) {
|
|
|
194
199
|
highlightCode: (code: string, lang?: string) =>
|
|
195
200
|
code.split("\n").map((line) => {
|
|
196
201
|
if (lang === "diff") {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
201
|
-
return theme.fg("toolDiffRemoved", line);
|
|
202
|
-
}
|
|
202
|
+
const kind = diffRowKind(line);
|
|
203
|
+
if (kind === "added") return theme.fg("toolDiffAdded", line);
|
|
204
|
+
if (kind === "removed") return theme.fg("toolDiffRemoved", line);
|
|
203
205
|
return theme.fg("toolDiffContext", line);
|
|
204
206
|
}
|
|
205
207
|
|
package/src/replace.ts
CHANGED
|
@@ -47,6 +47,7 @@ import { loadP, loadGuide } from "./prompts";
|
|
|
47
47
|
import { saveUndo } from "./replace-undo";
|
|
48
48
|
import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
|
|
49
49
|
import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
|
|
50
|
+
import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
|
|
50
51
|
|
|
51
52
|
const replacementLinesSchema = Type.Array(
|
|
52
53
|
Type.String({
|
|
@@ -107,6 +108,7 @@ interface PipelineResult {
|
|
|
107
108
|
resultHashes: string[];
|
|
108
109
|
totalAddedLines: number;
|
|
109
110
|
totalRemovedLines: number;
|
|
111
|
+
hadBoundaryDedup: boolean;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
const PREVIEW_DEBOUNCE_MS = 150;
|
|
@@ -179,6 +181,7 @@ export interface ExecPipelineOptions {
|
|
|
179
181
|
signal?: AbortSignal;
|
|
180
182
|
store?: HashStore;
|
|
181
183
|
noPersist?: boolean;
|
|
184
|
+
skipBoundaryDedup?: boolean;
|
|
182
185
|
}
|
|
183
186
|
|
|
184
187
|
function collectRemovedHashes(
|
|
@@ -252,6 +255,7 @@ export async function execPipeline(
|
|
|
252
255
|
originalHashes,
|
|
253
256
|
path,
|
|
254
257
|
served,
|
|
258
|
+
options?.skipBoundaryDedup,
|
|
255
259
|
);
|
|
256
260
|
} catch (error) {
|
|
257
261
|
if (options?.noPersist !== true) {
|
|
@@ -298,6 +302,7 @@ export async function execPipeline(
|
|
|
298
302
|
originalHashes,
|
|
299
303
|
totalAddedLines,
|
|
300
304
|
totalRemovedLines,
|
|
305
|
+
hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
|
|
301
306
|
};
|
|
302
307
|
}
|
|
303
308
|
|
|
@@ -477,6 +482,8 @@ export function buildToolDef(): ToolDef {
|
|
|
477
482
|
const path = normalizedParams.path;
|
|
478
483
|
const absolutePath = toCwd(path, ctx.cwd);
|
|
479
484
|
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
485
|
+
const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
|
|
486
|
+
const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
|
|
480
487
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
481
488
|
abortIf(signal);
|
|
482
489
|
|
|
@@ -492,21 +499,28 @@ export function buildToolDef(): ToolDef {
|
|
|
492
499
|
firstChangedLine,
|
|
493
500
|
lastChangedLine,
|
|
494
501
|
resultHashes,
|
|
502
|
+
hadBoundaryDedup,
|
|
495
503
|
totalAddedLines,
|
|
496
504
|
totalRemovedLines,
|
|
497
505
|
} = await execPipeline(
|
|
498
506
|
normalizedParams,
|
|
499
507
|
ctx.cwd,
|
|
500
|
-
{ accessMode: constants.R_OK | constants.W_OK, signal },
|
|
508
|
+
{ accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
|
|
501
509
|
);
|
|
502
510
|
|
|
503
511
|
if (resolution) {
|
|
504
512
|
warnings.unshift(resolution.warning);
|
|
505
513
|
}
|
|
514
|
+
if (boundaryBypass && originalNormalized !== result) {
|
|
515
|
+
warnings.push("[E_BOUNDARY_BYPASS] Boundary dedup was off for this call. Boundary dedup is now restored.");
|
|
516
|
+
}
|
|
506
517
|
|
|
507
518
|
const editsAttempted = 1;
|
|
508
519
|
if (originalNormalized === result) {
|
|
509
520
|
const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
|
|
521
|
+
if (hadBoundaryDedup) {
|
|
522
|
+
markBoundaryNoop(mutationTargetPath, noopPayload);
|
|
523
|
+
}
|
|
510
524
|
return buildNoop({
|
|
511
525
|
path,
|
|
512
526
|
noopEdit,
|
|
@@ -550,6 +564,7 @@ export function buildToolDef(): ToolDef {
|
|
|
550
564
|
await undo.restore();
|
|
551
565
|
throw error;
|
|
552
566
|
}
|
|
567
|
+
clearBoundaryBypass(mutationTargetPath);
|
|
553
568
|
const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
|
|
554
569
|
|
|
555
570
|
const editMeta: RMeta = {
|