Merge pull request #2079 from minpeter/feat/hashline-edit-op-schema
refactor(hashline-edit): align tool payload to op/pos/end/lines
This commit is contained in:
@@ -15,6 +15,7 @@ export function stripMergeOperatorChars(text: string): string {
|
||||
}
|
||||
|
||||
function leadingWhitespace(text: string): string {
|
||||
if (!text) return ""
|
||||
const match = text.match(/^\s*/)
|
||||
return match ? match[0] : ""
|
||||
}
|
||||
@@ -36,7 +37,9 @@ export function restoreOldWrappedLines(originalLines: string[], replacementLines
|
||||
const candidates: { start: number; len: number; replacement: string; canonical: string }[] = []
|
||||
for (let start = 0; start < replacementLines.length; start += 1) {
|
||||
for (let len = 2; len <= 10 && start + len <= replacementLines.length; len += 1) {
|
||||
const canonicalSpan = stripAllWhitespace(replacementLines.slice(start, start + len).join(""))
|
||||
const span = replacementLines.slice(start, start + len)
|
||||
if (span.some((line) => line.trim().length === 0)) continue
|
||||
const canonicalSpan = stripAllWhitespace(span.join(""))
|
||||
const original = canonicalToOriginal.get(canonicalSpan)
|
||||
if (original && original.count === 1 && canonicalSpan.length >= 6) {
|
||||
candidates.push({ start, len, replacement: original.line, canonical: canonicalSpan })
|
||||
@@ -159,6 +162,7 @@ export function restoreIndentForPairedReplacement(
|
||||
if (leadingWhitespace(line).length > 0) return line
|
||||
const indent = leadingWhitespace(originalLines[idx])
|
||||
if (indent.length === 0) return line
|
||||
if (originalLines[idx].trim() === line.trim()) return line
|
||||
return `${indent}${line}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function toHashlineContent(content: string): string {
|
||||
const hashlined = contentLines.map((line, i) => {
|
||||
const lineNum = i + 1
|
||||
const hash = computeLineHash(lineNum, line)
|
||||
return `${lineNum}#${hash}:${line}`
|
||||
return `${lineNum}#${hash}|${line}`
|
||||
})
|
||||
return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function applyReplaceLines(
|
||||
const corrected = autocorrectReplacementLines(originalRange, stripped)
|
||||
const restored = corrected.map((entry, idx) => {
|
||||
if (idx !== 0) return entry
|
||||
return restoreLeadingIndent(lines[startLine - 1], entry)
|
||||
return restoreLeadingIndent(lines[startLine - 1] ?? "", entry)
|
||||
})
|
||||
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
|
||||
return result
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -170,7 +141,7 @@ describe("hashline edit operations", () => {
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
|
||||
//#when
|
||||
const result = applySetLine(lines, anchorFor(lines, 2), "1#VK:first\n2#NP:second")
|
||||
const result = applySetLine(lines, anchorFor(lines, 2), "1#VK|first\n2#NP|second")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line 1", "first", "second", "line 3"])
|
||||
@@ -206,6 +177,28 @@ describe("hashline edit operations", () => {
|
||||
expect(result).toEqual(["if (x) {", " return 2", "}"])
|
||||
})
|
||||
|
||||
it("preserves intentional indentation removal (tab to no-tab)", () => {
|
||||
//#given
|
||||
const lines = ["# Title", "\t1절", "content"]
|
||||
|
||||
//#when
|
||||
const result = applySetLine(lines, anchorFor(lines, 2), "1절")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["# Title", "1절", "content"])
|
||||
})
|
||||
|
||||
it("preserves intentional indentation removal (spaces to no-spaces)", () => {
|
||||
//#given
|
||||
const lines = ["function foo() {", " indented", "}"]
|
||||
|
||||
//#when
|
||||
const result = applySetLine(lines, anchorFor(lines, 2), "indented")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["function foo() {", "indented", "}"])
|
||||
})
|
||||
|
||||
it("strips boundary echo around replace_lines content", () => {
|
||||
//#given
|
||||
const lines = ["before", "old 1", "old 2", "after"]
|
||||
@@ -227,16 +220,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", () => {
|
||||
@@ -250,6 +236,22 @@ describe("hashline edit operations", () => {
|
||||
expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"])
|
||||
})
|
||||
|
||||
it("preserves blank lines and indentation in range replace (no false unwrap)", () => {
|
||||
//#given — reproduces the 애국가 bug where blank+indented lines collapse
|
||||
const lines = ["", "동해물과 백두산이 마르고 닳도록", "하느님이 보우하사 우리나라 만세", "", "무궁화 삼천리 화려강산", "대한사람 대한으로 길이 보전하세", ""]
|
||||
|
||||
//#when — replace the range with indented version (blank lines preserved)
|
||||
const result = applyReplaceLines(
|
||||
lines,
|
||||
anchorFor(lines, 1),
|
||||
anchorFor(lines, 7),
|
||||
["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""]
|
||||
)
|
||||
|
||||
//#then — all 7 lines preserved with indentation, not collapsed to 3
|
||||
expect(result).toEqual(["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""])
|
||||
})
|
||||
|
||||
it("collapses wrapped replacement span back to unique original single line", () => {
|
||||
//#given
|
||||
const lines = [
|
||||
@@ -322,8 +324,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
|
||||
@@ -367,4 +369,33 @@ describe("hashline edit operations", () => {
|
||||
//#then
|
||||
expect(result).toEqual(["const a = 10;", "const b = 20;"])
|
||||
})
|
||||
|
||||
it("throws on overlapping range edits", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
|
||||
const lines = content.split("\n")
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", pos: anchorFor(lines, 1), end: anchorFor(lines, 3), lines: "replaced A" },
|
||||
{ op: "replace", pos: anchorFor(lines, 2), end: anchorFor(lines, 4), lines: "replaced B" },
|
||||
]
|
||||
|
||||
//#when / #then
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow(/overlapping/i)
|
||||
})
|
||||
|
||||
it("allows non-overlapping range edits", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
|
||||
const lines = content.split("\n")
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", pos: anchorFor(lines, 1), end: anchorFor(lines, 2), lines: "replaced A" },
|
||||
{ op: "replace", pos: anchorFor(lines, 4), end: anchorFor(lines, 5), lines: "replaced B" },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("replaced A\nline 3\nreplaced B")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { dedupeEdits } from "./edit-deduplication"
|
||||
import { collectLineRefs, getEditLineNumber } from "./edit-ordering"
|
||||
import { collectLineRefs, detectOverlappingRanges, getEditLineNumber } from "./edit-ordering"
|
||||
import type { HashlineEdit } from "./types"
|
||||
import {
|
||||
applyAppend,
|
||||
applyInsertAfter,
|
||||
applyInsertBefore,
|
||||
applyInsertBetween,
|
||||
applyPrepend,
|
||||
applyReplace,
|
||||
applyReplaceLines,
|
||||
applySetLine,
|
||||
} from "./edit-operation-primitives"
|
||||
@@ -33,42 +31,20 @@ 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)
|
||||
|
||||
const overlapError = detectOverlappingRanges(sortedEdits)
|
||||
if (overlapError) throw new Error(overlapError)
|
||||
|
||||
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 +53,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 +64,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 +74,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 +93,4 @@ export {
|
||||
applyReplaceLines,
|
||||
applyInsertAfter,
|
||||
applyInsertBefore,
|
||||
applyInsertBetween,
|
||||
applyReplace,
|
||||
} from "./edit-operation-primitives"
|
||||
|
||||
@@ -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,23 +16,41 @@ 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 []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function detectOverlappingRanges(edits: HashlineEdit[]): string | null {
|
||||
const ranges: { start: number; end: number; idx: number }[] = []
|
||||
for (let i = 0; i < edits.length; i++) {
|
||||
const edit = edits[i]
|
||||
if (edit.op !== "replace" || !edit.end) continue
|
||||
const start = parseLineRef(edit.pos).line
|
||||
const end = parseLineRef(edit.end).line
|
||||
ranges.push({ start, end, idx: i })
|
||||
}
|
||||
if (ranges.length < 2) return null
|
||||
|
||||
ranges.sort((a, b) => a.start - b.start || a.end - b.end)
|
||||
for (let i = 1; i < ranges.length; i++) {
|
||||
const prev = ranges[i - 1]
|
||||
const curr = ranges[i]
|
||||
if (curr.start <= prev.end) {
|
||||
return (
|
||||
`Overlapping range edits detected: ` +
|
||||
`edit ${prev.idx + 1} (lines ${prev.start}-${prev.end}) overlaps with ` +
|
||||
`edit ${curr.idx + 1} (lines ${curr.start}-${curr.end}). ` +
|
||||
`Use pos-only replace for single-line edits.`
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}:/
|
||||
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\|/
|
||||
const DIFF_PLUS_RE = /^[+](?![+])/
|
||||
|
||||
function equalsIgnoringWhitespace(a: string, b: string): boolean {
|
||||
@@ -7,6 +7,7 @@ function equalsIgnoringWhitespace(a: string, b: string): boolean {
|
||||
}
|
||||
|
||||
function leadingWhitespace(text: string): string {
|
||||
if (!text) return ""
|
||||
const match = text.match(/^\s*/)
|
||||
return match ? match[0] : ""
|
||||
}
|
||||
@@ -53,6 +54,7 @@ export function restoreLeadingIndent(templateLine: string, line: string): string
|
||||
const templateIndent = leadingWhitespace(templateLine)
|
||||
if (templateIndent.length === 0) return line
|
||||
if (leadingWhitespace(line).length > 0) return line
|
||||
if (templateLine.trim() === line.trim()) return line
|
||||
return `${templateIndent}${line}`
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("computeLineHash", () => {
|
||||
})
|
||||
|
||||
describe("formatHashLine", () => {
|
||||
it("formats single line as LINE#ID:content", () => {
|
||||
it("formats single line as LINE#ID|content", () => {
|
||||
//#given
|
||||
const lineNumber = 42
|
||||
const content = "const x = 42"
|
||||
@@ -69,12 +69,12 @@ describe("formatHashLine", () => {
|
||||
const result = formatHashLine(lineNumber, content)
|
||||
|
||||
//#then
|
||||
expect(result).toMatch(/^42#[ZPMQVRWSNKTXJBYH]{2}:const x = 42$/)
|
||||
expect(result).toMatch(/^42#[ZPMQVRWSNKTXJBYH]{2}\|const x = 42$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatHashLines", () => {
|
||||
it("formats all lines as LINE#ID:content", () => {
|
||||
it("formats all lines as LINE#ID|content", () => {
|
||||
//#given
|
||||
const content = "a\nb\nc"
|
||||
|
||||
@@ -84,9 +84,9 @@ describe("formatHashLines", () => {
|
||||
//#then
|
||||
const lines = result.split("\n")
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:a$/)
|
||||
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:b$/)
|
||||
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:c$/)
|
||||
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|a$/)
|
||||
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|b$/)
|
||||
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}\|c$/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function computeLineHash(lineNumber: number, content: string): string {
|
||||
|
||||
export function formatHashLine(lineNumber: number, content: string): string {
|
||||
const hash = computeLineHash(lineNumber, content)
|
||||
return `${lineNumber}#${hash}:${content}`
|
||||
return `${lineNumber}#${hash}|${content}`
|
||||
}
|
||||
|
||||
export function formatHashLines(content: string): string {
|
||||
|
||||
@@ -14,16 +14,16 @@ export function generateHashlineDiff(oldContent: string, newContent: string, fil
|
||||
const hash = computeLineHash(lineNum, newLine)
|
||||
|
||||
if (i >= oldLines.length) {
|
||||
diff += `+ ${lineNum}#${hash}:${newLine}\n`
|
||||
diff += `+ ${lineNum}#${hash}|${newLine}\n`
|
||||
continue
|
||||
}
|
||||
if (i >= newLines.length) {
|
||||
diff += `- ${lineNum}# :${oldLine}\n`
|
||||
diff += `- ${lineNum}# |${oldLine}\n`
|
||||
continue
|
||||
}
|
||||
if (oldLine !== newLine) {
|
||||
diff += `- ${lineNum}# :${oldLine}\n`
|
||||
diff += `+ ${lineNum}#${hash}:${newLine}\n`
|
||||
diff += `- ${lineNum}# |${oldLine}\n`
|
||||
diff += `+ ${lineNum}#${hash}|${newLine}\n`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
|
||||
|
||||
describe("normalizeHashlineEdits", () => {
|
||||
it("maps replace with pos to replace", () => {
|
||||
//#given
|
||||
const input: RawHashlineEdit[] = [{ op: "replace", pos: "2#VK", lines: "updated" }]
|
||||
|
||||
//#when
|
||||
const result = normalizeHashlineEdits(input)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([{ op: "replace", pos: "2#VK", lines: "updated" }])
|
||||
})
|
||||
|
||||
it("maps replace with pos and end to replace", () => {
|
||||
//#given
|
||||
const input: RawHashlineEdit[] = [{ op: "replace", pos: "2#VK", end: "4#MB", lines: ["a", "b"] }]
|
||||
|
||||
//#when
|
||||
const result = normalizeHashlineEdits(input)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([{ op: "replace", pos: "2#VK", end: "4#MB", lines: ["a", "b"] }])
|
||||
})
|
||||
|
||||
it("maps anchored append and prepend preserving op", () => {
|
||||
//#given
|
||||
const input: RawHashlineEdit[] = [
|
||||
{ op: "append", pos: "2#VK", lines: ["after"] },
|
||||
{ op: "prepend", pos: "4#MB", lines: ["before"] },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = normalizeHashlineEdits(input)
|
||||
|
||||
//#then
|
||||
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", () => {
|
||||
//#given
|
||||
const input: RawHashlineEdit[] = [{ op: "prepend", pos: "3#AA", end: "7#BB", lines: ["before"] }]
|
||||
|
||||
//#when
|
||||
const result = normalizeHashlineEdits(input)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([{ op: "prepend", pos: "3#AA", lines: ["before"] }])
|
||||
})
|
||||
|
||||
it("rejects legacy payload without op", () => {
|
||||
//#given
|
||||
const input = [{ type: "set_line", line: "2#VK", text: "updated" }] as unknown as Parameters<
|
||||
typeof normalizeHashlineEdits
|
||||
>[0]
|
||||
|
||||
//#when / #then
|
||||
expect(() => normalizeHashlineEdits(input)).toThrow(/legacy format was removed/i)
|
||||
})
|
||||
})
|
||||
@@ -1,142 +1,95 @@
|
||||
import type { HashlineEdit } from "./types"
|
||||
import type { AppendEdit, HashlineEdit, PrependEdit, ReplaceEdit } from "./types"
|
||||
|
||||
type HashlineToolOp = "replace" | "append" | "prepend"
|
||||
|
||||
export interface RawHashlineEdit {
|
||||
type?:
|
||||
| "set_line"
|
||||
| "replace_lines"
|
||||
| "insert_after"
|
||||
| "insert_before"
|
||||
| "insert_between"
|
||||
| "replace"
|
||||
| "append"
|
||||
| "prepend"
|
||||
line?: string
|
||||
start_line?: string
|
||||
end_line?: string
|
||||
after_line?: string
|
||||
before_line?: string
|
||||
text?: string | string[]
|
||||
old_text?: string
|
||||
new_text?: string | string[]
|
||||
op?: HashlineToolOp
|
||||
pos?: string
|
||||
end?: string
|
||||
lines?: string | string[] | null
|
||||
}
|
||||
|
||||
function firstDefined(...values: Array<string | undefined>): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim() !== "") return value
|
||||
function normalizeAnchor(value: string | undefined): string | undefined {
|
||||
if (typeof value !== "string") return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed === "" ? undefined : trimmed
|
||||
}
|
||||
|
||||
function requireLines(edit: RawHashlineEdit, index: number): string | string[] {
|
||||
if (edit.lines === undefined) {
|
||||
throw new Error(`Edit ${index}: lines is required for ${edit.op ?? "unknown"}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function requireText(edit: RawHashlineEdit, index: number): string | string[] {
|
||||
const text = edit.text ?? edit.new_text
|
||||
if (text === undefined) {
|
||||
throw new Error(`Edit ${index}: text is required for ${edit.type ?? "unknown"}`)
|
||||
if (edit.lines === null) {
|
||||
return []
|
||||
}
|
||||
return text
|
||||
return edit.lines
|
||||
}
|
||||
|
||||
function requireLine(anchor: string | undefined, index: number, op: string): string {
|
||||
function requireLine(anchor: string | undefined, index: number, op: HashlineToolOp): string {
|
||||
if (!anchor) {
|
||||
throw new Error(`Edit ${index}: ${op} requires at least one anchor line reference`)
|
||||
throw new Error(`Edit ${index}: ${op} requires at least one anchor line reference (pos or end)`)
|
||||
}
|
||||
return anchor
|
||||
}
|
||||
|
||||
export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] {
|
||||
const normalized: HashlineEdit[] = []
|
||||
function normalizeReplaceEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
|
||||
const pos = normalizeAnchor(edit.pos)
|
||||
const end = normalizeAnchor(edit.end)
|
||||
const anchor = requireLine(pos ?? end, index, "replace")
|
||||
const lines = requireLines(edit, index)
|
||||
|
||||
for (let index = 0; index < rawEdits.length; index += 1) {
|
||||
const edit = rawEdits[index] ?? {}
|
||||
const type = edit.type
|
||||
|
||||
switch (type) {
|
||||
case "set_line": {
|
||||
const anchor = firstDefined(edit.line, edit.start_line, edit.end_line, edit.after_line, edit.before_line)
|
||||
normalized.push({
|
||||
type: "set_line",
|
||||
line: requireLine(anchor, index, "set_line"),
|
||||
text: requireText(edit, index),
|
||||
})
|
||||
break
|
||||
}
|
||||
case "replace_lines": {
|
||||
const startAnchor = firstDefined(edit.start_line, edit.line, edit.after_line)
|
||||
const endAnchor = firstDefined(edit.end_line, edit.line, edit.before_line)
|
||||
|
||||
if (!startAnchor && !endAnchor) {
|
||||
throw new Error(`Edit ${index}: replace_lines requires start_line or end_line`)
|
||||
}
|
||||
|
||||
if (startAnchor && endAnchor) {
|
||||
normalized.push({
|
||||
type: "replace_lines",
|
||||
start_line: startAnchor,
|
||||
end_line: endAnchor,
|
||||
text: requireText(edit, index),
|
||||
})
|
||||
} else {
|
||||
normalized.push({
|
||||
type: "set_line",
|
||||
line: requireLine(startAnchor ?? endAnchor, index, "replace_lines"),
|
||||
text: requireText(edit, index),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case "insert_after": {
|
||||
const anchor = firstDefined(edit.line, edit.after_line, edit.end_line, edit.start_line)
|
||||
normalized.push({
|
||||
type: "insert_after",
|
||||
line: requireLine(anchor, index, "insert_after"),
|
||||
text: requireText(edit, index),
|
||||
})
|
||||
break
|
||||
}
|
||||
case "insert_before": {
|
||||
const anchor = firstDefined(edit.line, edit.before_line, edit.start_line, edit.end_line)
|
||||
normalized.push({
|
||||
type: "insert_before",
|
||||
line: requireLine(anchor, index, "insert_before"),
|
||||
text: requireText(edit, index),
|
||||
})
|
||||
break
|
||||
}
|
||||
case "insert_between": {
|
||||
const afterLine = firstDefined(edit.after_line, edit.line, edit.start_line)
|
||||
const beforeLine = firstDefined(edit.before_line, edit.end_line, edit.line)
|
||||
normalized.push({
|
||||
type: "insert_between",
|
||||
after_line: requireLine(afterLine, index, "insert_between.after_line"),
|
||||
before_line: requireLine(beforeLine, index, "insert_between.before_line"),
|
||||
text: requireText(edit, index),
|
||||
})
|
||||
break
|
||||
}
|
||||
case "replace": {
|
||||
const oldText = edit.old_text
|
||||
const newText = edit.new_text ?? edit.text
|
||||
if (!oldText) {
|
||||
throw new Error(`Edit ${index}: replace requires old_text`)
|
||||
}
|
||||
if (newText === undefined) {
|
||||
throw new Error(`Edit ${index}: replace requires new_text or text`)
|
||||
}
|
||||
normalized.push({ type: "replace", old_text: oldText, new_text: newText })
|
||||
break
|
||||
}
|
||||
case "append": {
|
||||
normalized.push({ type: "append", text: requireText(edit, index) })
|
||||
break
|
||||
}
|
||||
case "prepend": {
|
||||
normalized.push({ type: "prepend", text: requireText(edit, index) })
|
||||
break
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Edit ${index}: unsupported type "${String(type)}"`)
|
||||
}
|
||||
}
|
||||
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 lines = requireLines(edit, index)
|
||||
|
||||
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 lines = requireLines(edit, index)
|
||||
|
||||
const normalized: PrependEdit = {
|
||||
op: "prepend",
|
||||
lines,
|
||||
}
|
||||
if (anchor) normalized.pos = anchor
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] {
|
||||
return rawEdits.map((rawEdit, index) => {
|
||||
const edit = rawEdit ?? {}
|
||||
|
||||
switch (edit.op) {
|
||||
case "replace":
|
||||
return normalizeReplaceEdit(edit, index)
|
||||
case "append":
|
||||
return normalizeAppendEdit(edit, index)
|
||||
case "prepend":
|
||||
return normalizePrependEdit(edit, index)
|
||||
default:
|
||||
throw new Error(
|
||||
`Edit ${index}: unsupported op "${String(edit.op)}". Legacy format was removed; use op/pos/end/lines.`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,40 +5,40 @@ WORKFLOW:
|
||||
2. Pick the smallest operation per logical mutation site.
|
||||
3. Submit one edit call per file with all related operations.
|
||||
4. If same file needs another call, re-read first.
|
||||
5. Use anchors as "LINE#ID" only (never include trailing ":content").
|
||||
5. Use anchors as "LINE#ID" only (never include trailing "|content").
|
||||
|
||||
VALIDATION:
|
||||
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
|
||||
Each edit must be one of: set_line, replace_lines, insert_after, insert_before, insert_between, replace, append, prepend
|
||||
text/new_text must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
|
||||
CRITICAL: all operations validate against the same pre-edit file snapshot and apply bottom-up. Refs/tags are interpreted against the last-read version of the file.
|
||||
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
|
||||
Each edit must be one of: replace, append, prepend
|
||||
Edit shape: { "op": "replace"|"append"|"prepend", "pos"?: "LINE#ID", "end"?: "LINE#ID", "lines"?: string|string[]|null }
|
||||
lines must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
|
||||
CRITICAL: all operations validate against the same pre-edit file snapshot and apply bottom-up. Refs/tags are interpreted against the last-read version of the file.
|
||||
|
||||
LINE#ID FORMAT (CRITICAL):
|
||||
Each line reference must be in "LINE#ID" format where:
|
||||
LINE: 1-based line number
|
||||
ID: Two CID letters from the set ZPMQVRWSNKTXJBYH
|
||||
Each line reference must be in "{line_number}#{hash_id}" format where:
|
||||
{line_number}: 1-based line number
|
||||
{hash_id}: Two CID letters from the set ZPMQVRWSNKTXJBYH
|
||||
|
||||
FILE MODES:
|
||||
delete=true deletes file and requires edits=[] with no rename
|
||||
rename moves final content to a new path and removes old path
|
||||
|
||||
CONTENT FORMAT:
|
||||
text/new_text can be a string (single line) or string[] (multi-line, preferred).
|
||||
If you pass a multi-line string, it is split by real newline characters.
|
||||
Literal "\\n" is preserved as text.
|
||||
lines can be a string (single line) or string[] (multi-line, preferred).
|
||||
If you pass a multi-line string, it is split by real newline characters.
|
||||
Literal "\\n" is preserved as text.
|
||||
|
||||
FILE CREATION:
|
||||
append: adds content at EOF. If file does not exist, creates it.
|
||||
prepend: adds content at BOF. If file does not exist, creates it.
|
||||
CRITICAL: append/prepend are the only operations that work without an existing file.
|
||||
append without anchors adds content at EOF. If file does not exist, creates it.
|
||||
prepend without anchors adds content at BOF. If file does not exist, creates it.
|
||||
CRITICAL: only unanchored append/prepend can create a missing file.
|
||||
|
||||
OPERATION CHOICE:
|
||||
One line wrong -> set_line
|
||||
Adjacent block rewrite or swap/move -> replace_lines (prefer one range op over many single-line ops)
|
||||
Both boundaries known -> insert_between (ALWAYS prefer over insert_after/insert_before)
|
||||
One boundary known -> insert_after or insert_before
|
||||
New file or EOF/BOF addition -> append or prepend
|
||||
No LINE#ID available -> replace (last resort)
|
||||
replace with pos only -> replace one line at pos (MOST COMMON for single-line edits)
|
||||
replace with pos+end -> replace ENTIRE range pos..end as a block (ranges MUST NOT overlap across edits)
|
||||
append with pos/end anchor -> insert after that anchor
|
||||
prepend with pos/end anchor -> insert before that anchor
|
||||
append/prepend without anchors -> EOF/BOF insertion
|
||||
|
||||
RULES (CRITICAL):
|
||||
1. Minimize scope: one logical mutation site per operation.
|
||||
@@ -53,10 +53,9 @@ RULES (CRITICAL):
|
||||
TAG CHOICE (ALWAYS):
|
||||
- Copy tags exactly from read output or >>> mismatch output.
|
||||
- NEVER guess tags.
|
||||
- Prefer insert_between over insert_after/insert_before when both boundaries are known.
|
||||
- Anchor to structural lines (function/class/brace), NEVER blank lines.
|
||||
- Anti-pattern warning: blank/whitespace anchors are fragile.
|
||||
- Re-read after each successful edit call before issuing another on the same file.
|
||||
- Anchor to structural lines (function/class/brace), NEVER blank lines.
|
||||
- Anti-pattern warning: blank/whitespace anchors are fragile.
|
||||
- Re-read after each successful edit call before issuing another on the same file.
|
||||
|
||||
AUTOCORRECT (built-in - you do NOT need to handle these):
|
||||
Merged lines are auto-expanded back to original line count.
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("createHashlineEditTool", () => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("applies set_line with LINE#ID anchor", async () => {
|
||||
it("applies replace with single LINE#ID anchor", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
||||
@@ -41,7 +41,7 @@ describe("createHashlineEditTool", () => {
|
||||
const result = await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ type: "set_line", line: `2#${hash}`, text: "modified line2" }],
|
||||
edits: [{ op: "replace", pos: `2#${hash}`, lines: "modified line2" }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
@@ -51,7 +51,7 @@ describe("createHashlineEditTool", () => {
|
||||
expect(result).toBe(`Updated ${filePath}`)
|
||||
})
|
||||
|
||||
it("applies replace_lines and insert_after", async () => {
|
||||
it("applies ranged replace and anchored append", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2\nline3\nline4")
|
||||
@@ -65,15 +65,15 @@ describe("createHashlineEditTool", () => {
|
||||
filePath,
|
||||
edits: [
|
||||
{
|
||||
type: "replace_lines",
|
||||
start_line: `2#${line2Hash}`,
|
||||
end_line: `3#${line3Hash}`,
|
||||
text: "replaced",
|
||||
op: "replace",
|
||||
pos: `2#${line2Hash}`,
|
||||
end: `3#${line3Hash}`,
|
||||
lines: "replaced",
|
||||
},
|
||||
{
|
||||
type: "insert_after",
|
||||
line: `4#${line4Hash}`,
|
||||
text: "inserted",
|
||||
op: "append",
|
||||
pos: `4#${line4Hash}`,
|
||||
lines: "inserted",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -93,7 +93,7 @@ describe("createHashlineEditTool", () => {
|
||||
const result = await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ type: "set_line", line: "1#ZZ", text: "new" }],
|
||||
edits: [{ op: "replace", pos: "1#ZZ", lines: "new" }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
@@ -113,7 +113,7 @@ describe("createHashlineEditTool", () => {
|
||||
await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ type: "set_line", line: `1#${line1Hash}`, text: "join(\\n)" }],
|
||||
edits: [{ op: "replace", pos: `1#${line1Hash}`, lines: "join(\\n)" }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
@@ -121,7 +121,7 @@ describe("createHashlineEditTool", () => {
|
||||
await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ type: "insert_after", line: `1#${computeLineHash(1, "join(\\n)")}`, text: ["a", "b"] }],
|
||||
edits: [{ op: "append", pos: `1#${computeLineHash(1, "join(\\n)")}`, lines: ["a", "b"] }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
@@ -130,12 +130,11 @@ describe("createHashlineEditTool", () => {
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("join(\\n)\na\nb\nline2")
|
||||
})
|
||||
|
||||
it("supports insert_before and insert_between", async () => {
|
||||
it("supports anchored prepend and anchored append", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
||||
const line1 = computeLineHash(1, "line1")
|
||||
const line2 = computeLineHash(2, "line2")
|
||||
const line3 = computeLineHash(3, "line3")
|
||||
|
||||
//#when
|
||||
@@ -143,8 +142,8 @@ describe("createHashlineEditTool", () => {
|
||||
{
|
||||
filePath,
|
||||
edits: [
|
||||
{ type: "insert_before", line: `3#${line3}`, text: ["before3"] },
|
||||
{ type: "insert_between", after_line: `1#${line1}`, before_line: `2#${line2}`, text: ["between"] },
|
||||
{ op: "prepend", pos: `3#${line3}`, lines: ["before3"] },
|
||||
{ op: "append", pos: `1#${line1}`, lines: ["between"] },
|
||||
],
|
||||
},
|
||||
createMockContext(),
|
||||
@@ -164,7 +163,7 @@ describe("createHashlineEditTool", () => {
|
||||
const result = await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ type: "insert_after", line: `1#${line1}`, text: [] }],
|
||||
edits: [{ op: "append", pos: `1#${line1}`, lines: [] }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
@@ -186,7 +185,7 @@ describe("createHashlineEditTool", () => {
|
||||
{
|
||||
filePath,
|
||||
rename: renamedPath,
|
||||
edits: [{ type: "set_line", line: `2#${line2}`, text: "line2-updated" }],
|
||||
edits: [{ op: "replace", pos: `2#${line2}`, lines: "line2-updated" }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
@@ -226,8 +225,8 @@ describe("createHashlineEditTool", () => {
|
||||
{
|
||||
filePath,
|
||||
edits: [
|
||||
{ type: "append", text: ["line2"] },
|
||||
{ type: "prepend", text: ["line1"] },
|
||||
{ op: "append", lines: ["line2"] },
|
||||
{ op: "prepend", lines: ["line1"] },
|
||||
],
|
||||
},
|
||||
createMockContext(),
|
||||
@@ -239,7 +238,7 @@ describe("createHashlineEditTool", () => {
|
||||
expect(result).toBe(`Updated ${filePath}`)
|
||||
})
|
||||
|
||||
it("accepts replace_lines with one anchor and downgrades to set_line", async () => {
|
||||
it("accepts replace with one anchor", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "degrade.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
||||
@@ -249,7 +248,7 @@ describe("createHashlineEditTool", () => {
|
||||
const result = await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ type: "replace_lines", start_line: `2#${line2Hash}`, text: ["line2-updated"] }],
|
||||
edits: [{ op: "replace", pos: `2#${line2Hash}`, lines: ["line2-updated"] }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
@@ -259,7 +258,7 @@ describe("createHashlineEditTool", () => {
|
||||
expect(result).toBe(`Updated ${filePath}`)
|
||||
})
|
||||
|
||||
it("accepts insert_after using after_line alias", async () => {
|
||||
it("accepts anchored append using end alias", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "alias.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2")
|
||||
@@ -269,7 +268,7 @@ describe("createHashlineEditTool", () => {
|
||||
await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ type: "insert_after", after_line: `1#${line1Hash}`, text: ["inserted"] }],
|
||||
edits: [{ op: "append", end: `1#${line1Hash}`, lines: ["inserted"] }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
@@ -289,7 +288,7 @@ describe("createHashlineEditTool", () => {
|
||||
await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ type: "set_line", line: `2#${line2Hash}`, text: "line2-updated" }],
|
||||
edits: [{ op: "replace", pos: `2#${line2Hash}`, lines: "line2-updated" }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
|
||||
@@ -20,32 +20,19 @@ export function createHashlineEditTool(): ToolDefinition {
|
||||
edits: tool.schema
|
||||
.array(
|
||||
tool.schema.object({
|
||||
type: tool.schema
|
||||
op: tool.schema
|
||||
.union([
|
||||
tool.schema.literal("set_line"),
|
||||
tool.schema.literal("replace_lines"),
|
||||
tool.schema.literal("insert_after"),
|
||||
tool.schema.literal("insert_before"),
|
||||
tool.schema.literal("insert_between"),
|
||||
tool.schema.literal("replace"),
|
||||
tool.schema.literal("append"),
|
||||
tool.schema.literal("prepend"),
|
||||
])
|
||||
.describe("Edit operation type"),
|
||||
line: tool.schema.string().optional().describe("Anchor line in LINE#ID format"),
|
||||
start_line: tool.schema.string().optional().describe("Range start in LINE#ID format"),
|
||||
end_line: tool.schema.string().optional().describe("Range end in LINE#ID format"),
|
||||
after_line: tool.schema.string().optional().describe("Insert boundary (after) in LINE#ID format"),
|
||||
before_line: tool.schema.string().optional().describe("Insert boundary (before) in LINE#ID format"),
|
||||
text: tool.schema
|
||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
||||
.describe("Hashline edit operation mode"),
|
||||
pos: tool.schema.string().optional().describe("Primary anchor in LINE#ID format"),
|
||||
end: tool.schema.string().optional().describe("Range end anchor in LINE#ID format"),
|
||||
lines: tool.schema
|
||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string()), tool.schema.null()])
|
||||
.optional()
|
||||
.describe("Operation content"),
|
||||
old_text: tool.schema.string().optional().describe("Legacy text replacement source"),
|
||||
new_text: tool.schema
|
||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
||||
.optional()
|
||||
.describe("Legacy text replacement target"),
|
||||
.describe("Replacement or inserted lines. null/[] deletes with replace"),
|
||||
})
|
||||
)
|
||||
.describe("Array of edit operations to apply (empty when delete=true)"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("parseLineRef", () => {
|
||||
|
||||
it("accepts refs copied with markers and trailing content", () => {
|
||||
//#given
|
||||
const ref = ">>> 42#VK:const value = 1"
|
||||
const ref = ">>> 42#VK|const value = 1"
|
||||
|
||||
//#when
|
||||
const result = parseLineRef(ref)
|
||||
@@ -49,7 +49,7 @@ describe("validateLineRef", () => {
|
||||
const lines = ["function hello() {"]
|
||||
|
||||
//#when / #then
|
||||
expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}:/)
|
||||
expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}\|/)
|
||||
})
|
||||
|
||||
it("shows >>> mismatch context in batched validation", () => {
|
||||
@@ -58,7 +58,7 @@ describe("validateLineRef", () => {
|
||||
|
||||
//#when / #then
|
||||
expect(() => validateLineRefs(lines, ["2#ZZ"]))
|
||||
.toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}:two/)
|
||||
.toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}\|two/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("legacy LINE:HEX backward compatibility", () => {
|
||||
const lines = ["function hello() {"]
|
||||
|
||||
//#when / #then
|
||||
expect(() => validateLineRef(lines, "1:ab")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}:/)
|
||||
expect(() => validateLineRef(lines, "1:ab")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}\|/)
|
||||
})
|
||||
|
||||
it("extracts legacy ref from content with markers", () => {
|
||||
|
||||
@@ -115,7 +115,7 @@ export class HashlineMismatchError extends Error {
|
||||
|
||||
const content = fileLines[line - 1] ?? ""
|
||||
const hash = computeLineHash(line, content)
|
||||
const prefix = `${line}#${hash}:${content}`
|
||||
const prefix = `${line}#${hash}|${content}`
|
||||
if (mismatchByLine.has(line)) {
|
||||
output.push(`>>> ${prefix}`)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user