pi-hashline-edit-pro 4.1.0 → 4.2.1

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/src/config-ui.ts CHANGED
@@ -2,22 +2,27 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { readConfig, type Config } from "./config";
4
4
 
5
- export type ConfigToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupEnabled";
5
+ export type ConfigToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
9
9
  label: string;
10
10
  hint: string;
11
11
  enabled: boolean;
12
+ mode?: string;
13
+ cycle?: string[];
14
+ value?: number;
15
+ disabled?: boolean;
12
16
  }
13
17
 
14
18
  export function configRows(config: Config): ConfigRow[] {
15
19
  return [
16
20
  { key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
21
+ { key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
17
22
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
18
23
  { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
19
24
  { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
20
- { key: "boundaryDedupEnabled", label: "Boundary dedup", hint: "Strip re-included edge lines (off = literal)", enabled: config.boundaryDedupEnabled !== false },
25
+ { key: "boundaryDedupMode", label: "Boundary dedup", hint: "Strip edge lines: on, strict (reject), off (literal)", enabled: (config.boundaryDedupMode ?? "on") !== "off", mode: config.boundaryDedupMode ?? "on", cycle: ["on", "strict", "off"] },
21
26
  ];
22
27
  }
23
28
 
@@ -26,11 +31,22 @@ function padRow(theme: Theme, innerWidth: number, content: string): string {
26
31
  return theme.fg("border", "│") + padded + theme.fg("border", "│");
27
32
  }
28
33
 
34
+ function modeBox(theme: Theme, mode: string): string {
35
+ if (mode === "off") return theme.fg("dim", `[${mode}]`);
36
+ if (mode === "strict") return theme.fg("accent", `[${mode}]`);
37
+ return theme.fg("success", `[${mode}]`);
38
+ }
39
+
40
+ function numberBox(theme: Theme, value: number, disabled?: boolean): string {
41
+ if (disabled) return theme.fg("dim", `[${value}]`);
42
+ return theme.fg("accent", `[${value}]`);
43
+ }
44
+
29
45
  export class HashlineConfigOverlay {
30
46
  private rows: ConfigRow[];
31
47
  private selected = 0;
32
48
 
33
- constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey) => Promise<void> }) {
49
+ constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey, delta?: number) => Promise<void> }) {
34
50
  this.rows = [];
35
51
  }
36
52
 
@@ -38,12 +54,9 @@ export class HashlineConfigOverlay {
38
54
  this.rows = configRows(await readConfig());
39
55
  }
40
56
 
41
- private toggleSelected(): void {
42
- const row = this.rows[this.selected];
43
- if (!row) return;
44
- row.enabled = !row.enabled;
57
+ private runToggle(row: ConfigRow, delta?: number): void {
45
58
  this.opts.tui.requestRender(true);
46
- void this.opts.onToggle(row.key).then(async () => {
59
+ void this.opts.onToggle(row.key, delta).then(async () => {
47
60
  this.rows = configRows(await readConfig());
48
61
  this.opts.tui.requestRender(true);
49
62
  }).catch((error: unknown) => {
@@ -51,6 +64,32 @@ export class HashlineConfigOverlay {
51
64
  });
52
65
  }
53
66
 
67
+ private toggleSelected(): void {
68
+ const row = this.rows[this.selected];
69
+ if (!row || row.disabled) return;
70
+ if (row.value !== undefined) {
71
+ row.value += 1;
72
+ this.runToggle(row, 1);
73
+ return;
74
+ }
75
+ if (row.cycle && row.mode !== undefined) {
76
+ const next = row.cycle[(row.cycle.indexOf(row.mode) + 1) % row.cycle.length] ?? row.cycle[0];
77
+ if (next === undefined) return;
78
+ row.mode = next;
79
+ row.enabled = next !== "off";
80
+ } else {
81
+ row.enabled = !row.enabled;
82
+ }
83
+ this.runToggle(row);
84
+ }
85
+
86
+ private adjustSelected(delta: number): void {
87
+ const row = this.rows[this.selected];
88
+ if (!row || row.disabled || row.value === undefined) return;
89
+ row.value += delta;
90
+ this.runToggle(row, delta);
91
+ }
92
+
54
93
  handleInput(data: string): void {
55
94
  if (matchesKey(data, Key.up) || data === "k") {
56
95
  this.selected = (this.selected + this.rows.length - 1) % this.rows.length;
@@ -60,6 +99,14 @@ export class HashlineConfigOverlay {
60
99
  this.selected = (this.selected + 1) % this.rows.length;
61
100
  return;
62
101
  }
102
+ if (matchesKey(data, Key.right) || data === "+" || data === "=") {
103
+ this.adjustSelected(1);
104
+ return;
105
+ }
106
+ if (matchesKey(data, Key.left) || data === "-" || data === "_") {
107
+ this.adjustSelected(-1);
108
+ return;
109
+ }
63
110
  if (matchesKey(data, Key.space) || matchesKey(data, Key.enter) || data === " " || data === "\r" || data === "\n") {
64
111
  this.toggleSelected();
65
112
  return;
@@ -81,12 +128,12 @@ export class HashlineConfigOverlay {
81
128
  lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
82
129
  this.rows.forEach((row, index) => {
83
130
  const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
84
- const box = row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
85
- const label = index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
131
+ const box = row.value !== undefined ? numberBox(theme, row.value, row.disabled) : row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
132
+ const label = row.disabled ? theme.fg("dim", row.label) : index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
86
133
  lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", `— ${row.hint}`)}`));
87
134
  });
88
135
  lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
89
- lines.push(padRow(theme, innerWidth, theme.fg("dim", " ↑↓ navigate · space toggle · q close")));
136
+ lines.push(padRow(theme, innerWidth, theme.fg("dim", " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · q close")));
90
137
  lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
91
138
  return lines;
92
139
  }
package/src/config.ts CHANGED
@@ -3,12 +3,19 @@ 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
+
8
+ export const DEFAULT_DIFF_CONTEXT_LINES = 1;
9
+ export const MIN_DIFF_CONTEXT_LINES = 0;
10
+ export const MAX_DIFF_CONTEXT_LINES = 10;
11
+
6
12
  export interface Config {
7
13
  autoRead: boolean;
8
14
  anchorGrepEnabled: boolean;
9
15
  requirePath?: boolean;
10
16
  strictInput?: boolean;
11
- boundaryDedupEnabled?: boolean;
17
+ boundaryDedupMode?: BoundaryDedupMode;
18
+ diffContextLines?: number;
12
19
  }
13
20
 
14
21
  const DEFAULT_CONFIG: Config = {
@@ -16,9 +23,27 @@ const DEFAULT_CONFIG: Config = {
16
23
  anchorGrepEnabled: true,
17
24
  requirePath: false,
18
25
  strictInput: false,
19
- boundaryDedupEnabled: true
26
+ boundaryDedupMode: "on",
27
+ diffContextLines: DEFAULT_DIFF_CONTEXT_LINES
20
28
  };
21
29
 
30
+ const BOUNDARY_DEDUP_MODES: BoundaryDedupMode[] = ["on", "strict", "off"];
31
+
32
+ function parseBoundaryDedupMode(mode: unknown, legacy: unknown): BoundaryDedupMode {
33
+ if (mode === "on" || mode === "strict" || mode === "off") return mode;
34
+ if (legacy === true) return "on";
35
+ if (legacy === false) return "off";
36
+ return DEFAULT_CONFIG.boundaryDedupMode ?? "on";
37
+ }
38
+
39
+ export function normalizeDiffContextLines(value: unknown): number {
40
+ if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_DIFF_CONTEXT_LINES;
41
+ const floored = Math.floor(value);
42
+ if (floored < MIN_DIFF_CONTEXT_LINES) return MIN_DIFF_CONTEXT_LINES;
43
+ if (floored > MAX_DIFF_CONTEXT_LINES) return MAX_DIFF_CONTEXT_LINES;
44
+ return floored;
45
+ }
46
+
22
47
  function parseConfig(content: string): Config {
23
48
  const parsed = JSON.parse(content) as unknown;
24
49
  const autoRead = isRec(parsed) ? parsed.autoRead : undefined;
@@ -28,13 +53,16 @@ function parseConfig(content: string): Config {
28
53
  const anchorGrepEnabled = isRec(parsed) ? parsed.anchorGrepEnabled : undefined;
29
54
  const requirePath = isRec(parsed) ? parsed.requirePath : undefined;
30
55
  const strictInput = isRec(parsed) ? parsed.strictInput : undefined;
31
- const boundaryDedupEnabled = isRec(parsed) ? parsed.boundaryDedupEnabled : undefined;
56
+ const boundaryDedupMode = isRec(parsed) ? parsed.boundaryDedupMode : undefined;
57
+ const legacyBoundaryDedup = isRec(parsed) ? parsed.boundaryDedupEnabled : undefined;
58
+ const diffContextLines = isRec(parsed) ? parsed.diffContextLines : undefined;
32
59
  return {
33
60
  autoRead,
34
61
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
35
62
  requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
36
63
  strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
37
- boundaryDedupEnabled: typeof boundaryDedupEnabled === "boolean" ? boundaryDedupEnabled : DEFAULT_CONFIG.boundaryDedupEnabled,
64
+ boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
65
+ diffContextLines: normalizeDiffContextLines(diffContextLines),
38
66
  };
39
67
  }
40
68
 
@@ -83,10 +111,23 @@ export async function toggleStrictInput(): Promise<boolean> {
83
111
  return config.strictInput === true;
84
112
  }
85
113
 
86
- export async function toggleBoundaryDedup(): Promise<boolean> {
114
+ export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
115
+ const config = await readConfig();
116
+ const current = config.boundaryDedupMode ?? "on";
117
+ const next = BOUNDARY_DEDUP_MODES[(BOUNDARY_DEDUP_MODES.indexOf(current) + 1) % BOUNDARY_DEDUP_MODES.length] ?? "on";
118
+ config.boundaryDedupMode = next;
119
+ await writeConfig(config);
120
+ return next;
121
+ }
122
+
123
+ export async function getDiffContextLines(): Promise<number> {
124
+ return normalizeDiffContextLines((await readConfig()).diffContextLines);
125
+ }
126
+
127
+ export async function adjustDiffContextLines(delta: number): Promise<number> {
87
128
  const config = await readConfig();
88
- const enabled = config.boundaryDedupEnabled !== false;
89
- config.boundaryDedupEnabled = !enabled;
129
+ const next = normalizeDiffContextLines(normalizeDiffContextLines(config.diffContextLines) + delta);
130
+ config.diffContextLines = next;
90
131
  await writeConfig(config);
91
- return !enabled;
132
+ return next;
92
133
  }
@@ -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
- boundaryDedupEnabled: boolean;
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
- boundaryDedupEnabled: true,
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
- boundaryDedupEnabled: config.boundaryDedupEnabled !== false,
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 edit per turn; verify each result before the next edit on that file." : guideline);
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 (!flags.boundaryDedupEnabled) {
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; enable require-path in /hashline-config to require it.");
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 (disable it in /hashline-config).');
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 (disable it in /hashline-config):\n${fixes.join("\n")}`);
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 isBoundaryDedupEnabled(): Promise<boolean> {
146
- const { boundaryDedupEnabled } = await readConfig();
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; free anchors with /clear-anchors or narrow the search.]`);
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
  ? {
@@ -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
- abortIf(signal);
168
-
169
- const lineIndex = buildIdx(content);
170
- if (precomputedHashes === undefined) {
171
- throw new Error("[E_BAD_SHAPE] applyEdit requires the file's allocated anchors; derive them via lineHashes(content, path) first.");
172
- }
173
- const fileHashes = precomputedHashes;
174
- const warnings: string[] = [];
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 {
@@ -43,7 +43,10 @@ export {
43
43
  export {
44
44
  buildIdx,
45
45
  applyEdit,
46
+ planEdit,
47
+ type PlannedEdit,
46
48
  fmtRegion,
47
49
  fmtRow,
48
50
  changedRange,
51
+ assertNotEmpty,
49
52
  } from "./apply";