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/README.md +12 -8
- package/index.ts +27 -5
- 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 +6 -6
- package/src/batch.ts +615 -0
- package/src/commit.ts +3 -2
- package/src/config-ui.ts +58 -11
- package/src/config.ts +49 -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 +61 -51
- package/src/payload-contract.ts +30 -2
- package/src/replace-response.ts +2 -2
- package/src/replace-undo.ts +3 -2
- package/src/replace.ts +65 -25
- package/src/write-hook.ts +2 -2
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" | "
|
|
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: "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
89
|
-
config.
|
|
129
|
+
const next = normalizeDiffContextLines(normalizeDiffContextLines(config.diffContextLines) + delta);
|
|
130
|
+
config.diffContextLines = next;
|
|
90
131
|
await writeConfig(config);
|
|
91
|
-
return
|
|
132
|
+
return next;
|
|
92
133
|
}
|
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 {
|