diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts index 41b5c2e6d..754dff41c 100644 --- a/src/tools/hashline-edit/edit-operations.test.ts +++ b/src/tools/hashline-edit/edit-operations.test.ts @@ -6,13 +6,18 @@ import { applyReplaceLines, applySetLine, } from "./edit-operations" +import { computeLineHash } from "./hash-computation" import type { HashlineEdit, InsertAfter, Replace, ReplaceLines, SetLine } from "./types" describe("applySetLine", () => { + function anchorFor(lines: string[], line: number): string { + return `${line}:${computeLineHash(line, lines[line - 1])}` + } + it("replaces a single line at the specified anchor", () => { //#given const lines = ["line 1", "line 2", "line 3"] - const anchor = "2:b2" // line 2 hash + const anchor = anchorFor(lines, 2) //#when const result = applySetLine(lines, anchor, "new line 2") @@ -24,7 +29,7 @@ describe("applySetLine", () => { it("handles newline escapes in replacement text", () => { //#given const lines = ["line 1", "line 2", "line 3"] - const anchor = "2:b2" + const anchor = anchorFor(lines, 2) //#when const result = applySetLine(lines, anchor, "new\\nline") @@ -56,8 +61,8 @@ describe("applyReplaceLines", () => { it("replaces a range of lines", () => { //#given const lines = ["line 1", "line 2", "line 3", "line 4", "line 5"] - const startAnchor = "2:b2" - const endAnchor = "4:5f" + const startAnchor = `${2}:${computeLineHash(2, lines[1])}` + const endAnchor = `${4}:${computeLineHash(4, lines[3])}` //#when const result = applyReplaceLines(lines, startAnchor, endAnchor, "replacement") @@ -69,8 +74,8 @@ describe("applyReplaceLines", () => { it("handles newline escapes in replacement text", () => { //#given const lines = ["line 1", "line 2", "line 3"] - const startAnchor = "2:b2" - const endAnchor = "2:b2" + const startAnchor = `${2}:${computeLineHash(2, lines[1])}` + const endAnchor = `${2}:${computeLineHash(2, lines[1])}` //#when const result = applyReplaceLines(lines, startAnchor, endAnchor, "a\\nb") @@ -83,7 +88,7 @@ describe("applyReplaceLines", () => { //#given const lines = ["line 1", "line 2", "line 3"] const startAnchor = "2:ff" - const endAnchor = "3:83" + const endAnchor = `${3}:${computeLineHash(3, lines[2])}` //#when / #then expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow( @@ -94,7 +99,7 @@ describe("applyReplaceLines", () => { it("throws on end hash mismatch", () => { //#given const lines = ["line 1", "line 2", "line 3"] - const startAnchor = "2:b2" + const startAnchor = `${2}:${computeLineHash(2, lines[1])}` const endAnchor = "3:ff" //#when / #then @@ -106,8 +111,8 @@ describe("applyReplaceLines", () => { it("throws when start > end", () => { //#given const lines = ["line 1", "line 2", "line 3"] - const startAnchor = "3:83" - const endAnchor = "2:b2" + const startAnchor = `${3}:${computeLineHash(3, lines[2])}` + const endAnchor = `${2}:${computeLineHash(2, lines[1])}` //#when / #then expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow( @@ -120,7 +125,7 @@ describe("applyInsertAfter", () => { it("inserts text after the specified line", () => { //#given const lines = ["line 1", "line 2", "line 3"] - const anchor = "2:b2" + const anchor = `${2}:${computeLineHash(2, lines[1])}` //#when const result = applyInsertAfter(lines, anchor, "inserted") @@ -132,7 +137,7 @@ describe("applyInsertAfter", () => { it("handles newline escapes to insert multiple lines", () => { //#given const lines = ["line 1", "line 2", "line 3"] - const anchor = "2:b2" + const anchor = `${2}:${computeLineHash(2, lines[1])}` //#when const result = applyInsertAfter(lines, anchor, "a\\nb\\nc") @@ -144,7 +149,7 @@ describe("applyInsertAfter", () => { it("inserts at end when anchor is last line", () => { //#given const lines = ["line 1", "line 2"] - const anchor = "2:b2" + const anchor = `${2}:${computeLineHash(2, lines[1])}` //#when const result = applyInsertAfter(lines, anchor, "inserted") @@ -218,7 +223,8 @@ describe("applyHashlineEdits", () => { it("applies single set_line edit", () => { //#given const content = "line 1\nline 2\nline 3" - const edits: SetLine[] = [{ type: "set_line", line: "2:b2", text: "new line 2" }] + const line2Hash = computeLineHash(2, "line 2") + const edits: SetLine[] = [{ type: "set_line", line: `2:${line2Hash}`, text: "new line 2" }] //#when const result = applyHashlineEdits(content, edits) @@ -230,9 +236,11 @@ describe("applyHashlineEdits", () => { it("applies multiple edits bottom-up (descending line order)", () => { //#given const content = "line 1\nline 2\nline 3\nline 4\nline 5" + const line2Hash = computeLineHash(2, "line 2") + const line4Hash = computeLineHash(4, "line 4") const edits: SetLine[] = [ - { type: "set_line", line: "2:b2", text: "new 2" }, - { type: "set_line", line: "4:5f", text: "new 4" }, + { type: "set_line", line: `2:${line2Hash}`, text: "new 2" }, + { type: "set_line", line: `4:${line4Hash}`, text: "new 4" }, ] //#when @@ -245,9 +253,11 @@ describe("applyHashlineEdits", () => { it("applies mixed edit types", () => { //#given const content = "line 1\nline 2\nline 3" + const line1Hash = computeLineHash(1, "line 1") + const line3Hash = computeLineHash(3, "line 3") const edits: HashlineEdit[] = [ - { type: "insert_after", line: "1:02", text: "inserted" }, - { type: "set_line", line: "3:83", text: "modified" }, + { type: "insert_after", line: `1:${line1Hash}`, text: "inserted" }, + { type: "set_line", line: `3:${line3Hash}`, text: "modified" }, ] //#when @@ -260,8 +270,10 @@ describe("applyHashlineEdits", () => { it("applies replace_lines edit", () => { //#given const content = "line 1\nline 2\nline 3\nline 4" + const line2Hash = computeLineHash(2, "line 2") + const line3Hash = computeLineHash(3, "line 3") const edits: ReplaceLines[] = [ - { type: "replace_lines", start_line: "2:b2", end_line: "3:83", text: "replaced" }, + { type: "replace_lines", start_line: `2:${line2Hash}`, end_line: `3:${line3Hash}`, text: "replaced" }, ] //#when @@ -307,9 +319,11 @@ describe("applyHashlineEdits", () => { it("correctly handles index shifting with multiple edits", () => { //#given const content = "a\nb\nc\nd\ne" + const line2Hash = computeLineHash(2, "b") + const line4Hash = computeLineHash(4, "d") const edits: InsertAfter[] = [ - { type: "insert_after", line: "2:bf", text: "x" }, - { type: "insert_after", line: "4:90", text: "y" }, + { type: "insert_after", line: `2:${line2Hash}`, text: "x" }, + { type: "insert_after", line: `4:${line4Hash}`, text: "y" }, ] //#when diff --git a/src/tools/hashline-edit/hash-computation.test.ts b/src/tools/hashline-edit/hash-computation.test.ts index 8a0790357..180c92ae5 100644 --- a/src/tools/hashline-edit/hash-computation.test.ts +++ b/src/tools/hashline-edit/hash-computation.test.ts @@ -30,6 +30,18 @@ describe("computeLineHash", () => { expect(hash1).toBe(hash2) }) + it("uses line number in hash input", () => { + //#given + const content = "const stable = true" + + //#when + const hash1 = computeLineHash(1, content) + const hash2 = computeLineHash(2, content) + + //#then + expect(hash1).not.toBe(hash2) + }) + it("handles empty lines", () => { //#given const lineNumber = 1 diff --git a/src/tools/hashline-edit/hash-computation.ts b/src/tools/hashline-edit/hash-computation.ts index 2ab236136..8144b76c4 100644 --- a/src/tools/hashline-edit/hash-computation.ts +++ b/src/tools/hashline-edit/hash-computation.ts @@ -1,8 +1,9 @@ import { HASH_DICT } from "./constants" -export function computeLineHash(_lineNumber: number, content: string): string { +export function computeLineHash(lineNumber: number, content: string): string { const stripped = content.replace(/\s+/g, "") - const hash = Bun.hash.xxHash32(stripped) + const hashInput = `${lineNumber}:${stripped}` + const hash = Bun.hash.xxHash32(hashInput) const index = hash % 256 return HASH_DICT[index] } diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts index e2b852f93..61d8b916a 100644 --- a/src/tools/hashline-edit/tools.ts +++ b/src/tools/hashline-edit/tools.ts @@ -64,11 +64,13 @@ VALIDATION: - Each edit must be one of: set_line, replace_lines, insert_after, replace - text/new_text must contain plain replacement text only (no LINE:HASH prefixes, no diff + markers) -LINE:HASH FORMAT: +LINE:HASH FORMAT (CRITICAL - READ CAREFULLY): Each line reference must be in "LINE:HASH" format where: - LINE: 1-based line number -- HASH: First 2 characters of xxHash32 hash of line content (computed with computeLineHash) -- Example: "5:a3|const x = 1" means line 5 with hash "a3" +- HASH: First 2 characters of xxHash32 hash of line content (hex characters 0-9, a-f only) +- Example: "5:a3" means line 5 with hash "a3" +- WRONG: "2:co" (contains non-hex 'o') - will fail! +- CORRECT: "2:e8" (hex characters only) GETTING HASHES: Use the read tool - it returns lines in "LINE:HASH|content" format. diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts index b7ac1ab69..e824a4aae 100644 --- a/src/tools/hashline-edit/validation.test.ts +++ b/src/tools/hashline-edit/validation.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "bun:test" +import { computeLineHash } from "./hash-computation" import { parseLineRef, validateLineRef } from "./validation" describe("parseLineRef", () => { @@ -61,7 +62,7 @@ describe("validateLineRef", () => { it("validates matching hash", () => { //#given const lines = ["function hello() {", " return 42", "}"] - const ref = "1:42" + const ref = `1:${computeLineHash(1, lines[0])}` //#when & #then expect(() => validateLineRef(lines, ref)).not.toThrow()