pi-hashline-edit-pro 4.1.0 → 4.2.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 +9 -6
- package/index.ts +20 -2
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +1 -0
- package/prompts/insert.md +1 -1
- package/prompts/replace-guidelines.md +2 -1
- package/prompts/replace.md +1 -1
- package/src/anchor-registry.ts +1 -5
- package/src/batch.ts +614 -0
- package/src/commit.ts +1 -1
- package/src/config-ui.ts +19 -4
- package/src/config.ts +21 -8
- package/src/edit-common.ts +14 -12
- package/src/grep.ts +1 -1
- package/src/hashline/apply.ts +128 -84
- package/src/hashline/index.ts +3 -0
- package/src/insert.ts +59 -49
- package/src/payload-contract.ts +28 -0
- package/src/replace.ts +65 -25
package/src/config.ts
CHANGED
|
@@ -3,12 +3,14 @@ import { configPath } from "./paths";
|
|
|
3
3
|
import { errCode, isRec } from "./utils";
|
|
4
4
|
import { writeAtomic } from "./fs-write";
|
|
5
5
|
|
|
6
|
+
export type BoundaryDedupMode = "on" | "off" | "strict";
|
|
7
|
+
|
|
6
8
|
export interface Config {
|
|
7
9
|
autoRead: boolean;
|
|
8
10
|
anchorGrepEnabled: boolean;
|
|
9
11
|
requirePath?: boolean;
|
|
10
12
|
strictInput?: boolean;
|
|
11
|
-
|
|
13
|
+
boundaryDedupMode?: BoundaryDedupMode;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const DEFAULT_CONFIG: Config = {
|
|
@@ -16,9 +18,18 @@ const DEFAULT_CONFIG: Config = {
|
|
|
16
18
|
anchorGrepEnabled: true,
|
|
17
19
|
requirePath: false,
|
|
18
20
|
strictInput: false,
|
|
19
|
-
|
|
21
|
+
boundaryDedupMode: "on"
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
const BOUNDARY_DEDUP_MODES: BoundaryDedupMode[] = ["on", "strict", "off"];
|
|
25
|
+
|
|
26
|
+
function parseBoundaryDedupMode(mode: unknown, legacy: unknown): BoundaryDedupMode {
|
|
27
|
+
if (mode === "on" || mode === "strict" || mode === "off") return mode;
|
|
28
|
+
if (legacy === true) return "on";
|
|
29
|
+
if (legacy === false) return "off";
|
|
30
|
+
return DEFAULT_CONFIG.boundaryDedupMode ?? "on";
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
function parseConfig(content: string): Config {
|
|
23
34
|
const parsed = JSON.parse(content) as unknown;
|
|
24
35
|
const autoRead = isRec(parsed) ? parsed.autoRead : undefined;
|
|
@@ -28,13 +39,14 @@ function parseConfig(content: string): Config {
|
|
|
28
39
|
const anchorGrepEnabled = isRec(parsed) ? parsed.anchorGrepEnabled : undefined;
|
|
29
40
|
const requirePath = isRec(parsed) ? parsed.requirePath : undefined;
|
|
30
41
|
const strictInput = isRec(parsed) ? parsed.strictInput : undefined;
|
|
31
|
-
const
|
|
42
|
+
const boundaryDedupMode = isRec(parsed) ? parsed.boundaryDedupMode : undefined;
|
|
43
|
+
const legacyBoundaryDedup = isRec(parsed) ? parsed.boundaryDedupEnabled : undefined;
|
|
32
44
|
return {
|
|
33
45
|
autoRead,
|
|
34
46
|
anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
|
|
35
47
|
requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
|
|
36
48
|
strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
|
|
37
|
-
|
|
49
|
+
boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
|
|
38
50
|
};
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -83,10 +95,11 @@ export async function toggleStrictInput(): Promise<boolean> {
|
|
|
83
95
|
return config.strictInput === true;
|
|
84
96
|
}
|
|
85
97
|
|
|
86
|
-
export async function
|
|
98
|
+
export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
|
|
87
99
|
const config = await readConfig();
|
|
88
|
-
const
|
|
89
|
-
|
|
100
|
+
const current = config.boundaryDedupMode ?? "on";
|
|
101
|
+
const next = BOUNDARY_DEDUP_MODES[(BOUNDARY_DEDUP_MODES.indexOf(current) + 1) % BOUNDARY_DEDUP_MODES.length] ?? "on";
|
|
102
|
+
config.boundaryDedupMode = next;
|
|
90
103
|
await writeConfig(config);
|
|
91
|
-
return
|
|
104
|
+
return next;
|
|
92
105
|
}
|
package/src/edit-common.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { resolveInCwd } from "./fs-write";
|
|
|
3
3
|
import { abortIf, makePrepareArguments } from "./utils";
|
|
4
4
|
import { ownerOf } from "./anchor-registry";
|
|
5
5
|
import { parseHashRef, stripAnchorRow } from "./hashline";
|
|
6
|
-
import { readConfig } from "./config";
|
|
6
|
+
import { readConfig, type BoundaryDedupMode } from "./config";
|
|
7
7
|
import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
|
|
8
8
|
import type { ReplaceDetails } from "./replace";
|
|
9
9
|
export const editPrepare = makePrepareArguments();
|
|
@@ -11,14 +11,14 @@ export const editPrepare = makePrepareArguments();
|
|
|
11
11
|
export interface EditToolFlags {
|
|
12
12
|
requirePath: boolean;
|
|
13
13
|
strictInput: boolean;
|
|
14
|
-
|
|
14
|
+
boundaryDedupMode: BoundaryDedupMode;
|
|
15
15
|
autoRead: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
|
|
19
19
|
requirePath: false,
|
|
20
20
|
strictInput: false,
|
|
21
|
-
|
|
21
|
+
boundaryDedupMode: "on",
|
|
22
22
|
autoRead: true
|
|
23
23
|
};
|
|
24
24
|
|
|
@@ -27,7 +27,7 @@ export async function currentEditFlags(): Promise<EditToolFlags> {
|
|
|
27
27
|
return {
|
|
28
28
|
requirePath: config.requirePath === true,
|
|
29
29
|
strictInput: config.strictInput === true,
|
|
30
|
-
|
|
30
|
+
boundaryDedupMode: config.boundaryDedupMode ?? "on",
|
|
31
31
|
autoRead: config.autoRead !== false
|
|
32
32
|
};
|
|
33
33
|
}
|
|
@@ -38,7 +38,7 @@ export function withReplacePrompts(base: { description: string; snippet: string;
|
|
|
38
38
|
let guidelines = [...base.guidelines];
|
|
39
39
|
if (!flags.autoRead) {
|
|
40
40
|
description = description.replace(" Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading.", "");
|
|
41
|
-
guidelines = guidelines.map((guideline) => guideline.includes("post-edit diff") ? "`replace`: one
|
|
41
|
+
guidelines = guidelines.map((guideline) => guideline.includes("post-edit diff") ? "`replace`: one batch per file per turn; verify each result before the next edit on that file." : guideline);
|
|
42
42
|
}
|
|
43
43
|
const descriptionParts = [description];
|
|
44
44
|
if (flags.requirePath) {
|
|
@@ -50,9 +50,12 @@ export function withReplacePrompts(base: { description: string; snippet: string;
|
|
|
50
50
|
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
51
51
|
guidelines.push("`replace`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
|
|
52
52
|
}
|
|
53
|
-
if (
|
|
53
|
+
if (flags.boundaryDedupMode === "off") {
|
|
54
54
|
descriptionParts.push("Boundary dedup is off: edits apply literally.");
|
|
55
55
|
guidelines.push("`replace`: boundary dedup is off: edits apply literally.");
|
|
56
|
+
} else if (flags.boundaryDedupMode === "strict") {
|
|
57
|
+
descriptionParts.push("Boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
|
|
58
|
+
guidelines.push("`replace`: boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
|
|
56
59
|
}
|
|
57
60
|
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
58
61
|
}
|
|
@@ -116,10 +119,10 @@ export interface PathRequirementInput {
|
|
|
116
119
|
export async function resolveEditTargetWithRequirement(input: PathRequirementInput): Promise<string> {
|
|
117
120
|
const { requirePath } = await readConfig();
|
|
118
121
|
if (!requirePath && input.providedPath !== undefined) {
|
|
119
|
-
throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only;
|
|
122
|
+
throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; retry without `path`.");
|
|
120
123
|
}
|
|
121
124
|
if (requirePath && (typeof input.providedPath !== "string" || input.providedPath.length === 0)) {
|
|
122
|
-
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string when require-path mode is on
|
|
125
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string when require-path mode is on. Provide `path` matching the file the anchors were served for.');
|
|
123
126
|
}
|
|
124
127
|
const anchorTarget = typeof input.anchor === "string"
|
|
125
128
|
? resolveEditTarget(input.anchor)
|
|
@@ -138,13 +141,12 @@ export async function throwIfStrictInput(warnings: string[]): Promise<void> {
|
|
|
138
141
|
if (fixes.length === 0) return;
|
|
139
142
|
const { strictInput } = await readConfig();
|
|
140
143
|
if (strictInput === true) {
|
|
141
|
-
throw new Error(`[E_BAD_SHAPE] Strict-input mode rejects auto-fixable input
|
|
144
|
+
throw new Error(`[E_BAD_SHAPE] Strict-input mode rejects auto-fixable input:\n${fixes.join("\n")}`);
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
export async function
|
|
146
|
-
|
|
147
|
-
return boundaryDedupEnabled !== false;
|
|
148
|
+
export async function getBoundaryDedupMode(): Promise<BoundaryDedupMode> {
|
|
149
|
+
return (await readConfig()).boundaryDedupMode ?? "on";
|
|
148
150
|
}
|
|
149
151
|
|
|
150
152
|
export function editRenderCallWrapper(
|
package/src/grep.ts
CHANGED
|
@@ -677,7 +677,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
677
677
|
if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
|
|
678
678
|
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
679
679
|
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.]`);
|
|
680
|
-
if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted;
|
|
680
|
+
if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
|
|
681
681
|
const truncated = limitTruncated || rowTruncated;
|
|
682
682
|
const truncation: TruncationResult | undefined = rowTruncated
|
|
683
683
|
? {
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf, splitLines } from "../utils";
|
|
1
|
+
import { abortIf, clipLine, splitLines } from "../utils";
|
|
2
2
|
import { _lineHashesPure } from "./hash";
|
|
3
3
|
import {
|
|
4
4
|
valEdit,
|
|
@@ -52,7 +52,7 @@ type NoopSpan = {
|
|
|
52
52
|
loc: string;
|
|
53
53
|
currentContent: string;
|
|
54
54
|
};
|
|
55
|
-
function assertNotEmpty(originalContent: string, result: string): void {
|
|
55
|
+
export function assertNotEmpty(originalContent: string, result: string): void {
|
|
56
56
|
if (originalContent.length > 0 && result.length === 0) {
|
|
57
57
|
throw new Error(
|
|
58
58
|
"[E_WOULD_EMPTY] A replace cannot empty a non-empty file. Use `write` to clear the file."
|
|
@@ -148,6 +148,122 @@ function assemble(
|
|
|
148
148
|
return content.slice(0, span.start) + span.replacement + content.slice(span.end);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
export interface PlannedEdit {
|
|
152
|
+
resolved: RHEdit;
|
|
153
|
+
warnings: string[];
|
|
154
|
+
autoFixes?: AutoFix[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
function throwStrictBoundaryDedup(edit: HEdit, boundaryDups: BDup[], resolved: RHEdit): never {
|
|
159
|
+
const startLine = resolved.hash_bounds[0].line;
|
|
160
|
+
const endLine = resolved.hash_bounds[1].line;
|
|
161
|
+
const range = startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`;
|
|
162
|
+
const seen = new Set<number>();
|
|
163
|
+
for (const dup of boundaryDups) seen.add(dup.replacementLineIndex);
|
|
164
|
+
const removed = [...seen].sort((a, b) => a - b).map((index) => `replacement_lines line ${index + 1}: ${clipLine(edit.content_lines[index] ?? "")}`);
|
|
165
|
+
throw new Error(`[E_BOUNDARY_STRICT] Strict boundary dedup rejects this edit: ${seen.size} replacement line(s) re-include edge lines (${range}). ${removed.join("; ")}. Resend without those lines.`);
|
|
166
|
+
}
|
|
167
|
+
export function planEdit(
|
|
168
|
+
content: string,
|
|
169
|
+
edit: HEdit,
|
|
170
|
+
precomputedHashes: string[],
|
|
171
|
+
options?: {
|
|
172
|
+
filePath?: string;
|
|
173
|
+
servedHashes?: ReadonlyMap<string, string>;
|
|
174
|
+
skipBoundaryDedup?: boolean;
|
|
175
|
+
strictBoundaryDedup?: boolean;
|
|
176
|
+
signal?: AbortSignal;
|
|
177
|
+
},
|
|
178
|
+
): PlannedEdit {
|
|
179
|
+
const signal = options?.signal;
|
|
180
|
+
abortIf(signal);
|
|
181
|
+
const lineIndex = buildIdx(content);
|
|
182
|
+
const fileHashes = precomputedHashes;
|
|
183
|
+
const warnings: string[] = [];
|
|
184
|
+
|
|
185
|
+
const rangeFixed = swapReversedRanges(edit, fileHashes, warnings);
|
|
186
|
+
const prefixFixed = stripDiffPrefixes(
|
|
187
|
+
stripBarePrefixes(rangeFixed, fileHashes, warnings),
|
|
188
|
+
warnings,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const { resolved: initialResolved, mismatches, boundaryDups } = valEdit(
|
|
192
|
+
prefixFixed,
|
|
193
|
+
lineIndex.fileLines,
|
|
194
|
+
fileHashes,
|
|
195
|
+
warnings,
|
|
196
|
+
signal,
|
|
197
|
+
);
|
|
198
|
+
if (mismatches.length || !initialResolved) {
|
|
199
|
+
const feedback = fmtMismatchWithHashes(
|
|
200
|
+
mismatches,
|
|
201
|
+
lineIndex.fileLines,
|
|
202
|
+
fileHashes,
|
|
203
|
+
options?.filePath,
|
|
204
|
+
);
|
|
205
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
warnUnicodeEsc(prefixFixed, warnings);
|
|
209
|
+
|
|
210
|
+
if (options?.strictBoundaryDedup === true && boundaryDups.length > 0) {
|
|
211
|
+
throwStrictBoundaryDedup(prefixFixed, boundaryDups, initialResolved);
|
|
212
|
+
}
|
|
213
|
+
let resolved = initialResolved;
|
|
214
|
+
let autoFixes: AutoFix[] | undefined;
|
|
215
|
+
if (boundaryDups.length > 0 && !(options?.skipBoundaryDedup === true)) {
|
|
216
|
+
autoFixes = [];
|
|
217
|
+
const correctedEdit: HEdit = {
|
|
218
|
+
...prefixFixed,
|
|
219
|
+
content_lines: [...prefixFixed.content_lines],
|
|
220
|
+
};
|
|
221
|
+
const seen = new Set<number>();
|
|
222
|
+
const uniqueDups: BDup[] = [];
|
|
223
|
+
for (const dup of boundaryDups) {
|
|
224
|
+
if (seen.has(dup.replacementLineIndex)) continue;
|
|
225
|
+
seen.add(dup.replacementLineIndex);
|
|
226
|
+
uniqueDups.push(dup);
|
|
227
|
+
}
|
|
228
|
+
const dupsByIndex = uniqueDups.sort(
|
|
229
|
+
(a, b) => b.replacementLineIndex - a.replacementLineIndex,
|
|
230
|
+
);
|
|
231
|
+
for (const dup of dupsByIndex) {
|
|
232
|
+
const idx = dup.replacementLineIndex;
|
|
233
|
+
if (idx < 0 || idx >= correctedEdit.content_lines.length) continue;
|
|
234
|
+
const removed = correctedEdit.content_lines.splice(idx, 1)[0];
|
|
235
|
+
autoFixes.push({ kind: dup.kind, removedLine: removed, removedLineIndex: idx });
|
|
236
|
+
}
|
|
237
|
+
const correctedResult = valEdit(
|
|
238
|
+
correctedEdit,
|
|
239
|
+
lineIndex.fileLines,
|
|
240
|
+
fileHashes,
|
|
241
|
+
warnings,
|
|
242
|
+
signal,
|
|
243
|
+
);
|
|
244
|
+
if (correctedResult.mismatches.length || !correctedResult.resolved) {
|
|
245
|
+
const feedback = fmtMismatchWithHashes(
|
|
246
|
+
correctedResult.mismatches,
|
|
247
|
+
lineIndex.fileLines,
|
|
248
|
+
fileHashes,
|
|
249
|
+
options?.filePath,
|
|
250
|
+
);
|
|
251
|
+
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
252
|
+
}
|
|
253
|
+
resolved = correctedResult.resolved;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (options?.servedHashes) {
|
|
257
|
+
abortIf(signal);
|
|
258
|
+
assertRangeServed(resolved, lineIndex.fileLines, fileHashes, options.servedHashes, options?.filePath);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
resolved,
|
|
263
|
+
warnings,
|
|
264
|
+
...(autoFixes ? { autoFixes } : {}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
151
267
|
export function applyEdit(
|
|
152
268
|
content: string,
|
|
153
269
|
edit: HEdit,
|
|
@@ -156,6 +272,7 @@ export function applyEdit(
|
|
|
156
272
|
filePath?: string,
|
|
157
273
|
servedHashes?: ReadonlyMap<string, string>,
|
|
158
274
|
skipBoundaryDedup?: boolean,
|
|
275
|
+
strictBoundaryDedup?: boolean,
|
|
159
276
|
): {
|
|
160
277
|
content: string;
|
|
161
278
|
firstChangedLine: number | undefined;
|
|
@@ -164,88 +281,15 @@ export function applyEdit(
|
|
|
164
281
|
noopEdit?: NEdit;
|
|
165
282
|
autoFixes?: AutoFix[];
|
|
166
283
|
} {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const rangeFixed = swapReversedRanges(edit, fileHashes, warnings);
|
|
177
|
-
const prefixFixed = stripDiffPrefixes(
|
|
178
|
-
stripBarePrefixes(rangeFixed, fileHashes, warnings),
|
|
179
|
-
warnings,
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
const { resolved: initialResolved, mismatches, boundaryDups } = valEdit(
|
|
183
|
-
prefixFixed,
|
|
184
|
-
lineIndex.fileLines,
|
|
185
|
-
fileHashes,
|
|
186
|
-
warnings,
|
|
187
|
-
signal,
|
|
188
|
-
);
|
|
189
|
-
if (mismatches.length || !initialResolved) {
|
|
190
|
-
const feedback = fmtMismatchWithHashes(
|
|
191
|
-
mismatches,
|
|
192
|
-
lineIndex.fileLines,
|
|
193
|
-
fileHashes,
|
|
194
|
-
filePath,
|
|
195
|
-
);
|
|
196
|
-
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
warnUnicodeEsc(prefixFixed, warnings);
|
|
200
|
-
|
|
201
|
-
let resolved = initialResolved;
|
|
202
|
-
let autoFixes: AutoFix[] | undefined;
|
|
203
|
-
if (boundaryDups.length > 0 && !skipBoundaryDedup) {
|
|
204
|
-
autoFixes = [];
|
|
205
|
-
const correctedEdit: HEdit = {
|
|
206
|
-
...prefixFixed,
|
|
207
|
-
content_lines: [...prefixFixed.content_lines],
|
|
208
|
-
};
|
|
209
|
-
const seen = new Set<number>();
|
|
210
|
-
const uniqueDups: BDup[] = [];
|
|
211
|
-
for (const dup of boundaryDups) {
|
|
212
|
-
if (seen.has(dup.replacementLineIndex)) continue;
|
|
213
|
-
seen.add(dup.replacementLineIndex);
|
|
214
|
-
uniqueDups.push(dup);
|
|
215
|
-
}
|
|
216
|
-
const dupsByIndex = uniqueDups.sort(
|
|
217
|
-
(a, b) => b.replacementLineIndex - a.replacementLineIndex,
|
|
218
|
-
);
|
|
219
|
-
for (const dup of dupsByIndex) {
|
|
220
|
-
const idx = dup.replacementLineIndex;
|
|
221
|
-
if (idx < 0 || idx >= correctedEdit.content_lines.length) continue;
|
|
222
|
-
const removed = correctedEdit.content_lines.splice(idx, 1)[0];
|
|
223
|
-
autoFixes.push({ kind: dup.kind, removedLine: removed, removedLineIndex: idx });
|
|
224
|
-
}
|
|
225
|
-
const correctedResult = valEdit(
|
|
226
|
-
correctedEdit,
|
|
227
|
-
lineIndex.fileLines,
|
|
228
|
-
fileHashes,
|
|
229
|
-
warnings,
|
|
230
|
-
signal,
|
|
231
|
-
);
|
|
232
|
-
if (correctedResult.mismatches.length || !correctedResult.resolved) {
|
|
233
|
-
const feedback = fmtMismatchWithHashes(
|
|
234
|
-
correctedResult.mismatches,
|
|
235
|
-
lineIndex.fileLines,
|
|
236
|
-
fileHashes,
|
|
237
|
-
filePath,
|
|
238
|
-
);
|
|
239
|
-
throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
|
|
240
|
-
}
|
|
241
|
-
resolved = correctedResult.resolved;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (servedHashes) {
|
|
245
|
-
abortIf(signal);
|
|
246
|
-
assertRangeServed(resolved, lineIndex.fileLines, fileHashes, servedHashes, filePath);
|
|
247
|
-
}
|
|
248
|
-
|
|
284
|
+
abortIf(signal);
|
|
285
|
+
if (precomputedHashes === undefined) {
|
|
286
|
+
throw new Error("[E_BAD_SHAPE] applyEdit requires the file's allocated anchors; derive them via lineHashes(content, path) first.");
|
|
287
|
+
}
|
|
288
|
+
const planned = planEdit(content, edit, precomputedHashes, { filePath, servedHashes, skipBoundaryDedup, strictBoundaryDedup, signal });
|
|
289
|
+
const lineIndex = buildIdx(content);
|
|
290
|
+
const warnings = planned.warnings;
|
|
291
|
+
const resolved = planned.resolved;
|
|
292
|
+
const autoFixes = planned.autoFixes;
|
|
249
293
|
const spanResult = resToSpan(resolved, content, lineIndex);
|
|
250
294
|
if (spanResult.kind === "noop") {
|
|
251
295
|
return {
|
package/src/hashline/index.ts
CHANGED
package/src/insert.ts
CHANGED
|
@@ -3,43 +3,17 @@ import { Type } from "typebox";
|
|
|
3
3
|
import { constants } from "fs";
|
|
4
4
|
import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
5
5
|
import { commitEdit } from "./commit";
|
|
6
|
+
import { batchMemberFor, executeBatchMember, noteBatchFailure } from "./batch";
|
|
6
7
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
7
|
-
import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
|
|
8
|
+
import { MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, type Anchor, type HEdit } from "./hashline";
|
|
8
9
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
9
10
|
import { loadP, loadGuide } from "./prompts";
|
|
10
|
-
import { normReq } from "./payload-contract";
|
|
11
|
-
import { decodeStringArray, isRec,
|
|
11
|
+
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
12
|
+
import { decodeStringArray, isRec, splitLines } from "./utils";
|
|
12
13
|
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
13
14
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
14
15
|
import type { RPreview, RRState } from "./replace-render";
|
|
15
|
-
|
|
16
|
-
const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
|
|
17
|
-
|
|
18
|
-
export interface InsertReq {
|
|
19
|
-
path?: string;
|
|
20
|
-
anchor: string;
|
|
21
|
-
direction: "before" | "after";
|
|
22
|
-
lines: string[];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function assertInsertReq(request: unknown): asserts request is InsertReq {
|
|
26
|
-
if (!isRec(request)) {
|
|
27
|
-
throw new Error("[E_BAD_SHAPE] Insert request must be an object.");
|
|
28
|
-
}
|
|
29
|
-
rejectUnknownFields(request, INSERT_KS, "Insert request");
|
|
30
|
-
if (request.path !== undefined && typeof request.path !== "string") {
|
|
31
|
-
throw new Error('[E_BAD_SHAPE] Insert request field "path" must be a string when provided.');
|
|
32
|
-
}
|
|
33
|
-
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
34
|
-
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
|
|
35
|
-
}
|
|
36
|
-
if (request.direction !== "before" && request.direction !== "after") {
|
|
37
|
-
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
38
|
-
}
|
|
39
|
-
if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
|
|
40
|
-
throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
|
|
41
|
-
}
|
|
42
|
-
}
|
|
16
|
+
export { assertInsertReq, type InsertReq };
|
|
43
17
|
|
|
44
18
|
const insertAnchorSchema = Type.String({
|
|
45
19
|
description:
|
|
@@ -87,14 +61,14 @@ export function buildInsertToolSchema(requirePath: boolean): typeof insertToolSc
|
|
|
87
61
|
) as typeof insertToolSchema;
|
|
88
62
|
}
|
|
89
63
|
|
|
90
|
-
function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
|
|
64
|
+
export function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
|
|
91
65
|
const trimmedAnchor = raw.trim();
|
|
92
66
|
const warnings: string[] = [];
|
|
93
67
|
const anchorText = stripAnchorRow(trimmedAnchor, "anchor entry", warnings);
|
|
94
68
|
return { ref: parseHashRef(anchorText), warnings };
|
|
95
69
|
}
|
|
96
70
|
|
|
97
|
-
function buildInsertEdit(
|
|
71
|
+
export function buildInsertEdit(
|
|
98
72
|
req: InsertReq,
|
|
99
73
|
preload: NormFile,
|
|
100
74
|
ref: Anchor,
|
|
@@ -216,32 +190,68 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
216
190
|
anchor: req.anchor,
|
|
217
191
|
providedPath: req.path,
|
|
218
192
|
cwd: ctx.cwd,
|
|
193
|
+
}).catch((error: unknown) => {
|
|
194
|
+
const member = batchMemberFor(_toolCallId);
|
|
195
|
+
if (member) noteBatchFailure(member, error);
|
|
196
|
+
throw error;
|
|
219
197
|
});
|
|
220
|
-
|
|
221
|
-
|
|
198
|
+
let ref: Anchor;
|
|
199
|
+
let anchorWarnings: string[];
|
|
200
|
+
try {
|
|
201
|
+
({ ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor));
|
|
202
|
+
await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const member = batchMemberFor(_toolCallId);
|
|
205
|
+
if (member) noteBatchFailure(member, error);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
222
208
|
return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
209
|
+
const member = batchMemberFor(_toolCallId);
|
|
223
210
|
const preload = await readNormFile(targetPath, ctx.cwd, {
|
|
224
211
|
signal,
|
|
225
212
|
accessMode: constants.R_OK | constants.W_OK,
|
|
226
213
|
maxLines: MAX_HASH_LINES,
|
|
227
214
|
});
|
|
228
215
|
const { editParams, anchorLine } = buildInsertEdit(req, preload, ref, targetPath);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
216
|
+
if (!member) {
|
|
217
|
+
const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
|
|
218
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
219
|
+
signal,
|
|
220
|
+
preloadedNorm: preload,
|
|
221
|
+
skipBoundaryDedup: true,
|
|
222
|
+
});
|
|
223
|
+
return commitEdit(pipe, {
|
|
224
|
+
path: pipe.path,
|
|
225
|
+
absolutePath,
|
|
226
|
+
mutationTargetPath,
|
|
227
|
+
signal,
|
|
228
|
+
verb: "inserted",
|
|
229
|
+
noopNoun: "Insertion",
|
|
230
|
+
foldedAnchorLines: anchorLine === undefined ? 0 : 1,
|
|
231
|
+
prefixWarnings: [...anchorWarnings, ...insertWarnings],
|
|
232
|
+
onApplied: () => clearBoundaryBypass(mutationTargetPath),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
let hedit: HEdit;
|
|
236
|
+
const resWarnings: string[] = [];
|
|
237
|
+
try {
|
|
238
|
+
hedit = resEdit(editParams, resWarnings);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
noteBatchFailure(member, error);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
return executeBatchMember({
|
|
244
|
+
kind: "insert",
|
|
245
|
+
member,
|
|
246
|
+
targetPath,
|
|
238
247
|
mutationTargetPath,
|
|
248
|
+
cwd: ctx.cwd,
|
|
239
249
|
signal,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
250
|
+
hedit,
|
|
251
|
+
extraWarnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
|
|
252
|
+
skipBoundaryDedup: true,
|
|
253
|
+
strictBoundaryDedup: false,
|
|
254
|
+
foldedLines: anchorLine === undefined ? 0 : 1,
|
|
245
255
|
});
|
|
246
256
|
});
|
|
247
257
|
},
|
package/src/payload-contract.ts
CHANGED
|
@@ -109,3 +109,31 @@ export function getPreviewInput(args: unknown): { path?: string; remove_from: st
|
|
|
109
109
|
replacement_lines: normalized.replacement_lines as string[],
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
|
|
114
|
+
|
|
115
|
+
export interface InsertReq {
|
|
116
|
+
path?: string;
|
|
117
|
+
anchor: string;
|
|
118
|
+
direction: "before" | "after";
|
|
119
|
+
lines: string[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function assertInsertReq(request: unknown): asserts request is InsertReq {
|
|
123
|
+
if (!isRec(request)) {
|
|
124
|
+
throw new Error("[E_BAD_SHAPE] Insert request must be an object.");
|
|
125
|
+
}
|
|
126
|
+
rejectUnknownFields(request, INSERT_KS, "Insert request");
|
|
127
|
+
if (request.path !== undefined && typeof request.path !== "string") {
|
|
128
|
+
throw new Error('[E_BAD_SHAPE] Insert request field "path" must be a string when provided.');
|
|
129
|
+
}
|
|
130
|
+
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
131
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
|
|
132
|
+
}
|
|
133
|
+
if (request.direction !== "before" && request.direction !== "after") {
|
|
134
|
+
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
|
|
137
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
|
|
138
|
+
}
|
|
139
|
+
}
|