refactor(hashline-edit): enforce three-op edit model

Unify internal hashline edit handling around replace/append/prepend to remove legacy operation shapes. This keeps normalization, ordering, deduplication, execution, and tests aligned with the new op/pos/end/lines contract.
This commit is contained in:
minpeter
2026-02-24 05:06:41 +09:00
parent 6ec0ff732b
commit 08b663df86
10 changed files with 86 additions and 261 deletions

View File

@@ -6,23 +6,13 @@ function normalizeEditPayload(payload: string | string[]): string {
}
function buildDedupeKey(edit: HashlineEdit): string {
switch (edit.type) {
case "set_line":
return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}`
case "replace_lines":
return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}`
case "insert_after":
return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}`
case "insert_before":
return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}`
case "insert_between":
return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}`
switch (edit.op) {
case "replace":
return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}`
return `replace|${edit.pos}|${edit.end ?? ""}|${normalizeEditPayload(edit.lines)}`
case "append":
return `append|${normalizeEditPayload(edit.text)}`
return `append|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
case "prepend":
return `prepend|${normalizeEditPayload(edit.text)}`
return `prepend|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
default:
return JSON.stringify(edit)
}

View File

@@ -150,11 +150,3 @@ export function applyPrepend(lines: string[], text: string | string[]): string[]
}
return [...normalized, ...lines]
}
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
if (!content.includes(oldText)) {
throw new Error(`Text not found: "${oldText}"`)
}
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
return content.replaceAll(oldText, replacement)
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test"
import { applyHashlineEdits, applyInsertAfter, applyReplace, applyReplaceLines, applySetLine } from "./edit-operations"
import { applyAppend, applyPrepend } from "./edit-operation-primitives"
import { applyHashlineEdits, applyInsertAfter, applyReplaceLines, applySetLine } from "./edit-operations"
import { applyAppend, applyInsertBetween, applyPrepend } from "./edit-operation-primitives"
import { computeLineHash } from "./hash-computation"
import type { HashlineEdit } from "./types"
@@ -49,7 +49,7 @@ describe("hashline edit operations", () => {
//#when
const result = applyHashlineEdits(
lines.join("\n"),
[{ type: "insert_before", line: anchorFor(lines, 2), text: "before 2" }]
[{ op: "prepend", pos: anchorFor(lines, 2), lines: "before 2" }]
)
//#then
@@ -61,15 +61,7 @@ describe("hashline edit operations", () => {
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applyHashlineEdits(
lines.join("\n"),
[{
type: "insert_between",
after_line: anchorFor(lines, 1),
before_line: anchorFor(lines, 2),
text: ["between"],
}]
)
const result = applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["between"]).join("\n")
//#then
expect(result).toEqual("line 1\nbetween\nline 2\nline 3")
@@ -89,7 +81,7 @@ describe("hashline edit operations", () => {
//#when / #then
expect(() =>
applyHashlineEdits(lines.join("\n"), [{ type: "insert_before", line: anchorFor(lines, 1), text: [] }])
applyHashlineEdits(lines.join("\n"), [{ op: "prepend", pos: anchorFor(lines, 1), lines: [] }])
).toThrow(/non-empty/i)
})
@@ -98,28 +90,7 @@ describe("hashline edit operations", () => {
const lines = ["line 1", "line 2"]
//#when / #then
expect(() =>
applyHashlineEdits(
lines.join("\n"),
[{
type: "insert_between",
after_line: anchorFor(lines, 1),
before_line: anchorFor(lines, 2),
text: [],
}]
)
).toThrow(/non-empty/i)
})
it("applies replace operation", () => {
//#given
const content = "hello world foo"
//#when
const result = applyReplace(content, "world", "universe")
//#then
expect(result).toEqual("hello universe foo")
expect(() => applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), [])).toThrow(/non-empty/i)
})
it("applies mixed edits in one pass", () => {
@@ -127,8 +98,8 @@ describe("hashline edit operations", () => {
const content = "line 1\nline 2\nline 3"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ type: "insert_after", line: anchorFor(lines, 1), text: "inserted" },
{ type: "set_line", line: anchorFor(lines, 3), text: "modified" },
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
{ op: "replace", pos: anchorFor(lines, 3), lines: "modified" },
]
//#when
@@ -143,8 +114,8 @@ describe("hashline edit operations", () => {
const content = "line 1\nline 2"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ type: "insert_after", line: anchorFor(lines, 1), text: "inserted" },
{ type: "insert_after", line: anchorFor(lines, 1), text: "inserted" },
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
{ op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
]
//#when
@@ -227,16 +198,9 @@ describe("hashline edit operations", () => {
const lines = ["line 1", "line 2", "line 3"]
//#when / #then
expect(() =>
applyHashlineEdits(lines.join("\n"), [
{
type: "insert_between",
after_line: anchorFor(lines, 1),
before_line: anchorFor(lines, 2),
text: ["line 1", "line 2"],
},
])
).toThrow(/non-empty/i)
expect(() => applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["line 1", "line 2"])).toThrow(
/non-empty/i
)
})
it("restores indentation for first replace_lines entry", () => {
@@ -322,8 +286,8 @@ describe("hashline edit operations", () => {
//#when
const result = applyHashlineEdits(content, [
{ type: "append", text: ["line 3"] },
{ type: "prepend", text: ["line 0"] },
{ op: "append", lines: ["line 3"] },
{ op: "prepend", lines: ["line 0"] },
])
//#then

View File

@@ -5,9 +5,7 @@ import {
applyAppend,
applyInsertAfter,
applyInsertBefore,
applyInsertBetween,
applyPrepend,
applyReplace,
applyReplaceLines,
applySetLine,
} from "./edit-operation-primitives"
@@ -33,42 +31,17 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
let noopEdits = 0
let result = content
let lines = result.length === 0 ? [] : result.split("\n")
let lines = content.length === 0 ? [] : content.split("\n")
const refs = collectLineRefs(sortedEdits)
validateLineRefs(lines, refs)
for (const edit of sortedEdits) {
switch (edit.type) {
case "set_line": {
lines = applySetLine(lines, edit.line, edit.text, { skipValidation: true })
break
}
case "replace_lines": {
lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text, { skipValidation: true })
break
}
case "insert_after": {
const next = applyInsertAfter(lines, edit.line, edit.text, { skipValidation: true })
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
}
lines = next
break
}
case "insert_before": {
const next = applyInsertBefore(lines, edit.line, edit.text, { skipValidation: true })
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
}
lines = next
break
}
case "insert_between": {
const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text, { skipValidation: true })
switch (edit.op) {
case "replace": {
const next = edit.end
? applyReplaceLines(lines, edit.pos, edit.end, edit.lines, { skipValidation: true })
: applySetLine(lines, edit.pos, edit.lines, { skipValidation: true })
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
@@ -77,7 +50,9 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
break
}
case "append": {
const next = applyAppend(lines, edit.text)
const next = edit.pos
? applyInsertAfter(lines, edit.pos, edit.lines, { skipValidation: true })
: applyAppend(lines, edit.lines)
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
@@ -86,7 +61,9 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
break
}
case "prepend": {
const next = applyPrepend(lines, edit.text)
const next = edit.pos
? applyInsertBefore(lines, edit.pos, edit.lines, { skipValidation: true })
: applyPrepend(lines, edit.lines)
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
@@ -94,17 +71,6 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
lines = next
break
}
case "replace": {
result = lines.join("\n")
const replaced = applyReplace(result, edit.old_text, edit.new_text)
if (replaced === result) {
noopEdits += 1
break
}
result = replaced
lines = result.split("\n")
break
}
}
}
@@ -124,6 +90,4 @@ export {
applyReplaceLines,
applyInsertAfter,
applyInsertBefore,
applyInsertBetween,
applyReplace,
} from "./edit-operation-primitives"

View File

@@ -2,23 +2,13 @@ import { parseLineRef } from "./validation"
import type { HashlineEdit } from "./types"
export function getEditLineNumber(edit: HashlineEdit): number {
switch (edit.type) {
case "set_line":
return parseLineRef(edit.line).line
case "replace_lines":
return parseLineRef(edit.end_line).line
case "insert_after":
return parseLineRef(edit.line).line
case "insert_before":
return parseLineRef(edit.line).line
case "insert_between":
return parseLineRef(edit.before_line).line
case "append":
return Number.NEGATIVE_INFINITY
case "prepend":
return Number.NEGATIVE_INFINITY
switch (edit.op) {
case "replace":
return Number.NEGATIVE_INFINITY
return parseLineRef(edit.end ?? edit.pos).line
case "append":
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
case "prepend":
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
default:
return Number.POSITIVE_INFINITY
}
@@ -26,21 +16,12 @@ export function getEditLineNumber(edit: HashlineEdit): number {
export function collectLineRefs(edits: HashlineEdit[]): string[] {
return edits.flatMap((edit) => {
switch (edit.type) {
case "set_line":
return [edit.line]
case "replace_lines":
return [edit.start_line, edit.end_line]
case "insert_after":
return [edit.line]
case "insert_before":
return [edit.line]
case "insert_between":
return [edit.after_line, edit.before_line]
switch (edit.op) {
case "replace":
return edit.end ? [edit.pos, edit.end] : [edit.pos]
case "append":
case "prepend":
case "replace":
return []
return edit.pos ? [edit.pos] : []
default:
return []
}

View File

@@ -32,7 +32,7 @@ function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
if (edits.length === 0) return false
return edits.every((edit) => edit.type === "append" || edit.type === "prepend")
return edits.every((edit) => edit.op === "append" || edit.op === "prepend")
}
function buildSuccessMeta(

View File

@@ -8,14 +8,9 @@ export {
export { parseLineRef, validateLineRef } from "./validation"
export type { LineRef } from "./validation"
export type {
SetLine,
ReplaceLines,
InsertAfter,
InsertBefore,
InsertBetween,
Replace,
Append,
Prepend,
ReplaceEdit,
AppendEdit,
PrependEdit,
HashlineEdit,
} from "./types"
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants"
@@ -23,8 +18,6 @@ export {
applyHashlineEdits,
applyInsertAfter,
applyInsertBefore,
applyInsertBetween,
applyReplace,
applyReplaceLines,
applySetLine,
} from "./edit-operations"

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
describe("normalizeHashlineEdits", () => {
it("maps replace with pos to set_line", () => {
it("maps replace with pos to replace", () => {
//#given
const input: RawHashlineEdit[] = [{ op: "replace", pos: "2#VK", lines: "updated" }]
@@ -10,10 +10,10 @@ describe("normalizeHashlineEdits", () => {
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ type: "set_line", line: "2#VK", text: "updated" }])
expect(result).toEqual([{ op: "replace", pos: "2#VK", lines: "updated" }])
})
it("maps replace with pos and end to replace_lines", () => {
it("maps replace with pos and end to replace", () => {
//#given
const input: RawHashlineEdit[] = [{ op: "replace", pos: "2#VK", end: "4#MB", lines: ["a", "b"] }]
@@ -21,10 +21,10 @@ describe("normalizeHashlineEdits", () => {
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ type: "replace_lines", start_line: "2#VK", end_line: "4#MB", text: ["a", "b"] }])
expect(result).toEqual([{ op: "replace", pos: "2#VK", end: "4#MB", lines: ["a", "b"] }])
})
it("maps anchored append and prepend to insert operations", () => {
it("maps anchored append and prepend preserving op", () => {
//#given
const input: RawHashlineEdit[] = [
{ op: "append", pos: "2#VK", lines: ["after"] },
@@ -35,10 +35,7 @@ describe("normalizeHashlineEdits", () => {
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([
{ type: "insert_after", line: "2#VK", text: ["after"] },
{ type: "insert_before", line: "4#MB", text: ["before"] },
])
expect(result).toEqual([{ op: "append", pos: "2#VK", lines: ["after"] }, { op: "prepend", pos: "4#MB", lines: ["before"] }])
})
it("prefers pos over end for prepend anchors", () => {
@@ -49,7 +46,7 @@ describe("normalizeHashlineEdits", () => {
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ type: "insert_before", line: "3#AA", text: ["before"] }])
expect(result).toEqual([{ op: "prepend", pos: "3#AA", lines: ["before"] }])
})
it("rejects legacy payload without op", () => {

View File

@@ -1,4 +1,4 @@
import type { HashlineEdit } from "./types"
import type { AppendEdit, HashlineEdit, PrependEdit, ReplaceEdit } from "./types"
type HashlineToolOp = "replace" | "append" | "prepend"
@@ -36,62 +36,43 @@ function normalizeReplaceEdit(edit: RawHashlineEdit, index: number): HashlineEdi
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = requireLine(pos ?? end, index, "replace")
const text = requireLines(edit, index)
const lines = requireLines(edit, index)
if (pos && end) {
return {
type: "replace_lines",
start_line: pos,
end_line: end,
text,
}
}
return {
type: "set_line",
line: anchor,
text,
const normalized: ReplaceEdit = {
op: "replace",
pos: anchor,
lines,
}
if (end) normalized.end = end
return normalized
}
function normalizeAppendEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = pos ?? end
const text = requireLines(edit, index)
const lines = requireLines(edit, index)
if (!anchor) {
return {
type: "append",
text,
}
}
return {
type: "insert_after",
line: anchor,
text,
const normalized: AppendEdit = {
op: "append",
lines,
}
if (anchor) normalized.pos = anchor
return normalized
}
function normalizePrependEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = pos ?? end
const text = requireLines(edit, index)
const lines = requireLines(edit, index)
if (!anchor) {
return {
type: "prepend",
text,
}
}
return {
type: "insert_before",
line: anchor,
text,
const normalized: PrependEdit = {
op: "prepend",
lines,
}
if (anchor) normalized.pos = anchor
return normalized
}
export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] {

View File

@@ -1,57 +1,20 @@
export interface SetLine {
type: "set_line"
line: string
text: string | string[]
export interface ReplaceEdit {
op: "replace"
pos: string
end?: string
lines: string | string[]
}
export interface ReplaceLines {
type: "replace_lines"
start_line: string
end_line: string
text: string | string[]
export interface AppendEdit {
op: "append"
pos?: string
lines: string | string[]
}
export interface InsertAfter {
type: "insert_after"
line: string
text: string | string[]
export interface PrependEdit {
op: "prepend"
pos?: string
lines: string | string[]
}
export interface InsertBefore {
type: "insert_before"
line: string
text: string | string[]
}
export interface InsertBetween {
type: "insert_between"
after_line: string
before_line: string
text: string | string[]
}
export interface Replace {
type: "replace"
old_text: string
new_text: string | string[]
}
export interface Append {
type: "append"
text: string | string[]
}
export interface Prepend {
type: "prepend"
text: string | string[]
}
export type HashlineEdit =
| SetLine
| ReplaceLines
| InsertAfter
| InsertBefore
| InsertBetween
| Replace
| Append
| Prepend
export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit