refactor(hashline): redesigned patch syntax from sigil-ops to anchor+payload model

- Replaced `LINE↑`/`LINE↓`/`A-B:` op sigils with unified `A-B:` anchor + `|`/`↑`/`↓` payload sigils.
- Added `mode: "replacement"` tag to insert edits so the applier distinguishes replace-bucket from insert-bucket lines.
- Removed lenient fallbacks (implicit continuation, inline payload acceptance, escaped delimiter stripping).
- Updated grammar, prompt, tokenizer, parser, applier, and messages to match the new format.
This commit is contained in:
can1357
2026-05-27 14:00:34 +02:00
parent f0c7df60d5
commit 8a0329026c
15 changed files with 625 additions and 737 deletions
+1 -1
View File
@@ -362,7 +362,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
return previews.length > 0 ? previews : null;
},
renderStreamingFallback() {
// Never leak raw hashline syntax (`64↓`, `+payload`, `¶path#hash`)
// Never leak raw hashline syntax (`64:`, `|payload`, `¶path#hash`)
// to the user — the streaming preview already projects every
// parseable op onto the real file via applyPartialTo, and an
// unparseable trailing chunk renders as "no preview yet" rather
+219 -276
View File
@@ -88,8 +88,9 @@ beforeAll(async () => {
await Settings.init({ inMemory: true, cwd: process.cwd() });
});
const pl = (text: string): string => text;
const extra = (text: string): string => `\\${text}`;
const repl = (text: string): string => `|${text}`;
const above = (text: string): string => `↑${text}`;
const below = (text: string): string => `↓${text}`;
const outputSep = ":";
const outputSepRe = ":";
@@ -184,9 +185,9 @@ describe("hashline normalization", () => {
});
});
describe("hashline parser — suffix-op syntax", () => {
describe("hashline parser — range-anchor syntax", () => {
it("keeps parsed edits reusable across different target snapshots", () => {
const section = Patch.parseSingle(["¶a.ts", `${tag(2, "bbb")}↓`, extra("tail")].join("\n"));
const section = Patch.parseSingle(["¶a.ts", `${tag(2, "bbb")}:`, below("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");
@@ -196,145 +197,121 @@ describe("hashline parser — suffix-op syntax", () => {
it("inserts payload before/after a Lid, and at BOF/EOF", () => {
const diff = [
`${tag(2, "bbb")}↑`,
extra("before b"),
`${tag(2, "bbb")}↓`,
extra("after b"),
"BOF↓",
extra("top"),
"EOF↓",
extra("tail"),
`${tag(2, "bbb")}:`,
above("before b"),
below("after b"),
"BOF:",
below("top"),
"EOF:",
below("tail"),
].join("\n");
expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail");
});
it("inserts after the final line via `ANCHOR↓` instead of falling off the file", () => {
const diff = [`${tag(3, "ccc")}↓`, extra("tail")].join("\n");
it("inserts after the final line without falling off the file", () => {
const diff = [`${tag(3, "ccc")}:`, below("tail")].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\ntail");
});
it("blanks a line in place with `A:` when given an explicit empty payload", () => {
const explicit = `${sameLineRange(tag(2, "bbb"))}:`;
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("replaces a line with one blank when given an explicit empty replace payload", () => {
const explicit = [`${sameLineRange(tag(2, "bbb"))}:`, repl("")].join("\n");
expect(applyDiff(content, explicit)).toBe("aaa\n\nccc");
});
it("replaces one line or an inclusive range with payload lines", () => {
const single = [`${tag(2, "bbb")}:`, extra("BBB")].join("\n");
const single = [`${tag(2, "bbb")}:`, repl("BBB")].join("\n");
expect(applyDiff(content, single)).toBe("aaa\nBBB\nccc");
const range = [`${tag(2, "bbb")}-${tag(3, "ccc")}:`, extra("BBB"), extra("CCC")].join("\n");
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", () => {
const anchor = tag(2, "bbb");
expect(parseHashline(`${anchor}:\n${extra("BBB")}\n${extra("CCC")}`).edits).toEqual(
parseHashline(`${anchor}-${anchor}:\n${extra("BBB")}\n${extra("CCC")}`).edits,
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${extra("BBB")}\n${extra("CCC")}`)).toBe(
applyDiff(content, `${anchor}-${anchor}:\n${extra("BBB")}\n${extra("CCC")}`),
expect(applyDiff(content, `${anchor}:\n${repl("BBB")}\n${repl("CCC")}`)).toBe(
applyDiff(content, `${anchor}-${anchor}:\n${repl("BBB")}\n${repl("CCC")}`),
);
});
it("warns when inline payload is used on insert and replace ops but still applies the edit", () => {
it("rejects inline payload on anchor rows", () => {
const anchor = tag(2, "bbb");
const warning = /Accepted inline payload on the op line/;
const cases: Array<[string, string]> = [
[`${anchor}↓NEW`, "aaa\nbbb\nNEW\nccc"],
[`${anchor}↑NEW`, "aaa\nNEW\nbbb\nccc"],
[`${anchor}:NEW`, "aaa\nNEW\nccc"],
];
for (const [diff, expected] of cases) {
const parsed = parseHashline(diff);
expect(parsed.warnings.some(w => warning.test(w))).toBe(true);
expect(applyDiff(content, diff)).toBe(expected);
for (const diff of [`${anchor}:NEW`, `${anchor}-${tag(3, "ccc")}:NEW`, "BOF:NEW", "EOF:NEW"]) {
expect(() => parseHashline(diff)).toThrow(/Inline payload on the anchor line is rejected/);
}
});
it("treats a leading inline `\\` as the payload delimiter", () => {
const anchor = tag(2, "bbb");
const warning = /Accepted inline payload on the op line/;
const cases: Array<[string, string]> = [
[`${anchor}↓\\NEW`, "aaa\nbbb\nNEW\nccc"],
[`${anchor}↑\\NEW`, "aaa\nNEW\nbbb\nccc"],
[`${anchor}:\\NEW`, "aaa\nNEW\nccc"],
[`${anchor}:\\\\NEW`, "aaa\n\\NEW\nccc"],
[`${anchor}:\\\\ NEW`, "aaa\n NEW\nccc"],
];
for (const [diff, expected] of cases) {
const parsed = parseHashline(diff);
expect(parsed.warnings.some(w => warning.test(w))).toBe(true);
expect(applyDiff(content, diff)).toBe(expected);
}
it("routes interleaved payload rows to stable above, replace, and below buckets", () => {
const diff = [
`${tag(2, "bbb")}:`,
below("below 1"),
above("above 1"),
repl("BBB"),
above("above 2"),
below("below 2"),
].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nabove 1\nabove 2\nBBB\nbelow 1\nbelow 2\nccc");
});
it("treats an escaped `\\` before indented payload rows as the delimiter", () => {
const anchor = tag(2, "bbb");
const diff = [`${anchor}:`, "\\\\ const value = 1;", "\\\\\treturn value;"].join("\n");
const parsed = parseHashline(diff);
expect(parsed.warnings.some(w => w.includes("extra `\\` before an indented payload row"))).toBe(true);
expect(applyDiff(content, diff)).toBe("aaa\n const value = 1;\n\treturn value;\nccc");
it("preserves the anchor when only above/below payload rows are present", () => {
const diff = [`${tag(2, "bbb")}:`, above("before"), below("after")].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nbefore\nbbb\nafter\nccc");
});
it("preserves explicitly escaped literal leading `\\` before non-indented payload", () => {
const anchor = tag(2, "bbb");
expect(applyDiff(content, `${anchor}:\n\\\\literal`)).toBe("aaa\n\\literal\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("accepts payload lines on insert ops with `\\` continuation", () => {
const anchor = tag(2, "bbb");
const diff = [`${anchor}↓`, extra("first line"), extra("second line")].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nfirst line\nsecond line\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 payload lines on the replace op with `\\` continuation", () => {
it("rejects unprefixed payload continuation lines", () => {
const anchor = tag(2, "bbb");
const diff = [`${anchor}:`, extra("FIRST"), extra("SECOND")].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nFIRST\nSECOND\nccc");
});
it("accepts unprefixed payload continuation lines as implicit continuation with a warning", () => {
const anchor = tag(2, "bbb");
const parsed = parseHashline(`${anchor}:\n${extra("FIRST")}\nSECOND`);
expect(parsed.warnings.some(w => w.includes("without the `\\` prefix"))).toBe(true);
expect(applyDiff(content, `${anchor}:\n${extra("FIRST")}\nSECOND`)).toBe("aaa\nFIRST\nSECOND\nccc");
expect(() => parseHashline(`${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}↓`, extra(payload)].join("\n"))).toBe(`aaa\nbbb\n${payload}\nccc`);
expect(applyDiff(content, [`${anchor}↑`, extra(payload)].join("\n"))).toBe(`aaa\n${payload}\nbbb\nccc`);
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`);
});
it("auto-absorbs duplicated multiline prefix boundaries during replacement", () => {
const source = ["// one", "// two", "old();"].join("\n");
const diff = [`${sameLineRange(tag(3, "old();"))}:`, extra("// one"), extra("// two"), extra("new();")].join(
"\n",
);
const diff = [`${sameLineRange(tag(3, "old();"))}:`, repl("// one"), repl("// two"), repl("new();")].join("\n");
expect(applyDiff(source, diff)).toBe(["// one", "// two", "new();"].join("\n"));
});
it("auto-absorbs duplicated multiline suffix boundaries during replacement", () => {
const source = ["old();", "// one", "// two"].join("\n");
const diff = [`${sameLineRange(tag(1, "old();"))}:`, extra("new();"), extra("// one"), extra("// two")].join(
"\n",
);
const diff = [`${sameLineRange(tag(1, "old();"))}:`, repl("new();"), repl("// one"), repl("// two")].join("\n");
expect(applyDiff(source, diff)).toBe(["new();", "// one", "// two"].join("\n"));
});
it("auto-absorbs a duplicated single structural suffix during replacement", () => {
const source = ["old();", "};"].join("\n");
const diff = [`${sameLineRange(tag(1, "old();"))}:`, extra("new();"), extra("};")].join("\n");
const diff = [`${sameLineRange(tag(1, "old();"))}:`, repl("new();"), repl("};")].join("\n");
expect(applyDiff(source, diff)).toBe(["new();", "};"].join("\n"));
});
it("auto-absorbs a duplicated single structural prefix during replacement", () => {
const source = ["};", "old();"].join("\n");
const diff = [`${sameLineRange(tag(2, "old();"))}:`, extra("};"), extra("new();")].join("\n");
const diff = [`${sameLineRange(tag(2, "old();"))}:`, repl("};"), repl("new();")].join("\n");
expect(applyDiff(source, diff)).toBe(["};", "new();"].join("\n"));
});
@@ -344,14 +321,14 @@ describe("hashline parser — suffix-op syntax", () => {
// `}` is a legitimate part of the new block, not a duplicate of the file's
// existing `}`. The single-line structural absorb must NOT fire here.
const source = ["old();", "}"].join("\n");
const diff = [`${sameLineRange(tag(1, "old();"))}:`, extra("if ok {"), extra("}")].join("\n");
const diff = [`${sameLineRange(tag(1, "old();"))}:`, repl("if ok {"), repl("}")].join("\n");
expect(applyDiff(source, diff)).toBe(["if ok {", "}", "}"].join("\n"));
});
it("does not auto-absorb a single duplicated boundary line", () => {
const source = ["keep", "old();"].join("\n");
const diff = [`${sameLineRange(tag(2, "old();"))}:`, extra("keep"), extra("new();")].join("\n");
const diff = [`${sameLineRange(tag(2, "old();"))}:`, repl("keep"), repl("new();")].join("\n");
expect(applyDiff(source, diff)).toBe(["keep", "keep", "new();"].join("\n"));
});
@@ -363,11 +340,11 @@ describe("hashline parser — suffix-op syntax", () => {
const source = ["A", "B", "X", "Y", "Z"].join("\n");
const diff = [
`${tag(1, "A")}-${tag(2, "B")}:`,
extra("alpha"),
extra("X"),
extra("Y"),
`${tag(4, "Y")}↑`,
extra("extra"),
repl("alpha"),
repl("X"),
repl("Y"),
`${tag(4, "Y")}:`,
above("extra"),
].join("\n");
expect(applyDiff(source, diff)).toBe(["alpha", "X", "Y", "X", "extra", "Y", "Z"].join("\n"));
@@ -375,9 +352,7 @@ describe("hashline parser — suffix-op syntax", () => {
it("surfaces a warning when boundary duplicates are auto-absorbed", () => {
const source = ["// one", "// two", "old();"].join("\n");
const diff = [`${sameLineRange(tag(3, "old();"))}:`, extra("// one"), extra("// two"), extra("new();")].join(
"\n",
);
const diff = [`${sameLineRange(tag(3, "old();"))}:`, repl("// one"), repl("// two"), repl("new();")].join("\n");
const result = applyHashlineEdits(source, parseHashline(diff).edits);
expect(result.lines).toBe(["// one", "// two", "new();"].join("\n"));
@@ -392,7 +367,7 @@ describe("hashline parser — suffix-op syntax", () => {
// reads `const X = …` produced two consecutive declarations. With the
// opt-in on, the leading boundary line gets dropped.
const source = ["const X = …", "", "const LEGACY = {", " a: 1,", "}"].join("\n");
const diff = [`${tag(2, "")}-${tag(5, "}")}:`, extra("const X = …")].join("\n");
const diff = [`${tag(2, "")}-${tag(5, "}")}:`, repl("const X = …")].join("\n");
const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true });
expect(result.lines).toBe(["const X = …"].join("\n"));
@@ -406,7 +381,7 @@ describe("hashline parser — suffix-op syntax", () => {
// reads `## Subagents` produced two consecutive headings. With the
// opt-in on, the trailing boundary line gets dropped.
const source = ["## Legacy", "", "stale content", "", "## Subagents"].join("\n");
const diff = [`${tag(1, "## Legacy")}-${tag(4, "")}:`, extra("## Subagents")].join("\n");
const diff = [`${tag(1, "## Legacy")}-${tag(4, "")}:`, repl("## Subagents")].join("\n");
const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true });
expect(result.lines).toBe(["## Subagents"].join("\n"));
@@ -420,7 +395,7 @@ describe("hashline parser — suffix-op syntax", () => {
// produce two consecutive `foo` lines. The non-structural single-line
// absorber stays gated on `autoDropPureInsertDuplicates`.
const source = ["foo", "bar", "baz"].join("\n");
const diff = [`${sameLineRange(tag(2, "bar"))}:`, extra("foo")].join("\n");
const diff = [`${sameLineRange(tag(2, "bar"))}:`, repl("foo")].join("\n");
expect(applyDiff(source, diff)).toBe(["foo", "foo", "baz"].join("\n"));
});
@@ -430,97 +405,97 @@ describe("hashline parser — suffix-op syntax", () => {
// `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")}↓`, extra("aaa"), extra("bbb"), extra("NEW")].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");
});
it("preserves a duplicated single structural suffix for pure insert by default", () => {
const source = ["if ok {", " keep();", " }"].join("\n");
const diff = [`${tag(3, " }")}↑`, extra(" added();"), extra(" }")].join("\n");
const diff = [`${tag(3, " }")}:`, above(" added();"), above(" }")].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", () => {
const source = [" });", "next();"].join("\n");
const diff = [`${tag(1, " });")}↓`, extra(" });"), extra("added();")].join("\n");
const diff = [`${tag(1, " });")}:`, below(" });"), below("added();")].join("\n");
const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true });
expect(result.lines).toBe([" });", " });", "added();", "next();"].join("\n"));
expect(result.warnings).toBeUndefined();
});
it("preserves an intentional non-structural anchor duplicate for `ANCHOR↓` by default", () => {
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")}↓`, extra("bbb"), extra("NEW")].join("\n");
const diff = [`${tag(2, "bbb")}:`, below("bbb"), below("NEW")].join("\n");
expect(applyDiff(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc");
});
it("preserves an intentional non-structural anchor duplicate for `ANCHOR↑` by default", () => {
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")}↑`, extra("NEW"), extra("bbb")].join("\n");
const diff = [`${tag(2, "bbb")}:`, above("NEW"), above("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, "}")}↑`, extra("if inner {"), extra("}")].join("\n");
const diff = [`${tag(2, "}")}:`, above("if inner {"), above("}")].join("\n");
expect(applyDiff(source, diff)).toBe(["if outer {", "if inner {", "}", "}"].join("\n"));
});
it("auto-absorbs duplicated leading payload of a pure `ANCHOR↓` insert", () => {
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")}↓`, extra("aaa"), extra("bbb"), extra("NEW")].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 `ANCHOR↓`", () => {
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")}↓`, extra("aaa"), extra("bbb"), extra("NEW"), extra("ccc"), extra("ddd")].join(
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 `ANCHOR↑` insert", () => {
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")}↑`, extra("NEW"), extra("ccc"), extra("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↓", extra("bbb"), extra("ccc"), extra("NEW")].join("\n");
// `EOF:` payload echoes the last two file lines, then adds NEW.
const diff = ["EOF:", below("bbb"), below("ccc"), below("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↑", extra("NEW"), extra("aaa"), extra("bbb")].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");
});
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")}↓`, extra("bbb"), extra("NEW")].join("\n");
const diff = [`${tag(2, "bbb")}:`, below("bbb"), below("NEW")].join("\n");
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc");
});
it("surfaces a warning when pure-insert duplicates are auto-dropped", () => {
const source = ["aaa", "bbb", "ccc"].join("\n");
const diff = [`${tag(2, "bbb")}↓`, extra("aaa"), extra("bbb"), extra("NEW")].join("\n");
const diff = [`${tag(2, "bbb")}:`, below("aaa"), below("bbb"), below("NEW")].join("\n");
const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true });
expect(result.lines).toBe("aaa\nbbb\nNEW\nccc");
expect(result.warnings).toBeDefined();
@@ -532,17 +507,17 @@ describe("hashline parser — suffix-op syntax", () => {
it("preserves payload text exactly", () => {
const diff = [
`${sameLineRange(tag(2, "bbb"))}:`,
extra(""),
extra("# not a header"),
extra("+ not an op"),
extra("\\ not an op"),
extra(" spaced"),
repl(""),
repl("# not a header"),
repl("+ not an op"),
repl("\\ not an op"),
repl(" spaced"),
].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\n\n# not a header\n+ not an op\n\\ not an op\n spaced\nccc");
});
it("treats backslash-only payload lines as empty payload lines", () => {
const diff = [`${sameLineRange(tag(2, "bbb"))}:first`, extra(""), extra(""), extra("after")].join("\n");
it("treats explicit empty replace payload rows as blank lines", () => {
const diff = [`${sameLineRange(tag(2, "bbb"))}:`, repl("first"), repl(""), repl(""), repl("after")].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nfirst\n\n\nafter\nccc");
});
@@ -551,70 +526,64 @@ describe("hashline parser — suffix-op syntax", () => {
"# This is a comment line from a model explanation.",
"## Another comment line.",
`${tag(2, "bbb")}:`,
extra("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")}:`, extra("BBB")].join("\n");
const diff = ["# This is a stray comment.", "", `${tag(2, "bbb")}:`, repl("BBB")].join("\n");
expect(() => parseHashline(diff)).toThrow(/payload line has no preceding/);
});
it("preserves raw blank separators between ops", () => {
const diff = [`${sameLineRange(tag(1, "aaa"))}:AAA`, "", "", `${sameLineRange(tag(3, "ccc"))}:CCC`].join("\n");
const diff = [
`${sameLineRange(tag(1, "aaa"))}:`,
repl("AAA"),
"",
"",
`${sameLineRange(tag(3, "ccc"))}:`,
repl("CCC"),
].join("\n");
expect(applyDiff(content, diff)).toBe("AAA\nbbb\nCCC");
});
it("treats a bare insert op as inserting one empty line", () => {
// `LINE↑` / `LINE↓` with no payload default to one empty line.
const upAnchor = { line: 1 };
expect(parseHashline(`${tag(1, "aaa")}↑`).edits).toEqual([
{ kind: "insert", cursor: { kind: "before_anchor", anchor: upAnchor }, text: "", lineNum: 1, index: 0 },
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")}↓`).edits).toEqual([
{ kind: "insert", cursor: { kind: "after_anchor", anchor: upAnchor }, 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 },
]);
});
it("rejects orphan payload lines with no preceding op", () => {
expect(() => parseHashline(extra("orphan")).edits).toThrow(/payload line has no preceding/);
});
it("rejects op sigils written in prefix position", () => {
expect(() => parseHashline(`↑${tag(1, "aaa")}\nold`).edits).toThrow(/unrecognized op/);
expect(() => parseHashline(`↓${tag(1, "aaa")}\nold`).edits).toThrow(/unrecognized op/);
expect(() => parseHashline(`:${tag(1, "aaa")}\nold`).edits).toThrow(/unrecognized op/);
expect(() => parseHashline(repl("orphan")).edits).toThrow(/payload line has no preceding/);
});
it("rejects ranges with `..` separator", () => {
// `..` is no longer the range separator; the line is treated as orphan
// payload because `2..3:` does not match the new range pattern.
expect(() => parseHashline(`${tag(2, "bbb")}..${tag(3, "ccc")}:\n${extra("BBB")}`).edits).toThrow(
expect(() => parseHashline(`${tag(2, "bbb")}..${tag(3, "ccc")}:\n${repl("BBB")}`).edits).toThrow(
/payload line has no preceding/,
);
});
it("describes the new sigil shape on unknown-op lines", () => {
expect(() => parseHashline(`-${sameLineRange(tag(2, "bbb"))}`).edits).toThrow(/Use LINE↑.*LINE↓.*LINE: \/ A-B:/);
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:/);
});
it("accepts `LINE:TEXT` copied verbatim from read output with a deprecation warning", () => {
it("rejects `LINE:TEXT` copied verbatim from read output", () => {
const anchor = tag(2, "bbb");
const warning = /Accepted inline payload on the op line/;
const single = parseHashline(`${anchor}:BBB`);
expect(single.warnings.some(w => warning.test(w))).toBe(true);
expect(applyDiff(content, `${anchor}:BBB`)).toBe("aaa\nBBB\nccc");
const ranged = parseHashline(`${anchor}-${tag(3, "ccc")}:BBB`);
expect(ranged.warnings.some(w => warning.test(w))).toBe(true);
expect(applyDiff(content, `${anchor}-${tag(3, "ccc")}:BBB`)).toBe("aaa\nBBB");
expect(() => parseHashline(`${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/,
);
});
it("leniently strips `*`/`>` line-marker decoration from anchors", () => {
const anchor = tag(2, "bbb");
expect(applyDiff(content, `*${anchor}:\n${extra("BBB")}`)).toBe("aaa\nBBB\nccc");
expect(applyDiff(content, `>${anchor}↑\n${extra("X")}`)).toBe("aaa\nX\nbbb\nccc");
expect(applyDiff(content, `*${anchor}:\n${repl("BBB")}`)).toBe("aaa\nBBB\nccc");
expect(applyDiff(content, `>${anchor}:\n${above("X")}`)).toBe("aaa\nX\nbbb\nccc");
});
it("rejects arrow replace syntax as an unrecognized payload line", () => {
@@ -622,42 +591,31 @@ describe("hashline parser — suffix-op syntax", () => {
expect(() => parseHashline(`2-3→\nBBB`).edits).toThrow(/payload line has no preceding/);
});
it("treats `LINE:TEXT` as replace syntax even when TEXT contains ↑ / ↓", () => {
it("preserves payload text containing arrow sigils after the leading payload sigil", () => {
const anchor = tag(2, "bbb");
// Inline payload still warns but applies; the embedded ↑/↓ are literal payload bytes.
const warning = /Accepted inline payload on the op line/;
const a = parseHashline(`${anchor}:bbb↓`);
expect(a.warnings.some(w => warning.test(w))).toBe(true);
expect(applyDiff(content, `${anchor}:bbb↓`)).toBe("aaa\nbbb↓\nccc");
const b = parseHashline(`${anchor}:bbb↑\n${extra("X")}`);
expect(b.warnings.some(w => warning.test(w))).toBe(true);
expect(applyDiff(content, `${anchor}:bbb↑\n${extra("X")}`)).toBe("aaa\nbbb↑\nX\nccc");
expect(applyDiff(content, `${anchor}:\n${repl("bbb↑")}\n${below("tail↓")}`)).toBe("aaa\nbbb↑\ntail↓\nccc");
});
it("accepts BOF/EOF inserts with inline payload (with warning) or `\\` continuation", () => {
expect(applyDiff(content, `BOF↓\n${extra("HEAD")}`)).toBe("HEAD\naaa\nbbb\nccc");
expect(applyDiff(content, `EOF↓\n${extra("TAIL")}`)).toBe("aaa\nbbb\nccc\nTAIL");
const inline = parseHashline(`BOF↓HEAD`);
expect(inline.warnings.some(w => /Accepted inline payload on the op line/.test(w))).toBe(true);
expect(applyDiff(content, `BOF↓HEAD`)).toBe("HEAD\naaa\nbbb\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("coalesces two replace ops targeting the same single line (last wins)", () => {
const diff = `${tag(2, "bbb")}:\n${extra("BBB")}\n${tag(2, "bbb")}:\n${extra("BBB2")}`;
const diff = `${tag(2, "bbb")}:\n${repl("BBB")}\n${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([
"Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range — the payload is the final desired content, never both old and new.",
"Detected two identical-range hashline blocks; kept only the second block. Issue ONE block per range — payload is the final desired content, never both old and new.",
]);
});
it("coalesces two replace ops covering the same range (before/after-block pattern, last wins)", () => {
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:\n${extra("OLD")}\n${extra("OLD2")}\n${tag(2, "bbb")}-${tag(3, "ccc")}:\n${extra("NEW")}\n${extra("NEW2")}`;
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:\n${repl("OLD")}\n${repl("OLD2")}\n${tag(2, "bbb")}-${tag(3, "ccc")}:\n${repl("NEW")}\n${repl("NEW2")}`;
const { edits, warnings } = parseHashline(diff);
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd", edits).lines).toBe("aaa\nNEW\nNEW2\nddd");
expect(warnings).toEqual([
"Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range — the payload is the final desired content, never both old and new.",
"Detected two identical-range hashline blocks; kept only the second block. Issue ONE block per range — payload is the final desired content, never both old and new.",
]);
});
@@ -665,12 +623,12 @@ describe("hashline parser — suffix-op syntax", () => {
// 3-5 extends past the outer 2-4, so it is neither identical nor contained.
// 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${extra("NEW1")}\n${tag(3, "ccc")}-${tag(5, "eee")}:\n${extra("NEW2")}`;
expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by the .+ op on line 1/);
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/);
});
it("uses `\\` payload lines inside a multi-line replacement", () => {
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:\n${extra("line one")}\n${extra("line two")}\n${extra("line three")}`;
it("uses `|` payload lines inside a multi-line replacement", () => {
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:\n${repl("line one")}\n${repl("line two")}\n${repl("line three")}`;
const { edits, warnings } = parseHashline(diff);
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee", edits).lines).toBe(
"aaa\nline one\nline two\nline three\neee",
@@ -678,18 +636,13 @@ describe("hashline parser — suffix-op syntax", () => {
expect(warnings).toEqual([]);
});
it("demotes read-output `N:TEXT` lines inside a pending `A-B:` as payload continuation with a warning", () => {
// The demote path is the one place inline payload is tolerated: it
// strips the `LINE:` prefix and appends `TEXT` to the outer pending
// payload (with a warning) so the model's mistake doesn't fail loudly.
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:\n${extra("line one")}\n${tag(3, "ccc")}:line two`;
const { warnings } = parseHashline(diff);
expect(warnings.some(w => w.includes("LINE:TEXT"))).toBe(true);
expect(applyDiff("aaa\nbbb\nccc\nddd\neee", diff)).toBe("aaa\nline one\nline two\neee");
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`;
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${extra("line one")}\n${tag(5, "eee")}:\n${extra("line five")}`;
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:\n${repl("line one")}\n${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",
@@ -697,89 +650,83 @@ describe("hashline parser — suffix-op syntax", () => {
expect(warnings).toEqual([]);
});
it("accepts multiple inserts at the same anchor (sequential, not duplicates)", () => {
// Two ↑ at the same line is a legitimate accumulation pattern — both
// inserts above land in source order. Only deletes/replaces are
// considered overlapping.
const diff = `${tag(2, "bbb")}↑\n${extra("X")}\n${tag(2, "bbb")}↑\n${extra("Y")}`;
expect(() => parseHashline(diff).edits).not.toThrow();
it("accepts multiple inserts in the same bucket", () => {
const diff = `${tag(2, "bbb")}:\n${above("X")}\n${above("Y")}`;
expect(applyDiff(content, diff)).toBe("aaa\nX\nY\nbbb\nccc");
});
it("accepts a replace alongside an insert at the same anchor", () => {
// `N:foo` deletes line N and inserts at before_anchor: N; `N↑bar`
// adds another insert at the same cursor. No conflicting delete, so
// this is allowed.
const diff = `${tag(2, "bbb")}:\n${extra("NEW")}\n${tag(2, "bbb")}↑\n${extra("ABOVE")}`;
expect(() => parseHashline(diff).edits).not.toThrow();
const diff = `${tag(2, "bbb")}:\n${above("ABOVE")}\n${repl("NEW")}`;
expect(applyDiff(content, diff)).toBe("aaa\nABOVE\nNEW\nccc");
});
});
describe("hashline — file hash binding", () => {
it("rejects line-hash anchors as unrecognized payload lines", () => {
expect(() => parseHashline(`2ab:\n${extra("BBB")}`).edits).toThrow(/payload line has no preceding/);
expect(() => parseHashline(`2ab:\n${repl("BBB")}`).edits).toThrow(/payload line has no preceding/);
});
it("applies line-number edits without per-anchor hash validation", () => {
const diff = `${sameLineRange(tag(2, "bbb"))}:\n${extra("BBB")}`;
const diff = `${sameLineRange(tag(2, "bbb"))}:\n${repl("BBB")}`;
expect(applyDiff("aaa\nbbb\nccc", diff)).toBe("aaa\nBBB\nccc");
});
});
describe("splitHashlineInput — ¶ headers", () => {
it("extracts path, file hash, and diff body from ¶path#hash header", () => {
const input = [`¶src/foo.ts#1a2b`, `${sameLineRange(tag(2, "bbb"))}:`, extra("BBB")].join("\n");
const input = [`¶src/foo.ts#1a2b`, `${sameLineRange(tag(2, "bbb"))}:`, repl("BBB")].join("\n");
expect(splitHashlineInput(input)).toEqual({
path: "src/foo.ts",
fileHash: "1a2b",
diff: `${sameLineRange(tag(2, "bbb"))}:\n${extra("BBB")}`,
diff: `${sameLineRange(tag(2, "bbb"))}:\n${repl("BBB")}`,
});
});
it("strips leading blank lines", () => {
expect(splitHashlineInput(`\n¶foo.ts\nBOF↓\n${extra("x")}`)).toEqual({
expect(splitHashlineInput(`\n¶foo.ts\nBOF:\n${below("x")}`)).toEqual({
path: "foo.ts",
diff: `BOF↓\n${extra("x")}`,
diff: `BOF:\n${below("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${extra("x")}`, { cwd }).path).toBe("src/foo.ts");
expect(splitHashlineInput(`${absolute}\nBOF:\n${below("x")}`, { cwd }).path).toBe("src/foo.ts");
});
it("uses explicit fallback path only when input has recognizable operations", () => {
expect(splitHashlineInput(`BOF↓\n${extra("x")}`, { path: "a.ts" })).toEqual({
expect(splitHashlineInput(`BOF:\n${below("x")}`, { path: "a.ts" })).toEqual({
path: "a.ts",
diff: `BOF↓\n${extra("x")}`,
diff: `BOF:\n${below("x")}`,
});
expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/);
});
it("splits multiple edit sections", () => {
const input = ["¶a.ts", "BOF↓", extra("a"), "¶b.ts", "EOF↓", extra("b")].join("\n");
const input = ["¶a.ts", "BOF:", below("a"), "¶b.ts", "EOF:", below("b")].join("\n");
expect(splitHashlineInputs(input)).toEqual([
{ path: "a.ts", diff: `BOF↓\n${extra("a")}` },
{ path: "b.ts", diff: `EOF↓\n${extra("b")}` },
{ path: "a.ts", diff: `BOF:\n${below("a")}` },
{ path: "b.ts", diff: `EOF:\n${below("b")}` },
]);
});
it("tolerates extra ¶ chars on the section header", () => {
const input = ["¶¶a.ts", "BOF↓", extra("a"), "¶¶¶b.ts", "EOF↓", extra("b")].join("\n");
const input = ["¶¶a.ts", "BOF:", below("a"), "¶¶¶b.ts", "EOF:", below("b")].join("\n");
expect(splitHashlineInputs(input)).toEqual([
{ path: "a.ts", diff: `BOF↓\n${extra("a")}` },
{ path: "b.ts", diff: `EOF↓\n${extra("b")}` },
{ path: "a.ts", diff: `BOF:\n${below("a")}` },
{ path: "b.ts", diff: `EOF:\n${below("b")}` },
]);
});
it("silently drops a duplicate header with no operations between them", () => {
const input = ["¶¶src/foo.ts", "¶¶src/foo.ts", `BOF↓`, extra("x")].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "src/foo.ts", diff: `BOF↓\n${extra("x")}` }]);
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")}` }]);
});
it("silently drops a trailing header with no operations", () => {
const input = ["¶¶a.ts", "BOF↓", extra("a"), "¶¶b.ts"].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF↓\n${extra("a")}` }]);
const input = ["¶¶a.ts", "BOF:", below("a"), "¶¶b.ts"].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF:\n${below("a")}` }]);
});
});
@@ -794,10 +741,10 @@ it("preflights write policy for every section before committing a batch", async
const input = [
header("a.ts", "aaa\n"),
`${sameLineRange(tag(1, "aaa"))}:`,
extra("AAA"),
repl("AAA"),
header("b.ts", "bbb\n"),
`${sameLineRange(tag(1, "bbb"))}:`,
extra("BBB"),
repl("BBB"),
].join("\n");
await expect(new Patcher({ fs: fixture }).apply(Patch.parse(input))).rejects.toThrow(/blocked write: b\.ts/);
@@ -808,18 +755,17 @@ 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↓${pl("export const x = 1;")}\n`;
const input = `¶new.ts\nBOF:\n${below("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;");
});
});
it("honors the pure-insert duplicate auto-drop setting", async () => {
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")}↓${pl("aaa")}\n${extra("bbb")}\n${extra("NEW")}\n`;
const input = `${header("a.ts", source)}\n${tag(2, "bbb")}:\n${below("aaa")}\n${below("bbb")}\n${below("NEW")}\n`;
await Bun.write(filePath, source);
await executeHashlineSingle(hashlineExecuteOptions(tempDir, input));
@@ -843,10 +789,10 @@ describe("hashline executor", () => {
const input = [
header("a.ts", "aaa\n"),
`${sameLineRange(tag(1, "aaa"))}:`,
extra("AAA"),
repl("AAA"),
bHeader,
`${sameLineRange(tag(1, "bbb"))}:`,
extra("BBB"),
repl("BBB"),
].join("\n");
await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow(
@@ -865,10 +811,10 @@ describe("hashline executor", () => {
const input = [
header("a.ts", source),
`${sameLineRange(tag(1, "one"))}:`,
extra("ONE"),
repl("ONE"),
header("./a.ts", source),
`${sameLineRange(tag(2, "two"))}:`,
extra("TWO"),
repl("TWO"),
].join("\n");
await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow(
@@ -892,18 +838,18 @@ describe("hashline executor", () => {
const input = [
header("a.ts", `${original}\n`),
`${sameLineRange(tag(2, "L2"))}:`,
extra("L2a"),
extra("L2b"),
extra("L2c"),
extra("L2d"),
extra("L2e"),
extra("L2f"),
extra("L2g"),
extra("L2h"),
extra("L2i"),
repl("L2a"),
repl("L2b"),
repl("L2c"),
repl("L2d"),
repl("L2e"),
repl("L2f"),
repl("L2g"),
repl("L2h"),
repl("L2i"),
header("a.ts", `${original}\n`),
`${tag(8, "L8")}↓`,
extra("INSERTED"),
`${tag(8, "L8")}:`,
below("INSERTED"),
].join("\n");
await executeHashlineSingle(hashlineExecuteOptions(tempDir, input));
@@ -948,15 +894,15 @@ describe("hashlineEditParamsSchema — payload shape", () => {
});
it("tolerates provider extra fields without declaring `path`", () => {
expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF↓\n${extra("x")}` }).success).toBe(
expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF:\n${below("x")}` }).success).toBe(
true,
);
});
it("accepts `_input` as a provider-emitted alias for `input`", () => {
const parsed = hashlineEditParamsSchema.safeParse({ _input: `¶x.ts\nBOF↓\n${extra("x")}` });
const parsed = hashlineEditParamsSchema.safeParse({ _input: `¶x.ts\nBOF:\n${below("x")}` });
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.input).toBe(`¶x.ts\nBOF↓\n${extra("x")}`);
if (parsed.success) expect(parsed.data.input).toBe(`¶x.ts\nBOF:\n${below("x")}`);
});
it("still requires `input`", () => {
@@ -1024,7 +970,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => {
await Bun.write(filePath, `${v1Lines.join("\n")}\n`);
// Model authors anchor against V0 — line 2 is "L2" in V0.
const input = `${header("a.ts", v0Text)}\n${sameLineRange(tag(2, "L2"))}:\n${extra(pl("L2-MODEL"))}\n`;
const input = `${header("a.ts", v0Text)}\n${sameLineRange(tag(2, "L2"))}:\n${repl("L2-MODEL")}\n`;
const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session));
const finalLines = (await Bun.file(filePath).text()).replace(/\n$/, "").split("\n");
@@ -1059,7 +1005,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => {
v1Lines[5] = "L6-CHANGED";
await Bun.write(filePath, `${v1Lines.join("\n")}\n`);
const input = `${header("a.ts", v0Text)}\n${sameLineRange(tag(6, "L6"))}:\n${extra(pl("L6-MODEL"))}\n`;
const input = `${header("a.ts", v0Text)}\n${sameLineRange(tag(6, "L6"))}:\n${repl("L6-MODEL")}\n`;
await expect(
executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session)),
).rejects.toThrow(HashlineMismatchError);
@@ -1080,7 +1026,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => {
// Live file is completely different — patch context cannot match even
// with fuzz tolerance.
const currentText = "totally\nunrelated\ncontent\nhere\nnow\n";
const edits = parseHashline(`${sameLineRange(tag(2, "beta"))}:\n${extra(pl("BETA-MODEL"))}`).edits;
const edits = parseHashline(`${sameLineRange(tag(2, "beta"))}:\n${repl("BETA-MODEL")}`).edits;
const recovered = tryRecoverHashlineWithCache({
cache,
@@ -1118,7 +1064,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => {
// First edit: change line 2 : BETA. After the write, the cache should
// reflect V1 (post-edit), not V0.
const firstInput = `${header("a.ts", v0Text)}\n${sameLineRange(tag(2, "beta"))}:\n${extra(pl("BETA"))}\n`;
const firstInput = `${header("a.ts", v0Text)}\n${sameLineRange(tag(2, "beta"))}:\n${repl("BETA")}\n`;
await executeHashlineSingle(hashlineExecuteOptions(tempDir, firstInput, undefined, session));
const v1Lines = ["alpha", "BETA", "gamma", "delta", "epsilon"];
expect(await Bun.file(filePath).text()).toBe(`${v1Lines.join("\n")}\n`);
@@ -1134,7 +1080,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => {
const v2Lines = ["H1", "H2", "H3", "H4", "H5", "H6", "H7", ...v1Lines];
await Bun.write(filePath, `${v2Lines.join("\n")}\n`);
const secondInput = `${header("a.ts", `${v1Lines.join("\n")}\n`)}\n${sameLineRange(tag(3, "gamma"))}:\n${extra(pl("GAMMA"))}\n`;
const secondInput = `${header("a.ts", `${v1Lines.join("\n")}\n`)}\n${sameLineRange(tag(3, "gamma"))}:\n${repl("GAMMA")}\n`;
const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, secondInput, undefined, session));
const finalLines = (await Bun.file(filePath).text()).replace(/\n$/, "").split("\n");
@@ -1167,7 +1113,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => {
absolutePath: fakePath,
currentText,
fileHash: computeFileHash(v0Text),
edits: parseHashline(`10:\n${extra("L10-EDITED")}`).edits,
edits: parseHashline(`10:\n${repl("L10-EDITED")}`).edits,
options: {},
});
@@ -1228,7 +1174,7 @@ 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")}↓`, extra("HELLO"), sentinel, `${tag(99, "junk")}↓`, extra("never")].join("\n");
const diff = [`${tag(1, "alpha")}:`, below("HELLO"), sentinel, `${tag(99, "junk")}:`, below("never")].join("\n");
const { edits, warnings } = parseHashline(diff);
expect(edits).toHaveLength(1);
expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" });
@@ -1238,7 +1184,7 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
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${extra(pl("KEPT"))}\n*** Abort\n`;
const diff = `${tag(1, "alpha")}:\n${below("KEPT")}\n*** Abort\n`;
const { edits, warnings } = parseHashline(diff);
expect(edits).toHaveLength(1);
expect(edits[0]).toMatchObject({ text: "KEPT" });
@@ -1248,12 +1194,12 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
it("splitter respects *** Abort like *** End Patch", () => {
const input = [
`¶a.ts`,
`${tag(1, "alpha")}↓`,
extra("a-payload"),
`${tag(1, "alpha")}:`,
below("a-payload"),
sentinel,
`¶b.ts`,
`${tag(1, "beta")}↓`,
extra("never-emitted"),
`${tag(1, "beta")}:`,
below("never-emitted"),
].join("\n");
const sections = splitHashlineInputs(input);
expect(sections).toHaveLength(1);
@@ -1262,69 +1208,66 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
});
it("clean input without sentinel produces no warning", () => {
const diff = `${tag(1, "alpha")}↓\n${extra(pl("PAYLOAD"))}\n`;
const diff = `${tag(1, "alpha")}:\n${below("PAYLOAD")}\n`;
const { warnings } = parseHashline(diff);
expect(warnings).toEqual([]);
});
});
describe("hashline parser — bare ':' replaces with a single blank line", () => {
it("bare A: replaces the line with a single blank line", () => {
describe("hashline parser — blank payload rows", () => {
it("bare A: deletes the line", () => {
const text = "line1\nline2\nline3\n";
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2:\n`);
expect(applyDiff(text, diff)).toBe("line1\n\nline3\n");
expect(applyDiff(text, diff)).toBe("line1\nline3\n");
});
it("bare A-B: replaces the range with a single blank line", () => {
it("bare A-B: deletes the range", () => {
const text = "line1\nline2\nline3\nline4\n";
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-3:\n`);
expect(applyDiff(text, diff)).toBe("line1\n\nline4\n");
expect(applyDiff(text, diff)).toBe("line1\nline4\n");
});
it("A: with inline body still works", () => {
it("A: with inline body is rejected", () => {
const text = "line1\nline2\nline3\n";
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2:replacement\n`);
expect(applyDiff(text, diff)).toBe("line1\nreplacement\nline3\n");
expect(() => parseHashline(diff)).toThrow(/Inline payload on the anchor line is rejected/);
});
it("bare A↑ still inserts a blank line above", () => {
it("explicit empty above/below rows insert blank lines", () => {
const text = "line1\nline2\nline3\n";
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2↑\n`);
expect(applyDiff(text, diff)).toBe("line1\n\nline2\nline3\n");
});
const aboveDiff = splitHashlineInput(`${header("a.ts", text)}\n2:\n${above("")}\n`).diff;
expect(applyDiff(text, aboveDiff)).toBe("line1\n\nline2\nline3\n");
it("bare A↓ still inserts a blank line below", () => {
const text = "line1\nline2\nline3\n";
const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2↓\n`);
expect(applyDiff(text, diff)).toBe("line1\nline2\n\nline3\n");
const belowDiff = splitHashlineInput(`${header("a.ts", text)}\n2:\n${below("")}\n`).diff;
expect(applyDiff(text, belowDiff)).toBe("line1\nline2\n\nline3\n");
});
});
describe("hashline parser — backslash-prefixed blank payload lines", () => {
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:A\n\n3:C\n`;
const ops = `${header("a.ts", text)}\n1:\n${repl("A")}\n\n3:\n${repl("C")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n");
});
it("backslash-only continuation lines are appended as empty payload lines", () => {
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:A\n${extra("")}\n${extra("")}\n3:C\n`;
const ops = `${header("a.ts", text)}\n1:\n${repl("A")}\n${repl("")}\n${repl("")}\n3:\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 backslash-only lines replaces the line with two blanks", () => {
it("bare 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${extra("")}\n${extra("")}\n4:D\n`;
const ops = `${header("a.ts", text)}\n2:\n${repl("")}\n${repl("")}\n4:\n${repl("D")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n");
});
it("backslash-only line inside payload between two content lines is preserved", () => {
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:first\n${extra("")}\n${extra("second")}\n`;
const ops = `${header("a.ts", text)}\n2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n");
});
+4 -4
View File
@@ -236,9 +236,9 @@ describe("computeHashlineDiff", () => {
const line = "unchanged content";
await Bun.write(sourcePath, `${line}\n`);
// `1:` with the same line as payload is a true no-op: the edit
// `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:${line}\n`;
const input = `${sourcePath}#${computeFileHash(`${line}\n`)}\n1:\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↓\nsecond` }, 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" }, 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);
@@ -64,8 +64,8 @@ describe("hashline streaming preview (multi-section)", () => {
});
test("ignores parse errors from the trailing in-progress section", async () => {
// `7↓` is a malformed anchor — the trailing section is still being typed.
const input = ["¶a.ts", "BOF↓", "// new", "¶b.ts", "7↓"].join("\n");
// `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 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,
@@ -49,14 +49,14 @@ describe("editToolRenderer", () => {
expect(rendered).not.toContain("The first line of the patch must be");
});
it("shows hashline envelope input while preview diff is not computable yet", async () => {
it("shows hashline envelope target path while preview diff is not computable yet", async () => {
await getUiTheme();
const uiStub = { requestRender() {} } as unknown as TUI;
const hashlineTool = { name: "edit", label: "Edit", mode: "hashline" } as unknown as AgentTool;
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",
),
},
@@ -67,8 +67,8 @@ describe("editToolRenderer", () => {
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("crates/pi-natives/src/shell.rs");
expect(rendered).toContain("EOF↓");
expect(rendered).toContain("pub fn streaming_preview() {");
expect(rendered).not.toContain("EOF:");
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",
},
);
+2 -4
View File
@@ -6,7 +6,7 @@
### Breaking Changes
- Changed hashline payload continuations from `+TEXT` to `\TEXT`; use `\` for an explicit blank payload line.
- Redesigned hashline syntax around range anchors (`A-B:`, `A:`, `BOF:`, `EOF:`) and per-line payload sigils (`|`, `↑`, `↓`). Old op-line insert syntax and `\` payload continuations are no longer supported.
### Added
@@ -16,12 +16,10 @@
### Fixed
- Parser now skips markdown-style `# ...` lines when they directly precede a hashline operation, making model-generated explanatory rows in prompt examples non-blocking.
- Parser now treats a leading `\` on inline payload bodies as the payload delimiter, matching standalone payload rows.
- Restored the warning emitted when escaped indented payload rows (`\\ TEXT`) are accepted as payload delimiters.
### Removed
- Removed the `A-B!` / `A!` deletion operator. Use `A-B:` with the desired payload (or empty payload to blank the range) instead.
- Removed legacy deletion semantics that treated bare `A-B:` as a blank-line replacement; a bare range anchor now deletes the range.
All notable changes to this package will be documented in this file.
+6 -7
View File
@@ -27,7 +27,7 @@ await fs.writeText(
const patcher = new Patcher({ fs });
const patch = Patch.parse(String.raw`¶hello.ts
1:
\const greeting = "hello";`);
|const greeting = "hello";`);
const result = await patcher.apply(patch);
console.log(result.sections[0].op); // "update"
@@ -47,12 +47,11 @@ session-aware recovery).
Inside a hunk:
|Op|Meaning|
|---|---|
|`LINE↑`|Insert before LINE (or `BOF↑` for the beginning of file)|
|`LINE↓`|Insert after LINE (or `EOF↓` for the end of file)|
|`A-B:`|Replace lines A..B (single-anchor `A:` is sugar for `A-A:`)|
|`\TEXT`|Payload continuation. The `\` prefix is stripped|
- `A-B:` — anchor lines A..B (single-anchor `A:` is sugar for `A-A:`).
- `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).
## Abstractions
+28 -19
View File
@@ -33,6 +33,10 @@ interface ReplacementGroup {
deletes: DeleteEdit[];
}
function isReplacementInsert(edit: Edit): edit is Extract<Edit, { kind: "insert" }> & { mode: "replacement" } {
return edit.kind === "insert" && edit.mode === "replacement";
}
function getEditAnchors(edit: Edit): Anchor[] {
if (edit.kind === "delete") return [edit.anchor];
if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
@@ -99,14 +103,14 @@ function collectAnchorTargetLines(edits: Edit[]): Set<number> {
function findReplacementGroup(edits: Edit[], startIndex: number): ReplacementGroup | undefined {
const first = edits[startIndex];
if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined;
if (!isReplacementInsert(first) || first.cursor.kind !== "before_anchor") return undefined;
const sourceLineNum = first.lineNum;
const replacement: string[] = [];
let index = startIndex;
while (index < edits.length) {
const edit = edits[index];
if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
if (!isReplacementInsert(edit) || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
replacement.push(edit.text);
index++;
}
@@ -265,9 +269,9 @@ function countMatchingSingleStructuralSuffixBoundary(
/**
* Single-line non-structural boundary duplicate detector for replacement
* groups. Mirrors the same boundary check the pure-insert absorber uses for
* `ANCHOR↓` (leading) / `ANCHOR↑` (trailing) inserts, but applied to the
* top/bottom edges of an `A-B:payload` range. Catches mistakes like
* `103-138:const X = …` where line 102 already reads `const X = …`.
* `A:` + `↓` (leading) / `A:` + `↑` (trailing) inserts, 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
@@ -346,7 +350,7 @@ function cursorMatches(a: Cursor, b: Cursor): boolean {
*/
function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup | undefined {
const first = edits[startIndex];
if (first?.kind !== "insert") return undefined;
if (first?.kind !== "insert" || isReplacementInsert(first)) return undefined;
const sourceLineNum = first.lineNum;
const cursor = first.cursor;
@@ -354,7 +358,7 @@ function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup
let index = startIndex;
while (index < edits.length) {
const edit = edits[index];
if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
if (edit.kind !== "insert" || isReplacementInsert(edit) || edit.lineNum !== sourceLineNum) break;
if (!cursorMatches(edit.cursor, cursor)) break;
payload.push(edit.text);
index++;
@@ -686,20 +690,23 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
const idx = line - 1;
const currentLine = fileLines[idx] ?? "";
const beforeLines: string[] = [];
const insertLines: string[] = [];
const replacementLines: string[] = [];
let deleteLine = false;
for (const { edit } of bucket) {
if (edit.kind === "insert") {
beforeLines.push(edit.text);
if (isReplacementInsert(edit)) {
replacementLines.push(edit.text);
} else if (edit.kind === "insert") {
insertLines.push(edit.text);
} else if (edit.kind === "delete") {
deleteLine = true;
}
}
if (beforeLines.length === 0 && !deleteLine) continue;
if (insertLines.length === 0 && replacementLines.length === 0 && !deleteLine) continue;
const replaceMode = beforeLines.length > 0;
if (deleteLine && !replaceMode) {
const hasReplacementPayload = replacementLines.length > 0;
if (deleteLine && !hasReplacementPayload) {
const balance = computeDelimiterBalance([currentLine]);
const trimmedCurrentLine = currentLine.trim();
const touchesStructuralBoundary =
@@ -711,15 +718,17 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
trimmedCurrentLine.endsWith("{");
if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) {
warnings.push(
`Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use 'A:<replacement>' to keep the boundary intact.`,
`Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use '|replacement' payload to keep the boundary intact.`,
);
}
}
const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
const origins = replacement.map((): LineOrigin => (deleteLine ? "replacement" : "insert"));
if (!deleteLine) {
origins[origins.length - 1] = lineOrigins[idx] ?? "original";
}
const replacement = deleteLine
? [...insertLines, ...replacementLines]
: [...insertLines, ...replacementLines, currentLine];
const origins: LineOrigin[] = [];
for (let i = 0; i < insertLines.length; i++) origins.push("insert");
for (let i = 0; i < replacementLines.length; i++) origins.push(deleteLine ? "replacement" : "insert");
if (!deleteLine) origins.push(lineOrigins[idx] ?? "original");
fileLines.splice(idx, 1, ...replacement);
lineOrigins.splice(idx, 1, ...origins);
+9 -9
View File
@@ -4,18 +4,18 @@
* parser, the tokenizer, the prompt, and the formal grammar.
*/
/** Op sigil used immediately after a line-number anchor to insert before it. */
export const HL_OP_INSERT_BEFORE = "↑";
/** Op sigil used immediately after a line-number anchor to insert after it. */
export const HL_OP_INSERT_AFTER = "↓";
/** Op sigil used after a range (or single anchor) to replace its lines. */
/** Anchor terminator for every hashline operation block. */
export const HL_OP_REPLACE = ":";
/** All hashline edit op sigils, concatenated for fast membership tests. */
export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}`;
/** Payload sigil for lines that replace the anchored range in place. */
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 = "↓";
/** Prefix for payload continuation lines. The prefix itself is not written. */
export const HL_PAYLOAD_PREFIX = "\\";
/** All hashline payload sigils, concatenated for fast membership tests. */
export const HL_PAYLOAD_CHARS = `${HL_PAYLOAD_REPLACE}${HL_PAYLOAD_ABOVE}${HL_PAYLOAD_BELOW}`;
/** Hashline edit file-section header marker. */
export const HL_FILE_PREFIX = "¶";
+7 -7
View File
@@ -3,18 +3,18 @@ begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?
hunk: update_hunk
update_hunk: "¶" filename ("#" file_hash)? LF line_op*
update_hunk: "¶" filename ("#" file_hash)? LF block*
filename: /([^\s#]+)/
file_hash: /[0-9a-f]{4}/
line_op: insert_before | insert_after | replace
insert_before: anchor "↑" LF payload*
insert_after: anchor "↓" LF payload*
replace: range ":" LF payload*
payload: "\\" /[^\n]*/ LF
block: anchor ":" LF payload*
payload: above_payload | replace_payload | below_payload
above_payload: "↑" /[^\n]*/ LF
replace_payload: "|" /[^\n]*/ LF
below_payload: "↓" /[^\n]*/ LF
anchor: LID | "EOF" | "BOF"
anchor: range | "BOF" | "EOF"
range: LID ("-" LID)?
LID: /[1-9]\d*/
+8 -39
View File
@@ -27,49 +27,18 @@ export const ABORT_WARNING =
"Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
/**
* Warning text appended when two consecutive `A-B:` ops on the exact same
* range get coalesced (model painted a before/after pair). The second op wins;
* the first op's payload is silently discarded.
* Warning text appended when two consecutive blocks target the exact same
* concrete range. The second block wins; the first block is discarded.
*/
export const REPLACE_PAIR_COALESCED_WARNING =
"Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range — the payload is the final desired content, never both old and new.";
"Detected two identical-range hashline blocks; kept only the second block. Issue ONE block per range — payload is the final desired content, never both old and new.";
/**
* Warning text appended when un-prefixed continuation lines are accepted as
* implicit payload (lenient legacy behavior). The author wrote a multi-line
* replace without `\` prefixes; the parser accepted it because the lines did
* not classify as ops/headers/payloads, but the canonical syntax requires `\`
* on every continuation line after the op.
*/
export const IMPLICIT_CONTINUATION_WARNING =
"Accepted continuation line(s) without the `\\` prefix as implicit payload. Canonical syntax is `A-B:` followed by `\\` on every continuation row; without `\\`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
/** 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.";
/**
* Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
* op arrives while an outer `A-B:` replace is still pending and the inner
* anchor falls inside the outer range. The author used the read-output
* `LINE:TEXT` format as if it were a payload-continuation line; we strip the
* `LINE:` prefix and append the body to the pending payload, but warn so the
* canonical `\`-continuation form remains preferred.
*/
export const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING =
"Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside an `A-B:` block, every payload line must be on its own row prefixed with `\\` — never reuse the read-output gutter format.";
/**
* Warning text appended when an op carries an inline payload (`LINE:TEXT`,
* `LINE↑CONTENT`, `LINE↓CONTENT`). Canonical syntax is the bare op followed
* by `\`-prefixed payload rows on the next line(s).
*/
export const INLINE_PAYLOAD_ACCEPTED_WARNING =
"Accepted inline payload on the op line (e.g. `LINE:CONTENT`, `LINE↑CONTENT`). Canonical syntax is the bare op followed by `\\`-prefixed payload rows on the next line(s). Prefer the explicit form.";
/**
* Warning text appended when a payload row uses an extra `\` before indented
* content (`\\ TEXT`). Models often JSON-escape the payload delimiter; the
* parser strips the accidental second delimiter so code indentation survives.
*/
export const ESCAPED_PAYLOAD_DELIMITER_ACCEPTED_WARNING =
"Accepted an extra `\\` before an indented payload row and treated it as the payload delimiter, not file content. Use exactly one `\\` before indented payload lines.";
/** Error text emitted when `|` replacement payload targets BOF/EOF. */
export const VIRTUAL_REPLACE_REJECTED_MESSAGE =
"BOF:/EOF: anchors are virtual positions and cannot use `|` replacement payload. Use `↑` or `↓` payload lines.";
/** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
export const RECOVERY_EXTERNAL_WARNING =
+152 -173
View File
@@ -6,23 +6,28 @@
* Lifecycle:
*
* 1. Construct one {@link Executor} per hunk (or share one with `reset()`).
* 2. Feed it tokens via {@link Executor.feed}. Multi-line payloads are
* accumulated across tokens until the next op flushes them.
* 3. Call {@link Executor.end} to flush the trailing pending op and validate
* cross-op invariants (no overlapping deletes, etc.).
* 2. Feed it tokens via {@link Executor.feed}. Block payload rows are
* accumulated across tokens until the next anchor block flushes them.
* 3. Call {@link Executor.end} to flush the trailing pending block and validate
* cross-block invariants (no overlapping deletes, etc.).
*
* Convenience entry point: {@link parsePatch}.
*/
import { HL_OP_CHARS, HL_OP_INSERT_AFTER, HL_OP_INSERT_BEFORE, HL_OP_REPLACE, HL_PAYLOAD_PREFIX } from "./format";
import { HL_OP_REPLACE, HL_PAYLOAD_ABOVE, HL_PAYLOAD_BELOW, HL_PAYLOAD_REPLACE } from "./format";
import {
ABORT_WARNING,
ESCAPED_PAYLOAD_DELIMITER_ACCEPTED_WARNING,
IMPLICIT_CONTINUATION_WARNING,
INLINE_PAYLOAD_ACCEPTED_WARNING,
PAYLOAD_LINE_PREFIX_DEMOTED_WARNING,
INLINE_PAYLOAD_REJECTED_PREFIX,
REPLACE_PAIR_COALESCED_WARNING,
VIRTUAL_REPLACE_REJECTED_MESSAGE,
} from "./messages";
import { cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
import {
type BlockTarget,
cloneCursor,
type ParsedRange,
type PayloadBucket,
type Token,
Tokenizer,
} from "./tokenizer";
import type { Anchor, Cursor, Edit } from "./types";
function validateRangeOrder(range: ParsedRange, lineNum: number): void {
@@ -31,29 +36,12 @@ function validateRangeOrder(range: ParsedRange, lineNum: number): void {
}
}
function hasEscapedIndentPayloadDelimiter(text: string): boolean {
if (!text.startsWith(HL_PAYLOAD_PREFIX)) return false;
if (text.length === HL_PAYLOAD_PREFIX.length) return true;
const next = text.charCodeAt(HL_PAYLOAD_PREFIX.length);
return next === 32 || next === 9;
}
function shouldStripEscapedPayloadDelimiters(payload: readonly string[]): boolean {
let sawIndentedPayload = false;
for (const text of payload) {
if (!hasEscapedIndentPayloadDelimiter(text)) return false;
if (text.length > HL_PAYLOAD_PREFIX.length) sawIndentedPayload = true;
}
return sawIndentedPayload;
}
function rangesEqual(a: ParsedRange, b: ParsedRange): boolean {
return a.start.line === b.start.line && a.end.line === b.end.line;
}
function rangeContains(outer: ParsedRange, inner: ParsedRange): boolean {
return outer.start.line <= inner.start.line && inner.end.line <= outer.end.line;
function targetsEqualConcreteRange(a: BlockTarget, b: BlockTarget): boolean {
return a.kind === "range" && b.kind === "range" && rangesEqual(a.range, b.range);
}
function expandRange(range: ParsedRange): Anchor[] {
@@ -68,26 +56,42 @@ 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}:`;
}
interface PendingComment {
lineNum: number;
text: string;
}
type PendingOp =
| { kind: "insert"; cursor: Cursor; lineNum: number }
| { kind: "replace"; range: ParsedRange; lineNum: number };
interface PayloadRow {
bucket: PayloadBucket;
text: string;
lineNum: number;
}
interface Pending {
op: PendingOp;
payload: string[];
target: BlockTarget;
lineNum: number;
payloads: PayloadRow[];
}
/**
* Token-driven state machine that turns a stream of {@link Token}s into a
* flat list of {@link Edit}s.
*
* `feed()` accepts tokens one at a time; multi-line payloads accumulate
* until the next op or {@link end} flushes them. After `terminated` flips
* `feed()` accepts tokens one at a time; block payload rows accumulate until
* the next anchor block or {@link end} flushes them. After `terminated` flips
* true (on `envelope-end` or `abort`) subsequent feeds are silently ignored
* so callers can keep draining their tokenizer.
*/
@@ -144,7 +148,7 @@ export class Executor {
return;
case "payload":
this.#consumePendingSkippableComments();
this.#handlePayload(token.text, token.lineNum);
this.#handlePayload(token.bucket, token.text, token.lineNum);
return;
case "raw":
if (this.#pending === undefined && isSkippableCommentLine(token.text)) {
@@ -154,74 +158,36 @@ export class Executor {
this.#consumePendingSkippableComments();
this.#handleRaw(token.text, token.lineNum);
return;
case "op-insert":
case "op-block":
this.#discardPendingSkippableComments();
this.#flushPending();
this.#pending = {
op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
payload: [],
};
if (token.inlineBody !== undefined) {
this.#pending.payload.push(token.inlineBody);
if (!this.#warnings.includes(INLINE_PAYLOAD_ACCEPTED_WARNING)) {
this.#warnings.push(INLINE_PAYLOAD_ACCEPTED_WARNING);
}
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}.`,
);
}
return;
case "op-replace":
this.#discardPendingSkippableComments();
validateRangeOrder(token.range, token.lineNum);
if (this.#pending !== undefined && this.#pending.op.kind === "replace") {
const outer = this.#pending.op.range;
const inner = token.range;
if (rangesEqual(outer, inner)) {
// Identical-range before/after pair. Drop the "before" payload
// silently; the second op proceeds as the lone winner. Other
// overlap shapes (different ranges) still hit the post-hoc
// validator.
this.#pending = undefined;
if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
}
} else if (rangeContains(outer, inner)) {
// Model wrote a payload line in read-output `LINE:TEXT` format
// (or `A-B:TEXT` for a sub-range) inside an outer `A-B:` block.
// The tokenizer can't tell payload from op when the anchor and
// sigil shape are identical, so demote: append the op's inline
// body to the pending payload, strip the `LINE:` prefix, and
// keep accumulating. Without this the inner anchors would each
// register as their own delete and clash with the outer range.
this.#pending.payload.push(token.inlineBody ?? "");
if (!this.#warnings.includes(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING)) {
this.#warnings.push(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING);
}
return;
}
}
this.#flushPending();
this.#pending = {
op: { kind: "replace", range: token.range, lineNum: token.lineNum },
payload: [],
};
if (token.inlineBody !== undefined) {
this.#pending.payload.push(token.inlineBody);
if (!this.#warnings.includes(INLINE_PAYLOAD_ACCEPTED_WARNING)) {
this.#warnings.push(INLINE_PAYLOAD_ACCEPTED_WARNING);
if (token.target.kind === "range") validateRangeOrder(token.target.range, token.lineNum);
if (this.#pending !== undefined && targetsEqualConcreteRange(this.#pending.target, token.target)) {
this.#pending = undefined;
if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
}
} else {
this.#flushPending();
}
this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [] };
return;
}
}
/**
* Flush any open pending op (with its full accumulated payload, including
* explicit `\` blank lines) and return the accumulated edits and
* warnings. The executor is single-use; {@link reset} is required for
* reuse.
* Flush any open pending block and return the accumulated edits and
* warnings. The executor is single-use; {@link reset} is required for reuse.
*
* Throws if two replace ops target the same line with non-identical
* ranges. Identical-range `A-B:` pairs in the same hunk are coalesced
* last-wins by `feed()` with a warning, so they never reach the
* Throws if two replacement/delete blocks target the same line with
* non-identical ranges. Identical-range blocks in the same hunk are
* coalesced last-wins by `feed()` with a warning, so they never reach the
* validator.
*/
end(): { edits: Edit[]; warnings: string[] } {
@@ -233,15 +199,13 @@ export class Executor {
/**
* Streaming-tolerant variant of {@link end}. Identical, except a pending
* op whose payload has not yet accumulated any rows is treated as still
* in flight and dropped instead of flushed (which would otherwise emit a
* phantom blank-line insert/replace). Callers driving an in-progress
* stream should use this so the trailing op the model is still typing
* does not pollute the partial result.
* block whose payload has not yet accumulated any rows is treated as still
* in flight and dropped instead of flushed (which would otherwise preview a
* destructive bare delete while the model may still be typing payload).
*/
endStreaming(): { edits: Edit[]; warnings: string[] } {
this.#consumePendingSkippableComments();
if (this.#pending && this.#pending.payload.length > 0) {
if (this.#pending && this.#pending.payloads.length > 0) {
this.#flushPending();
} else {
this.#pending = undefined;
@@ -261,14 +225,10 @@ export class Executor {
}
/**
* Each `:` op contributes a delete edit per line in its range; if any
* line ends up targeted by deletes originating from two different source
* ops (distinguished by their `lineNum`), the patch is internally
* inconsistent. Identical-range `A-B:` pairs are already collapsed by
* `feed()`; remaining shapes here are an `A-B:` that overlaps a later
* `N:` with a different range. The applier would run both literally and
* the file would end up with two copies of the line, not a chosen
* winner.
* Each replacement/delete block contributes a delete edit per line in its
* range; if any line ends up targeted by deletes originating from two
* different source blocks (distinguished by their `lineNum`), the patch is
* internally inconsistent.
*/
#validateNoOverlappingDeletes(): void {
const sourceLinesByAnchor = new Map<number, number[]>();
@@ -283,97 +243,116 @@ export class Executor {
}
for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
if (sourceLines.length < 2) continue;
const [firstOp, secondOp] = [...sourceLines].sort((a, b) => a - b);
const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b);
throw new Error(
`line ${secondOp}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE} op on line ${firstOp}. ` +
`Issue ONE op per range; payload is only the final desired content, never a before/after pair.`,
`line ${secondBlock}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE} block on line ${firstBlock}. ` +
`Issue ONE block per range; payload is only the final desired content, never a before/after pair.`,
);
}
}
#handlePayload(text: string, lineNum: number): void {
if (this.#pending) {
this.#pending.payload.push(text);
return;
#handlePayload(bucket: PayloadBucket, 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}`)}.`,
);
}
throw new Error(
`line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, or ${HL_OP_REPLACE} operation. ` +
`Got ${JSON.stringify(`${HL_PAYLOAD_PREFIX}${text}`)}.`,
);
if (bucket === "replace" && pending.target.kind !== "range") {
throw new Error(`line ${lineNum}: ${VIRTUAL_REPLACE_REJECTED_MESSAGE}`);
}
pending.payloads.push({ bucket, text, lineNum });
}
#handleRaw(text: string, lineNum: number): void {
if (this.#pending) {
if (text.trim().length === 0) return;
// Lenient legacy fallback: the tokenizer routes a line to `raw` only
// when it does not parse as an op, header, payload, or envelope
// marker. A `raw` token while a pending op exists is therefore an
// unambiguous continuation row that the author wrote without the
// `\` prefix. Accept it as payload and warn so the canonical
// `\`-prefixed form remains preferred.
this.#pending.payload.push(text);
if (!this.#warnings.includes(IMPLICIT_CONTINUATION_WARNING)) {
this.#warnings.push(IMPLICIT_CONTINUATION_WARNING);
}
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)}.`,
);
}
// Whitespace-only raw lines outside any pending op are silently dropped;
// Whitespace-only raw lines outside any pending block are silently dropped;
// fully empty lines arrive as `blank` tokens.
if (text.trim().length === 0) return;
const firstChar = text[0];
const startsWithOp = firstChar !== undefined && HL_OP_CHARS.includes(firstChar);
if (startsWithOp || firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") {
if (firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") {
throw new Error(
`line ${lineNum}: unrecognized op. Use LINE${HL_OP_INSERT_BEFORE} (insert before), LINE${HL_OP_INSERT_AFTER} (insert after), or LINE${HL_OP_REPLACE} / A-B${HL_OP_REPLACE} (replace). ` +
`Got ${JSON.stringify(text)}.`,
`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)}.`,
);
}
throw new Error(
`line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, or ${HL_OP_REPLACE} operation. ` +
`Got ${JSON.stringify(text)}.`,
`line ${lineNum}: payload line has no preceding A-B:, A:, BOF:, or EOF: anchor. Got ${JSON.stringify(text)}.`,
);
}
#pushInsert(cursor: Cursor, text: string, lineNum: number, mode?: "replacement"): void {
this.#edits.push({
kind: "insert",
cursor: cloneCursor(cursor),
text,
lineNum,
index: this.#editIndex++,
...(mode === undefined ? {} : { mode }),
});
}
#pushDelete(anchor: Anchor, lineNum: number): void {
this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
}
#flushPending(): void {
const pending = this.#pending;
if (!pending) return;
const { op, payload } = pending;
let linesToInsert = payload.length === 0 ? [""] : payload;
if (payload.length > 0 && shouldStripEscapedPayloadDelimiters(payload)) {
linesToInsert = payload.map(text => text.slice(HL_PAYLOAD_PREFIX.length));
if (!this.#warnings.includes(ESCAPED_PAYLOAD_DELIMITER_ACCEPTED_WARNING)) {
this.#warnings.push(ESCAPED_PAYLOAD_DELIMITER_ACCEPTED_WARNING);
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);
}
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);
}
}
if (op.kind === "insert") {
for (const text of linesToInsert) {
this.#edits.push({
kind: "insert",
cursor: cloneCursor(op.cursor),
text,
lineNum: op.lineNum,
index: this.#editIndex++,
});
}
} else {
for (const text of linesToInsert) {
this.#edits.push({
kind: "insert",
cursor: { kind: "before_anchor", anchor: { ...op.range.start } },
text,
lineNum: op.lineNum,
index: this.#editIndex++,
});
}
for (const anchor of expandRange(op.range)) {
this.#edits.push({ kind: "delete", anchor, lineNum: op.lineNum, index: this.#editIndex++ });
}
for (const text of below) {
this.#pushInsert({ kind: "after_anchor", anchor: { ...target.range.end } }, text, lineNum);
}
this.#pending = undefined;
@@ -406,14 +385,14 @@ export function parsePatch(diff: string): { edits: Edit[]; warnings: string[] }
* parsed successfully when the diff is still being typed:
*
* - per-token feed errors stop the drain but preserve the edits already
* collected (the trailing op is malformed mid-stream — wait for the next
* collected (the trailing block is malformed mid-stream — wait for the next
* chunk),
* - the trailing pending op is dropped if it has no payload yet (avoids a
* phantom blank-line insert/replace).
* - the trailing pending block is dropped if it has no payload yet (avoids a
* destructive bare-delete preview while payload may still be coming).
*
* Throws only on the cross-op overlap validator, which catches conflicting
* shapes (two replaces hitting the same anchor). Streaming preview callers
* should treat any throw here as "no preview this tick".
* Throws only on the cross-block overlap validator, which catches conflicting
* shapes (two replacements/deletes hitting the same anchor). Streaming preview
* callers should treat any throw here as "no preview this tick".
*/
export function parsePatchStreaming(diff: string): { edits: Edit[]; warnings: string[] } {
const tokenizer = new Tokenizer();
+107 -80
View File
@@ -1,97 +1,124 @@
Your patch language is a compact, line-anchored edit format.
<payload>
Patch payload is a series of hunks: `¶PATH#HASH` header followed by any number of operations. `HASH` should be copied as is from read/search. Missing? Re-`read`.
- No context rows, no gutters.
- NEVER restate unchanged lines "for context".
- Op lines carry NO payload. Every payload line lives on its own row and MUST start with `\`; that delimiter is stripped.
- Payload indentation is literal.
</payload>
Patch payload = one or more file sections:
<ops>
LINE↑ insert before (or BOF↑) — anchor SURVIVES
LINE↓ insert after (or EOF↓) — anchor SURVIVES
A-B: replace A..B (or A: == A..A) — anchor DELETED, then payload written in its place
\PAYLOAD payload line for the preceding op
</ops>
<rules>
- **Payload is only what's NEW.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat anchor lines or neighbors.
- **Use `\` for a blank payload line; use `\\text` to write a line starting with `\text`.**
- **Inserts add ONLY the rows you list.** The file's existing newlines around the anchor stay. NEVER tack a trailing `\` blank "for spacing" — it writes a literal blank line into the file, doubling whatever is already there.
- **A bare `LINE↑`/`LINE↓` with no payload still inserts ONE blank line.** Not a no-op. Omit the op if you want nothing there.
- **Pick the op for your intent.** Does the anchor's existing content SURVIVE?
- Survives + new lines next to it → `↑` / `↓`. Go small: prefer `↑`/`↓` over `:` whenever you can.
- Changes in place → `:`
When unsure: you wanted `↓`. `:` is destructive — it deletes the anchor line.
- **`A-B:` deletes EXACTLY A..B. Payload length never extends the deletion.** `1:` with 10 payload lines still deletes only line 1, then writes 10 lines there. To prepend without deleting, use `1↑` (or `BOF↑`).
- **Line numbers are frozen references to what you have seen.** Later ops in the same hunk still use original line numbers; they do NOT shift as earlier ops apply.
</rules>
<common-failures>
- **NEVER replay past your range.** Stop before B+1; extend B if needed.
- **Read lines look like replace ops.** `84:content` = "make line 84 content" — and inline content is rejected. Don't echo read-style rows.
- **`LINE:` from a read is NOT `LINE:` as an op.** Read shows what's there; the op DELETES it. Want to keep what you just read? Use `↑`/`↓`, not `:`.
- **NEVER fabricate file hashes.** Missing? Re-`read`.
</common-failures>
<example>
```a.ts#1a2b
1:const X = "a";
2:
3:export function f() { return X; }
4:f();
```
¶PATH#HASH
A-B:
|replacement line
↑inserted above line
↓inserted below line
```
# replace one line, insert after
- `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.
</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>
<payload-sigils>
`|content` — replace A..B with `content`.
`↑content` — insert `content` before A.
`↓content` — insert `content` after B.
</payload-sigils>
<semantics>
- **No payload rows → delete.** `5:` deletes line 5.
- **Any `|` row → replace.** Delete A..B; insert all `|` rows there.
- **Only `↑`/`↓` rows → preserve.** Anchor lines stay unchanged.
- **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 rows are explicit.** Bare `|`, `↑`, or `↓` writes one blank line.
- **BOF/EOF only insert.** `↑` and `↓` are equivalent there; `|` is invalid.
- **Escape leading payload sigils by doubling.** `||x` writes `|x`; `↑↑x` writes `↑x`; `↓↓x` writes `↓x`.
- **Line numbers are frozen.** Later anchors still reference pre-edit lines.
</semantics>
<examples>
# Replace line 1 with two lines; insert one line below the replacement.
```
¶a.ts#1a2b
1:
\const X = "b";
\export const Y = X;
1↓
\const Z = Y;
|const X = "b";
|export const Y = X;
↓const Z = Y;
```
</example>
# Insert above line 3. Line 3 survives because there is no `|` row.
```
¶a.ts#1a2b
3:
↑function helper() { return 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 `|`.
- **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 the sigil is rejected
1:const X = "b";
1↓const Z = Y;
1-2:const X = "b";
\export const Y = X;
# WRONG — INSERT used to change a line (old line survives)
1↓
\const X = "b";
# WRONG — REPLACE used to add a line (original is silently deleted)
# intent: keep `const X = "a";`, add `const Y = X;` on the next line
# 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 — 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:
\const Y = X;
# `1:` replaces line 1 — `const X = "a";` is gone, breaking `f()` which returns X. Use `1↓` to insert after.
# WRONG — multi-line payload on `:` does NOT mean "insert N lines here"; the anchor is still destroyed.
# intent: prepend a helper ABOVE `const X = "a";`
1:
\function helper() { return X; }
\const Y = X;
# `1:` deletes line 1 and writes the payload there — payload count never extends the deletion range. To prepend: `1↑` (or `BOF↑`).
# WRONG — echoing read-style lines as context before the real op
1:const X = "a";
1-2:
\const X = "b";
\export const Y = X;
# WRONG — trailing `\` blank writes a literal empty line; the new blank lands right next to the orig blank at line 2, doubling it
1↓
\const Y = X;
\
# WRONG — `2↓` still anchors at PRE-EDIT line 2 (frozen), NOT at the line just inserted by `1↓`. Both inserts land at their own anchors, giving three consecutive blanks (new from `1↓`, orig blank line 2, new from `2↓`).
1↓
2↓
↓new line
2:
↓another new line
# `2:` still targets original line 2, not `new line`.
</anti-pattern>
<critical>
- One op per range, ever.
- Pick op precisely. Update: `:`, add: `↑`/`↓`.
- Payload always lives on its own `\`-prefixed line — never inline with the op.
- Payload is only what's NEW; never repeat anchor lines or neighbors.
- Anchor exactly; don't anchor neighbors.
- 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>
+57 -102
View File
@@ -10,17 +10,17 @@
* The tokenizer is intentionally permissive about decorations and prefixes
* the model may echo back from `read`/`search` output — leading `*`/`>`/`-`
* markers, CR-terminated lines, leading whitespace before line numbers, and
* so on are all stripped before classification.
* so on are all stripped before anchor classification.
*/
import {
describeAnchorExamples,
HL_FILE_HASH_SEP,
HL_FILE_PREFIX,
HL_OP_INSERT_AFTER,
HL_OP_INSERT_BEFORE,
HL_OP_REPLACE,
HL_PAYLOAD_PREFIX,
HL_PAYLOAD_ABOVE,
HL_PAYLOAD_BELOW,
HL_PAYLOAD_REPLACE,
} from "./format";
import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages";
import type { Anchor, Cursor, ParsedRange } from "./types";
@@ -32,10 +32,14 @@ const CHAR_NINE = 57;
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_PAYLOAD_PREFIX = HL_PAYLOAD_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 FILE_HASH_LENGTH = 4;
function isDigitCode(code: number): boolean {
@@ -47,7 +51,7 @@ function isNonZeroDigitCode(code: number): boolean {
}
function isDecorationCode(code: number): boolean {
return code === 42 || code === 45 || code === 62;
return code === 42 || code === CHAR_HYPHEN || code === 62;
}
function isHexDigitCode(code: number): boolean {
@@ -108,7 +112,7 @@ export function cloneCursor(cursor: Cursor): Cursor {
// Leniently accept anchors copied from read/search output:
// - optional leading line-marker decoration (`*`, `>`, `-`)
// - the required bare line number
// - the required bare line number / BOF / EOF anchor
function skipDecoratedAnchorPrefix(line: string, end = trimEndIndex(line)): number {
let index = skipWhitespace(line, 0, end);
while (index < end && isDecorationCode(line.charCodeAt(index))) index++;
@@ -134,7 +138,7 @@ function scanLineNumber(line: string, index: number, end: number): NumberScan |
return { line: lineNumber, nextIndex };
}
/** Parse a bare line-number anchor (used by insert ops). Throws on malformed input. */
/** Parse a bare line-number anchor. Throws on malformed input. */
export function parseLid(raw: string, lineNum: number): Anchor {
const end = trimEndIndex(raw);
const numberStart = skipDecoratedAnchorPrefix(raw, end);
@@ -160,7 +164,7 @@ function scanRange(line: string, end = trimEndIndex(line)): RangeScan | null {
let nextIndex = start.nextIndex;
let rangeEnd = start.line;
if (nextIndex < end && line.charCodeAt(nextIndex) === 45) {
if (nextIndex < end && line.charCodeAt(nextIndex) === CHAR_HYPHEN) {
const endNumber = scanLineNumber(line, nextIndex + 1, end);
if (endNumber === null) return null;
rangeEnd = endNumber.line;
@@ -181,100 +185,55 @@ function startsWithWord(line: string, index: number, end: number, word: string):
return true;
}
function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): Cursor {
const end = trimEndIndex(raw);
const targetStart = skipDecoratedAnchorPrefix(raw, end);
export type BlockTarget = { kind: "range"; range: ParsedRange } | { kind: "bof" } | { kind: "eof" };
if (startsWithWord(raw, targetStart, end, "BOF") && skipWhitespace(raw, targetStart + 3, end) === end) {
return { kind: "bof" };
}
if (startsWithWord(raw, targetStart, end, "EOF") && skipWhitespace(raw, targetStart + 3, end) === end) {
return { kind: "eof" };
}
export type PayloadBucket = "above" | "replace" | "below";
const cursorKind = kind === "before" ? "before_anchor" : "after_anchor";
return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
interface TargetScan {
target: BlockTarget;
nextIndex: number;
}
function startsWithEscapedIndentPayload(text: string): boolean {
if (!text.startsWith(HL_PAYLOAD_PREFIX)) return false;
const next = text.charCodeAt(HL_PAYLOAD_PREFIX.length);
return next === CHAR_SPACE || next === CHAR_TAB;
}
function scanInlineBody(line: string, index: number): string | undefined {
const end = trimEndIndex(line);
if (index >= end) return undefined;
const body = line.slice(index, end);
if (!body.startsWith(HL_PAYLOAD_PREFIX)) return body;
const payload = body.slice(HL_PAYLOAD_PREFIX.length);
return startsWithEscapedIndentPayload(payload) ? payload.slice(HL_PAYLOAD_PREFIX.length) : payload;
}
interface ParsedInsertOp {
kind: "insert";
cursor: Cursor;
inlineBody: string | undefined;
}
interface ParsedReplaceOp {
kind: "replace";
range: ParsedRange;
inlineBody: string | undefined;
}
type ParsedOp = ParsedInsertOp | ParsedReplaceOp;
function tryParseInsertOp(line: string, sigil: string, kind: "before" | "after"): ParsedInsertOp | null {
const end = trimEndIndex(line);
function scanBlockTarget(line: string, end = trimEndIndex(line)): TargetScan | null {
const targetStart = skipDecoratedAnchorPrefix(line, end);
let targetEnd: number;
if (startsWithWord(line, targetStart, end, "BOF") || startsWithWord(line, targetStart, end, "EOF")) {
targetEnd = targetStart + 3;
} else {
const anchor = scanLineNumber(line, targetStart, end);
if (anchor === null) return null;
targetEnd = anchor.nextIndex;
if (startsWithWord(line, targetStart, end, "BOF")) {
const nextIndex = skipWhitespace(line, targetStart + 3, end);
return { target: { kind: "bof" }, nextIndex };
}
if (startsWithWord(line, targetStart, end, "EOF")) {
const nextIndex = skipWhitespace(line, targetStart + 3, end);
return { target: { kind: "eof" }, nextIndex };
}
const opIndex = skipWhitespace(line, targetEnd, end);
if (opIndex >= end || line[opIndex] !== sigil) return null;
// parseInsertTarget can only throw on inputs that already passed the
// BOF/EOF/line-number scan above, but guard the throw anyway — the
// tokenizer contract forbids it and a future refactor of the prefix
// scan must not silently start raising here.
try {
return {
kind: "insert",
cursor: parseInsertTarget(line.slice(0, opIndex), 0, kind),
inlineBody: scanInlineBody(line, opIndex + sigil.length),
};
} catch {
return null;
}
const range = scanRange(line, end);
return range === null ? null : { target: { kind: "range", range: range.range }, nextIndex: range.nextIndex };
}
function tryParseReplaceOp(line: string): ParsedReplaceOp | null {
interface ParsedBlockOp {
target: BlockTarget;
inlineBody: string | undefined;
}
function tryParseBlockOp(line: string): ParsedBlockOp | null {
const end = trimEndIndex(line);
const range = scanRange(line, end);
if (range === null || range.nextIndex >= end || line[range.nextIndex] !== HL_OP_REPLACE) return null;
const target = scanBlockTarget(line, end);
if (target === null) return null;
const opIndex = skipWhitespace(line, target.nextIndex, end);
if (opIndex >= end || line.charCodeAt(opIndex) !== CHAR_OP_REPLACE) return null;
const inlineStart = opIndex + HL_OP_REPLACE.length;
return {
kind: "replace",
range: range.range,
inlineBody: scanInlineBody(line, range.nextIndex + HL_OP_REPLACE.length),
target: target.target,
inlineBody: skipWhitespace(line, inlineStart, end) === end ? undefined : line.slice(inlineStart, end),
};
}
function tryParseOp(line: string): ParsedOp | null {
return (
tryParseInsertOp(line, HL_OP_INSERT_BEFORE, "before") ??
tryParseInsertOp(line, HL_OP_INSERT_AFTER, "after") ??
tryParseReplaceOp(line)
);
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;
}
/**
@@ -330,9 +289,8 @@ export type Token =
| (TokenBase & { kind: "envelope-end" })
| (TokenBase & { kind: "abort" })
| (TokenBase & { kind: "header"; path: string; fileHash?: string })
| (TokenBase & { kind: "op-insert"; cursor: Cursor; inlineBody: string | undefined })
| (TokenBase & { kind: "op-replace"; range: ParsedRange; inlineBody: string | undefined })
| (TokenBase & { kind: "payload"; text: string })
| (TokenBase & { kind: "op-block"; target: BlockTarget; inlineBody: string | undefined })
| (TokenBase & { kind: "payload"; bucket: PayloadBucket; text: string })
| (TokenBase & { kind: "raw"; text: string });
function classifyLine(line: string, lineNum: number): Token {
@@ -350,17 +308,14 @@ function classifyLine(line: string, lineNum: number): Token {
}
}
if (line.charCodeAt(0) === CHAR_PAYLOAD_PREFIX) {
return { kind: "payload", lineNum, text: line.slice(HL_PAYLOAD_PREFIX.length) };
}
const op = tryParseOp(line);
if (op !== null) {
if (op.kind === "insert") {
return { kind: "op-insert", lineNum, cursor: op.cursor, inlineBody: op.inlineBody };
}
return { kind: "op-replace", lineNum, range: op.range, inlineBody: op.inlineBody };
const payloadBucket = payloadBucketForCode(line.charCodeAt(0));
if (payloadBucket !== undefined) {
return { kind: "payload", lineNum, bucket: payloadBucket, text: line.slice(1) };
}
const op = tryParseBlockOp(line);
if (op !== null) return { kind: "op-block", lineNum, target: op.target, inlineBody: op.inlineBody };
return { kind: "raw", lineNum, text: line };
}
@@ -429,7 +384,7 @@ export class Tokenizer {
}
isOp(line: string): boolean {
return tryParseOp(line) !== null;
return tryParseBlockOp(line) !== null;
}
isHeader(line: string): boolean {
+11 -2
View File
@@ -19,10 +19,19 @@ export type Cursor =
/**
* 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.
* 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.
*/
export type Edit =
| { kind: "insert"; cursor: Cursor; text: string; lineNum: number; index: number }
| {
kind: "insert";
cursor: Cursor;
text: string;
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. */