Files
oh-my-pi/packages/coding-agent/test/core/hashline.test.ts
T
can1357 ffdec04af2 refactor(packages/coding-agent): reorganized atom LID parsing rules
- Centralized `line-hash.ts` hash regex sources and resolved `atom.lark` via `resolveLarkLidPlaceholders`.
- Expanded `computeLineHash` to emit `>[a-z]` and `[a-z]<` hashes for brace-context anchors.
- Replaced atom/hashline parsers' hard-coded lid regex with shared `HASHLINE_HASH_RE_SRC` and lax counterparts.
- Removed `\\TEXT` continuation handling in atom rewrites and switched multi-line replacements to `+TEXT`.
- Added brace-body insertion warning when `@Lid` on `{`-ending lines inserts at non-body-safe indent.
- Suppressed duplicate auto-rebase warnings and kept unmatched `-`/`+` ranges separate in compact previews.
2026-05-02 04:34:39 +02:00

1302 lines
54 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import {
applyHashlineEdits,
buildCompactHashlineDiffPreview,
computeLineHash,
formatHashLines,
HASHLINE_BIGRAMS,
HASHLINE_BIGRAMS_COUNT,
HASHLINE_HASH_LAX_RE_SRC,
HASHLINE_HASH_RE_SRC,
HashlineMismatchError,
hashlineParseText,
LARK_LID_HASH_LAX_PLACEHOLDER,
parseTag,
resolveLarkLidPlaceholders,
streamHashLinesFromLines,
streamHashLinesFromUtf8,
stripHashlinePrefixes,
stripNewLinePrefixes,
validateLineRef,
} from "@oh-my-pi/pi-coding-agent/edit";
import type { Anchor, HashlineEdit } from "@oh-my-pi/pi-coding-agent/edit/modes/hashline";
function makeTag(line: number, content: string): Anchor {
return {
line,
hash: computeLineHash(line, content),
};
}
/** Returns a hash that's guaranteed NOT to equal the real hash of `(line, content)`. */
function staleBigramFor(line: number, content: string): string {
const real = computeLineHash(line, content);
if (real.startsWith(">")) {
// Closing-brace marker: keep the `>[a-z]` shape but rotate the letter.
const next = String.fromCharCode(((real.charCodeAt(1) - 97 + 1) % 26) + 97);
return `>${next}`;
}
if (real.endsWith("<")) {
// Opening-brace marker: keep the `[a-z]<` shape but rotate the letter.
const next = String.fromCharCode(((real.charCodeAt(0) - 97 + 1) % 26) + 97);
return `${next}<`;
}
const idx = HASHLINE_BIGRAMS.indexOf(real as (typeof HASHLINE_BIGRAMS)[number]);
return HASHLINE_BIGRAMS[(idx + 1) % HASHLINE_BIGRAMS_COUNT];
}
// ═══════════════════════════════════════════════════════════════════════════
// computeLineHash
// ═══════════════════════════════════════════════════════════════════════════
describe("computeLineHash", () => {
it("returns 2-character hash string", () => {
const hash = computeLineHash(1, "hello");
expect(hash).toMatch(new RegExp(`^${HASHLINE_HASH_RE_SRC}$`));
});
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(new RegExp(`^${HASHLINE_HASH_RE_SRC}$`));
});
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);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// computeLineHash — brace markers
// ═══════════════════════════════════════════════════════════════════════════
describe("computeLineHash — brace markers", () => {
it("bare closing brace `}` hashes to `>[a-z]`", () => {
expect(computeLineHash(1, "}")).toMatch(/^>[a-z]$/);
});
it("`});` is a closer", () => {
expect(computeLineHash(7, "});")).toMatch(/^>[a-z]$/);
});
it("`},` is a closer", () => {
expect(computeLineHash(3, "},")).toMatch(/^>[a-z]$/);
});
it("indented closer ` }` keeps closing form", () => {
expect(computeLineHash(2, " }")).toMatch(/^>[a-z]$/);
});
it("`}//comment` (no space after brace) is still a closer", () => {
expect(computeLineHash(4, "}//comment")).toMatch(/^>[a-z]$/);
});
it("opener `function foo() {` hashes to `[a-z]<`", () => {
expect(computeLineHash(1, "function foo() {")).toMatch(/^[a-z]<$/);
});
it("opener `if (x) {` hashes to `[a-z]<`", () => {
expect(computeLineHash(2, "if (x) {")).toMatch(/^[a-z]<$/);
});
it("`} else {` resolves to opening form (open-brace wins)", () => {
expect(computeLineHash(5, "} else {")).toMatch(/^[a-z]<$/);
});
it("`} catch (err) {` resolves to opening form", () => {
expect(computeLineHash(8, "} catch (err) {")).toMatch(/^[a-z]<$/);
});
it("plain comment line stays in bigram alphabet (no braces)", () => {
const hash = computeLineHash(1, "// just a comment");
expect(hash).toMatch(/^[a-z]{2}$/);
expect(hash).not.toMatch(/[<>]/);
});
it("two `}` lines on different line numbers get distinct closing hashes", () => {
const a = computeLineHash(1, "}");
const b = computeLineHash(2, "}");
expect(a).toMatch(/^>[a-z]$/);
expect(b).toMatch(/^>[a-z]$/);
expect(a).not.toBe(b);
});
it("two `{` lines on different line numbers get distinct opening hashes", () => {
const a = computeLineHash(1, "{");
const b = computeLineHash(2, "{");
expect(a).toMatch(/^[a-z]<$/);
expect(b).toMatch(/^[a-z]<$/);
expect(a).not.toBe(b);
});
it("HASHLINE_HASH_RE_SRC matches both brace forms and bigrams", () => {
const re = new RegExp(`^${HASHLINE_HASH_RE_SRC}$`);
expect(re.test(computeLineHash(1, "}"))).toBe(true);
expect(re.test(computeLineHash(1, "{"))).toBe(true);
expect(re.test(computeLineHash(1, "hello"))).toBe(true);
expect(re.test(">a")).toBe(true);
expect(re.test("a<")).toBe(true);
expect(re.test("ab")).toBe(true);
expect(re.test(">>")).toBe(false);
expect(re.test("<<")).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 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(new RegExp(`^2${HASHLINE_HASH_RE_SRC}|$`));
});
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(new RegExp(`^(\\d+)(${HASHLINE_HASH_RE_SRC})\\|(.*)$`));
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("5th");
expect(ref).toEqual({ line: 5, hash: "th" });
});
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("100thQQ");
expect(ref).toEqual({ line: 100, hash: "th" });
});
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("0th")).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/);
});
it("strips leading anchor decorations (`*`, `+`, `-`, `>`) before parsing", () => {
expect(parseTag("*5th")).toEqual({ line: 5, hash: "th" });
expect(parseTag(" 5th")).toEqual({ line: 5, hash: "th" });
expect(parseTag("+5th")).toEqual({ line: 5, hash: "th" });
expect(parseTag("-5th")).toEqual({ line: 5, hash: "th" });
expect(parseTag(">5th")).toEqual({ line: 5, hash: "th" });
expect(parseTag(">>>+5th")).toEqual({ line: 5, hash: "th" });
});
it("parses brace-marker hashes through HASHLINE_ANCHOR_RE_SRC", () => {
expect(parseTag("3>p")).toEqual({ line: 3, hash: ">p" });
expect(parseTag("1a<")).toEqual({ line: 1, hash: "a<" });
// Decorations + brace marker still parses.
expect(parseTag("*3>p")).toEqual({ line: 3, hash: ">p" });
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 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(/Edit rejected:.*has changed/);
});
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_line", 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_range", 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_range", 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_line", 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_line", 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_line", 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_range", 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_line", 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_line", 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_line", 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_at", 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_at", 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_at", 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_at", 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: HashlineEdit[] = [{ op: "append_file", lines: ["NEW"] }];
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: HashlineEdit[] = [{ op: "append_file", lines: ["NEW"] }];
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: HashlineEdit[] = [{ op: "append_file", lines: [] }];
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_at", 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_at", 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_at", 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: HashlineEdit[] = [{ op: "prepend_file", lines: ["NEW"] }];
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_at", 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_at", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
{ op: "append_at", 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_at", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
{ op: "replace_line", 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_line",
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_range",
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_range", pos: good, end: good, lines: ["BBB"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nBBB\nccc");
});
it("preserves duplicated trailing closer lines exactly as provided", () => {
const content = "if (ok) {\n run();\n}\nafter();";
const edits: HashlineEdit[] = [
{
op: "replace_range",
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}\n}\nafter();");
expect(result.warnings).toHaveLength(1);
expect(result.warnings?.[0]).toContain("Possible boundary duplication");
expect(result.warnings?.[0]).toContain(`set \`end\` to 3${computeLineHash(3, "}")}`);
});
it("preserves duplicated trailing content when replacement re-emits the next line", () => {
const content = "start\n oldCall();\nnextCall();\nafter();";
const edits: HashlineEdit[] = [
{
op: "replace_range",
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();\nnextCall();\nafter();");
expect(result.warnings).toHaveLength(1);
expect(result.warnings?.[0]).toContain("Possible boundary duplication");
expect(result.warnings?.[0]).toContain(`set \`end\` to 3${computeLineHash(3, "nextCall();")}`);
});
it("preserves duplicated leading content when replacement re-emits the previous line", () => {
const content = "if (x) {\n oldBody();\n}\nafter();";
const edits: HashlineEdit[] = [
{
op: "replace_range",
pos: makeTag(2, " oldBody();"),
end: makeTag(3, "}"),
lines: ["if (x) {", " newBody();", "}"],
},
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("if (x) {\nif (x) {\n newBody();\n}\nafter();");
expect(result.warnings).toBeUndefined();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 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_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace_line", 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_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace_line", 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_line", pos: makeTag(3, "ccc"), lines: ["CCC"] },
{ op: "append_at", 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_range",
pos: makeTag(2, "two"),
end: makeTag(3, "three"),
lines: ["TWO_THREE"],
},
{ op: "replace_line", 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_line", 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_line", pos: parseTag(`2${staleBigramFor(2, "bbb")}`), 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_line", pos: parseTag(`2${staleBigramFor(2, "bbb")}`), lines: ["BBB"] },
];
try {
applyHashlineEdits(content, edits);
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(HashlineMismatchError);
const msg = (err as HashlineMismatchError).message;
// Mismatched line uses leading `*` and `|` separator (grep-style)
const correctHash = computeLineHash(2, "bbb");
expect(msg).toContain(`*2${correctHash}|bbb`);
// Context lines use leading space and `|` separator
const contextLines = msg.split("\n").filter(l => new RegExp(`^ \\d+${HASHLINE_HASH_RE_SRC}\\|`).test(l));
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_line", pos: parseTag(`2${staleBigramFor(2, "bbb")}`), lines: ["BBB"] },
{ op: "replace_line", pos: parseTag(`4${staleBigramFor(4, "ddd")}`), 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 mismatched lines use `*` prefix (vs leading space for context)
const markerLines = e.message
.split("\n")
.filter(l => new RegExp(`^\\*\\d+${HASHLINE_HASH_RE_SRC}\\|`).test(l));
expect(markerLines).toHaveLength(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_line", 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_line", pos: parseTag(`10${HASHLINE_BIGRAMS[0]}`), 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_range", 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_at", pos: makeTag(1, "aaa"), lines: [] }];
expect(applyHashlineEdits(content, appendEdits).lines).toBe("aaa\n\nbbb");
const prependEdits: HashlineEdit[] = [{ op: "prepend_at", 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).not.toContain("more unchanged lines");
expect(preview.preview).toContain(`+5${computeLineHash(5, "added")}|added`);
});
it("preserves all added lines 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(`+4${computeLineHash(4, "three")}|three`);
expect(preview.preview).toContain(`+5${computeLineHash(5, "four")}|four`);
expect(preview.preview).toContain("-2--|old");
expect(preview.preview).not.toContain(`-2${computeLineHash(2, "old")}`);
expect(preview.addedLines).toBe(4);
expect(preview.removedLines).toBe(1);
});
it("collapses adjacent (-, +) into a single `*` modification line and keeps leading context", () => {
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).not.toContain(`+10${computeLineHash(10, "new")}|new`);
expect(preview.preview).not.toContain("-10--|old");
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).not.toContain("more unchanged lines");
// `*` modifications still count toward both added and removed totals.
expect(preview.addedLines).toBe(1);
expect(preview.removedLines).toBe(1);
});
it("does NOT fold when more old lines were dropped than added; keeps clean -/+ runs", () => {
const diff = ["-100|del-a", "-101|del-b", "-102|del-c", "+100|new-a", " 103|tail"].join("\n");
const preview = buildCompactHashlineDiffPreview(diff);
const lines = preview.preview.split("\n");
expect(lines).toEqual([
"-100--|del-a",
"-101--|del-b",
"-102--|del-c",
`+100${computeLineHash(100, "new-a")}|new-a`,
` 101${computeLineHash(101, "tail")}|tail`,
]);
expect(preview.addedLines).toBe(1);
expect(preview.removedLines).toBe(3);
});
it("does NOT fold when more new lines were added than removed; keeps clean -/+ runs", () => {
const diff = ["-10|old", "+10|new-a", "+11|new-b", "+12|new-c", " 11|tail"].join("\n");
const preview = buildCompactHashlineDiffPreview(diff);
const lines = preview.preview.split("\n");
expect(lines).toEqual([
"-10--|old",
`+10${computeLineHash(10, "new-a")}|new-a`,
`+11${computeLineHash(11, "new-b")}|new-b`,
`+12${computeLineHash(12, "new-c")}|new-c`,
` 13${computeLineHash(13, "tail")}|tail`,
]);
expect(preview.addedLines).toBe(3);
expect(preview.removedLines).toBe(1);
});
it("does not pair when `+` runs precede `-` runs (preserves unified-diff ordering only)", () => {
const diff = [" 1|head", "+2|one", "+3|two", "-2|old"].join("\n");
const preview = buildCompactHashlineDiffPreview(diff);
expect(preview.preview).not.toContain("*");
expect(preview.preview).toContain(`+2${computeLineHash(2, "one")}|one`);
expect(preview.preview).toContain(`+3${computeLineHash(3, "two")}|two`);
expect(preview.preview).toContain("-2--|old");
});
it("never truncates change runs — every removed and added line is shown in full", () => {
const dels = Array.from({ length: 30 }, (_, i) => `-${100 + i}|del-${i}`);
const diff = [" 99|head", ...dels, " 130|tail"].join("\n");
const preview = buildCompactHashlineDiffPreview(diff);
expect(preview.removedLines).toBe(30);
expect(preview.preview).not.toContain("more removed lines");
expect(preview.preview).not.toContain("more preview lines");
for (let i = 0; i < 30; i++) {
expect(preview.preview).toContain(`-${100 + i}--|del-${i}`);
}
});
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 = ["1th|foo", "2er|bar", "3in|baz"];
expect(stripNewLinePrefixes(lines)).toEqual(["foo", "bar", "baz"]);
});
it("strips diff `+` markers from anchored hashline output", () => {
const lines = [
`+1${computeLineHash(1, "foo")}|foo`,
`+2${computeLineHash(2, "bar")}|bar`,
`+3${computeLineHash(3, "baz")}|baz`,
];
expect(stripNewLinePrefixes(lines)).toEqual(["foo", "bar", "baz"]);
});
it("strips plus hashline prefixes in mixed +/ - change style", () => {
const body = "**Storage location TBD:**";
const lines = [`-${body}`, `+1${computeLineHash(1, body)}|${body}`];
expect(stripNewLinePrefixes(lines)).toEqual([`-${body}`, body]);
});
it("does NOT strip hashline prefixes when any non-empty line is plain content", () => {
const lines = ["1th|foo", "bar", "3in|baz"];
expect(stripNewLinePrefixes(lines)).toEqual(["1th|foo", "bar", "3in|baz"]);
});
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"]);
});
it("strips hashline prefixes when truncation marker is present (anchor corruption bug)", () => {
const lines = [
"1an|---",
"2re|title: example",
"3an|---",
"",
"[Showing lines 1-300 of 332. Use sel=301 to continue]",
];
const result = stripNewLinePrefixes(lines);
expect(result).not.toContain("[Showing lines 1-300 of 332. Use sel=301 to continue]");
expect(result[0]).toBe("---");
expect(result[1]).toBe("title: example");
});
it("strips hashline prefixes when generic read truncation notice is present", () => {
const lines = ["1an|line one", "2re|line two", "", "[42 more lines in file. Use sel=3 to continue]"];
const result = stripNewLinePrefixes(lines);
expect(result[0]).toBe("line one");
expect(result[1]).toBe("line two");
});
it("strips nested hashline prefixes (already-corrupted content re-read)", () => {
const lines = ["1at|1an|---", "2en|2re|title: example", "3nd|3an|---"];
const result = stripNewLinePrefixes(lines);
expect(result[0]).toBe("---");
expect(result[1]).toBe("title: example");
expect(result[2]).toBe("---");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// stripHashlinePrefixes — used by Write tool
// ═══════════════════════════════════════════════════════════════════════════
describe("stripHashlinePrefixes", () => {
it("strips when all non-empty lines have hashline prefixes", () => {
const lines = ["1an|---", "2re|title", "", "4on|content"];
expect(stripHashlinePrefixes(lines)).toEqual(["---", "title", "", "content"]);
});
it("does NOT strip when lines are plain content", () => {
const lines = ["hello", "world"];
expect(stripHashlinePrefixes(lines)).toBe(lines);
});
it("strips hashline prefixes even when truncation marker is present (anchor corruption bug)", () => {
const lines = [
"1an|---",
"2re|title: example",
"3an|---",
"",
"[Showing lines 1-300 of 332. Use sel=301 to continue]",
];
const result = stripHashlinePrefixes(lines);
expect(result).not.toContain("[Showing lines 1-300 of 332. Use sel=301 to continue]");
expect(result[0]).toBe("---");
expect(result[1]).toBe("title: example");
});
it("strips nested hashline prefixes from already-corrupted content", () => {
const lines = ["1at|1an|---", "2en|2re|title"];
const result = stripHashlinePrefixes(lines);
expect(result[0]).toBe("---");
expect(result[1]).toBe("title");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 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 = ["259th|", "260er|{{/*", "261in|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_line",
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_line", 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 used to match '# Note:', '# TODO:', etc. because the
// hash ID segment was overly permissive ([0-9a-zA-Z]{1,16}). It now matches only the
// 40-bigram alphabet from HASHLINE_BIGRAMS, so accidental colon-suffixed words don't strip.
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_line",
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_line",
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;");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Hash format centralization — single source of truth for regex shape
// ═══════════════════════════════════════════════════════════════════════════
describe("hash format — central source of truth", () => {
it("strict regex matches every hash computeLineHash produces (sample sweep)", () => {
const strict = new RegExp(`^${HASHLINE_HASH_RE_SRC}$`);
const samples = [
[1, "hello"],
[2, " return result;"],
[3, "}"],
[4, "function foo() {"],
[5, "} else {"],
[6, ""],
[7, "\t}"],
] as const;
for (const [line, content] of samples) {
const hash = computeLineHash(line, content);
expect(hash).toMatch(strict);
}
});
it("lax regex is a strict superset of the strict regex (every produced hash also matches lax)", () => {
const lax = new RegExp(`^${HASHLINE_HASH_LAX_RE_SRC}$`);
// Sample a few bigrams plus brace markers.
const samples = [
HASHLINE_BIGRAMS[0],
HASHLINE_BIGRAMS[42],
HASHLINE_BIGRAMS[HASHLINE_BIGRAMS_COUNT - 1],
">a",
">z",
"a<",
"z<",
];
for (const hash of samples) {
expect(hash).toMatch(lax);
}
});
it("lax regex accepts non-bigram letter pairs that strict rejects", () => {
const lax = new RegExp(`^${HASHLINE_HASH_LAX_RE_SRC}$`);
const strict = new RegExp(`^${HASHLINE_HASH_RE_SRC}$`);
// `bq`, `qj`, `xq` are letter pairs the BPE alphabet excludes (rare q/x/z heavy combos).
const offBigrams = ["bq", "qj", "xq"].filter(
p => !HASHLINE_BIGRAMS.includes(p as (typeof HASHLINE_BIGRAMS)[number]),
);
expect(offBigrams.length).toBeGreaterThan(0);
for (const candidate of offBigrams) {
expect(candidate).toMatch(lax);
expect(candidate).not.toMatch(strict);
}
});
it("resolveLarkLidPlaceholders substitutes the placeholder with the lax regex source", () => {
const grammar = `LID: /[1-9][0-9]*${LARK_LID_HASH_LAX_PLACEHOLDER}/`;
const resolved = resolveLarkLidPlaceholders(grammar);
expect(resolved).not.toContain(LARK_LID_HASH_LAX_PLACEHOLDER);
expect(resolved).toContain(HASHLINE_HASH_LAX_RE_SRC);
});
it("resolveLarkLidPlaceholders is a no-op when the grammar has no placeholder", () => {
const grammar = `start: "x"`;
expect(resolveLarkLidPlaceholders(grammar)).toBe(grammar);
});
it("atom.lark embeds the placeholder so resolveLarkLidPlaceholders has work to do", async () => {
const atomLark = await Bun.file(new URL("../../src/edit/modes/atom.lark", import.meta.url).pathname).text();
expect(atomLark).toContain(LARK_LID_HASH_LAX_PLACEHOLDER);
const resolved = resolveLarkLidPlaceholders(atomLark);
expect(resolved).not.toContain(LARK_LID_HASH_LAX_PLACEHOLDER);
expect(resolved).toContain(HASHLINE_HASH_LAX_RE_SRC);
});
});