d4b6e869cb
- Changed bash interceptor configuration from boolean flags to customizable pattern-based rules array. - Clarified hashline range replace semantics: end parameter is now strictly exclusive boundary. - Fixed bash interceptor to apply built-in default rules when no custom patterns are configured. - Updated hashline range validation and calculations to enforce exclusive end semantics throughout.
1100 lines
47 KiB
TypeScript
1100 lines
47 KiB
TypeScript
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<string>): Promise<string> {
|
|
const parts: string[] = [];
|
|
for await (const part of gen) {
|
|
parts.push(part);
|
|
}
|
|
return parts.join("\n");
|
|
}
|
|
|
|
async function* utf8Chunks(text: string, chunkSize: number): AsyncGenerator<Uint8Array> {
|
|
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) — end is exclusive", () => {
|
|
const content = "aaa\nbbb\nccc\nddd";
|
|
// end points to "ccc" which survives; only line 2 ("bbb") is consumed
|
|
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\nccc\nddd");
|
|
});
|
|
|
|
it("range replace consuming multiple lines", () => {
|
|
const content = "aaa\nbbb\nccc\nddd";
|
|
// end points to "ddd" which survives; lines 2-3 are consumed
|
|
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(4, "ddd"), 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";
|
|
// Consume lines 2-3, replace with two lines. "ddd" survives.
|
|
const edits: HashlineEdit[] = [
|
|
{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(4, "ddd"), lines: ["XXX", "YYY"] },
|
|
];
|
|
|
|
const result = applyHashlineEdits(content, edits);
|
|
expect(result.lines).toBe("aaa\nXXX\nYYY\nddd");
|
|
expect(result.firstChangedLine).toBe(2);
|
|
});
|
|
|
|
it("applies prepend at the surviving end boundary before the enclosing range replace", () => {
|
|
const content = "a\nb\nc\nd";
|
|
const edits: HashlineEdit[] = [
|
|
{ op: "replace", pos: makeTag(2, "b"), end: makeTag(4, "d"), lines: ["X"] },
|
|
{ op: "prepend", pos: makeTag(4, "d"), lines: ["Y"] },
|
|
];
|
|
const result = applyHashlineEdits(content, edits);
|
|
expect(result.lines).toBe("a\nX\nY\nd");
|
|
expect(result.firstChangedLine).toBe(2);
|
|
});
|
|
|
|
it("applies single-line replace at the surviving end boundary before the enclosing range replace", () => {
|
|
const content = "a\nb\nc\nd";
|
|
const edits: HashlineEdit[] = [
|
|
{ op: "replace", pos: makeTag(2, "b"), end: makeTag(4, "d"), lines: ["X"] },
|
|
{ op: "replace", pos: makeTag(4, "d"), lines: ["Y"] },
|
|
];
|
|
const result = applyHashlineEdits(content, edits);
|
|
expect(result.lines).toBe("a\nX\nY");
|
|
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 (end exclusive)", () => {
|
|
const content = "aaa\nbbb\nccc\nddd";
|
|
// end points to "ddd" which survives; lines 2-3 deleted
|
|
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(4, "ddd"), 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");
|
|
// end points to "const x = 1;" (survives); lines 1-2 are consumed
|
|
const edits: HashlineEdit[] = [
|
|
{
|
|
op: "replace",
|
|
pos: makeTag(1, "import { foo } from 'x';"),
|
|
end: makeTag(3, "const x = 1;"),
|
|
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("rejects same-line range (pos must be < end)", () => {
|
|
const content = "aaa\nbbb\nccc";
|
|
const good = makeTag(2, "bbb");
|
|
const edits: HashlineEdit[] = [{ op: "replace", pos: good, end: good, lines: ["BBB"] }];
|
|
expect(() => applyHashlineEdits(content, edits)).toThrow(/must be < end/);
|
|
});
|
|
|
|
it("auto-corrects when model re-emits the exclusive end line (closing brace)", () => {
|
|
const content = "if (ok) {\n run();\n}\nafter();";
|
|
// end points to "}" which should survive. Model accidentally includes it in lines.
|
|
const edits: HashlineEdit[] = [
|
|
{
|
|
op: "replace",
|
|
pos: makeTag(1, "if (ok) {"),
|
|
end: makeTag(3, "}"),
|
|
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 when model re-emits the exclusive end line (");" closer)', () => {
|
|
const content = "doThing(\n value,\n);\nnext();";
|
|
// end points to ");" which should survive. Model accidentally includes it.
|
|
const edits: HashlineEdit[] = [
|
|
{
|
|
op: "replace",
|
|
pos: makeTag(1, "doThing("),
|
|
end: makeTag(3, ");"),
|
|
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 when model re-emits the exclusive end line (generic content)", () => {
|
|
const content = "start\n oldCall();\nnextCall();\nafter();";
|
|
// end points to "nextCall();" which should survive. Model includes it in lines.
|
|
const edits: HashlineEdit[] = [
|
|
{
|
|
op: "replace",
|
|
pos: makeTag(1, "start"),
|
|
end: makeTag(3, "nextCall();"),
|
|
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();";
|
|
// pos is body line 2, end is "}" (exclusive, survives).
|
|
// Model includes "if (x) {" in lines, duplicating preceding line.
|
|
const edits: HashlineEdit[] = [
|
|
{
|
|
op: "replace",
|
|
pos: makeTag(2, " oldBody();"),
|
|
end: makeTag(3, "}"),
|
|
lines: ["if (x) {", " newBody();"],
|
|
},
|
|
];
|
|
const result = applyHashlineEdits(content, edits);
|
|
// Leading "if (x) {" is popped; "}" survives from exclusive end
|
|
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";
|
|
// end is exclusive: "four" survives, lines 2-3 are consumed
|
|
const edits: HashlineEdit[] = [
|
|
{
|
|
op: "replace",
|
|
pos: makeTag(2, "two"),
|
|
end: makeTag(4, "four"),
|
|
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("single-line replace expanding to multiple lines is not a noop", () => {
|
|
const content = "aaa\n\nccc";
|
|
const blankHash = computeLineHash(2, "");
|
|
const edits: HashlineEdit[] = [{ op: "replace", pos: { line: 2, hash: blankHash }, lines: ["", "inserted", ""] }];
|
|
const result = applyHashlineEdits(content, edits);
|
|
expect(result.lines).toBe("aaa\n\ninserted\n\nccc");
|
|
expect(result.firstChangedLine).toBe(2);
|
|
});
|
|
|
|
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 (exclusive 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 and hashes visible lines", () => {
|
|
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("ctx-a");
|
|
expect(preview.preview).not.toContain("ctx-b");
|
|
expect(preview.preview).toContain(` 3#${computeLineHash(3, "ctx-c")}|ctx-c`);
|
|
expect(preview.preview).toContain(` 4#${computeLineHash(4, "ctx-d")}|ctx-d`);
|
|
expect(preview.preview).toContain(" ... 2 more unchanged lines");
|
|
expect(preview.preview).toContain(`+ 5#${computeLineHash(5, "added")}|added`);
|
|
});
|
|
|
|
it("collapses long addition runs and leaves removed lines unhashed", () => {
|
|
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#${computeLineHash(2, "one")}|one`);
|
|
expect(preview.preview).toContain(`+ 3#${computeLineHash(3, "two")}|two`);
|
|
expect(preview.preview).toContain(" ... 2 more added lines");
|
|
expect(preview.preview).toContain("- 2 |old");
|
|
expect(preview.preview).not.toContain(`- 2#${computeLineHash(2, "old")}|old`);
|
|
expect(preview.addedLines).toBe(4);
|
|
expect(preview.removedLines).toBe(1);
|
|
});
|
|
|
|
it("keeps leading context for last unchanged run and hashes visible lines", () => {
|
|
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(`+10#${computeLineHash(10, "new")}|new`);
|
|
expect(preview.preview).toContain(` 11#${computeLineHash(11, "ctx-a")}|ctx-a`);
|
|
expect(preview.preview).toContain(` 12#${computeLineHash(12, "ctx-b")}|ctx-b`);
|
|
expect(preview.preview).not.toContain("ctx-c");
|
|
expect(preview.preview).not.toContain("ctx-d");
|
|
expect(preview.preview).toContain(" ... 2 more unchanged lines");
|
|
});
|
|
|
|
it("uses new-file line numbers for unchanged lines after insertions", () => {
|
|
const diff = ["+2|inserted", " 2|bravo", " 3|charlie"].join("\n");
|
|
|
|
const preview = buildCompactHashlineDiffPreview(diff);
|
|
|
|
expect(preview.preview).toContain(`+2#${computeLineHash(2, "inserted")}|inserted`);
|
|
expect(preview.preview).toContain(` 3#${computeLineHash(3, "bravo")}|bravo`);
|
|
expect(preview.preview).toContain(` 4#${computeLineHash(4, "charlie")}|charlie`);
|
|
expect(preview.preview).not.toContain(` 2#${computeLineHash(2, "bravo")}|bravo`);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 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("strips plus hashline prefixes when all non-empty lines carry them", () => {
|
|
const lines = ["+WQ:foo", "+TZ:bar", "+HX:baz"];
|
|
expect(stripNewLinePrefixes(lines)).toEqual(["foo", "bar", "baz"]);
|
|
});
|
|
|
|
it("strips plus hashline prefixes in mixed +/ - change style", () => {
|
|
const lines = ["-**Storage location TBD:**", "+MW:**Storage location TBD:**"];
|
|
expect(stripNewLinePrefixes(lines)).toEqual(["-**Storage location TBD:**", "**Storage location TBD:**"]);
|
|
});
|
|
|
|
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;");
|
|
});
|
|
});
|