feat(hashline): introduced explicit range syntax and repeat edit kind for hashline

- Replaced anchor shorthand syntax with explicit range format (1: -> 1-1:) and removed ^/v sigils in favor of ^A-B repeat and A-B:- delete operations.
- Added repeat edit kind to support ^A-B syntax for copying lines A through B, and inline delete syntax A-B:- for range deletions.
- Removed after_anchor cursor kind and standalone delete rows; empty anchor blocks now produce blank-line replacements instead of deletions.
- Updated parser, tokenizer, and type system to discriminate literal and repeat payloads, and refactored apply/recovery logic to expand repeat edits into individual inserts.
- Updated coding-agent test fixtures and settings documentation to reflect new hashline syntax and behavior.
This commit is contained in:
can1357
2026-05-27 22:51:21 +02:00
parent 6fac33f099
commit 7fa55750f9
22 changed files with 605 additions and 507 deletions
+7 -1
View File
@@ -1,6 +1,12 @@
# Changelog
## [Unreleased]
### Changed
- Updated hashline syntax: replaced `↑`/`↓` payload sigils with `^` repeat syntax and `|` literal rows for clearer edit semantics
- Changed hashline delete syntax from bare `A:` or `A-B:` to explicit `A-B:-` inline delete marker
- Modified hashline anchor syntax to require explicit range notation `A-B:` instead of shorthand `A:` for single-line operations
- Updated hashline description in settings to clarify pure insert context behavior without arrow notation
## [15.5.7] - 2026-05-27
### Added
@@ -8887,4 +8893,4 @@ Initial public release.
- Git branch display in footer
- Message queueing during streaming responses
- OAuth integration for Gmail and Google Calendar access
- HTML export with syntax highlighting and collapsible sections
- HTML export with syntax highlighting and collapsible sections
@@ -1575,7 +1575,7 @@ export const SETTINGS_SCHEMA = {
tab: "editing",
label: "Hashline Duplicate Insert Drop",
description:
"Drop payload lines that duplicate adjacent file context — 2+-line context echoes on `↑`/`↓` inserts, and a single boundary line at either edge of an `A-B:` replacement",
"Drop payload lines that duplicate adjacent file context — 2+-line context echoes on pure inserts, and a single boundary line at either edge of an `A-B:` replacement",
},
},
"edit.blockAutoGenerated": {
+176 -168
View File
@@ -32,10 +32,8 @@ import {
import * as z from "zod/v4";
/**
* The test bodies were written against the legacy hashline API surface. The
* shims below project the new `@oh-my-pi/hashline` shapes onto the legacy
* names so production code can use the new names directly while we keep the
* pre-existing behavior assertions intact.
* The test bodies use a small adapter over the package API so production code
* can use the package names directly while assertions stay compact.
*/
function applyHashlineEdits(
text: string,
@@ -89,8 +87,7 @@ beforeAll(async () => {
});
const repl = (text: string): string => `|${text}`;
const above = (text: string): string => `↑${text}`;
const below = (text: string): string => `↓${text}`;
const repeat = (start: string, end = start): string => `^${start}-${end}`;
const outputSep = ":";
const outputSepRe = ":";
@@ -187,7 +184,9 @@ describe("hashline normalization", () => {
describe("hashline parser — range-anchor syntax", () => {
it("keeps parsed edits reusable across different target snapshots", () => {
const section = Patch.parseSingle(["¶a.ts", `${tag(2, "bbb")}:`, below("tail")].join("\n"));
const section = Patch.parseSingle(
["¶a.ts", `${sameLineRange(tag(2, "bbb"))}:`, repeat(tag(2, "bbb")), repl("tail")].join("\n"),
);
expect(section.applyTo("aaa\nbbb").text).toBe("aaa\nbbb\ntail");
expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc");
@@ -197,26 +196,27 @@ describe("hashline parser — range-anchor syntax", () => {
it("inserts payload before/after a Lid, and at BOF/EOF", () => {
const diff = [
`${tag(2, "bbb")}:`,
above("before b"),
below("after b"),
`${sameLineRange(tag(2, "bbb"))}:`,
repl("before b"),
repeat(tag(2, "bbb")),
repl("after b"),
"BOF:",
below("top"),
repl("top"),
"EOF:",
below("tail"),
repl("tail"),
].join("\n");
expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail");
});
it("inserts after the final line without falling off the file", () => {
const diff = [`${tag(3, "ccc")}:`, below("tail")].join("\n");
const diff = [`${sameLineRange(tag(3, "ccc"))}:`, repeat(tag(3, "ccc")), repl("tail")].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\ntail");
});
it("deletes a line or range when the block has no payload rows", () => {
expect(applyDiff(content, `${sameLineRange(tag(2, "bbb"))}:`)).toBe("aaa\nccc");
expect(applyDiff(content, `${tag(2, "bbb")}-${tag(3, "ccc")}:`)).toBe("aaa");
it("deletes a line or range via inline delete", () => {
expect(applyDiff(content, `${sameLineRange(tag(2, "bbb"))}:-`)).toBe("aaa\nccc");
expect(applyDiff(content, `${tag(2, "bbb")}-${tag(3, "ccc")}:-`)).toBe("aaa");
});
it("replaces a line with one blank when given an explicit empty replace payload", () => {
@@ -225,67 +225,76 @@ describe("hashline parser — range-anchor syntax", () => {
});
it("replaces one line or an inclusive range with payload lines", () => {
const single = [`${tag(2, "bbb")}:`, repl("BBB")].join("\n");
const single = [`${sameLineRange(tag(2, "bbb"))}:`, repl("BBB")].join("\n");
expect(applyDiff(content, single)).toBe("aaa\nBBB\nccc");
const range = [`${tag(2, "bbb")}-${tag(3, "ccc")}:`, repl("BBB"), repl("CCC")].join("\n");
expect(applyDiff(content, range)).toBe("aaa\nBBB\nCCC");
});
it("treats single-anchor replace sugar as equivalent to an explicit one-line range", () => {
it("rejects the removed bare `A:` shorthand as a normal unrecognized row", () => {
const anchor = tag(2, "bbb");
expect(parseHashline(`${anchor}:\n${repl("BBB")}\n${repl("CCC")}`).edits).toEqual(
parseHashline(`${anchor}-${anchor}:\n${repl("BBB")}\n${repl("CCC")}`).edits,
);
expect(applyDiff(content, `${anchor}:\n${repl("BBB")}\n${repl("CCC")}`)).toBe(
applyDiff(content, `${anchor}-${anchor}:\n${repl("BBB")}\n${repl("CCC")}`),
);
expect(() => parseHashline(`${anchor}:\n${repl("BBB")}`)).toThrow(/payload line has no preceding/);
});
it("replaces empty anchor blocks with one blank line", () => {
const anchor = tag(2, "bbb");
expect(applyDiff(content, `${sameLineRange(anchor)}:`)).toBe("aaa\n\nccc");
expect(applyDiff(content, `${anchor}-${tag(3, "ccc")}:`)).toBe("aaa\n");
});
it("rejects inline payload on anchor rows", () => {
const anchor = tag(2, "bbb");
for (const diff of [`${anchor}:NEW`, `${anchor}-${tag(3, "ccc")}:NEW`, "BOF:NEW", "EOF:NEW"]) {
for (const diff of [`${sameLineRange(anchor)}:NEW`, `${anchor}-${tag(3, "ccc")}:NEW`, "BOF:NEW", "EOF:NEW"]) {
expect(() => parseHashline(diff)).toThrow(/Inline payload on the anchor line is rejected/);
}
});
it("routes interleaved payload rows to stable above, replace, and below buckets", () => {
it("emits body rows in textual order", () => {
const diff = [
`${tag(2, "bbb")}:`,
below("below 1"),
above("above 1"),
`${sameLineRange(tag(2, "bbb"))}:`,
repl("above 1"),
repl("above 2"),
repl("BBB"),
above("above 2"),
below("below 2"),
repl("below 1"),
repl("below 2"),
].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nabove 1\nabove 2\nBBB\nbelow 1\nbelow 2\nccc");
});
it("preserves the anchor when only above/below payload rows are present", () => {
const diff = [`${tag(2, "bbb")}:`, above("before"), below("after")].join("\n");
it("preserves the anchor when repeat rows re-emit it", () => {
const diff = [`${sameLineRange(tag(2, "bbb"))}:`, repl("before"), repeat(tag(2, "bbb")), repl("after")].join(
"\n",
);
expect(applyDiff(content, diff)).toBe("aaa\nbefore\nbbb\nafter\nccc");
});
it("escapes literal leading payload sigils by doubling them", () => {
const diff = [`${tag(2, "bbb")}:`, repl("|literal"), above("↑literal"), below("↓literal")].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\n↑literal\n|literal\n↓literal\nccc");
it("escapes literal leading payload sigils with literal rows", () => {
const diff = [`${sameLineRange(tag(2, "bbb"))}:`, repl("|literal"), repl("^literal"), repl("↓literal")].join(
"\n",
);
expect(applyDiff(content, diff)).toBe("aaa\n|literal\n^literal\n↓literal\nccc");
});
it("rejects replacement payload at virtual BOF/EOF anchors", () => {
expect(() => parseHashline(["BOF:", repl("HEAD")].join("\n"))).toThrow(/virtual positions/);
expect(() => parseHashline(["EOF:", repl("TAIL")].join("\n"))).toThrow(/virtual positions/);
it("accepts literal payload at virtual BOF/EOF anchors", () => {
expect(applyDiff(content, ["BOF:", repl("HEAD")].join("\n"))).toBe("HEAD\naaa\nbbb\nccc");
expect(applyDiff(content, ["EOF:", repl("TAIL")].join("\n"))).toBe("aaa\nbbb\nccc\nTAIL");
});
it("rejects unprefixed payload continuation lines", () => {
const anchor = tag(2, "bbb");
expect(() => parseHashline(`${anchor}:\n${repl("FIRST")}\nSECOND`)).toThrow(/must start with/);
expect(() => parseHashline(`${sameLineRange(anchor)}:\n${repl("FIRST")}\nSECOND`)).toThrow(/must start with/);
});
it("preserves whitespace-bearing payload exactly", () => {
const anchor = tag(2, "bbb");
const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;";
expect(applyDiff(content, [`${anchor}:`, below(payload)].join("\n"))).toBe(`aaa\nbbb\n${payload}\nccc`);
expect(applyDiff(content, [`${anchor}:`, above(payload)].join("\n"))).toBe(`aaa\n${payload}\nbbb\nccc`);
expect(applyDiff(content, [`${sameLineRange(anchor)}:`, repeat(anchor), repl(payload)].join("\n"))).toBe(
`aaa\nbbb\n${payload}\nccc`,
);
expect(applyDiff(content, [`${sameLineRange(anchor)}:`, repl(payload), repeat(anchor)].join("\n"))).toBe(
`aaa\n${payload}\nbbb\nccc`,
);
});
it("auto-absorbs duplicated multiline prefix boundaries during replacement", () => {
@@ -343,8 +352,9 @@ describe("hashline parser — range-anchor syntax", () => {
repl("alpha"),
repl("X"),
repl("Y"),
`${tag(4, "Y")}:`,
above("extra"),
`${sameLineRange(tag(4, "Y"))}:`,
repl("extra"),
repeat(tag(4, "Y")),
].join("\n");
expect(applyDiff(source, diff)).toBe(["alpha", "X", "Y", "X", "extra", "Y", "Z"].join("\n"));
@@ -401,24 +411,29 @@ describe("hashline parser — range-anchor syntax", () => {
});
it("does not auto-drop generic (multi-line) pure-insert duplicate boundaries by default", () => {
// Multi-line context echo (`aaa`, `bbb`) is gated on the
// Multi-line context echo (`bbb`, `ccc`) is gated on the
// `autoDropPureInsertDuplicates` opt-in. Single-line pure-insert
// duplicates stay literal because they are ambiguous.
const source = ["aaa", "bbb", "ccc"].join("\n");
const diff = [`${tag(2, "bbb")}:`, below("aaa"), below("bbb"), below("NEW")].join("\n");
expect(applyDiff(source, diff)).toBe("aaa\nbbb\naaa\nbbb\nNEW\nccc");
const diff = ["EOF:", repl("bbb"), repl("ccc"), repl("NEW")].join("\n");
expect(applyDiff(source, diff)).toBe("aaa\nbbb\nccc\nbbb\nccc\nNEW");
});
it("preserves a duplicated single structural suffix for pure insert by default", () => {
const source = ["if ok {", " keep();", " }"].join("\n");
const diff = [`${tag(3, " }")}:`, above(" added();"), above(" }")].join("\n");
const diff = ["EOF:", repl(" added();"), repl(" }")].join("\n");
expect(applyDiff(source, diff)).toBe(["if ok {", " keep();", " added();", " }", " }"].join("\n"));
expect(applyDiff(source, diff)).toBe(["if ok {", " keep();", " }", " added();", " }"].join("\n"));
});
it("preserves a duplicated single structural prefix for pure insert even when duplicate absorption is enabled", () => {
it("preserves a duplicated single structural prefix even when duplicate absorption is enabled", () => {
const source = [" });", "next();"].join("\n");
const diff = [`${tag(1, " });")}:`, below(" });"), below("added();")].join("\n");
const diff = [
`${sameLineRange(tag(1, " });"))}:`,
repeat(tag(1, " });")),
repl(" });"),
repl("added();"),
].join("\n");
const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true });
expect(result.lines).toBe([" });", " });", "added();", "next();"].join("\n"));
@@ -427,77 +442,49 @@ describe("hashline parser — range-anchor syntax", () => {
it("preserves an intentional non-structural anchor duplicate for below insert by default", () => {
const source = ["aaa", "bbb", "ccc"].join("\n");
const diff = [`${tag(2, "bbb")}:`, below("bbb"), below("NEW")].join("\n");
const diff = [`${sameLineRange(tag(2, "bbb"))}:`, repeat(tag(2, "bbb")), repl("bbb"), repl("NEW")].join("\n");
expect(applyDiff(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc");
});
it("preserves an intentional non-structural anchor duplicate for above insert by default", () => {
const source = ["aaa", "bbb", "ccc"].join("\n");
const diff = [`${tag(2, "bbb")}:`, above("NEW"), above("bbb")].join("\n");
const diff = [`${sameLineRange(tag(2, "bbb"))}:`, repl("NEW"), repl("bbb"), repeat(tag(2, "bbb"))].join("\n");
expect(applyDiff(source, diff)).toBe("aaa\nNEW\nbbb\nbbb\nccc");
});
it("does not drop a single structural pure-insert suffix when it preserves balance", () => {
const source = ["if outer {", "}"].join("\n");
const diff = [`${tag(2, "}")}:`, above("if inner {"), above("}")].join("\n");
const diff = [`${sameLineRange(tag(2, "}"))}:`, repl("if inner {"), repl("}"), repeat(tag(2, "}"))].join("\n");
expect(applyDiff(source, diff)).toBe(["if outer {", "if inner {", "}", "}"].join("\n"));
});
it("auto-absorbs duplicated leading payload of a pure below insert", () => {
// Payload echoes the two file lines AT/ABOVE the insertion point
// (aaa, bbb), then adds NEW. The leading echo is absorbed.
const source = ["aaa", "bbb", "ccc"].join("\n");
const diff = [`${tag(2, "bbb")}:`, below("aaa"), below("bbb"), below("NEW")].join("\n");
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc");
});
it("auto-absorbs context-wrap echo (leading-above + trailing-below) on below insert", () => {
// Payload wraps NEW with context above (aaa, bbb) AND below (ccc, ddd).
// Both ends should be absorbed, leaving only NEW inserted after bbb.
const source = ["aaa", "bbb", "ccc", "ddd"].join("\n");
const diff = [`${tag(2, "bbb")}:`, below("aaa"), below("bbb"), below("NEW"), below("ccc"), below("ddd")].join(
"\n",
);
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd");
});
it("auto-absorbs duplicated trailing payload of a pure above insert", () => {
// Insert before line 3 ("ccc"). Trailing payload echoes the anchor and the
// line after it. Drop the trailing duplicates.
const source = ["aaa", "bbb", "ccc", "ddd"].join("\n");
const diff = [`${tag(3, "ccc")}:`, above("NEW"), above("ccc"), above("ddd")].join("\n");
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd");
});
it("auto-absorbs duplicated leading payload at EOF insert", () => {
const source = ["aaa", "bbb", "ccc"].join("\n");
// `EOF:` payload echoes the last two file lines, then adds NEW.
const diff = ["EOF:", below("bbb"), below("ccc"), below("NEW")].join("\n");
const diff = ["EOF:", repl("bbb"), repl("ccc"), repl("NEW")].join("\n");
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nccc\nNEW");
});
it("auto-absorbs duplicated trailing payload at BOF insert", () => {
const source = ["aaa", "bbb", "ccc"].join("\n");
// `BOF:` payload prepends NEW but trails with the first two file lines.
const diff = ["BOF:", above("NEW"), above("aaa"), above("bbb")].join("\n");
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("NEW\naaa\nbbb\nccc");
const source = ["aaa", "bbb", "ccc", "ddd"].join("\n");
const diff = ["BOF:", repl("NEW"), repl("aaa"), repl("bbb")].join("\n");
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("NEW\naaa\nbbb\nccc\nddd");
});
it("preserves a single duplicated anchor line in a pure insert even when generic duplicate absorption is enabled", () => {
const source = ["aaa", "bbb", "ccc"].join("\n");
const diff = [`${tag(2, "bbb")}:`, below("bbb"), below("NEW")].join("\n");
const diff = ["EOF:", repl("ccc"), repl("NEW")].join("\n");
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc");
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nccc\nccc\nNEW");
});
it("surfaces a warning when pure-insert duplicates are auto-dropped", () => {
const source = ["aaa", "bbb", "ccc"].join("\n");
const diff = [`${tag(2, "bbb")}:`, below("aaa"), below("bbb"), below("NEW")].join("\n");
const diff = ["EOF:", repl("bbb"), repl("ccc"), repl("NEW")].join("\n");
const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true });
expect(result.lines).toBe("aaa\nbbb\nNEW\nccc");
expect(result.lines).toBe("aaa\nbbb\nccc\nNEW");
expect(result.warnings).toBeDefined();
expect(result.warnings).toEqual(
expect.arrayContaining([expect.stringMatching(/Auto-dropped 2 duplicate line\(s\) at the start of insert/)]),
@@ -525,14 +512,14 @@ describe("hashline parser — range-anchor syntax", () => {
const diff = [
"# This is a comment line from a model explanation.",
"## Another comment line.",
`${tag(2, "bbb")}:`,
`${sameLineRange(tag(2, "bbb"))}:`,
repl("BBB"),
].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nBBB\nccc");
});
it("does not skip comment lines when they are not immediately before an operation", () => {
const diff = ["# This is a stray comment.", "", `${tag(2, "bbb")}:`, repl("BBB")].join("\n");
const diff = ["# This is a stray comment.", "", `${sameLineRange(tag(2, "bbb"))}:`, repl("BBB")].join("\n");
expect(() => parseHashline(diff)).toThrow(/payload line has no preceding/);
});
@@ -549,13 +536,12 @@ describe("hashline parser — range-anchor syntax", () => {
});
it("inserts explicit blank lines above and below an anchor", () => {
const anchor = { line: 1 };
expect(parseHashline(`${tag(1, "aaa")}:\n${above("")}`).edits).toEqual([
{ kind: "insert", cursor: { kind: "before_anchor", anchor }, text: "", lineNum: 1, index: 0 },
]);
expect(parseHashline(`${tag(1, "aaa")}:\n${below("")}`).edits).toEqual([
{ kind: "insert", cursor: { kind: "after_anchor", anchor }, text: "", lineNum: 1, index: 0 },
]);
expect(applyDiff(content, `${sameLineRange(tag(1, "aaa"))}:\n${repl("")}\n${repeat(tag(1, "aaa"))}`)).toBe(
"\naaa\nbbb\nccc",
);
expect(applyDiff(content, `${sameLineRange(tag(1, "aaa"))}:\n${repeat(tag(1, "aaa"))}\n${repl("")}`)).toBe(
"aaa\n\nbbb\nccc",
);
});
it("rejects orphan payload lines with no preceding op", () => {
@@ -563,18 +549,20 @@ describe("hashline parser — range-anchor syntax", () => {
});
it("rejects ranges with `..` separator", () => {
expect(() => parseHashline(`${tag(2, "bbb")}..${tag(3, "ccc")}:\n${repl("BBB")}`).edits).toThrow(
expect(() => parseHashline(`${tag(2, "bbb")}..${sameLineRange(tag(3, "ccc"))}:\n${repl("BBB")}`).edits).toThrow(
/payload line has no preceding/,
);
});
it("describes the new block shape on unknown-op lines", () => {
expect(() => parseHashline(`-${sameLineRange(tag(2, "bbb"))}`).edits).toThrow(/Use A-B:, A:, BOF:, or EOF:/);
expect(() => parseHashline(`-${sameLineRange(tag(2, "bbb"))}`).edits).toThrow(/Use A-B:, A-B:-, BOF:, or EOF:/);
});
it("rejects `LINE:TEXT` copied verbatim from read output", () => {
const anchor = tag(2, "bbb");
expect(() => parseHashline(`${anchor}:BBB`)).toThrow(/Inline payload on the anchor line is rejected/);
expect(() => parseHashline(`${sameLineRange(anchor)}:BBB`)).toThrow(
/Inline payload on the anchor line is rejected/,
);
expect(() => parseHashline(`${anchor}-${tag(3, "ccc")}:BBB`)).toThrow(
/Inline payload on the anchor line is rejected/,
);
@@ -582,8 +570,10 @@ describe("hashline parser — range-anchor syntax", () => {
it("leniently strips `*`/`>` line-marker decoration from anchors", () => {
const anchor = tag(2, "bbb");
expect(applyDiff(content, `*${anchor}:\n${repl("BBB")}`)).toBe("aaa\nBBB\nccc");
expect(applyDiff(content, `>${anchor}:\n${above("X")}`)).toBe("aaa\nX\nbbb\nccc");
expect(applyDiff(content, `*${sameLineRange(anchor)}:\n${repl("BBB")}`)).toBe("aaa\nBBB\nccc");
expect(applyDiff(content, `>${sameLineRange(anchor)}:\n${repl("X")}\n${repeat(anchor)}`)).toBe(
"aaa\nX\nbbb\nccc",
);
});
it("rejects arrow replace syntax as an unrecognized payload line", () => {
@@ -593,16 +583,18 @@ describe("hashline parser — range-anchor syntax", () => {
it("preserves payload text containing arrow sigils after the leading payload sigil", () => {
const anchor = tag(2, "bbb");
expect(applyDiff(content, `${anchor}:\n${repl("bbb↑")}\n${below("tail↓")}`)).toBe("aaa\nbbb↑\ntail↓\nccc");
expect(applyDiff(content, `${sameLineRange(anchor)}:\n${repl("bbb↑")}\n${repl("tail↓")}`)).toBe(
"aaa\nbbb↑\ntail↓\nccc",
);
});
it("accepts BOF/EOF inserts with either arrow payload sigil", () => {
expect(applyDiff(content, `BOF:\n${below("HEAD")}`)).toBe("HEAD\naaa\nbbb\nccc");
expect(applyDiff(content, `EOF:\n${above("TAIL")}`)).toBe("aaa\nbbb\nccc\nTAIL");
it("accepts BOF/EOF inserts with literal payload rows", () => {
expect(applyDiff(content, `BOF:\n${repl("HEAD")}`)).toBe("HEAD\naaa\nbbb\nccc");
expect(applyDiff(content, `EOF:\n${repl("TAIL")}`)).toBe("aaa\nbbb\nccc\nTAIL");
});
it("coalesces two replace ops targeting the same single line (last wins)", () => {
const diff = `${tag(2, "bbb")}:\n${repl("BBB")}\n${tag(2, "bbb")}:\n${repl("BBB2")}`;
const diff = `${sameLineRange(tag(2, "bbb"))}:\n${repl("BBB")}\n${sameLineRange(tag(2, "bbb"))}:\n${repl("BBB2")}`;
const { edits, warnings } = parseHashline(diff);
expect(applyHashlineEdits("aaa\nbbb\nccc", edits).lines).toBe("aaa\nBBB2\nccc");
expect(warnings).toEqual([
@@ -624,7 +616,7 @@ describe("hashline parser — range-anchor syntax", () => {
// The inner anchors still clash with the outer range's deletes and the
// post-hoc validator catches the overlap.
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:\n${repl("NEW1")}\n${tag(3, "ccc")}-${tag(5, "eee")}:\n${repl("NEW2")}`;
expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by the : block on line 1/);
expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by another op on line 1/);
});
it("uses `|` payload lines inside a multi-line replacement", () => {
@@ -637,12 +629,12 @@ describe("hashline parser — range-anchor syntax", () => {
});
it("rejects read-output `N:TEXT` lines inside a pending `A-B:` block", () => {
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:\n${repl("line one")}\n${tag(3, "ccc")}:line two`;
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:\n${repl("line one")}\n${sameLineRange(tag(3, "ccc"))}:line two`;
expect(() => parseHashline(diff)).toThrow(/Inline payload on the anchor line is rejected/);
});
it("treats `N:` outside the pending range as a separate op", () => {
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:\n${repl("line one")}\n${tag(5, "eee")}:\n${repl("line five")}`;
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:\n${repl("line one")}\n${sameLineRange(tag(5, "eee"))}:\n${repl("line five")}`;
const { edits, warnings } = parseHashline(diff);
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee\nfff", edits).lines).toBe(
"aaa\nline one\nddd\nline five\nfff",
@@ -650,13 +642,13 @@ describe("hashline parser — range-anchor syntax", () => {
expect(warnings).toEqual([]);
});
it("accepts multiple inserts in the same bucket", () => {
const diff = `${tag(2, "bbb")}:\n${above("X")}\n${above("Y")}`;
it("accepts multiple literal rows before a repeated anchor", () => {
const diff = `${sameLineRange(tag(2, "bbb"))}:\n${repl("X")}\n${repl("Y")}\n${repeat(tag(2, "bbb"))}`;
expect(applyDiff(content, diff)).toBe("aaa\nX\nY\nbbb\nccc");
});
it("accepts a replace alongside an insert at the same anchor", () => {
const diff = `${tag(2, "bbb")}:\n${above("ABOVE")}\n${repl("NEW")}`;
it("accepts a replace alongside surrounding literal rows", () => {
const diff = `${sameLineRange(tag(2, "bbb"))}:\n${repl("ABOVE")}\n${repl("NEW")}`;
expect(applyDiff(content, diff)).toBe("aaa\nABOVE\nNEW\nccc");
});
});
@@ -683,50 +675,50 @@ describe("splitHashlineInput — ¶ headers", () => {
});
it("strips leading blank lines", () => {
expect(splitHashlineInput(`\n¶foo.ts\nBOF:\n${below("x")}`)).toEqual({
expect(splitHashlineInput(`\n¶foo.ts\nBOF:\n${repl("x")}`)).toEqual({
path: "foo.ts",
diff: `BOF:\n${below("x")}`,
diff: `BOF:\n${repl("x")}`,
});
});
it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => {
const cwd = process.cwd();
const absolute = path.join(cwd, "src", "foo.ts");
expect(splitHashlineInput(`${absolute}\nBOF:\n${below("x")}`, { cwd }).path).toBe("src/foo.ts");
expect(splitHashlineInput(`${absolute}\nBOF:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts");
});
it("uses explicit fallback path only when input has recognizable operations", () => {
expect(splitHashlineInput(`BOF:\n${below("x")}`, { path: "a.ts" })).toEqual({
expect(splitHashlineInput(`BOF:\n${repl("x")}`, { path: "a.ts" })).toEqual({
path: "a.ts",
diff: `BOF:\n${below("x")}`,
diff: `BOF:\n${repl("x")}`,
});
expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/);
});
it("splits multiple edit sections", () => {
const input = ["¶a.ts", "BOF:", below("a"), "¶b.ts", "EOF:", below("b")].join("\n");
const input = ["¶a.ts", "BOF:", repl("a"), "¶b.ts", "EOF:", repl("b")].join("\n");
expect(splitHashlineInputs(input)).toEqual([
{ path: "a.ts", diff: `BOF:\n${below("a")}` },
{ path: "b.ts", diff: `EOF:\n${below("b")}` },
{ path: "a.ts", diff: `BOF:\n${repl("a")}` },
{ path: "b.ts", diff: `EOF:\n${repl("b")}` },
]);
});
it("tolerates extra ¶ chars on the section header", () => {
const input = ["¶¶a.ts", "BOF:", below("a"), "¶¶¶b.ts", "EOF:", below("b")].join("\n");
const input = ["¶¶a.ts", "BOF:", repl("a"), "¶¶¶b.ts", "EOF:", repl("b")].join("\n");
expect(splitHashlineInputs(input)).toEqual([
{ path: "a.ts", diff: `BOF:\n${below("a")}` },
{ path: "b.ts", diff: `EOF:\n${below("b")}` },
{ path: "a.ts", diff: `BOF:\n${repl("a")}` },
{ path: "b.ts", diff: `EOF:\n${repl("b")}` },
]);
});
it("silently drops a duplicate header with no operations between them", () => {
const input = ["¶¶src/foo.ts", "¶¶src/foo.ts", "BOF:", below("x")].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "src/foo.ts", diff: `BOF:\n${below("x")}` }]);
const input = ["¶¶src/foo.ts", "¶¶src/foo.ts", "BOF:", repl("x")].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "src/foo.ts", diff: `BOF:\n${repl("x")}` }]);
});
it("silently drops a trailing header with no operations", () => {
const input = ["¶¶a.ts", "BOF:", below("a"), "¶¶b.ts"].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF:\n${below("a")}` }]);
const input = ["¶¶a.ts", "BOF:", repl("a"), "¶¶b.ts"].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF:\n${repl("a")}` }]);
});
});
@@ -755,7 +747,7 @@ it("preflights write policy for every section before committing a batch", async
describe("hashline executor", () => {
it("creates a missing file with a file-scoped insert", async () => {
await withTempDir(async tempDir => {
const input = `¶new.ts\nBOF:\n${below("export const x = 1;")}\n`;
const input = `¶new.ts\nBOF:\n${repl("export const x = 1;")}\n`;
const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input));
expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain("¶new.ts#");
expect(await Bun.file(path.join(tempDir, "new.ts")).text()).toBe("export const x = 1;");
@@ -765,16 +757,15 @@ describe("hashline executor", () => {
await withTempDir(async tempDir => {
const filePath = path.join(tempDir, "a.ts");
const source = ["aaa", "bbb", "ccc"].join("\n");
const input = `${header("a.ts", source)}\n${tag(2, "bbb")}:\n${below("aaa")}\n${below("bbb")}\n${below("NEW")}\n`;
const input = `${header("a.ts", source)}\nEOF:\n${repl("bbb")}\n${repl("ccc")}\n${repl("NEW")}\n`;
await Bun.write(filePath, source);
await executeHashlineSingle(hashlineExecuteOptions(tempDir, input));
expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\naaa\nbbb\nNEW\nccc");
expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\nccc\nbbb\nccc\nNEW");
await Bun.write(filePath, source);
const enabled = Settings.isolated({ "edit.hashlineAutoDropPureInsertDuplicates": true });
const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, enabled));
expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\nNEW\nccc");
expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\nccc\nNEW");
expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain("Auto-dropped");
});
});
@@ -848,8 +839,9 @@ describe("hashline executor", () => {
repl("L2h"),
repl("L2i"),
header("a.ts", `${original}\n`),
`${tag(8, "L8")}:`,
below("INSERTED"),
`${sameLineRange(tag(8, "L8"))}:`,
repeat(tag(8, "L8")),
repl("INSERTED"),
].join("\n");
await executeHashlineSingle(hashlineExecuteOptions(tempDir, input));
@@ -894,15 +886,15 @@ describe("hashlineEditParamsSchema — payload shape", () => {
});
it("tolerates provider extra fields without declaring `path`", () => {
expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF:\n${below("x")}` }).success).toBe(
expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF:\n${repl("x")}` }).success).toBe(
true,
);
});
it("accepts `_input` as a provider-emitted alias for `input`", () => {
const parsed = hashlineEditParamsSchema.safeParse({ _input: `¶x.ts\nBOF:\n${below("x")}` });
const parsed = hashlineEditParamsSchema.safeParse({ _input: `¶x.ts\nBOF:\n${repl("x")}` });
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.input).toBe(`¶x.ts\nBOF:\n${below("x")}`);
if (parsed.success) expect(parsed.data.input).toBe(`¶x.ts\nBOF:\n${repl("x")}`);
});
it("still requires `input`", () => {
@@ -1147,7 +1139,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => {
absolutePath: fakePath,
currentText,
fileHash: computeFileHash(v0Text),
edits: parseHashline(`10:\n${repl("L10-EDITED")}`).edits,
edits: parseHashline(`10-10:\n${repl("L10-EDITED")}`).edits,
options: {},
});
@@ -1208,32 +1200,42 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
const sentinel = "*** Abort";
it("parser breaks at *** Abort and surfaces a warning", () => {
const diff = [`${tag(1, "alpha")}:`, below("HELLO"), sentinel, `${tag(99, "junk")}:`, below("never")].join("\n");
const diff = [
`${sameLineRange(tag(1, "alpha"))}:`,
repeat(tag(1, "alpha")),
repl("HELLO"),
sentinel,
`${sameLineRange(tag(99, "junk"))}:`,
repeat(tag(99, "junk")),
repl("never"),
].join("\n");
const { edits, warnings } = parseHashline(diff);
expect(edits).toHaveLength(1);
expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" });
expect(edits).toHaveLength(3);
expect(edits[1]).toMatchObject({ kind: "insert", text: "HELLO" });
expect(warnings.length).toBeGreaterThan(0);
expect(warnings[0]).toMatch(/truncated mid-call/i);
});
it("appended sentinel from harmony-leak truncation: ops above are preserved", () => {
// Mirrors the exact shape harmony-leak emits inside a single section.
const diff = `${tag(1, "alpha")}:\n${below("KEPT")}\n*** Abort\n`;
const diff = `${sameLineRange(tag(1, "alpha"))}:\n${repeat(tag(1, "alpha"))}\n${repl("KEPT")}\n*** Abort\n`;
const { edits, warnings } = parseHashline(diff);
expect(edits).toHaveLength(1);
expect(edits[0]).toMatchObject({ text: "KEPT" });
expect(edits).toHaveLength(3);
expect(edits[1]).toMatchObject({ text: "KEPT" });
expect(warnings.length).toBeGreaterThan(0);
});
it("splitter respects *** Abort like *** End Patch", () => {
const input = [
`¶a.ts`,
`${tag(1, "alpha")}:`,
below("a-payload"),
`${sameLineRange(tag(1, "alpha"))}:`,
repeat(tag(1, "alpha")),
repl("a-payload"),
sentinel,
`¶b.ts`,
`${tag(1, "beta")}:`,
below("never-emitted"),
`${sameLineRange(tag(1, "beta"))}:`,
repeat(tag(1, "beta")),
repl("never-emitted"),
].join("\n");
const sections = splitHashlineInputs(input);
expect(sections).toHaveLength(1);
@@ -1242,37 +1244,43 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
});
it("clean input without sentinel produces no warning", () => {
const diff = `${tag(1, "alpha")}:\n${below("PAYLOAD")}\n`;
const diff = `${sameLineRange(tag(1, "alpha"))}:\n${repeat(tag(1, "alpha"))}\n${repl("PAYLOAD")}\n`;
const { warnings } = parseHashline(diff);
expect(warnings).toEqual([]);
});
});
describe("hashline parser — blank payload rows", () => {
it("bare A: deletes the line", () => {
describe("hashline parser — delete and empty-block semantics", () => {
it("inline delete deletes a single line", () => {
const text = "line1\nline2\nline3\n";
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2:\n`);
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-2:-\n`);
expect(applyDiff(text, diff)).toBe("line1\nline3\n");
});
it("bare A-B: deletes the range", () => {
it("inline delete deletes the range", () => {
const text = "line1\nline2\nline3\nline4\n";
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-3:\n`);
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-3:-\n`);
expect(applyDiff(text, diff)).toBe("line1\nline4\n");
});
it("A: with inline body is rejected", () => {
it("an `A-B:` anchor with no payload becomes a blank-line replacement", () => {
const text = "line1\nline2\nline3\n";
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2:replacement\n`);
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-2:\n`);
expect(applyDiff(text, diff)).toBe("line1\n\nline3\n");
});
it("`A-B:` with inline body is still rejected", () => {
const text = "line1\nline2\nline3\n";
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-2:replacement\n`);
expect(() => parseHashline(diff)).toThrow(/Inline payload on the anchor line is rejected/);
});
it("explicit empty above/below rows insert blank lines", () => {
it("explicit empty literal rows insert blank lines when the anchor is repeated", () => {
const text = "line1\nline2\nline3\n";
const aboveDiff = splitHashlineInput(`${header("a.ts", text)}\n2:\n${above("")}\n`).diff;
const aboveDiff = splitHashlineInput(`${header("a.ts", text)}\n2-2:\n${repl("")}\n${repeat("2")}\n`).diff;
expect(applyDiff(text, aboveDiff)).toBe("line1\n\nline2\nline3\n");
const belowDiff = splitHashlineInput(`${header("a.ts", text)}\n2:\n${below("")}\n`).diff;
const belowDiff = splitHashlineInput(`${header("a.ts", text)}\n2-2:\n${repeat("2")}\n${repl("")}\n`).diff;
expect(applyDiff(text, belowDiff)).toBe("line1\nline2\n\nline3\n");
});
});
@@ -1280,28 +1288,28 @@ describe("hashline parser — blank payload rows", () => {
describe("hashline parser — explicit blank payload rows", () => {
it("raw blank lines between ops are ignored", () => {
const text = "a\nb\nc\nd\ne\n";
const ops = `${header("a.ts", text)}\n1:\n${repl("A")}\n\n3:\n${repl("C")}\n`;
const ops = `${header("a.ts", text)}\n1-1:\n${repl("A")}\n\n3-3:\n${repl("C")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n");
});
it("empty replace payload rows are appended as blank payload lines", () => {
const text = "a\nb\nc\nd\ne\n";
const ops = `${header("a.ts", text)}\n1:\n${repl("A")}\n${repl("")}\n${repl("")}\n3:\n${repl("C")}\n`;
const ops = `${header("a.ts", text)}\n1-1:\n${repl("A")}\n${repl("")}\n${repl("")}\n3-3:\n${repl("C")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("A\n\n\nb\nC\nd\ne\n");
});
it("bare A: followed by two empty replace rows replaces the line with two blanks", () => {
it("`A-A:` followed by two empty replace rows replaces the line with two blanks", () => {
const text = "a\nb\nc\nd\ne\n";
const ops = `${header("a.ts", text)}\n2:\n${repl("")}\n${repl("")}\n4:\n${repl("D")}\n`;
const ops = `${header("a.ts", text)}\n2-2:\n${repl("")}\n${repl("")}\n4-4:\n${repl("D")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n");
});
it("empty replace row inside payload between two content lines is preserved", () => {
const text = "a\nb\nc\n";
const ops = `${header("a.ts", text)}\n2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`;
const ops = `${header("a.ts", text)}\n2-2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n");
});
+4 -4
View File
@@ -236,9 +236,9 @@ describe("computeHashlineDiff", () => {
const line = "unchanged content";
await Bun.write(sourcePath, `${line}\n`);
// `1:` with the same line in the replace bucket is a true no-op: the edit
// `1-1:` with the same line in the replace bucket is a true no-op: the edit
// fires through computeHashlineDiff but produces identical content.
const input = `${sourcePath}#${computeFileHash(`${line}\n`)}\n1:\n|${line}\n`;
const input = `${sourcePath}#${computeFileHash(`${line}\n`)}\n1-1:\n|${line}\n`;
const result = await computeHashlineDiff({ input }, tempDir);
expect("error" in result).toBe(true);
if ("error" in result) {
@@ -250,14 +250,14 @@ describe("computeHashlineDiff", () => {
const sourcePath = path.join(tempDir, "source.txt");
await Bun.write(sourcePath, "first\n");
const result = await computeHashlineDiff({ input: `${sourcePath}\nEOF:\n↓second` }, tempDir);
const result = await computeHashlineDiff({ input: `${sourcePath}\nEOF:\n|second` }, tempDir);
expect("diff" in result).toBe(true);
if ("diff" in result) {
expect(result.diff).toContain("second");
}
});
test("returns a handled error when the source path is a local URL", async () => {
const result = await computeHashlineDiff({ input: "¶local://PLAN.md\nEOF:\n↓x" }, tempDir);
const result = await computeHashlineDiff({ input: "¶local://PLAN.md\nEOF:\n|x" }, tempDir);
expect("error" in result).toBe(true);
if ("error" in result) {
@@ -54,7 +54,7 @@ describe("hashline streaming preview (multi-section)", () => {
const ctx = (cwd: string) => ({ cwd, signal: new AbortController().signal });
test("keeps section A's preview when section B's header just arrived", async () => {
const input = ["¶a.ts", "BOF:", "↓// new", "¶b.ts"].join("\n");
const input = ["¶a.ts", "BOF:", "|// new", "¶b.ts"].join("\n");
const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
expect(previews).not.toBeNull();
expect(previews).toHaveLength(1);
@@ -65,7 +65,7 @@ describe("hashline streaming preview (multi-section)", () => {
test("ignores parse errors from the trailing in-progress section", async () => {
// `7:bad` has inline payload — the trailing section is still being typed.
const input = ["¶a.ts", "BOF:", "↓// new", "¶b.ts", "7:bad"].join("\n");
const input = ["¶a.ts", "BOF:", "|// new", "¶b.ts", "7:bad"].join("\n");
const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
expect(previews).not.toBeNull();
expect(previews).toHaveLength(1);
@@ -74,7 +74,7 @@ describe("hashline streaming preview (multi-section)", () => {
});
test("renders both sections once each has at least one valid op", async () => {
const input = ["¶a.ts", "BOF:", "↓// new a", "¶b.ts", "BOF:", "↓// new b"].join("\n");
const input = ["¶a.ts", "BOF:", "|// new a", "¶b.ts", "BOF:", "|// new b"].join("\n");
const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
expect(previews).toHaveLength(2);
expect(previews?.map(p => p.path).sort()).toEqual(["a.ts", "b.ts"]);
@@ -38,7 +38,7 @@ describe("editToolRenderer", () => {
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderCall(
{
input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n↓// preview",
input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview",
},
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
@@ -56,7 +56,7 @@ describe("editToolRenderer", () => {
const component = new ToolExecutionComponent(
"edit",
{
input: ["*** Begin Patch", "¶crates/pi-natives/src/shell.rs", "EOF:", "↓pub fn streaming_preview() {"].join(
input: ["*** Begin Patch", "¶crates/pi-natives/src/shell.rs", "EOF:", "|pub fn streaming_preview() {"].join(
"\n",
),
},
@@ -68,7 +68,7 @@ describe("editToolRenderer", () => {
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("crates/pi-natives/src/shell.rs");
expect(rendered).not.toContain("EOF:");
expect(rendered).not.toContain("↓pub fn streaming_preview() {");
expect(rendered).not.toContain("|pub fn streaming_preview() {");
expect(rendered).not.toContain("*** Begin Patch");
});
@@ -76,7 +76,7 @@ describe("editToolRenderer", () => {
const uiTheme = await getUiTheme();
const compactComponent = editToolRenderer.renderCall(
{
input: "¶foo bar.ts\nBOF:\n↓// preview",
input: "¶foo bar.ts\nBOF:\n|// preview",
},
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
@@ -84,7 +84,7 @@ describe("editToolRenderer", () => {
const quotedComponent = editToolRenderer.renderCall(
{
input: "¶'baz qux.ts'\nBOF:\n↓// preview",
input: "¶'baz qux.ts'\nBOF:\n|// preview",
},
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
@@ -103,7 +103,7 @@ describe("editToolRenderer", () => {
// renderer keeps the title clean.
const canonical = editToolRenderer.renderCall(
{
input: "¶packages/coding-agent/src/slash-commands/builtin-registry.ts\nBOF:\n↓// preview",
input: "¶packages/coding-agent/src/slash-commands/builtin-registry.ts\nBOF:\n|// preview",
},
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
@@ -111,7 +111,7 @@ describe("editToolRenderer", () => {
// Even longer runs should still produce the clean path.
const triple = editToolRenderer.renderCall(
{ input: "¶¶¶a/b/c.ts\nBOF:\n↓// preview" },
{ input: "¶¶¶a/b/c.ts\nBOF:\n|// preview" },
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme,
);
@@ -138,7 +138,7 @@ describe("editToolRenderer", () => {
{ expanded: false, isPartial: false, renderContext: { editMode: "hashline" } },
uiTheme,
{
input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n↓// preview",
input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview",
},
);
+21
View File
@@ -1,6 +1,27 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed `A:` shorthand syntax; use explicit `A-A:` for single-line anchors
- Removed `↑` and `↓` payload sigils; use `|TEXT` for literal rows and `^A-B` for repeating original lines
- Removed standalone delete rows; use inline `A-B:-` syntax instead
- Removed `after_anchor` cursor kind; all inserts now use `before_anchor` positioning
- Replaced insert-above/insert-below payload sigils with linear body rows: `|TEXT` emits literal text and `^A-B` repeats original file lines inline.
- Replaced standalone delete rows with inline range deletes: use `A-B:-`.
- Changed empty `A-B:`, `BOF:`, and `EOF:` blocks to write one blank line instead of being rejected.
### Added
- Added `A-B:-` inline delete syntax for concrete range anchors
- Added `^A-B` repeat payload syntax to emit original file lines inline
- Added support for empty anchor blocks to write one blank line at the anchor position
### Changed
- Changed payload row format from three sigils (`|`, `↑`, `↓`) to two (`|`, `^`)
- Changed range anchor syntax to require explicit `A-B` form (no single-line shorthand)
- Changed error messages to reference new syntax and remove references to removed sigils
## [15.5.5] - 2026-05-27
+6 -6
View File
@@ -26,7 +26,7 @@ await fs.writeText(
const patcher = new Patcher({ fs });
const patch = Patch.parse(String.raw`¶hello.ts
1:
1-1:
|const greeting = "hello";`);
const result = await patcher.apply(patch);
@@ -46,12 +46,12 @@ hash and the edit, the patcher refuses (or, with a `SnapshotStore`, tries
session-aware recovery).
Inside a hunk:
- `A-B:` — anchor lines A..B (single-anchor `A:` is sugar for `A-A:`).
- `A-B:` — anchor lines A..B (use `A-A:` for a single line; no shorthand).
- `A-B:-` — delete lines A..B.
- `BOF:` / `EOF:` — virtual anchors at the beginning/end of file.
- `|TEXT` — replace-bucket payload. A non-empty replace bucket replaces A..B.
- `↑TEXT` — insert before A (`BOF:` treats `↑`/`↓` equivalently).
- `↓TEXT` — insert after B (`EOF:` treats `↑`/`↓` equivalently).
- `|TEXT` — literal body row.
- `^A-B` — repeat original file lines A..B inline (`^A-A` for one line).
- Empty body — write one blank line at the anchor/virtual position.
## Abstractions
+1
View File
@@ -28,6 +28,7 @@
"check": "biome check . && bun run check:types",
"check:types": "tsgo -p tsconfig.json --noEmit",
"lint": "biome lint .",
"test": "bun test",
"fix": "biome check --write --unsafe .",
"fmt": "biome format --write ."
},
+77 -64
View File
@@ -18,13 +18,15 @@ import type { Anchor, ApplyOptions, ApplyResult, Cursor, Edit } from "./types";
type LineOrigin = "original" | "insert" | "replacement";
type InsertEdit = Extract<Edit, { kind: "insert" }>;
type DeleteEdit = Extract<Edit, { kind: "delete" }>;
type AppliedEdit = InsertEdit | DeleteEdit;
interface IndexedEdit {
edit: Edit;
edit: AppliedEdit;
idx: number;
}
type DeleteEdit = Extract<Edit, { kind: "delete" }>;
interface ReplacementGroup {
startIndex: number;
endIndex: number;
@@ -33,31 +35,32 @@ interface ReplacementGroup {
deletes: DeleteEdit[];
}
function isReplacementInsert(edit: Edit): edit is Extract<Edit, { kind: "insert" }> & { mode: "replacement" } {
function isReplacementInsert(edit: Edit): edit is InsertEdit & { mode: "replacement" } {
return edit.kind === "insert" && edit.mode === "replacement";
}
function rangeAnchors(start: Anchor, end: Anchor): Anchor[] {
const anchors: Anchor[] = [];
for (let line = start.line; line <= end.line; line++) anchors.push({ line });
return anchors;
}
function getCursorAnchors(cursor: Cursor): Anchor[] {
return cursor.kind === "before_anchor" ? [cursor.anchor] : [];
}
function getEditAnchors(edit: Edit): Anchor[] {
if (edit.kind === "delete") return [edit.anchor];
switch (edit.cursor.kind) {
case "before_anchor":
case "after_anchor":
return [edit.cursor.anchor];
case "bof":
case "eof":
return [];
default: {
const _exhaustive: never = edit.cursor;
return _exhaustive;
}
}
if (edit.kind === "repeat")
return [...getCursorAnchors(edit.cursor), ...rangeAnchors(edit.range.start, edit.range.end)];
return getCursorAnchors(edit.cursor);
}
/**
* Verify every anchored edit points at an existing line. File-version binding is
* checked once per section via the header hash before this function runs.
*/
function validateLineBounds(edits: Edit[], fileLines: string[]): void {
function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void {
for (const edit of edits) {
for (const anchor of getEditAnchors(edit)) {
if (anchor.line < 1 || anchor.line > fileLines.length) {
@@ -67,6 +70,44 @@ function validateLineBounds(edits: Edit[], fileLines: string[]): void {
}
}
function assertLineExists(line: number, fileLines: string[]): void {
if (line < 1 || line > fileLines.length) {
throw new Error(`Line ${line} does not exist (file has ${fileLines.length} lines)`);
}
}
function cloneAppliedEdit(edit: AppliedEdit, index: number): AppliedEdit {
if (edit.kind === "delete") return { ...edit, anchor: { ...edit.anchor }, index };
return { ...edit, cursor: cloneCursor(edit.cursor), index };
}
function expandRepeatEdits(edits: Edit[], fileLines: string[]): AppliedEdit[] {
const expanded: AppliedEdit[] = [];
for (const edit of edits) {
if (edit.kind !== "repeat") {
expanded.push(cloneAppliedEdit(edit, expanded.length));
continue;
}
if (edit.range.end.line < edit.range.start.line) {
throw new Error(
`line ${edit.lineNum}: range ${edit.range.start.line}-${edit.range.end.line} ends before it starts.`,
);
}
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) {
assertLineExists(line, fileLines);
expanded.push({
kind: "insert",
cursor: cloneCursor(edit.cursor),
text: fileLines[line - 1] ?? "",
lineNum: edit.lineNum,
index: expanded.length,
...(edit.mode === undefined ? {} : { mode: edit.mode }),
});
}
}
return expanded;
}
function insertAtStart(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): void {
if (lines.length === 0) return;
const origins = lines.map((): LineOrigin => "insert");
@@ -95,13 +136,13 @@ function insertAtEnd(fileLines: string[], lineOrigins: LineOrigin[], lines: stri
}
/** Bucket edits by the line they target so we can apply each line's group in one splice. */
function getAnchorTargetLine(edit: Edit): number | undefined {
function getAnchorTargetLine(edit: AppliedEdit): number | undefined {
if (edit.kind === "delete") return edit.anchor.line;
if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line;
if (edit.cursor.kind === "before_anchor") return edit.cursor.anchor.line;
return undefined;
}
function collectAnchorTargetLines(edits: Edit[]): Set<number> {
function collectAnchorTargetLines(edits: AppliedEdit[]): Set<number> {
const lines = new Set<number>();
for (const edit of edits) {
const line = getAnchorTargetLine(edit);
@@ -110,7 +151,7 @@ function collectAnchorTargetLines(edits: Edit[]): Set<number> {
return lines;
}
function findReplacementGroup(edits: Edit[], startIndex: number): ReplacementGroup | undefined {
function findReplacementGroup(edits: AppliedEdit[], startIndex: number): ReplacementGroup | undefined {
const first = edits[startIndex];
if (!isReplacementInsert(first) || first.cursor.kind !== "before_anchor") return undefined;
@@ -278,14 +319,14 @@ function countMatchingSingleStructuralSuffixBoundary(
/**
* Single-line non-structural boundary duplicate detector for replacement
* groups. Mirrors the same boundary check the pure-insert absorber uses for
* `A:` + `↓` (leading) / `A:` + `↑` (trailing) inserts, but applied to the
* top/bottom edges of an `A-B:` replacement payload. Catches mistakes like
* leading/trailing context echoes, but applied to the top/bottom edges of an
* `A-B:` replacement payload. Catches mistakes like
* `103-138:` + `|const X = …` where line 102 already reads `const X = …`.
*
* Gated by `options.autoDropPureInsertDuplicates`: the existing 2+-line block
* absorb already runs unconditionally, and the structural single-line
* absorber is balance-validated; a non-structural single-line duplicate is
* ambiguous (could be an intentional `2:foo` over a line that happens to
* ambiguous (could be an intentional `2-2:foo` over a line that happens to
* sit next to another `foo`), so we only fire when the user has opted in.
*/
function countMatchingSingleNonStructuralPrefixDuplicate(
@@ -325,7 +366,7 @@ function contiguousRange(start: number, count: number): number[] {
return Array.from({ length: count }, (_, offset) => start + offset);
}
function deleteEditForAutoAbsorbedLine(line: number, sourceLineNum: number, index: number): Edit {
function deleteEditForAutoAbsorbedLine(line: number, sourceLineNum: number, index: number): AppliedEdit {
return {
kind: "delete",
anchor: { line },
@@ -345,9 +386,7 @@ interface PureInsertGroup {
function cursorMatches(a: Cursor, b: Cursor): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === "bof" || a.kind === "eof") return true;
const aAnchor = (a as { anchor: Anchor }).anchor;
const bAnchor = (b as { anchor: Anchor }).anchor;
return aAnchor.line === bAnchor.line;
return a.anchor.line === b.anchor.line;
}
/**
@@ -357,7 +396,7 @@ function cursorMatches(a: Cursor, b: Cursor): boolean {
* instead). Returns the contiguous payload so we can check it for boundary
* duplicates against the file.
*/
function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup | undefined {
function findPureInsertGroup(edits: AppliedEdit[], startIndex: number): PureInsertGroup | undefined {
const first = edits[startIndex];
if (first?.kind !== "insert" || isReplacementInsert(first)) return undefined;
@@ -393,11 +432,7 @@ function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup
function pureInsertNeighborhood(cursor: Cursor, fileLines: string[]): { aboveEndIdx: number; belowStartIdx: number } {
if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 };
if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length };
if (cursor.kind === "before_anchor") {
return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
}
// after_anchor
return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line };
return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
}
interface PureInsertAbsorbResult {
@@ -411,8 +446,8 @@ interface PureInsertAbsorbResult {
/**
* For a pure-insert group, drop only multi-line context echoes that exactly
* duplicate the file lines adjacent to the insertion point. Single-line pure
* insert duplicates are ambiguous (`N↓}` may be an accidental anchor echo or an
* intentional inserted delimiter), so they are left literal even when generic
* insert duplicates are ambiguous (a repeated `}` may be an accidental anchor
* echo or an intentional inserted delimiter), so they are left literal even when
* duplicate absorption is enabled.
*/
function tryAbsorbPureInsertGroup(
@@ -479,13 +514,13 @@ function tryAbsorbPureInsertGroup(
}
function absorbReplacementBoundaryDuplicates(
edits: Edit[],
edits: AppliedEdit[],
fileLines: string[],
warnings: string[],
options: ApplyOptions,
): Edit[] {
): AppliedEdit[] {
let nextSyntheticIndex = edits.length;
const absorbed: Edit[] = [];
const absorbed: AppliedEdit[] = [];
// Anchor targets are stable across the loop because we only ever append
// synthetic deletes (never mutate originals). A line in this set that
@@ -649,32 +684,10 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
};
validateLineBounds(edits, fileLines);
const expandedEdits = expandRepeatEdits(edits, fileLines);
validateLineBounds(expandedEdits, fileLines);
const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
const targetEdits: Edit[] = [];
// Normalize after_anchor inserts to before_anchor of the next line, or EOF
// when the anchor is the final line. Keep the authored edit objects
// immutable: PatchSection caches parsed edits and callers may apply them
// repeatedly against different snapshots.
for (const edit of normalizedEdits) {
if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") {
targetEdits.push(edit);
continue;
}
const anchorLine = edit.cursor.anchor.line;
targetEdits.push({
...edit,
cursor:
anchorLine >= fileLines.length
? { kind: "eof" }
: {
kind: "before_anchor",
anchor: { line: anchorLine + 1 },
},
});
}
const targetEdits = absorbReplacementBoundaryDuplicates(expandedEdits, fileLines, warnings, options);
// Partition edits into BOF, EOF, and anchor-targeted buckets.
const bofLines: string[] = [];
+13 -7
View File
@@ -7,15 +7,16 @@
/** Anchor terminator for every hashline operation block. */
export const HL_OP_REPLACE = ":";
/** Payload sigil for lines that replace the anchored range in place. */
/** Inline-delete suffix for concrete range anchors (`A-B:-`). */
export const HL_OP_DELETE_SUFFIX = ":-";
/** Payload sigil for literal body rows. */
export const HL_PAYLOAD_REPLACE = "|";
/** Payload sigil for lines inserted before the anchored range. */
export const HL_PAYLOAD_ABOVE = "↑";
/** Payload sigil for lines inserted after the anchored range. */
export const HL_PAYLOAD_BELOW = "↓";
/** Payload sigil for body rows that repeat original file lines. */
export const HL_PAYLOAD_REPEAT = "^";
/** All hashline payload sigils, concatenated for fast membership tests. */
export const HL_PAYLOAD_CHARS = `${HL_PAYLOAD_REPLACE}${HL_PAYLOAD_ABOVE}${HL_PAYLOAD_BELOW}`;
export const HL_PAYLOAD_CHARS = `${HL_PAYLOAD_REPLACE}${HL_PAYLOAD_REPEAT}`;
/** Hashline edit file-section header marker. */
export const HL_FILE_PREFIX = "¶";
@@ -46,7 +47,12 @@ export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`;
export const HL_LINE_RE_RAW = `[1-9]\\d*`;
/** Capture-group form of {@link HL_LINE_RE_RAW}. */
export const HL_LINE_CAPTURE_RE_RAW = `([1-9]\\d*)`;
export const HL_LINE_CAPTURE_RE_RAW = `(${HL_LINE_RE_RAW})`;
/** Regex for repeat payload rows (`^A-B`). */
export const HL_PAYLOAD_REPEAT_RE = new RegExp(
`^\\${HL_PAYLOAD_REPEAT}${HL_LINE_CAPTURE_RE_RAW}-${HL_LINE_CAPTURE_RE_RAW}$`,
);
/** Four-hex-character file hash carried by a hashline section header. */
export const HL_FILE_HASH_RE_RAW = `[0-9a-f]{4}`;
+7 -6
View File
@@ -8,14 +8,15 @@ update_hunk: "¶" filename ("#" file_hash)? LF block*
filename: /([^\s#]+)/
file_hash: /[0-9a-f]{4}/
block: anchor ":" LF payload*
payload: above_payload | replace_payload | below_payload
above_payload: "↑" /[^\n]*/ LF
replace_payload: "|" /[^\n]*/ LF
below_payload: "↓" /[^\n]*/ LF
block: anchor ":" delete_suffix? LF payload*
delete_suffix: "-"
payload: literal_payload | repeat_payload
literal_payload: "|" /[^\n]*/ LF
repeat_payload: "^" range LF
anchor: range | "BOF" | "EOF"
range: LID ("-" LID)?
range: LID "-" LID
LID: /[1-9]\d*/
%import common.LF
+9 -6
View File
@@ -192,14 +192,14 @@ export class PatchSection {
}
/**
* True when at least one edit anchors to a concrete file line (range or
* before/after_anchor insert). Pure BOF/EOF inserts do not count: those
* are safe to apply to files that don't yet exist.
* True when at least one edit anchors to concrete file content. Pure BOF/EOF
* literal inserts do not count: those are safe to apply to files that don't
* yet exist.
*/
get hasAnchorScopedEdit(): boolean {
return this.edits.some(edit => {
if (edit.kind === "delete") return true;
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
if (edit.kind === "delete" || edit.kind === "repeat") return true;
return edit.cursor.kind === "before_anchor";
});
}
@@ -211,7 +211,10 @@ export class PatchSection {
lines.add(edit.anchor.line);
continue;
}
if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
if (edit.kind === "repeat") {
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) lines.add(line);
}
if (edit.cursor.kind === "before_anchor") {
lines.add(edit.cursor.anchor.line);
}
}
+6 -2
View File
@@ -36,9 +36,13 @@ export const REPLACE_PAIR_COALESCED_WARNING =
/** Error text prefix emitted when an anchor line carries inline payload. */
export const INLINE_PAYLOAD_REJECTED_PREFIX = "Inline payload on the anchor line is rejected.";
/** Error text emitted when `|` replacement payload targets BOF/EOF. */
/** Error text emitted when inline delete targets BOF/EOF. */
export const VIRTUAL_REPLACE_REJECTED_MESSAGE =
"BOF:/EOF: anchors are virtual positions and cannot use `|` replacement payload. Use `↑` or `↓` payload lines.";
"BOF:/EOF: anchors are virtual positions and cannot use `:-`. Use `|TEXT` or `^A-B` body rows to insert at a virtual position.";
/** Error text emitted when `^A` repeat shorthand is used. */
export const REPEAT_SHORTHAND_REJECTED_MESSAGE =
"Repeat payload shorthand `^A` is rejected. Use explicit `^A-A` for one line.";
/** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
export const RECOVERY_EXTERNAL_WARNING =
+82 -69
View File
@@ -13,21 +13,15 @@
*
* Convenience entry point: {@link parsePatch}.
*/
import { HL_OP_REPLACE, HL_PAYLOAD_ABOVE, HL_PAYLOAD_BELOW, HL_PAYLOAD_REPLACE } from "./format";
import { HL_PAYLOAD_REPEAT, HL_PAYLOAD_REPLACE } from "./format";
import {
ABORT_WARNING,
INLINE_PAYLOAD_REJECTED_PREFIX,
REPEAT_SHORTHAND_REJECTED_MESSAGE,
REPLACE_PAIR_COALESCED_WARNING,
VIRTUAL_REPLACE_REJECTED_MESSAGE,
} from "./messages";
import {
type BlockTarget,
cloneCursor,
type ParsedRange,
type PayloadBucket,
type Token,
Tokenizer,
} from "./tokenizer";
import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
import type { Anchor, Cursor, Edit } from "./types";
function validateRangeOrder(range: ParsedRange, lineNum: number): void {
@@ -56,17 +50,11 @@ function isSkippableCommentLine(line: string): boolean {
return line.trimStart().startsWith("#");
}
function sigilForBucket(bucket: PayloadBucket): string {
if (bucket === "above") return HL_PAYLOAD_ABOVE;
if (bucket === "below") return HL_PAYLOAD_BELOW;
return HL_PAYLOAD_REPLACE;
}
function describeTarget(target: BlockTarget): string {
if (target.kind === "bof") return "BOF:";
if (target.kind === "eof") return "EOF:";
const { start, end } = target.range;
return start.line === end.line ? `${start.line}:` : `${start.line}-${end.line}:`;
return `${start.line}-${end.line}:`;
}
interface PendingComment {
@@ -74,11 +62,9 @@ interface PendingComment {
text: string;
}
interface PayloadRow {
bucket: PayloadBucket;
text: string;
lineNum: number;
}
type PayloadRow =
| { kind: "literal"; text: string; lineNum: number }
| { kind: "repeat"; range: ParsedRange; lineNum: number };
interface Pending {
target: BlockTarget;
@@ -146,10 +132,17 @@ export class Executor {
case "blank":
this.#consumePendingSkippableComments();
return;
case "payload":
case "payload-literal":
this.#consumePendingSkippableComments();
this.#handlePayload(token.bucket, token.text, token.lineNum);
this.#handleLiteralPayload(token.text, token.lineNum);
return;
case "payload-repeat":
this.#consumePendingSkippableComments();
this.#handleRepeatPayload(token.range, token.lineNum);
return;
case "payload-repeat-shorthand":
this.#consumePendingSkippableComments();
throw new Error(`line ${token.lineNum}: ${REPEAT_SHORTHAND_REJECTED_MESSAGE}`);
case "raw":
if (this.#pending === undefined && isSkippableCommentLine(token.text)) {
this.#skippableComments.push({ text: token.text, lineNum: token.lineNum });
@@ -160,11 +153,22 @@ export class Executor {
return;
case "op-block":
this.#discardPendingSkippableComments();
if (token.deleteSuffix) {
if (token.target.kind !== "range") {
throw new Error(`line ${token.lineNum}: ${VIRTUAL_REPLACE_REJECTED_MESSAGE}`);
}
validateRangeOrder(token.target.range, token.lineNum);
this.#flushPending();
for (const anchor of expandRange(token.target.range)) {
this.#pushDelete(anchor, token.lineNum);
}
return;
}
if (token.inlineBody !== undefined) {
throw new Error(
`line ${token.lineNum}: ${INLINE_PAYLOAD_REJECTED_PREFIX} ` +
`Use a bare anchor line such as ${describeTarget(token.target)}, then put payload on following rows prefixed with ` +
`${HL_PAYLOAD_REPLACE}, ${HL_PAYLOAD_ABOVE}, or ${HL_PAYLOAD_BELOW}.`,
`Use a bare anchor line such as ${describeTarget(token.target)}, then put body rows below it prefixed with ` +
`${HL_PAYLOAD_REPLACE} or ${HL_PAYLOAD_REPEAT}.`,
);
}
if (token.target.kind === "range") validateRangeOrder(token.target.range, token.lineNum);
@@ -245,24 +249,33 @@ export class Executor {
if (sourceLines.length < 2) continue;
const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b);
throw new Error(
`line ${secondBlock}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE} block on line ${firstBlock}. ` +
`line ${secondBlock}: anchor line ${anchorLine} is already targeted by another op on line ${firstBlock}. ` +
`Issue ONE block per range; payload is only the final desired content, never a before/after pair.`,
);
}
}
#handlePayload(bucket: PayloadBucket, text: string, lineNum: number): void {
#handleLiteralPayload(text: string, lineNum: number): void {
const pending = this.#pending;
if (!pending) {
throw new Error(
`line ${lineNum}: payload line has no preceding A-B:, A:, BOF:, or EOF: anchor. ` +
`Got ${JSON.stringify(`${sigilForBucket(bucket)}${text}`)}.`,
`line ${lineNum}: payload line has no preceding A-B:, BOF:, or EOF: anchor. ` +
`Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`,
);
}
if (bucket === "replace" && pending.target.kind !== "range") {
throw new Error(`line ${lineNum}: ${VIRTUAL_REPLACE_REJECTED_MESSAGE}`);
pending.payloads.push({ kind: "literal", text, lineNum });
}
#handleRepeatPayload(range: ParsedRange, lineNum: number): void {
const pending = this.#pending;
if (!pending) {
throw new Error(
`line ${lineNum}: payload line has no preceding A-B:, BOF:, or EOF: anchor. ` +
`Got ${JSON.stringify(`${HL_PAYLOAD_REPEAT}${range.start.line}-${range.end.line}`)}.`,
);
}
pending.payloads.push({ bucket, text, lineNum });
validateRangeOrder(range, lineNum);
pending.payloads.push({ kind: "repeat", range, lineNum });
}
#handleRaw(text: string, lineNum: number): void {
@@ -270,7 +283,7 @@ export class Executor {
if (text.trim().length === 0) return;
throw new Error(
`line ${lineNum}: payload row in a hashline block must start with ` +
`${HL_PAYLOAD_REPLACE}, ${HL_PAYLOAD_ABOVE}, or ${HL_PAYLOAD_BELOW}. Got ${JSON.stringify(text)}.`,
`${HL_PAYLOAD_REPLACE} or ${HL_PAYLOAD_REPEAT}A-B. Got ${JSON.stringify(text)}.`,
);
}
@@ -281,13 +294,13 @@ export class Executor {
const firstChar = text[0];
if (firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") {
throw new Error(
`line ${lineNum}: unrecognized hashline block. Use A-B:, A:, BOF:, or EOF: anchors followed by ` +
`${HL_PAYLOAD_REPLACE}, ${HL_PAYLOAD_ABOVE}, or ${HL_PAYLOAD_BELOW} payload rows. Got ${JSON.stringify(text)}.`,
`line ${lineNum}: unrecognized hashline block. Use A-B:, A-B:-, BOF:, or EOF: anchors followed by ` +
`${HL_PAYLOAD_REPLACE}TEXT or ${HL_PAYLOAD_REPEAT}A-B body rows. Got ${JSON.stringify(text)}.`,
);
}
throw new Error(
`line ${lineNum}: payload line has no preceding A-B:, A:, BOF:, or EOF: anchor. Got ${JSON.stringify(text)}.`,
`line ${lineNum}: payload line has no preceding A-B:, BOF:, or EOF: anchor. Got ${JSON.stringify(text)}.`,
);
}
@@ -302,10 +315,29 @@ export class Executor {
});
}
#pushRepeat(cursor: Cursor, range: ParsedRange, lineNum: number, mode?: "replacement"): void {
this.#edits.push({
kind: "repeat",
cursor: cloneCursor(cursor),
range: { start: { ...range.start }, end: { ...range.end } },
lineNum,
index: this.#editIndex++,
...(mode === undefined ? {} : { mode }),
});
}
#pushDelete(anchor: Anchor, lineNum: number): void {
this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
}
#emitPayloadRow(cursor: Cursor, payload: PayloadRow, lineNum: number, mode?: "replacement"): void {
if (payload.kind === "literal") {
this.#pushInsert(cursor, payload.text, lineNum, mode);
return;
}
this.#pushRepeat(cursor, payload.range, lineNum, mode);
}
#flushPending(): void {
const pending = this.#pending;
if (!pending) return;
@@ -313,46 +345,27 @@ export class Executor {
const { target, lineNum, payloads } = pending;
if (target.kind === "bof" || target.kind === "eof") {
const cursor: Cursor = target.kind === "bof" ? { kind: "bof" } : { kind: "eof" };
for (const payload of payloads) {
this.#pushInsert(cursor, payload.text, lineNum);
if (payloads.length === 0) {
this.#pushInsert(cursor, "", lineNum);
} else {
for (const payload of payloads) {
this.#emitPayloadRow(cursor, payload, lineNum);
}
}
this.#pending = undefined;
return;
}
const above: string[] = [];
const replacement: string[] = [];
const below: string[] = [];
for (const payload of payloads) {
if (payload.bucket === "above") above.push(payload.text);
else if (payload.bucket === "below") below.push(payload.text);
else replacement.push(payload.text);
}
for (const text of above) {
this.#pushInsert({ kind: "before_anchor", anchor: { ...target.range.start } }, text, lineNum);
}
if (replacement.length > 0) {
for (const text of replacement) {
this.#pushInsert(
{ kind: "before_anchor", anchor: { ...target.range.start } },
text,
lineNum,
"replacement",
);
}
for (const anchor of expandRange(target.range)) {
this.#pushDelete(anchor, lineNum);
}
} else if (above.length === 0 && below.length === 0) {
for (const anchor of expandRange(target.range)) {
this.#pushDelete(anchor, lineNum);
const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } };
if (payloads.length === 0) {
this.#pushInsert(cursor, "", lineNum, "replacement");
} else {
for (const payload of payloads) {
this.#emitPayloadRow(cursor, payload, lineNum, "replacement");
}
}
for (const text of below) {
this.#pushInsert({ kind: "after_anchor", anchor: { ...target.range.end } }, text, lineNum);
for (const anchor of expandRange(target.range)) {
this.#pushDelete(anchor, lineNum);
}
this.#pending = undefined;
+2 -2
View File
@@ -106,8 +106,8 @@ export class PreparedSection {
function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
return edits.some(edit => {
if (edit.kind === "delete") return true;
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
if (edit.kind === "delete" || edit.kind === "repeat") return true;
return edit.cursor.kind === "before_anchor";
});
}
+27 -108
View File
@@ -2,129 +2,48 @@ Your patch language is a compact, line-anchored edit format.
<payload>
Patch payload = one or more file sections:
```
¶PATH#HASH
A-B:
|replacement line
↑inserted above line
↓inserted below line
|literal line
^A-B
A-B:-
BOF:
|literal at start
EOF:
|literal at end
```
- `HASH` comes from the latest `read`/`search` header. Missing? Re-`read`.
- No context rows, no gutters, no unchanged lines.
- Anchor rows are ALWAYS bare: `A-B:`, `A:`, `BOF:`, `EOF:`.
- Payload rows MUST start with `|`, `↑`, or `↓`.
- The first sigil is stripped; remaining bytes are file content.
- `HASH` comes from latest `read`/`search`; missing? re-read.
- `A-B:` anchors original lines A..B; use `A-A:` for one line.
- `BOF:`/`EOF:` insert at file start/end.
- `A-B:-` deletes original lines A..B.
- Body rows are linear; output order = row order.
- `|TEXT` emits literal `TEXT`; bare `|` emits blank.
- `^A-B` repeats original lines A..B; one line = `^A-A`.
</payload>
<anchors>
`A-B:` — anchor A..B inclusive.
`A:` — shorthand for `A-A:`.
`BOF:` — virtual position before line 1.
`EOF:` — virtual position after the last line.
</anchors>
<sigils>
`|content` — replace A..B with `content`.
`↑content` — insert `content` before A.
`↓content` — insert `content` after B.
</sigils>
<semantics>
- **No payload → delete.** `5:` deletes line 5.
- **Buckets combine.** `↑` before A, `|` in place, `↓` after B.
- **Bucket order ignores interleaving.** Output order = all `↑`, then `|`/original, then all `↓`.
- **Order within a bucket is preserved.** Two `↑` rows stack top-down.
- **Blank payload = explicit.** Bare `|`, `↑`, or `↓` writes one blank line.
- **Line numbers are frozen.** Later anchors still reference pre-edit lines.
- Concrete `A-B:` body replaces A..B.
- Concrete `A-B:` with no body replaces A..B with one blank line.
- Virtual `BOF:`/`EOF:` body inserts there.
- Virtual empty body inserts one blank line.
- Line numbers are frozen for the whole patch.
</semantics>
<examples>
# Replace line 1 with two lines; insert one line below the replacement.
# Replace line 1 with two lines.
```
¶a.ts#1a2b
1:
1-1:
|const X = "b";
|export const Y = X;
↓const Z = Y;
```
# Insert above line 3. Line 3 survives because there is no `|` row.
# Insert below line 5.
```
¶a.ts#1a2b
3:
↑function helper() { return X; }
5-5:
^5-5
|const Y = X;
```
# Delete lines 5..7.
```
¶a.ts#1a2b
5-7:
```
# Replace line 5 with one blank line.
```
¶a.ts#1a2b
5:
|
```
</examples>
<common-failures>
- **NEVER use inline payload.** `5:content` is invalid; write `5:` then `|content`.
- **Do not repeat preserved lines.** If line 5 should survive, omit `|`.
- **`↑`/`↓` payloads are new bytes only.** Never echo the anchor or a neighbor line — that line already exists; copying it into a `↓` row appends a duplicate.
- **Do not echo read gutters.** `84:content` is not payload.
- **Do not replay past B.** Stop before B+1; widen the anchor if B+1 changes.
- **NEVER fabricate file hashes.** Missing? Re-`read`.
</common-failures>
<anti-pattern>
# WRONG — inline payload after anchor.
5:const X = "b";
# RIGHT
5:
|const X = "b";
# WRONG — replacing line 5 just to keep it while inserting above.
5:
↑const Y = X;
|const X = "a";
# RIGHT — no `|`; line 5 survives automatically.
5:
↑const Y = X;
# WRONG — echoing the anchor into a ↓ payload duplicates it.
# Line 5 already contains `const X = 1;`.
5:
↓const X = 1;
↓const Y = 2;
# RIGHT — payload is only the new line; the anchor survives automatically.
5:
↓const Y = 2;
# WRONG — read-output gutters inside payload.
5-6:
5:const X = "b";
6:export const Y = X;
# RIGHT
5-6:
|const X = "b";
|export const Y = X;
# WRONG — line numbers shifted mentally after the first block.
1:
↓new line
2:
↓another new line
# `2:` still targets original line 2, not `new line`.
</anti-pattern>
<critical>
- Anchor rows are bare ranges ending in `:`.
- Payload rows start with `|`, `↑`, or `↓`.
- `|` means replace anchored lines.
- Only `↑`/`↓` means preserve anchored lines.
- Payload is only new content; no context rows.
</critical>
# Delete lines 5..7: `5-7:-`.
</examples>
+7 -11
View File
@@ -73,18 +73,14 @@ function collectAnchorLines(edits: readonly Edit[]): number[] {
function getEditAnchors(edit: Edit): Anchor[] {
if (edit.kind === "delete") return [edit.anchor];
switch (edit.cursor.kind) {
case "before_anchor":
case "after_anchor":
return [edit.cursor.anchor];
case "bof":
case "eof":
return [];
default: {
const _exhaustive: never = edit.cursor;
return _exhaustive;
}
const cursorAnchors = edit.cursor.kind === "before_anchor" ? [edit.cursor.anchor] : [];
if (edit.kind === "insert") return cursorAnchors;
const repeatAnchors: Anchor[] = [];
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) {
repeatAnchors.push({ line });
}
return cursorAnchors.concat(repeatAnchors);
}
/**
+54 -29
View File
@@ -17,9 +17,9 @@ import {
describeAnchorExamples,
HL_FILE_HASH_SEP,
HL_FILE_PREFIX,
HL_OP_DELETE_SUFFIX,
HL_OP_REPLACE,
HL_PAYLOAD_ABOVE,
HL_PAYLOAD_BELOW,
HL_PAYLOAD_REPEAT,
HL_PAYLOAD_REPLACE,
} from "./format";
import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages";
@@ -33,13 +33,13 @@ const CHAR_HASH = 35;
const CHAR_TAB = 9;
const CHAR_SPACE = 32;
const CHAR_HYPHEN = 45;
const CHAR_LOWER_A = 97;
const CHAR_LOWER_F = 102;
const CHAR_PILCROW = HL_FILE_PREFIX.charCodeAt(0);
const CHAR_OP_REPLACE = HL_OP_REPLACE.charCodeAt(0);
const CHAR_PAYLOAD_REPLACE = HL_PAYLOAD_REPLACE.charCodeAt(0);
const CHAR_PAYLOAD_ABOVE = HL_PAYLOAD_ABOVE.charCodeAt(0);
const CHAR_PAYLOAD_BELOW = HL_PAYLOAD_BELOW.charCodeAt(0);
const CHAR_PAYLOAD_REPEAT = HL_PAYLOAD_REPEAT.charCodeAt(0);
const FILE_HASH_LENGTH = 4;
function isDigitCode(code: number): boolean {
@@ -106,10 +106,8 @@ export function splitHashlineLines(text: string): string[] {
export function cloneCursor(cursor: Cursor): Cursor {
if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
return cursor;
}
// Leniently accept anchors copied from read/search output:
// - optional leading line-marker decoration (`*`, `>`, `-`)
// - the required bare line number / BOF / EOF anchor
@@ -162,18 +160,14 @@ function scanRange(line: string, end = trimEndIndex(line)): RangeScan | null {
const start = scanLineNumber(line, numberStart, end);
if (start === null) return null;
let nextIndex = start.nextIndex;
let rangeEnd = start.line;
if (nextIndex < end && line.charCodeAt(nextIndex) === CHAR_HYPHEN) {
const endNumber = scanLineNumber(line, nextIndex + 1, end);
if (endNumber === null) return null;
rangeEnd = endNumber.line;
nextIndex = endNumber.nextIndex;
}
// Ranges MUST be written `A-B` (the explicit single-line form `A-A` is fine).
if (start.nextIndex >= end || line.charCodeAt(start.nextIndex) !== CHAR_HYPHEN) return null;
const endNumber = scanLineNumber(line, start.nextIndex + 1, end);
if (endNumber === null) return null;
return {
range: { start: { line: start.line }, end: { line: rangeEnd } },
nextIndex: skipWhitespace(line, nextIndex, end),
range: { start: { line: start.line }, end: { line: endNumber.line } },
nextIndex: skipWhitespace(line, endNumber.nextIndex, end),
};
}
@@ -187,8 +181,6 @@ function startsWithWord(line: string, index: number, end: number, word: string):
export type BlockTarget = { kind: "range"; range: ParsedRange } | { kind: "bof" } | { kind: "eof" };
export type PayloadBucket = "above" | "replace" | "below";
interface TargetScan {
target: BlockTarget;
nextIndex: number;
@@ -212,6 +204,7 @@ function scanBlockTarget(line: string, end = trimEndIndex(line)): TargetScan | n
interface ParsedBlockOp {
target: BlockTarget;
inlineBody: string | undefined;
deleteSuffix: boolean;
}
function tryParseBlockOp(line: string): ParsedBlockOp | null {
@@ -222,18 +215,35 @@ function tryParseBlockOp(line: string): ParsedBlockOp | null {
const opIndex = skipWhitespace(line, target.nextIndex, end);
if (opIndex >= end || line.charCodeAt(opIndex) !== CHAR_OP_REPLACE) return null;
if (
opIndex === target.nextIndex &&
line.startsWith(HL_OP_DELETE_SUFFIX, opIndex) &&
opIndex + HL_OP_DELETE_SUFFIX.length === end
) {
return { target: target.target, inlineBody: undefined, deleteSuffix: true };
}
const inlineStart = opIndex + HL_OP_REPLACE.length;
return {
target: target.target,
inlineBody: skipWhitespace(line, inlineStart, end) === end ? undefined : line.slice(inlineStart, end),
deleteSuffix: false,
};
}
function payloadBucketForCode(code: number): PayloadBucket | undefined {
if (code === CHAR_PAYLOAD_ABOVE) return "above";
if (code === CHAR_PAYLOAD_REPLACE) return "replace";
if (code === CHAR_PAYLOAD_BELOW) return "below";
return undefined;
function tryParseRepeatPayload(line: string): ParsedRange | "shorthand" | null {
const end = trimEndIndex(line);
if (line.length === 0 || line.charCodeAt(0) !== CHAR_PAYLOAD_REPEAT) return null;
const start = scanLineNumber(line, 1, end);
if (start === null) return null;
if (start.nextIndex === end) return "shorthand";
if (start.nextIndex >= end || line.charCodeAt(start.nextIndex) !== CHAR_HYPHEN) return null;
const finish = scanLineNumber(line, start.nextIndex + 1, end);
if (finish === null) return null;
if (skipWhitespace(line, finish.nextIndex, end) !== end) return null;
return { start: { line: start.line }, end: { line: finish.line } };
}
/**
@@ -289,8 +299,10 @@ export type Token =
| (TokenBase & { kind: "envelope-end" })
| (TokenBase & { kind: "abort" })
| (TokenBase & { kind: "header"; path: string; fileHash?: string })
| (TokenBase & { kind: "op-block"; target: BlockTarget; inlineBody: string | undefined })
| (TokenBase & { kind: "payload"; bucket: PayloadBucket; text: string })
| (TokenBase & { kind: "op-block"; target: BlockTarget; inlineBody: string | undefined; deleteSuffix: boolean })
| (TokenBase & { kind: "payload-literal"; text: string })
| (TokenBase & { kind: "payload-repeat"; range: ParsedRange })
| (TokenBase & { kind: "payload-repeat-shorthand" })
| (TokenBase & { kind: "raw"; text: string });
function classifyLine(line: string, lineNum: number): Token {
@@ -308,13 +320,26 @@ function classifyLine(line: string, lineNum: number): Token {
}
}
const payloadBucket = payloadBucketForCode(line.charCodeAt(0));
if (payloadBucket !== undefined) {
return { kind: "payload", lineNum, bucket: payloadBucket, text: line.slice(1) };
const firstCode = line.charCodeAt(0);
if (firstCode === CHAR_PAYLOAD_REPLACE) {
return { kind: "payload-literal", lineNum, text: line.slice(1) };
}
if (firstCode === CHAR_PAYLOAD_REPEAT) {
const range = tryParseRepeatPayload(line);
if (range === "shorthand") return { kind: "payload-repeat-shorthand", lineNum };
if (range !== null) return { kind: "payload-repeat", lineNum, range };
}
const op = tryParseBlockOp(line);
if (op !== null) return { kind: "op-block", lineNum, target: op.target, inlineBody: op.inlineBody };
if (op !== null) {
return {
kind: "op-block",
lineNum,
target: op.target,
inlineBody: op.inlineBody,
deleteSuffix: op.deleteSuffix,
};
}
return { kind: "raw", lineNum, text: line };
}
+14 -10
View File
@@ -9,19 +9,15 @@ export interface Anchor {
line: number;
}
/** Where an `insert` edit should land relative to existing content. */
export type Cursor =
| { kind: "bof" }
| { kind: "eof" }
| { kind: "before_anchor"; anchor: Anchor }
| { kind: "after_anchor"; anchor: Anchor };
/** Where an `insert` or `repeat` edit should land relative to existing content. */
export type Cursor = { kind: "bof" } | { kind: "eof" } | { kind: "before_anchor"; anchor: Anchor };
/**
* A single low-level edit produced by the parser and consumed by the applier.
* Multi-line replacements decompose to one `insert` per replacement line plus
* one `delete` per consumed line. Replacement inserts are tagged so the applier
* can distinguish "insert above this deleted line" from "new content for this
* deleted line" when a source block carries both buckets.
* Multi-line replacements decompose to one `insert`/`repeat` per replacement
* line plus one `delete` per consumed line. Replacement payloads are tagged so
* the applier can distinguish literal insertion from new content for a deleted
* line.
*/
export type Edit =
| {
@@ -32,6 +28,14 @@ export type Edit =
index: number;
mode?: "replacement";
}
| {
kind: "repeat";
cursor: Cursor;
range: ParsedRange;
lineNum: number;
index: number;
mode?: "replacement";
}
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
/** Result of applying a parsed set of edits to a text body. */
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "bun:test";
import { applyEdits, parsePatch, parsePatchStreaming } from "@oh-my-pi/hashline";
function applyPatch(text: string, diff: string): string {
return applyEdits(text, parsePatch(diff).edits).text;
}
describe("hashline format v2", () => {
it("emits literal and repeat body rows in textual order", () => {
const text = "a\nb\nc";
const diff = ["2-2:", "|before", "^1-2", "|after"].join("\n");
expect(applyPatch(text, diff)).toBe("a\nbefore\na\nb\nafter\nc");
});
it("repeats a single source line with explicit A-A syntax", () => {
const text = "a\nb\nc";
const diff = ["2-2:", "^3-3"].join("\n");
expect(applyPatch(text, diff)).toBe("a\nc\nc");
});
it("keeps the file unchanged when a repeat covers the anchored range", () => {
const text = "a\nb\nc\nd";
const diff = ["2-3:", "^2-3"].join("\n");
expect(applyPatch(text, diff)).toBe(text);
});
it("deletes a concrete range with inline delete", () => {
const text = "a\nb\nc\nd";
expect(applyPatch(text, "2-3:-")).toBe("a\nd");
});
it("rejects body rows after inline delete", () => {
expect(() => parsePatch("2-2:-\n|x")).toThrow(/payload line has no preceding/);
});
it("treats an empty concrete block as a blank-line replacement", () => {
const text = "a\nb\nc";
expect(applyPatch(text, "2-2:")).toBe("a\n\nc");
});
it("treats empty BOF and EOF blocks as one blank-line insert", () => {
const text = "a\nb";
expect(applyPatch(text, "BOF:")).toBe("\na\nb");
expect(applyPatch(text, "EOF:")).toBe("a\nb\n");
});
it("rejects repeat shorthand with an explicit-range hint", () => {
expect(() => parsePatch("2-2:\n^2")).toThrow(/\^A-A/);
});
it("rejects removed insert sigils through the normal body-row diagnostic", () => {
expect(() => parsePatch("2-2:\n↑x")).toThrow(/must start with \| or \^A-B/);
expect(() => parsePatch("2-2:\n↓x")).toThrow(/must start with \| or \^A-B/);
});
it("rejects removed standalone delete rows through the normal op diagnostic", () => {
expect(() => parsePatch("-5")).toThrow(/unrecognized hashline block/);
expect(() => parsePatch("-5..7")).toThrow(/unrecognized hashline block/);
});
it("validates repeat ranges against file bounds", () => {
const edits = parsePatch("1-1:\n^4-4").edits;
expect(() => applyEdits("a\nb", edits)).toThrow(/Line 4 does not exist/);
});
it("does not flush a streaming pending empty block", () => {
const result = parsePatchStreaming("5-5:\n");
expect(result.edits).toEqual([]);
});
});
@@ -41,7 +41,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
// rewrote. Replaying onto current would overwrite "L5-CHANGED" with
// payload the model authored against the stale "L5". That is
// corruption, not recovery.
const { edits } = parsePatch("5-5:\n+L5-MODEL");
const { edits } = parsePatch("5-5:\n|L5-MODEL");
const recovered = new Recovery(store).tryRecover({
path: PATH,
@@ -59,7 +59,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
// merge fails (patch context includes the rewritten line 5), but the
// replay fallback is safe because the model's anchor still names the
// same logical content.
const { edits } = parsePatch("3-3:\n+L3-MODEL");
const { edits } = parsePatch("3-3:\n|L3-MODEL");
const recovered = new Recovery(store).tryRecover({
path: PATH,