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:
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ."
|
||||
},
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user