import { describe, expect, it } from "bun:test"; import { applyHashlineEdits, buildCompactHashlineDiffPreview, computeLineHash, formatHashLines, HashlineMismatchError, hashlineParseText, parseTag, streamHashLinesFromLines, streamHashLinesFromUtf8, stripNewLinePrefixes, validateLineRef, } from "@oh-my-pi/pi-coding-agent/patch"; import { type Anchor, formatLineTag, type HashlineEdit } from "@oh-my-pi/pi-coding-agent/patch/hashline"; function makeTag(line: number, content: string): Anchor { return parseTag(formatLineTag(line, content)); } // ═══════════════════════════════════════════════════════════════════════════ // computeLineHash // ═══════════════════════════════════════════════════════════════════════════ describe("computeLineHash", () => { it("returns 2-4 character alphanumeric hash string", () => { const hash = computeLineHash(1, "hello"); expect(hash).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/); }); it("same content at same line produces same hash", () => { const a = computeLineHash(1, "hello"); const b = computeLineHash(1, "hello"); expect(a).toBe(b); }); it("different content produces different hash", () => { const a = computeLineHash(1, "hello"); const b = computeLineHash(1, "world"); expect(a).not.toBe(b); }); it("empty line produces valid hash", () => { const hash = computeLineHash(1, ""); expect(hash).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/); }); it("uses line number for symbol-only lines", () => { const a = computeLineHash(1, "***"); const b = computeLineHash(2, "***"); expect(a).not.toBe(b); }); it("does not use line number for alphanumeric lines", () => { const a = computeLineHash(1, "hello"); const b = computeLineHash(2, "hello"); expect(a).toBe(b); }); }); // ═══════════════════════════════════════════════════════════════════════════ // formatHashLines // ═══════════════════════════════════════════════════════════════════════════ describe("formatHashLines", () => { it("formats single line", () => { const result = formatHashLines("hello"); const hash = computeLineHash(1, "hello"); expect(result).toBe(`1#${hash}:hello`); }); it("formats multiple lines with 1-indexed numbers", () => { const result = formatHashLines("foo\nbar\nbaz"); const lines = result.split("\n"); expect(lines).toHaveLength(3); expect(lines[0]).toStartWith("1#"); expect(lines[1]).toStartWith("2#"); expect(lines[2]).toStartWith("3#"); }); it("respects custom startLine", () => { const result = formatHashLines("foo\nbar", 10); const lines = result.split("\n"); expect(lines[0]).toStartWith("10#"); expect(lines[1]).toStartWith("11#"); }); it("handles empty lines in content", () => { const result = formatHashLines("foo\n\nbar"); const lines = result.split("\n"); expect(lines).toHaveLength(3); expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:$/); }); it("round-trips with computeLineHash", () => { const content = "function hello() {\n return 42;\n}"; const formatted = formatHashLines(content); const lines = formatted.split("\n"); for (let i = 0; i < lines.length; i++) { const match = lines[i].match(/^(\d+)#([ZPMQVRWSNKTXJBYH]{2}):(.*)$/); expect(match).not.toBeNull(); const lineNum = Number.parseInt(match![1], 10); const hash = match![2]; const lineContent = match![3]; expect(computeLineHash(lineNum, lineContent)).toBe(hash); } }); }); // ═══════════════════════════════════════════════════════════════════════════ // streamHashLinesFromUtf8 / streamHashLinesFromLines // ═══════════════════════════════════════════════════════════════════════════ describe("streamHashLinesFrom*", () => { async function collectText(gen: AsyncIterable): Promise { const parts: string[] = []; for await (const part of gen) { parts.push(part); } return parts.join("\n"); } async function* utf8Chunks(text: string, chunkSize: number): AsyncGenerator { const bytes = new TextEncoder().encode(text); for (let i = 0; i < bytes.length; i += chunkSize) { yield bytes.slice(i, i + chunkSize); } } it("streamHashLinesFromUtf8 matches formatHashLines", async () => { const content = "foo\nbar\nbaz"; const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 })); expect(streamed).toBe(formatHashLines(content)); }); it("streamHashLinesFromUtf8 handles empty content", async () => { const content = ""; const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 })); expect(streamed).toBe(formatHashLines(content)); }); it("streamHashLinesFromLines matches formatHashLines (including trailing newline)", async () => { const content = "foo\nbar\n"; const lines = ["foo", "bar", ""]; // match `content.split("\\n")` const streamed = await collectText(streamHashLinesFromLines(lines, { maxChunkLines: 2 })); expect(streamed).toBe(formatHashLines(content)); }); it("chunking respects maxChunkLines", async () => { const content = "a\nb\nc"; const parts: string[] = []; for await (const part of streamHashLinesFromUtf8(utf8Chunks(content, 1), { maxChunkLines: 1, maxChunkBytes: 1024, })) { parts.push(part); } expect(parts).toHaveLength(3); expect(parts.join("\n")).toBe(formatHashLines(content)); }); }); // ═══════════════════════════════════════════════════════════════════════════ // parseTag // ═══════════════════════════════════════════════════════════════════════════ describe("parseTag", () => { it("parses valid reference", () => { const ref = parseTag("5#QQ"); expect(ref).toEqual({ line: 5, hash: "QQ" }); }); it("rejects single-character hash", () => { expect(() => parseTag("1#Q")).toThrow(/Invalid line reference/); }); it("parses long hash by taking strict 2-char prefix", () => { const ref = parseTag("100#QQQQ"); expect(ref).toEqual({ line: 100, hash: "QQ" }); }); it("rejects missing separator", () => { expect(() => parseTag("5QQ")).toThrow(/Invalid line reference/); }); it("rejects non-numeric line", () => { expect(() => parseTag("abc#Q")).toThrow(/Invalid line reference/); }); it("rejects non-alphanumeric hash", () => { expect(() => parseTag("5#$$$$")).toThrow(/Invalid line reference/); }); it("rejects line number 0", () => { expect(() => parseTag("0#QQ")).toThrow(/Line number must be >= 1/); }); it("rejects empty string", () => { expect(() => parseTag("")).toThrow(/Invalid line reference/); }); it("rejects empty hash", () => { expect(() => parseTag("5#")).toThrow(/Invalid line reference/); }); }); // ═══════════════════════════════════════════════════════════════════════════ // validateLineRef // ═══════════════════════════════════════════════════════════════════════════ describe("validateLineRef", () => { it("accepts valid ref with matching hash", () => { const lines = ["hello", "world"]; const hash = computeLineHash(1, "hello"); expect(() => validateLineRef({ line: 1, hash }, lines)).not.toThrow(); }); it("rejects line out of range (too high)", () => { const lines = ["hello"]; const hash = computeLineHash(1, "hello"); expect(() => validateLineRef({ line: 2, hash }, lines)).toThrow(/does not exist/); }); it("rejects line out of range (zero)", () => { const lines = ["hello"]; expect(() => validateLineRef({ line: 0, hash: "aaaa" }, lines)).toThrow(/does not exist/); }); it("rejects mismatched hash", () => { const lines = ["hello", "world"]; expect(() => validateLineRef({ line: 1, hash: "0000" }, lines)).toThrow(/has changed since last read/); }); it("validates last line correctly", () => { const lines = ["a", "b", "c"]; const hash = computeLineHash(3, "c"); expect(() => validateLineRef({ line: 3, hash }, lines)).not.toThrow(); }); }); // ═══════════════════════════════════════════════════════════════════════════ // applyHashlineEdits — replace // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — replace", () => { it("replaces single line", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nBBB\nccc"); expect(result.firstChangedLine).toBe(2); }); it("range replace (shrink)", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["ONE"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nONE\nddd"); }); it("range replace (same count)", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["XXX", "YYY"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nXXX\nYYY\nddd"); expect(result.firstChangedLine).toBe(2); }); it("replaces first line", () => { const content = "first\nsecond\nthird"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(1, "first"), lines: ["FIRST"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("FIRST\nsecond\nthird"); expect(result.firstChangedLine).toBe(1); }); it("replaces last line", () => { const content = "first\nsecond\nthird"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "third"), lines: ["THIRD"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("first\nsecond\nTHIRD"); expect(result.firstChangedLine).toBe(3); }); }); // ═══════════════════════════════════════════════════════════════════════════ // applyHashlineEdits — delete // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — delete", () => { it("deletes single line", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: [] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nccc"); expect(result.firstChangedLine).toBe(2); }); it("deletes range of lines", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: [] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nddd"); }); it("deletes first line", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(1, "aaa"), lines: [] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("bbb\nccc"); }); it("deletes last line", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "ccc"), lines: [] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nbbb"); }); it("replaces line with blank line when lines is ['']", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: [""] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\n\nccc"); expect(result.firstChangedLine).toBe(2); }); }); // ═══════════════════════════════════════════════════════════════════════════ // applyHashlineEdits — append // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — append", () => { it("inserts after a line", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nNEW\nbbb\nccc"); expect(result.firstChangedLine).toBe(2); }); it("inserts multiple lines", () => { const content = "aaa\nbbb"; const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: ["x", "y", "z"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nx\ny\nz\nbbb"); }); it("inserts after last line", () => { const content = "aaa\nbbb"; const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(2, "bbb"), lines: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nbbb\nNEW"); }); it("insert with empty dst inserts an empty line", () => { const content = "aaa\nbbb"; const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: [] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\n\nbbb"); expect(result.firstChangedLine).toBe(2); }); it("inserts at EOF without anchors", () => { const content = "aaa\nbbb"; const edits = [{ op: "append", lines: ["NEW"] }] as unknown as HashlineEdit[]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nbbb\nNEW"); expect(result.firstChangedLine).toBe(3); }); it("inserts at EOF into empty file without anchors", () => { const content = ""; const edits = [{ op: "append", lines: ["NEW"] }] as unknown as HashlineEdit[]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("NEW"); expect(result.firstChangedLine).toBe(1); }); it("insert at EOF with empty dst inserts a trailing empty line", () => { const content = "aaa\nbbb"; const edits = [{ op: "append", lines: [] }] as unknown as HashlineEdit[]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nbbb\n"); expect(result.firstChangedLine).toBe(3); }); }); // ═══════════════════════════════════════════════════════════════════════════ // applyHashlineEdits — prepend // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — prepend", () => { it("inserts before a line", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nNEW\nbbb\nccc"); expect(result.firstChangedLine).toBe(2); }); it("inserts multiple lines before", () => { const content = "aaa\nbbb"; const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["x", "y", "z"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nx\ny\nz\nbbb"); }); it("inserts before first line", () => { const content = "aaa\nbbb"; const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(1, "aaa"), lines: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("NEW\naaa\nbbb"); }); it("prepends at BOF without anchor", () => { const content = "aaa\nbbb"; const edits = [{ op: "prepend", lines: ["NEW"] }] as unknown as HashlineEdit[]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("NEW\naaa\nbbb"); expect(result.firstChangedLine).toBe(1); }); it("insert with before and empty text inserts an empty line", () => { const content = "aaa\nbbb"; const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(1, "aaa"), lines: [] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("\naaa\nbbb"); expect(result.firstChangedLine).toBe(1); }); it("insert before and insert after at same line produce correct order", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [ { op: "prepend", pos: makeTag(2, "bbb"), lines: ["BEFORE"] }, { op: "append", pos: makeTag(2, "bbb"), lines: ["AFTER"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nBEFORE\nbbb\nAFTER\nccc"); }); it("insert before with set at same line", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [ { op: "prepend", pos: makeTag(2, "bbb"), lines: ["BEFORE"] }, { op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nBEFORE\nBBB\nccc"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════ // applyHashlineEdits — heuristics // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — heuristics", () => { it("accepts polluted src that starts with LINE#ID but includes trailing content", () => { const content = "aaa\nbbb\nccc"; const srcHash = computeLineHash(2, "bbb"); const edits: HashlineEdit[] = [ { op: "replace", pos: parseTag(`2#${srcHash}export function foo(a, b) {}`), // comma in trailing content lines: ["BBB"], }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nBBB\nccc"); }); it("does not override model whitespace choices in replacement content", () => { const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n"); const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(1, "import { foo } from 'x';"), end: makeTag(2, "import { bar } from 'y';"), lines: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"], }, ]; const result = applyHashlineEdits(content, edits); const outLines = result.lines.split("\n"); // Model's whitespace choice is respected -- no longer overridden expect(outLines[0]).toBe("import {foo} from 'x';"); expect(outLines[1]).toBe("import { bar } from 'y';"); expect(outLines[2]).toBe("// added"); expect(outLines[3]).toBe("const x = 1;"); }); it("treats same-line ranges as single-line replacements", () => { const content = "aaa\nbbb\nccc"; const good = makeTag(2, "bbb"); const edits: HashlineEdit[] = [{ op: "replace", pos: good, end: good, lines: ["BBB"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nBBB\nccc"); }); it("auto-corrects off-by-one range end that duplicates a closing brace", () => { const content = "if (ok) {\n run();\n}\nafter();"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(1, "if (ok) {"), end: makeTag(2, " run();"), lines: ["if (ok) {", " runSafe();", "}"], }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("if (ok) {\n runSafe();\n}\nafter();"); expect(result.warnings).toHaveLength(1); expect(result.warnings?.[0]).toContain("Auto-corrected range replace"); expect(result.warnings?.[0]).toContain('"}"'); }); it('auto-corrects off-by-one range end that duplicates a ");" closer', () => { const content = "doThing(\n value,\n);\nnext();"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(1, "doThing("), end: makeTag(2, " value,"), lines: ["doThing(", " normalize(value),", ");"], }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("doThing(\n normalize(value),\n);\nnext();"); expect(result.warnings).toHaveLength(1); expect(result.warnings?.[0]).toContain('");"'); }); it("auto-corrects duplicated trailing lines when they match the next surviving line", () => { const content = "start\n oldCall();\nnextCall();\nafter();"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(1, "start"), end: makeTag(2, " oldCall();"), lines: ["start", " newCall();", "nextCall();"], }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("start\n newCall();\nnextCall();\nafter();"); expect(result.warnings).toHaveLength(1); expect(result.warnings?.[0]).toContain("removed trailing replacement line"); }); it("auto-corrects off-by-one range start that duplicates a preceding line", () => { const content = "if (x) {\n oldBody();\n}\nafter();"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(2, " oldBody();"), end: makeTag(3, "}"), lines: ["if (x) {", " newBody();", "}"], }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("if (x) {\n newBody();\n}\nafter();"); expect(result.warnings).toHaveLength(1); expect(result.warnings?.[0]).toContain("removed leading replacement line"); }); it("auto-corrects leading escaped tab indentation by default", () => { const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; try { const content = "root\n\tchild\n\t\tvalue\nend"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("root\n\tchild\n\t\treplaced\nend"); expect(result.warnings).toHaveLength(1); expect(result.warnings?.[0]).toContain("Auto-corrected escaped tab indentation"); } finally { if (previous === undefined) delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous; } }); it("does not auto-correct escaped tab indentation when disabled by env", () => { const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = "0"; try { const content = "root\n\tchild\n\t\tvalue\nend"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("root\n\tchild\n\\t\\treplaced\nend"); expect(result.warnings).toBeUndefined(); } finally { if (previous === undefined) delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous; } }); it("does not auto-correct when edit already includes real tab characters", () => { const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; try { const content = "root\n\tchild\n\t\tvalue\nend"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\t\talready-tab", "\\t\\tescaped-still-literal"], }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("root\n\tchild\n\t\talready-tab\n\\t\\tescaped-still-literal\nend"); expect(result.warnings).toBeUndefined(); } finally { if (previous === undefined) delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS; else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous; } }); it("warns on literal \\uDDDD without changing content", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\n\\uDDDD\nccc"); expect(result.warnings).toHaveLength(1); expect(result.warnings?.[0]).toContain("Detected literal \\uDDDD"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // applyHashlineEdits — multiple edits // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — multiple edits", () => { it("applies two non-overlapping replaces (bottom-up safe)", () => { const content = "aaa\nbbb\nccc\nddd\neee"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }, { op: "replace", pos: makeTag(4, "ddd"), lines: ["DDD"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nBBB\nccc\nDDD\neee"); expect(result.firstChangedLine).toBe(2); }); it("applies replace + delete in one call", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }, { op: "replace", pos: makeTag(4, "ddd"), lines: [] }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nBBB\nccc"); }); it("applies replace + append in one call", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(3, "ccc"), lines: ["CCC"] }, { op: "append", pos: makeTag(1, "aaa"), lines: ["INSERTED"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("aaa\nINSERTED\nbbb\nCCC"); }); it("applies non-overlapping edits against original anchors when line counts change", () => { const content = "one\ntwo\nthree\nfour\nfive\nsix"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(2, "two"), end: makeTag(3, "three"), lines: ["TWO_THREE"], }, { op: "replace", pos: makeTag(6, "six"), lines: ["SIX"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.lines).toBe("one\nTWO_THREE\nfour\nfive\nSIX"); }); it("empty edits array is a no-op", () => { const content = "aaa\nbbb"; const result = applyHashlineEdits(content, []); expect(result.lines).toBe(content); expect(result.firstChangedLine).toBeUndefined(); }); }); // ═══════════════════════════════════════════════════════════════════════════ // applyHashlineEdits — error cases // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — errors", () => { it("rejects stale hash", () => { const content = "aaa\nbbb\nccc"; // Use a hash that doesn't match any line (avoid 00 — ccc hashes to 00) const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("2#QQ"), lines: ["BBB"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); }); it("stale hash error shows >>> markers with correct hashes", () => { const content = "aaa\nbbb\nccc\nddd\neee"; const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("2#QQ"), lines: ["BBB"] }]; try { applyHashlineEdits(content, edits); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(HashlineMismatchError); const msg = (err as HashlineMismatchError).message; // Should contain >>> marker on the mismatched line expect(msg).toContain(">>>"); // Should show the correct hash for line 2 const correctHash = computeLineHash(2, "bbb"); expect(msg).toContain(`2#${correctHash}:bbb`); // Context lines should NOT have >>> markers const lines = msg.split("\n"); const contextLines = lines.filter(l => l.startsWith(" ") && !l.startsWith(" ...") && l.includes("#")); expect(contextLines.length).toBeGreaterThan(0); } }); it("stale hash error collects all mismatches", () => { const content = "aaa\nbbb\nccc\nddd\neee"; // Use hashes that don't match any line (avoid 00 — ccc hashes to 00) const edits: HashlineEdit[] = [ { op: "replace", pos: parseTag("2#ZZ"), lines: ["BBB"] }, { op: "replace", pos: parseTag("4#ZZ"), lines: ["DDD"] }, ]; try { applyHashlineEdits(content, edits); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(HashlineMismatchError); const e = err as HashlineMismatchError; expect(e.mismatches).toHaveLength(2); expect(e.mismatches[0].line).toBe(2); expect(e.mismatches[1].line).toBe(4); // Both lines should have >>> markers const markerLines = e.message.split("\n").filter(l => l.startsWith(">>>")); expect(markerLines).toHaveLength(2); } }); it("does not relocate stale line refs even when hash uniquely matches another line", () => { const content = "aaa\nbbb\nccc"; const staleButUnique = parseTag(`2#${computeLineHash(1, "ccc")}`); const edits: HashlineEdit[] = [{ op: "replace", pos: staleButUnique, lines: ["CCC"] }]; try { applyHashlineEdits(content, edits); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(HashlineMismatchError); const e = err as HashlineMismatchError; expect(e.mismatches[0].line).toBe(2); } }); it("does not relocate when expected hash is non-unique", () => { const content = "dup\nmid\ndup"; const staleDuplicate = parseTag(`2#${computeLineHash(1, "dup")}`); const edits: HashlineEdit[] = [{ op: "replace", pos: staleDuplicate, lines: ["DUP"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); }); it("rejects out-of-range line", () => { const content = "aaa\nbbb"; const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("10#ZZ"), lines: ["X"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/); }); it("rejects range with start > end", () => { const content = "aaa\nbbb\nccc\nddd\neee"; const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(5, "eee"), end: makeTag(2, "bbb"), lines: ["X"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); it("accepts append/prepend with empty text by inserting empty lines", () => { const content = "aaa\nbbb"; const appendEdits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: [] }]; expect(applyHashlineEdits(content, appendEdits).lines).toBe("aaa\n\nbbb"); const prependEdits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(1, "aaa"), lines: [] }]; expect(applyHashlineEdits(content, prependEdits).lines).toBe("\naaa\nbbb"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // buildCompactHashlineDiffPreview // ═══════════════════════════════════════════════════════════════════════════ describe("buildCompactHashlineDiffPreview", () => { it("keeps trailing context for first unchanged run", () => { const diff = [" 1|ctx-a", " 2|ctx-b", " 3|ctx-c", " 4|ctx-d", "+5|added"].join("\n"); const preview = buildCompactHashlineDiffPreview(diff); expect(preview.preview).not.toContain(" 1|ctx-a"); expect(preview.preview).not.toContain(" 2|ctx-b"); expect(preview.preview).toContain(" 3|ctx-c"); expect(preview.preview).toContain(" 4|ctx-d"); expect(preview.preview).toContain(" ... 2 more unchanged lines"); expect(preview.preview).toContain("+5|added"); }); it("collapses long addition runs and tracks line counts", () => { const diff = [" 1|head", "+2|one", "+3|two", "+4|three", "+5|four", "-2|old"].join("\n"); const preview = buildCompactHashlineDiffPreview(diff); expect(preview.preview).toContain("+2|one"); expect(preview.preview).toContain("+3|two"); expect(preview.preview).toContain(" ... 2 more added lines"); expect(preview.addedLines).toBe(4); expect(preview.removedLines).toBe(1); }); it("keeps leading context for last unchanged run", () => { const diff = ["-10|old", "+10|new", " 11|ctx-a", " 12|ctx-b", " 13|ctx-c", " 14|ctx-d"].join("\n"); const preview = buildCompactHashlineDiffPreview(diff); expect(preview.preview).toContain(" 11|ctx-a"); expect(preview.preview).toContain(" 12|ctx-b"); expect(preview.preview).not.toContain(" 13|ctx-c"); expect(preview.preview).not.toContain(" 14|ctx-d"); expect(preview.preview).toContain(" ... 2 more unchanged lines"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // stripNewLinePrefixes — regression tests for DIFF_PLUS_RE // ═══════════════════════════════════════════════════════════════════════════ describe("stripNewLinePrefixes", () => { it("strips leading '+' when majority of lines start with '+'", () => { const lines = ["+line one", "+line two", "+line three"]; expect(stripNewLinePrefixes(lines)).toEqual(["line one", "line two", "line three"]); }); it("does NOT strip leading '-' from Markdown list items", () => { const lines = ["- item one", "- item two", "- item three"]; expect(stripNewLinePrefixes(lines)).toEqual(["- item one", "- item two", "- item three"]); }); it("does NOT strip leading '-' from checkbox list items", () => { const lines = ["- [ ] task one", "- [x] task two", "- [ ] task three"]; expect(stripNewLinePrefixes(lines)).toEqual(["- [ ] task one", "- [x] task two", "- [ ] task three"]); }); it("does NOT strip when fewer than 50% of lines start with '+'", () => { const lines = ["+added", "regular", "regular", "regular"]; expect(stripNewLinePrefixes(lines)).toEqual(["+added", "regular", "regular", "regular"]); }); it("strips hashline prefixes when all non-empty lines carry them", () => { const lines = ["1#WQ:foo", "2#TZ:bar", "3#HX:baz"]; expect(stripNewLinePrefixes(lines)).toEqual(["foo", "bar", "baz"]); }); it("does NOT strip hashline prefixes when any non-empty line is plain content", () => { const lines = ["1#WQ:foo", "bar", "3#HX:baz"]; expect(stripNewLinePrefixes(lines)).toEqual(["1#WQ:foo", "bar", "3#HX:baz"]); }); it("strips hash-only prefixes when all non-empty lines carry them", () => { const lines = ["#WQ:", "#TZ:{{/*", "#HX:OC deployment container livenessProbe template"]; expect(stripNewLinePrefixes(lines)).toEqual(["", "{{/*", "OC deployment container livenessProbe template"]); }); it("does NOT strip comment lines that look like hashline prefixes (# Word:)", () => { // Regression: HASHLINE_PREFIX_RE was too broad and matched '# Note:', '# TODO:', etc. // A single-line replacement whose content is a comment would have nonEmpty===hashPrefixCount===1, // triggering stripping and eating the '# Note: ' prefix from the written line. expect(stripNewLinePrefixes([" # Note: Using a fixed version"])).toEqual([" # Note: Using a fixed version"]); expect(stripNewLinePrefixes(["# TODO: remove this"])).toEqual(["# TODO: remove this"]); expect(stripNewLinePrefixes(["# FIXME: broken"])).toEqual(["# FIXME: broken"]); // Bash/Python/PS1 comment with colon (e.g. setup scripts) expect(stripNewLinePrefixes([" # step: do thing"])).toEqual([" # step: do thing"]); }); it("does NOT strip '+' when line starts with '++'", () => { const lines = ["++conflict marker", "++another"]; expect(stripNewLinePrefixes(lines)).toEqual(["++conflict marker", "++another"]); }); }); // ═══════════════════════════════════════════════════════════════════════════ // hashlineParseContent — string vs array input // ═══════════════════════════════════════════════════════════════════════════ describe("hashlineParseContent", () => { it("returns empty array for null", () => { expect(hashlineParseText(null)).toEqual([]); }); it("returns array input as-is when no strip heuristic applies", () => { const input = ["- [x] done", "- [ ] todo"]; expect(hashlineParseText(input)).toBe(input); }); it("strips hashline prefixes from array input when all non-empty lines are prefixed", () => { const input = ["259#WQ:", "260#TZ:{{/*", "261#HX:OC deployment container livenessProbe template"]; expect(hashlineParseText(input)).toEqual(["", "{{/*", "OC deployment container livenessProbe template"]); }); it("strips hash-only prefixes from array input when all non-empty lines are prefixed", () => { const input = ["#WQ:", "#TZ:{{/*", "#HX:OC deployment container livenessProbe template"]; expect(hashlineParseText(input)).toEqual(["", "{{/*", "OC deployment container livenessProbe template"]); }); it("splits string on newline and preserves Markdown list '-' prefix", () => { const result = hashlineParseText("- item one\n- item two\n- item three"); expect(result).toEqual(["- item one", "- item two", "- item three"]); }); it("strips '+' diff markers from string input", () => { const result = hashlineParseText("+line one\n+line two"); expect(result).toEqual(["line one", "line two"]); }); it("preserves [''] as a single blank line from array input", () => { expect(hashlineParseText([""])).toEqual([""]); }); it("preserves trailing empty strings in array input", () => { expect(hashlineParseText(["foo", ""])).toEqual(["foo", ""]); }); it("still strips trailing empty from string split", () => { expect(hashlineParseText("foo\n")).toEqual(["foo"]); }); it("regression: set op with Markdown list string content preserves '-' in file", () => { // Reproducer for the bug where DIFF_PLUS_RE = /^[+-](?![+-])/ matched '-' // and stripped it from every line, corrupting list-item replacements. const fileContent = "# Title\n- old item\n- old item 2\nfooter"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(2, "- old item"), lines: hashlineParseText("- [x] new item"), }, ]; const result = applyHashlineEdits(fileContent, edits); expect(result.lines).toBe("# Title\n- [x] new item\n- old item 2\nfooter"); }); it("regression: set op replacing multiple list items preserves all '-' prefixes", () => { // All replacement lines start with '- ', triggering the 50% heuristic when '-' matched. const fileContent = "- [x] done\n- [ ] pending\n- [ ] also pending"; const newContent = hashlineParseText("- [x] done"); const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "- [ ] pending"), lines: newContent }]; const result = applyHashlineEdits(fileContent, edits); expect(result.lines).toBe("- [x] done\n- [x] done\n- [ ] also pending"); }); it("preserves comment lines starting with '# Word:' through hashlineParseText", () => { // Regression: HASHLINE_PREFIX_RE matched '# Note:', '# TODO:', etc. because the // hash ID segment was [0-9a-zA-Z]{1,16} instead of [ZPMQVRWSNKTXJBYH]{2}. expect(hashlineParseText([" # Note: Using version 1.24.x"])).toEqual([" # Note: Using version 1.24.x"]); expect(hashlineParseText(["# TODO: remove this"])).toEqual(["# TODO: remove this"]); expect(hashlineParseText(["# step: install deps"])).toEqual(["# step: install deps"]); expect(hashlineParseText(" # Note: v1.24.x\n # Requires: CUDA 12")).toEqual([ " # Note: v1.24.x", " # Requires: CUDA 12", ]); }); it("regression: replacing a comment line preserves '# Note:' prefix in output file", () => { // Before fix: HASHLINE_PREFIX_RE matched '# Note:' as a hashline prefix. // With a single replacement line the strip heuristic fired (nonEmpty===1, // hashPrefixCount===1), eating the comment marker and writing bare text. const fileContent = [" # cuDNN section", " # Note: Using version 1.23.0", ' $Version = "1.23.0"'].join("\n"); const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(2, " # Note: Using version 1.23.0"), lines: hashlineParseText([" # Note: Using version 1.24.x"]), }, ]; const result = applyHashlineEdits(fileContent, edits); expect(result.lines).toBe( [" # cuDNN section", " # Note: Using version 1.24.x", ' $Version = "1.23.0"'].join("\n"), ); }); it("regression: replacing a TODO comment preserves '# TODO:' prefix", () => { const fileContent = "const x = 1;\n// TODO: old\n# TODO: remove this\nconst y = 2;"; const edits: HashlineEdit[] = [ { op: "replace", pos: makeTag(3, "# TODO: remove this"), lines: hashlineParseText(["# TODO: remove this -- done"]), }, ]; const result = applyHashlineEdits(fileContent, edits); expect(result.lines).toBe("const x = 1;\n// TODO: old\n# TODO: remove this -- done\nconst y = 2;"); }); });