From 6ec0ff732ba6a231740bc29454463850ff571787 Mon Sep 17 00:00:00 2001 From: minpeter Date: Tue, 24 Feb 2026 03:00:38 +0900 Subject: [PATCH 1/7] refactor(hashline-edit): align tool payload to op/pos/end/lines Unify hashline_edit input with replace/append/prepend + pos/end/lines semantics so callers use a single stable shape. Add normalization coverage and refresh tool guidance/tests to reduce schema confusion and stale legacy payload usage. --- .../hashline-edit/normalize-edits.test.ts | 64 +++++ src/tools/hashline-edit/normalize-edits.ts | 218 ++++++++---------- src/tools/hashline-edit/tool-description.ts | 39 ++-- src/tools/hashline-edit/tools.test.ts | 51 ++-- src/tools/hashline-edit/tools.ts | 27 +-- 5 files changed, 210 insertions(+), 189 deletions(-) create mode 100644 src/tools/hashline-edit/normalize-edits.test.ts diff --git a/src/tools/hashline-edit/normalize-edits.test.ts b/src/tools/hashline-edit/normalize-edits.test.ts new file mode 100644 index 00000000..87cd05a6 --- /dev/null +++ b/src/tools/hashline-edit/normalize-edits.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test" +import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits" + +describe("normalizeHashlineEdits", () => { + it("maps replace with pos to set_line", () => { + //#given + const input: RawHashlineEdit[] = [{ op: "replace", pos: "2#VK", lines: "updated" }] + + //#when + const result = normalizeHashlineEdits(input) + + //#then + expect(result).toEqual([{ type: "set_line", line: "2#VK", text: "updated" }]) + }) + + it("maps replace with pos and end to replace_lines", () => { + //#given + const input: RawHashlineEdit[] = [{ op: "replace", pos: "2#VK", end: "4#MB", lines: ["a", "b"] }] + + //#when + const result = normalizeHashlineEdits(input) + + //#then + expect(result).toEqual([{ type: "replace_lines", start_line: "2#VK", end_line: "4#MB", text: ["a", "b"] }]) + }) + + it("maps anchored append and prepend to insert operations", () => { + //#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([ + { type: "insert_after", line: "2#VK", text: ["after"] }, + { type: "insert_before", line: "4#MB", text: ["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([{ type: "insert_before", line: "3#AA", text: ["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) + }) +}) diff --git a/src/tools/hashline-edit/normalize-edits.ts b/src/tools/hashline-edit/normalize-edits.ts index b4e49d38..913b86ed 100644 --- a/src/tools/hashline-edit/normalize-edits.ts +++ b/src/tools/hashline-edit/normalize-edits.ts @@ -1,142 +1,114 @@ import type { HashlineEdit } 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 { - 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 text = 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)}"`) - } + if (pos && end) { + return { + type: "replace_lines", + start_line: pos, + end_line: end, + text, } } - return normalized + return { + type: "set_line", + line: anchor, + text, + } +} + +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) + + if (!anchor) { + return { + type: "append", + text, + } + } + + return { + type: "insert_after", + line: anchor, + text, + } +} + +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) + + if (!anchor) { + return { + type: "prepend", + text, + } + } + + return { + type: "insert_before", + line: anchor, + text, + } +} + +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.` + ) + } + }) } diff --git a/src/tools/hashline-edit/tool-description.ts b/src/tools/hashline-edit/tool-description.ts index d255ea8c..f95ea902 100644 --- a/src/tools/hashline-edit/tool-description.ts +++ b/src/tools/hashline-edit/tool-description.ts @@ -8,10 +8,11 @@ WORKFLOW: 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: @@ -23,22 +24,21 @@ FILE MODES: 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 + replace with pos+end -> replace range pos..end + 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. diff --git a/src/tools/hashline-edit/tools.test.ts b/src/tools/hashline-edit/tools.test.ts index 8f2c4f65..46f663a3 100644 --- a/src/tools/hashline-edit/tools.test.ts +++ b/src/tools/hashline-edit/tools.test.ts @@ -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(), ) diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts index 98a25894..13265029 100644 --- a/src/tools/hashline-edit/tools.ts +++ b/src/tools/hashline-edit/tools.ts @@ -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)"), From 08b663df86fdc55dbbfb271b8a43c4f2e5457c3a Mon Sep 17 00:00:00 2001 From: minpeter Date: Tue, 24 Feb 2026 05:06:41 +0900 Subject: [PATCH 2/7] 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. --- src/tools/hashline-edit/edit-deduplication.ts | 18 ++--- .../edit-operation-primitives.ts | 8 --- .../hashline-edit/edit-operations.test.ts | 66 +++++-------------- src/tools/hashline-edit/edit-operations.ts | 60 ++++------------- src/tools/hashline-edit/edit-ordering.ts | 39 +++-------- .../hashline-edit/hashline-edit-executor.ts | 2 +- src/tools/hashline-edit/index.ts | 13 +--- .../hashline-edit/normalize-edits.test.ts | 17 ++--- src/tools/hashline-edit/normalize-edits.ts | 59 ++++++----------- src/tools/hashline-edit/types.ts | 65 ++++-------------- 10 files changed, 86 insertions(+), 261 deletions(-) diff --git a/src/tools/hashline-edit/edit-deduplication.ts b/src/tools/hashline-edit/edit-deduplication.ts index d6ccaa1b..e689bb53 100644 --- a/src/tools/hashline-edit/edit-deduplication.ts +++ b/src/tools/hashline-edit/edit-deduplication.ts @@ -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) } diff --git a/src/tools/hashline-edit/edit-operation-primitives.ts b/src/tools/hashline-edit/edit-operation-primitives.ts index efd88f79..fc07c611 100644 --- a/src/tools/hashline-edit/edit-operation-primitives.ts +++ b/src/tools/hashline-edit/edit-operation-primitives.ts @@ -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) -} diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts index d169cb72..24c0cdce 100644 --- a/src/tools/hashline-edit/edit-operations.test.ts +++ b/src/tools/hashline-edit/edit-operations.test.ts @@ -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 diff --git a/src/tools/hashline-edit/edit-operations.ts b/src/tools/hashline-edit/edit-operations.ts index 7e2279a2..0aa2e539 100644 --- a/src/tools/hashline-edit/edit-operations.ts +++ b/src/tools/hashline-edit/edit-operations.ts @@ -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" diff --git a/src/tools/hashline-edit/edit-ordering.ts b/src/tools/hashline-edit/edit-ordering.ts index d8196bab..f5658779 100644 --- a/src/tools/hashline-edit/edit-ordering.ts +++ b/src/tools/hashline-edit/edit-ordering.ts @@ -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 [] } diff --git a/src/tools/hashline-edit/hashline-edit-executor.ts b/src/tools/hashline-edit/hashline-edit-executor.ts index 777ae162..bbbba4ac 100644 --- a/src/tools/hashline-edit/hashline-edit-executor.ts +++ b/src/tools/hashline-edit/hashline-edit-executor.ts @@ -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( diff --git a/src/tools/hashline-edit/index.ts b/src/tools/hashline-edit/index.ts index d76f7e1f..97a0ba7b 100644 --- a/src/tools/hashline-edit/index.ts +++ b/src/tools/hashline-edit/index.ts @@ -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" diff --git a/src/tools/hashline-edit/normalize-edits.test.ts b/src/tools/hashline-edit/normalize-edits.test.ts index 87cd05a6..45cf6f25 100644 --- a/src/tools/hashline-edit/normalize-edits.test.ts +++ b/src/tools/hashline-edit/normalize-edits.test.ts @@ -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", () => { diff --git a/src/tools/hashline-edit/normalize-edits.ts b/src/tools/hashline-edit/normalize-edits.ts index 913b86ed..03b28f7c 100644 --- a/src/tools/hashline-edit/normalize-edits.ts +++ b/src/tools/hashline-edit/normalize-edits.ts @@ -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[] { diff --git a/src/tools/hashline-edit/types.ts b/src/tools/hashline-edit/types.ts index f69f2757..e0fc8485 100644 --- a/src/tools/hashline-edit/types.ts +++ b/src/tools/hashline-edit/types.ts @@ -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 From 1cb362773b02e0ed9858ca67de26ef6637cd9735 Mon Sep 17 00:00:00 2001 From: minpeter Date: Tue, 24 Feb 2026 05:47:05 +0900 Subject: [PATCH 3/7] fix(hashline-read-enhancer): handle inline tag from updated OpenCode read tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode updated its read tool output format — the tag now shares a line with the first content line (1: content) with no newline. The hook's exact indexOf('') detection returned -1, causing all read output to pass through unmodified (no hash anchors). This silently disabled the entire hashline-edit workflow. Fixes: - Sub-bug 1: Use findIndex + startsWith instead of exact indexOf match - Sub-bug 2: Extract inline content after prefix as first line - Sub-bug 3: Normalize open-tag line to bare tag in output (no duplicate) Also adds backward compat for legacy + 00001| pipe format. --- src/hooks/hashline-read-enhancer/hook.ts | 67 +++++++++++--- .../hashline-read-enhancer/index.test.ts | 87 +++++++++++++++++++ 2 files changed, 140 insertions(+), 14 deletions(-) diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 9a3b5863..3852d64f 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -6,9 +6,12 @@ interface HashlineReadEnhancerConfig { hashline_edit?: { enabled: boolean } } -const READ_LINE_PATTERN = /^(\d+): ?(.*)$/ +const COLON_READ_LINE_PATTERN = /^\s*(\d+): ?(.*)$/ +const PIPE_READ_LINE_PATTERN = /^\s*(\d+)\| ?(.*)$/ const CONTENT_OPEN_TAG = "" const CONTENT_CLOSE_TAG = "" +const FILE_OPEN_TAG = "" +const FILE_CLOSE_TAG = "" function isReadTool(toolName: string): boolean { return toolName.toLowerCase() === "read" @@ -24,18 +27,36 @@ function shouldProcess(config: HashlineReadEnhancerConfig): boolean { function isTextFile(output: string): boolean { const firstLine = output.split("\n")[0] ?? "" - return READ_LINE_PATTERN.test(firstLine) + return COLON_READ_LINE_PATTERN.test(firstLine) || PIPE_READ_LINE_PATTERN.test(firstLine) +} + +function parseReadLine(line: string): { lineNumber: number; content: string } | null { + const colonMatch = COLON_READ_LINE_PATTERN.exec(line) + if (colonMatch) { + return { + lineNumber: Number.parseInt(colonMatch[1], 10), + content: colonMatch[2], + } + } + + const pipeMatch = PIPE_READ_LINE_PATTERN.exec(line) + if (pipeMatch) { + return { + lineNumber: Number.parseInt(pipeMatch[1], 10), + content: pipeMatch[2], + } + } + + return null } function transformLine(line: string): string { - const match = READ_LINE_PATTERN.exec(line) - if (!match) { + const parsed = parseReadLine(line) + if (!parsed) { return line } - const lineNumber = parseInt(match[1], 10) - const content = match[2] - const hash = computeLineHash(lineNumber, content) - return `${lineNumber}#${hash}:${content}` + const hash = computeLineHash(parsed.lineNumber, parsed.content) + return `${parsed.lineNumber}#${hash}:${parsed.content}` } function transformOutput(output: string): string { @@ -44,25 +65,43 @@ function transformOutput(output: string): string { } const lines = output.split("\n") - const contentStart = lines.indexOf(CONTENT_OPEN_TAG) + const contentStart = lines.findIndex( + (line) => line === CONTENT_OPEN_TAG || line.startsWith(CONTENT_OPEN_TAG) + ) const contentEnd = lines.indexOf(CONTENT_CLOSE_TAG) + const fileStart = lines.findIndex((line) => line === FILE_OPEN_TAG || line.startsWith(FILE_OPEN_TAG)) + const fileEnd = lines.indexOf(FILE_CLOSE_TAG) - if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart + 1) { - const fileLines = lines.slice(contentStart + 1, contentEnd) + const blockStart = contentStart !== -1 ? contentStart : fileStart + const blockEnd = contentStart !== -1 ? contentEnd : fileEnd + const openTag = contentStart !== -1 ? CONTENT_OPEN_TAG : FILE_OPEN_TAG + + if (blockStart !== -1 && blockEnd !== -1 && blockEnd > blockStart) { + const openLine = lines[blockStart] ?? "" + const inlineFirst = openLine.startsWith(openTag) && openLine !== openTag + ? openLine.slice(openTag.length) + : null + const fileLines = inlineFirst !== null + ? [inlineFirst, ...lines.slice(blockStart + 1, blockEnd)] + : lines.slice(blockStart + 1, blockEnd) if (!isTextFile(fileLines[0] ?? "")) { return output } const result: string[] = [] for (const line of fileLines) { - if (!READ_LINE_PATTERN.test(line)) { + if (!parseReadLine(line)) { result.push(...fileLines.slice(result.length)) break } result.push(transformLine(line)) } - return [...lines.slice(0, contentStart + 1), ...result, ...lines.slice(contentEnd)].join("\n") + const prefixLines = inlineFirst !== null + ? [...lines.slice(0, blockStart), openTag] + : lines.slice(0, blockStart + 1) + + return [...prefixLines, ...result, ...lines.slice(blockEnd)].join("\n") } if (!isTextFile(lines[0] ?? "")) { @@ -71,7 +110,7 @@ function transformOutput(output: string): string { const result: string[] = [] for (const line of lines) { - if (!READ_LINE_PATTERN.test(line)) { + if (!parseReadLine(line)) { result.push(...lines.slice(result.length)) break } diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index 6081a6f5..14f2f23b 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, it, expect } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import { createHashlineReadEnhancerHook } from "./hook" @@ -50,6 +52,38 @@ describe("hashline-read-enhancer", () => { expect(lines[10]).toBe("1: keep this unchanged") }) + it("hashifies inline format from updated OpenCode read tool", async () => { + //#given + const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) + const input = { tool: "read", sessionID: "s", callID: "c" } + const output = { + title: "demo.ts", + output: [ + "/tmp/demo.ts", + "file", + "1: const x = 1", + "2: const y = 2", + "", + "(End of file - total 2 lines)", + "", + ].join("\n"), + metadata: {}, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then + const lines = output.output.split("\n") + expect(lines[0]).toBe("/tmp/demo.ts") + expect(lines[1]).toBe("file") + expect(lines[2]).toBe("") + expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) + expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) + expect(lines[6]).toBe("(End of file - total 2 lines)") + expect(lines[7]).toBe("") + }) + it("hashifies plain read output without content tags", async () => { //#given const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) @@ -77,6 +111,59 @@ describe("hashline-read-enhancer", () => { expect(lines[4]).toBe("(End of file - total 3 lines)") }) + it("hashifies read output with and zero-padded pipe format", async () => { + //#given + const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) + const input = { tool: "read", sessionID: "s", callID: "c" } + const output = { + title: "demo.ts", + output: [ + "", + "00001| const x = 1", + "00002| const y = 2", + "", + "(End of file - total 2 lines)", + "", + ].join("\n"), + metadata: {}, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then + const lines = output.output.split("\n") + expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) + expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) + expect(lines[5]).toBe("") + }) + + it("hashifies pipe format even with leading spaces", async () => { + //#given + const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) + const input = { tool: "read", sessionID: "s", callID: "c" } + const output = { + title: "demo.ts", + output: [ + "", + " 00001| const x = 1", + " 00002| const y = 2", + "", + "(End of file - total 2 lines)", + "", + ].join("\n"), + metadata: {}, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then + const lines = output.output.split("\n") + expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) + expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) + }) + it("appends LINE#ID output for write tool using metadata filepath", async () => { //#given const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) From 54b756c145ec78bc9d0a57af476a489db6dacff0 Mon Sep 17 00:00:00 2001 From: minpeter Date: Tue, 24 Feb 2026 06:01:24 +0900 Subject: [PATCH 4/7] refactor(hashline): change content separator from colon to pipe Change LINE#HASH:content format to LINE#HASH|content across the entire codebase. The pipe separator is more visually distinct and avoids conflicts with TypeScript colons in code content. 15 files updated: implementation, prompts, tests, and READMEs. --- README.ja.md | 6 ++-- README.ko.md | 6 ++-- README.md | 6 ++-- README.zh-cn.md | 6 ++-- src/hooks/hashline-read-enhancer/hook.ts | 6 ++-- .../hashline-read-enhancer/index.test.ts | 28 +++++++++---------- src/tools/hashline-edit/diff-utils.ts | 2 +- .../hashline-edit/edit-operations.test.ts | 2 +- .../hashline-edit/edit-text-normalization.ts | 2 +- .../hashline-edit/hash-computation.test.ts | 12 ++++---- src/tools/hashline-edit/hash-computation.ts | 2 +- src/tools/hashline-edit/hashline-edit-diff.ts | 8 +++--- src/tools/hashline-edit/tool-description.ts | 2 +- src/tools/hashline-edit/validation.test.ts | 8 +++--- src/tools/hashline-edit/validation.ts | 2 +- 15 files changed, 49 insertions(+), 49 deletions(-) diff --git a/README.ja.md b/README.ja.md index 80de7ca6..31de23e8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -217,9 +217,9 @@ MCPサーバーがあなたのコンテキスト予算を食いつぶしてい [oh-my-pi](https://github.com/can1357/oh-my-pi) に触発され、**Hashline**を実装しました。エージェントが読むすべての行にコンテンツハッシュがタグ付けされて返されます: ``` -11#VK: function hello() { -22#XJ: return "world"; -33#MB: } +11#VK| function hello() { +22#XJ| return "world"; +33#MB| } ``` エージェントはこのタグを参照して編集します。最後に読んだ後でファイルが変更されていた場合、ハッシュが一致せず、コードが壊れる前に編集が拒否されます。空白を正確に再現する必要もなく、間違った行を編集するエラー (stale-line) もありません。 diff --git a/README.ko.md b/README.ko.md index 213b0b39..2c72b545 100644 --- a/README.ko.md +++ b/README.ko.md @@ -216,9 +216,9 @@ MCP 서버들이 당신의 컨텍스트 예산을 다 잡아먹죠. 우리가 [oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감을 받아, **Hashline**을 구현했습니다. 에이전트가 읽는 모든 줄에는 콘텐츠 해시 태그가 붙어 나옵니다: ``` -11#VK: function hello() { -22#XJ: return "world"; -33#MB: } +11#VK| function hello() { +22#XJ| return "world"; +33#MB| } ``` 에이전트는 이 태그를 참조해서 편집합니다. 마지막으로 읽은 후 파일이 변경되었다면 해시가 일치하지 않아 코드가 망가지기 전에 편집이 거부됩니다. 공백을 똑같이 재현할 필요도 없고, 엉뚱한 줄을 수정하는 에러(stale-line)도 없습니다. diff --git a/README.md b/README.md index 1ce701e7..e314903b 100644 --- a/README.md +++ b/README.md @@ -220,9 +220,9 @@ The harness problem is real. Most agent failures aren't the model. It's the edit Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi), we implemented **Hashline**. Every line the agent reads comes back tagged with a content hash: ``` -11#VK: function hello() { -22#XJ: return "world"; -33#MB: } +11#VK| function hello() { +22#XJ| return "world"; +33#MB| } ``` The agent edits by referencing those tags. If the file changed since the last read, the hash won't match and the edit is rejected before corruption. No whitespace reproduction. No stale-line errors. diff --git a/README.zh-cn.md b/README.zh-cn.md index 0968baac..8d0c34dc 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -218,9 +218,9 @@ Harness 问题是真的。绝大多数所谓的 Agent 故障,其实并不是 受 [oh-my-pi](https://github.com/can1357/oh-my-pi) 的启发,我们实现了 **Hashline** 技术。Agent 读到的每一行代码,末尾都会打上一个强绑定的内容哈希值: ``` -11#VK: function hello() { -22#XJ: return "world"; -33#MB: } +11#VK| function hello() { +22#XJ| return "world"; +33#MB| } ``` Agent 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。 diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 3852d64f..b6f4db30 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -56,7 +56,7 @@ function transformLine(line: string): string { return line } const hash = computeLineHash(parsed.lineNumber, parsed.content) - return `${parsed.lineNumber}#${hash}:${parsed.content}` + return `${parsed.lineNumber}#${hash}|${parsed.content}` } function transformOutput(output: string): string { @@ -137,7 +137,7 @@ function extractFilePath(metadata: unknown): string | undefined { } async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise { - if (output.output.includes("Updated file (LINE#ID:content):")) { + if (output.output.includes("Updated file (LINE#ID|content):")) { return } @@ -153,7 +153,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk const content = await file.text() const hashlined = toHashlineContent(content) - output.output = `${output.output}\n\nUpdated file (LINE#ID:content):\n${hashlined}` + output.output = `${output.output}\n\nUpdated file (LINE#ID|content):\n${hashlined}` } export function createHashlineReadEnhancerHook( diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index 14f2f23b..0f41874b 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -47,8 +47,8 @@ describe("hashline-read-enhancer", () => { //#then const lines = output.output.split("\n") - expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) - expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) + expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/) + expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/) expect(lines[10]).toBe("1: keep this unchanged") }) @@ -78,8 +78,8 @@ describe("hashline-read-enhancer", () => { expect(lines[0]).toBe("/tmp/demo.ts") expect(lines[1]).toBe("file") expect(lines[2]).toBe("") - expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) - expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) + expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/) + expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/) expect(lines[6]).toBe("(End of file - total 2 lines)") expect(lines[7]).toBe("") }) @@ -105,9 +105,9 @@ describe("hashline-read-enhancer", () => { //#then const lines = output.output.split("\n") - expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:# Oh-My-OpenCode Features$/) - expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:$/) - expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:Hashline test$/) + expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|# Oh-My-OpenCode Features$/) + expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|$/) + expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}\|Hashline test$/) expect(lines[4]).toBe("(End of file - total 3 lines)") }) @@ -133,8 +133,8 @@ describe("hashline-read-enhancer", () => { //#then const lines = output.output.split("\n") - expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) - expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) + expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/) + expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/) expect(lines[5]).toBe("") }) @@ -160,8 +160,8 @@ describe("hashline-read-enhancer", () => { //#then const lines = output.output.split("\n") - expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) - expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) + expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/) + expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/) }) it("appends LINE#ID output for write tool using metadata filepath", async () => { @@ -181,9 +181,9 @@ describe("hashline-read-enhancer", () => { await hook["tool.execute.after"](input, output) //#then - expect(output.output).toContain("Updated file (LINE#ID:content):") - expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1/) - expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2/) + expect(output.output).toContain("Updated file (LINE#ID|content):") + expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1/) + expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2/) fs.rmSync(tempDir, { recursive: true, force: true }) }) diff --git a/src/tools/hashline-edit/diff-utils.ts b/src/tools/hashline-edit/diff-utils.ts index f2f74732..7104a3a4 100644 --- a/src/tools/hashline-edit/diff-utils.ts +++ b/src/tools/hashline-edit/diff-utils.ts @@ -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") } diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts index 24c0cdce..53910660 100644 --- a/src/tools/hashline-edit/edit-operations.test.ts +++ b/src/tools/hashline-edit/edit-operations.test.ts @@ -141,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"]) diff --git a/src/tools/hashline-edit/edit-text-normalization.ts b/src/tools/hashline-edit/edit-text-normalization.ts index beb6ac87..8e38c50c 100644 --- a/src/tools/hashline-edit/edit-text-normalization.ts +++ b/src/tools/hashline-edit/edit-text-normalization.ts @@ -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 { diff --git a/src/tools/hashline-edit/hash-computation.test.ts b/src/tools/hashline-edit/hash-computation.test.ts index d73a2db2..fe4fff0c 100644 --- a/src/tools/hashline-edit/hash-computation.test.ts +++ b/src/tools/hashline-edit/hash-computation.test.ts @@ -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$/) }) }) diff --git a/src/tools/hashline-edit/hash-computation.ts b/src/tools/hashline-edit/hash-computation.ts index 8371887d..ec6773a9 100644 --- a/src/tools/hashline-edit/hash-computation.ts +++ b/src/tools/hashline-edit/hash-computation.ts @@ -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 { diff --git a/src/tools/hashline-edit/hashline-edit-diff.ts b/src/tools/hashline-edit/hashline-edit-diff.ts index 9ea1f613..901828e7 100644 --- a/src/tools/hashline-edit/hashline-edit-diff.ts +++ b/src/tools/hashline-edit/hashline-edit-diff.ts @@ -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` } } diff --git a/src/tools/hashline-edit/tool-description.ts b/src/tools/hashline-edit/tool-description.ts index f95ea902..4a9a6dd5 100644 --- a/src/tools/hashline-edit/tool-description.ts +++ b/src/tools/hashline-edit/tool-description.ts @@ -5,7 +5,7 @@ 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 } diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts index e821bea1..fc401cbf 100644 --- a/src/tools/hashline-edit/validation.test.ts +++ b/src/tools/hashline-edit/validation.test.ts @@ -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", () => { diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts index 5f09610f..f81ccbaa 100644 --- a/src/tools/hashline-edit/validation.ts +++ b/src/tools/hashline-edit/validation.ts @@ -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 { From c7efe8f002f8845bbdb4645c6a94e4b16e5f3d4e Mon Sep 17 00:00:00 2001 From: minpeter Date: Tue, 24 Feb 2026 14:07:21 +0900 Subject: [PATCH 5/7] fix(hashline-edit): preserve intentional whitespace removal in autocorrect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restoreIndentForPairedReplacement() and restoreLeadingIndent() unconditionally restored original indentation when replacement had none, preventing intentional indentation changes (e.g. removing a tab from '\t1절' to '1절'). Skip indent restoration when trimmed content is identical, indicating a whitespace-only edit. --- .../autocorrect-replacement-lines.ts | 1 + .../hashline-edit/edit-operations.test.ts | 22 +++++++++++++++++++ .../hashline-edit/edit-text-normalization.ts | 1 + 3 files changed, 24 insertions(+) diff --git a/src/tools/hashline-edit/autocorrect-replacement-lines.ts b/src/tools/hashline-edit/autocorrect-replacement-lines.ts index 6b9d9a77..719f9d6c 100644 --- a/src/tools/hashline-edit/autocorrect-replacement-lines.ts +++ b/src/tools/hashline-edit/autocorrect-replacement-lines.ts @@ -159,6 +159,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}` }) } diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts index 53910660..d66c2d94 100644 --- a/src/tools/hashline-edit/edit-operations.test.ts +++ b/src/tools/hashline-edit/edit-operations.test.ts @@ -177,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"] diff --git a/src/tools/hashline-edit/edit-text-normalization.ts b/src/tools/hashline-edit/edit-text-normalization.ts index 8e38c50c..9bae8cc4 100644 --- a/src/tools/hashline-edit/edit-text-normalization.ts +++ b/src/tools/hashline-edit/edit-text-normalization.ts @@ -53,6 +53,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}` } From 60cf2de16fc0f830e47faea9e4d0388f8393150f Mon Sep 17 00:00:00 2001 From: minpeter Date: Tue, 24 Feb 2026 14:46:17 +0900 Subject: [PATCH 6/7] fix(hashline-edit): detect overlapping ranges and prevent false unwrap of blank-line spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add detectOverlappingRanges() to reject edits with overlapping pos..end ranges instead of crashing with undefined.match() - Add bounds guard (?? "") in edit-operation-primitives for out-of-range line access - Add null guard in leadingWhitespace() for undefined/empty input - Fix restoreOldWrappedLines false unwrap: skip candidate spans containing blank/whitespace-only lines, preventing incorrect collapse of structural blank lines and indentation (the "애국가 bug") - Improve tool description for range replace clarity - Add tests: overlapping range detection, false unwrap prevention --- .../autocorrect-replacement-lines.ts | 5 ++- .../edit-operation-primitives.ts | 2 +- .../hashline-edit/edit-operations.test.ts | 45 +++++++++++++++++++ src/tools/hashline-edit/edit-operations.ts | 5 ++- src/tools/hashline-edit/edit-ordering.ts | 27 +++++++++++ .../hashline-edit/edit-text-normalization.ts | 1 + src/tools/hashline-edit/tool-description.ts | 4 +- 7 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/tools/hashline-edit/autocorrect-replacement-lines.ts b/src/tools/hashline-edit/autocorrect-replacement-lines.ts index 719f9d6c..fe0c5c72 100644 --- a/src/tools/hashline-edit/autocorrect-replacement-lines.ts +++ b/src/tools/hashline-edit/autocorrect-replacement-lines.ts @@ -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 }) diff --git a/src/tools/hashline-edit/edit-operation-primitives.ts b/src/tools/hashline-edit/edit-operation-primitives.ts index fc07c611..43904011 100644 --- a/src/tools/hashline-edit/edit-operation-primitives.ts +++ b/src/tools/hashline-edit/edit-operation-primitives.ts @@ -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 diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts index d66c2d94..eb0adbaf 100644 --- a/src/tools/hashline-edit/edit-operations.test.ts +++ b/src/tools/hashline-edit/edit-operations.test.ts @@ -236,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 = [ @@ -353,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") + }) }) diff --git a/src/tools/hashline-edit/edit-operations.ts b/src/tools/hashline-edit/edit-operations.ts index 0aa2e539..8558b51d 100644 --- a/src/tools/hashline-edit/edit-operations.ts +++ b/src/tools/hashline-edit/edit-operations.ts @@ -1,5 +1,5 @@ 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, @@ -36,6 +36,9 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi const refs = collectLineRefs(sortedEdits) validateLineRefs(lines, refs) + const overlapError = detectOverlappingRanges(sortedEdits) + if (overlapError) throw new Error(overlapError) + for (const edit of sortedEdits) { switch (edit.op) { case "replace": { diff --git a/src/tools/hashline-edit/edit-ordering.ts b/src/tools/hashline-edit/edit-ordering.ts index f5658779..3a9444fc 100644 --- a/src/tools/hashline-edit/edit-ordering.ts +++ b/src/tools/hashline-edit/edit-ordering.ts @@ -27,3 +27,30 @@ export function collectLineRefs(edits: HashlineEdit[]): string[] { } }) } + +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 +} diff --git a/src/tools/hashline-edit/edit-text-normalization.ts b/src/tools/hashline-edit/edit-text-normalization.ts index 9bae8cc4..7eaf1da2 100644 --- a/src/tools/hashline-edit/edit-text-normalization.ts +++ b/src/tools/hashline-edit/edit-text-normalization.ts @@ -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] : "" } diff --git a/src/tools/hashline-edit/tool-description.ts b/src/tools/hashline-edit/tool-description.ts index 4a9a6dd5..958509b7 100644 --- a/src/tools/hashline-edit/tool-description.ts +++ b/src/tools/hashline-edit/tool-description.ts @@ -34,8 +34,8 @@ FILE CREATION: CRITICAL: only unanchored append/prepend can create a missing file. OPERATION CHOICE: - replace with pos only -> replace one line at pos - replace with pos+end -> replace range pos..end + 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 From f50f3d3c3770e784c1260a250c847a3f03fbdb40 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Feb 2026 15:00:06 +0900 Subject: [PATCH 7/7] fix(hashline-edit): clarify LINE#ID placeholder to prevent literal interpretation --- src/tools/hashline-edit/tool-description.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tools/hashline-edit/tool-description.ts b/src/tools/hashline-edit/tool-description.ts index 958509b7..0b0ee00f 100644 --- a/src/tools/hashline-edit/tool-description.ts +++ b/src/tools/hashline-edit/tool-description.ts @@ -15,9 +15,9 @@ VALIDATION: 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