feat(hashline)!: required + prefix on payload continuation lines

- Changed multiline payload syntax so continuation lines must start with `+`; that prefix is stripped before writing.
- Raw unprefixed lines after an op now throw an error instead of being silently accepted as payload.
- Removed `PAYLOAD_LINE_PREFIX_DEMOTED_WARNING` and the nested-replace demotion path; inner `N:` ops inside a pending `A-B:` now raise an overlap error.
- Raw blank lines between ops are ignored; use `+` alone for an empty payload line.
This commit is contained in:
can1357
2026-05-27 00:37:03 +02:00
parent d24c477b6e
commit 4e32fd0060
11 changed files with 200 additions and 179 deletions
+9 -7
View File
@@ -9,7 +9,7 @@
- `packages/coding-agent/src/utils/edit-mode.ts` — selects active edit mode
- `packages/coding-agent/src/hashline/grammar.lark` — custom-tool grammar for hashline mode
- `packages/coding-agent/src/hashline/input.ts` — splits `¶PATH` sections
- `packages/coding-agent/src/hashline/parser.ts` — parses op-prefixed edits and verbatim payload lines
- `packages/coding-agent/src/hashline/executor.ts` / `tokenizer.ts` — parses op-prefixed edits and `+`-prefixed payload continuation lines
- `packages/coding-agent/src/hashline/apply.ts` — validates anchors and applies edits
- `packages/coding-agent/src/hashline/anchors.ts` — stale-anchor mismatch formatting
- `packages/coding-agent/src/hashline/recovery.ts` — cache-based stale-anchor recovery
@@ -37,7 +37,7 @@ Patch language inside `input`:
- Single-line replace sugar: `A:[payload]` means `A-A:[payload]`
- Delete range: `A-B!`
- Single-line delete sugar: `A!` means `A-A!`
- **Payload semantics:** the first payload line is whatever follows the sigil on the op line itself; additional payload lines follow on subsequent lines and append after the inline first line. An empty inline (just the sigil followed by a newline) means the first payload line is empty. So bare `A↑` / `A↓` insert one blank line, and bare `A:` / `A-B:` replace the line/range with one blank line. But `A↓\nfoo` inserts blank-then-`foo`, not just `foo` — for a single-line insert, put `foo` inline as `A↓foo`.
- **Payload semantics:** the first payload line may follow the sigil on the op line itself. Additional payload lines must be on subsequent lines prefixed with `+`; that delimiter is stripped before writing. Use `+` alone for an empty payload line, and `++text` to write a payload line that begins with `+text`. Bare `A↑` / `A↓` insert one blank line, and bare `A:` / `A-B:` replace the line/range with one blank line.
- `!` deletes and forbids payload.
- Read lines like `84:content` are already valid single-line replacements.
- Special anchors: `BOF`, `EOF` (both support inline payload, e.g. `BOF↓export const done = true;`).
@@ -75,11 +75,11 @@ Warnings:
2. `executeHashlineSingle()` in `packages/coding-agent/src/hashline/execute.ts` splits the raw `input` into `¶PATH#HASH` / `¶PATH` sections with `splitHashlineInputs()`.
3. If multiple sections target the same path, `mergeSamePathSections()` concatenates them before execution so every op still refers to the original file snapshot.
4. Multi-section calls run a preflight pass (`preflightHashlineSection()`): parse ops, enforce plan-mode write rules, load the current file, reject anchor-scoped edits against missing files, reject auto-generated files, apply edits in memory, and fail if the result is a no-op. This prevents partial batches.
5. `parseHashlineWithWarnings()` in `packages/coding-agent/src/hashline/parser.ts` tokenizes the diff body:
- ignores blank lines and optional `*** Begin Patch`
5. `parseHashline()` in `packages/coding-agent/src/hashline/executor.ts` tokenizes the diff body:
- ignores raw blank lines and optional `*** Begin Patch`
- stops at `*** End Patch`
- stops at `*** Abort` and emits `ABORT_WARNING`
- turns `↓` / `↑` payload runs (inline plus subsequent lines) into one `insert` edit per payload line
- turns `↓` / `↑` payload runs (inline plus `+`-prefixed subsequent lines) into one `insert` edit per payload line
- turns `A-B:` with payload into inserts before `A`, then deletes for `A-B`
- turns `A-B!` into one `delete` edit per line in the range; payload is forbidden
6. `executeHashlineSingle()` computes the current file hash before applying anchored edits. If it differs from the section `#HASH`, recovery tries the read/search snapshot cache before any write.
@@ -103,7 +103,7 @@ Warnings:
- `patch` — structured JSON diff-hunk mode (`packages/coding-agent/src/edit/modes/patch.ts`).
- `apply_patch` — freeform Codex-style `*** Begin Patch` envelope, internally expanded into patch-mode entries (`packages/coding-agent/src/edit/modes/apply-patch.ts`).
Hashline op examples (single-line payloads are inline; multi-line payloads continue on subsequent lines):
Hashline op examples (single-line payloads are inline; multi-line payloads continue on `+`-prefixed subsequent lines):
```text
¶src/a.ts#1a2b
@@ -123,7 +123,7 @@ Hashline op examples (single-line payloads are inline; multi-line payloads conti
```text
¶src/a.ts#1a2b
4-5:const clean = (name || DEF).trim();
return clean.length === 0 ? DEF : clean.toUpperCase();
+return clean.length === 0 ? DEF : clean.toUpperCase();
```
```text
@@ -210,6 +210,8 @@ Multi-file example:
- `line N: range A-B ends before it starts.`
- Payload forbidden for `!`:
- `line N: ! deletes only. Payload is forbidden after !; use : to replace.`
- Missing `+` on a continuation line:
- `line N: payload continuation lines must start with +.`
- Stray payload line:
- `line N: payload line has no preceding ↑, ↓, :, or ! operation.`
- Unknown op:
+9
View File
@@ -1,13 +1,22 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Changed the hashline patch format so payload continuation lines now require a leading `+`, rejecting unprefixed multiline payload rows that were previously accepted as fallback payload text
### Changed
- Changed hashline payload parsing so blank lines are only preserved when prefixed with `+`, so blank separator lines between operations are ignored unless explicitly marked
- Changed payload escaping so a line beginning with `+` is now represented as `++...` while the leading marker is stripped before writing
- Changed the default `task.simple` mode from `default` to `schema-free`, so task-call `schema` inputs are disabled by default while shared `context` and user prompt/session-defined output schemas remain available
- Changed `tools.approvalMode: yolo` to auto-approve tool calls even when a tool marks `override: true`; user `tools.approval.<tool>` policies (`allow`/`prompt`/`deny`) now remain the only controls for yolo mode.
- Changed the hashline edit executor to coalesce two consecutive `A-B:` ops on the identical range last-wins (the model painted a before/after pair) and append a warning, instead of throwing `anchor line X is already targeted by the :/! op on line Y`. Other overlap shapes (different ranges, `A-B:`+`!`, `!`+`!`) still throw.
### Fixed
- Fixed nested replace parsing so line-anchored `N:` rows inside a pending `A-B:` replacement now trigger overlap errors instead of being silently folded into the replacement payload
## [15.5.1] - 2026-05-26
### Breaking Changes
+3 -4
View File
@@ -388,6 +388,9 @@ function buildHashlineNaturalOrderPreviews(
case "abort":
case "op-delete":
continue;
case "blank":
case "raw":
continue;
case "header":
currentPath = token.path;
if (currentPath) ensure(currentPath);
@@ -404,10 +407,6 @@ function buildHashlineNaturalOrderPreviews(
if (!currentPath || token.inlineBody === undefined) continue;
ensure(currentPath).push(`+${token.inlineBody}`);
continue;
case "blank":
if (!currentPath) continue;
ensure(currentPath).push("+");
continue;
case "payload":
if (!currentPath) continue;
ensure(currentPath).push(`+${token.text}`);
@@ -28,14 +28,3 @@ export const ABORT_WARNING =
*/
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.";
/**
* Warning text appended when a single-line replace op like `83: content`
* arrives while a multi-line replace `A-B:` is still pending and `83` is
* inside `A-B`. The model used the read-output `LINE:TEXT` format as if it
* were a payload-continuation line; we strip the `LINE:` prefix and treat
* `content` as the next payload line, but warn so the model learns the
* cleaner format on its own.
*/
export const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING =
"Detected one or more `LINE:TEXT` lines whose anchors fell inside the pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside a multi-line `A-B:` block, payload lines after the first do not need a line-number prefix.";
+47 -40
View File
@@ -1,5 +1,12 @@
import { ABORT_WARNING, PAYLOAD_LINE_PREFIX_DEMOTED_WARNING, REPLACE_PAIR_COALESCED_WARNING } from "./constants";
import { HL_OP_CHARS, HL_OP_DELETE, HL_OP_INSERT_AFTER, HL_OP_INSERT_BEFORE, HL_OP_REPLACE } from "./hash";
import { ABORT_WARNING, REPLACE_PAIR_COALESCED_WARNING } from "./constants";
import {
HL_OP_CHARS,
HL_OP_DELETE,
HL_OP_INSERT_AFTER,
HL_OP_INSERT_BEFORE,
HL_OP_REPLACE,
HL_PAYLOAD_PREFIX,
} from "./hash";
import {
cloneCursor,
type HashlineToken,
@@ -19,10 +26,6 @@ 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 expandRange(range: ParsedRange): Anchor[] {
const anchors: Anchor[] = [];
for (let line = range.start.line; line <= range.end.line; line++) {
@@ -91,11 +94,13 @@ export class HashlineExecutor {
this.#flushPending();
return;
case "blank":
if (this.#pending) this.#pending.payload.push("");
return;
case "payload":
this.#handlePayload(token.text, token.lineNum);
return;
case "raw":
this.#handleRaw(token.text, token.lineNum);
return;
case "op-delete":
this.#flushPending();
if (token.trailingPayload) {
@@ -112,51 +117,38 @@ export class HashlineExecutor {
this.#flushPending();
this.#pending = {
op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
payload: [token.inlineBody ?? ""],
payload: token.inlineBody === undefined ? [] : [token.inlineBody],
};
return;
case "op-replace":
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, replace+delete,
// delete+delete) 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;
if (
this.#pending !== undefined &&
this.#pending.op.kind === "replace" &&
rangesEqual(this.#pending.op.range, token.range)
) {
// Identical-range before/after pair. Drop the "before" payload
// silently; the second op proceeds as the lone winner. Other
// overlap shapes (different ranges, replace+delete, delete+delete)
// still hit the post-hoc validator.
this.#pending = undefined;
if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
}
}
this.#flushPending();
this.#pending = {
op: { kind: "replace", range: token.range, lineNum: token.lineNum },
payload: [token.inlineBody ?? ""],
payload: token.inlineBody === undefined ? [] : [token.inlineBody],
};
return;
}
}
/**
* Flush any open pending op (with its full accumulated payload, blanks
* included) and return the accumulated edits and warnings. The executor
* is single-use; reset() is required for reuse.
* 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; reset() is required for reuse.
* Throws if two replace/delete ops target the same line with non-identical
* shapes (different ranges, replace+delete, delete+delete). Identical-range
* `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
@@ -214,10 +206,25 @@ export class HashlineExecutor {
return;
}
// Whitespace-only payload outside any pending op is silently dropped;
throw new Error(
`line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` +
`Got ${JSON.stringify(`${HL_PAYLOAD_PREFIX}${text}`)}.`,
);
}
#handleRaw(text: string, lineNum: number): void {
if (this.#pending) {
if (text.trim().length === 0) return;
throw new Error(
`line ${lineNum}: payload continuation lines must start with ${HL_PAYLOAD_PREFIX}. ` +
`Got ${JSON.stringify(text)}.`,
);
}
// Whitespace-only raw lines outside any pending op are silently dropped;
// fully empty lines arrive as `blank` tokens.
if (text.trim().length === 0) return;
// Orphan payload outside any pending op: pick the most specific
// Orphan raw text outside any pending op: pick the most specific
// diagnostic so the model sees the actionable hint.
if (isDeleteOpWithPayload(text)) {
throw new Error(
@@ -245,7 +252,7 @@ export class HashlineExecutor {
if (!pending) return;
const { op, payload } = pending;
const linesToInsert = payload;
const linesToInsert = payload.length === 0 ? [""] : payload;
if (op.kind === "insert") {
for (const text of linesToInsert) {
@@ -14,7 +14,7 @@ insert_after: anchor "$HOP_INSERT_AFTER$" inline_body? LF payload*
replace: range "$HOP_REPLACE$" inline_body? LF payload*
delete: range "$HOP_DELETE$" LF
inline_body: /[^\n]+/
payload: /(.*)/ LF
payload: "+" /[^\n]*/ LF
anchor: LID | "EOF" | "BOF"
range: LID ("-" LID)?
+9 -6
View File
@@ -7,12 +7,12 @@ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g,
/**
* Decoration prefix that may precede a line number in tool output:
* `>` (context line in grep), `+` (added line in diff), `-` (removed line),
* `*` (match line). Any combination, in any order, surrounded by optional
* `>` (context line in grep), `-` (removed line), `*` (match line).
* Any combination, in any order, surrounded by optional
* whitespace. Output formatters emit at most one decoration per line; the
* parser stays liberal because it accepts whatever the model echoes back.
*/
export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>+\\-*]*\\s*`;
export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>\\-*]*\\s*`;
/** Capture-group regex source for a decorated bare line-number anchor. */
export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`;
@@ -74,9 +74,9 @@ export function resolveHashlineGrammarPlaceholders(grammar: string): string {
/**
* op lines have an `ANCHOR<SIGIL>[INLINE_PAYLOAD]` shape, where SIGIL is one of
* {@link HL_OP_INSERT_BEFORE}, {@link HL_OP_INSERT_AFTER}, {@link HL_OP_REPLACE},
* or {@link HL_OP_DELETE}.
* Multi-line payloads follow on subsequent lines as verbatim file content with no
* per-line marker.
* or {@link HL_OP_DELETE}. Multi-line payloads follow on subsequent lines
* prefixed with {@link HL_PAYLOAD_PREFIX}; that prefix is stripped before the
* payload is written.
*
* These constants are the single source of truth for the edit parser, grammar,
* renderer, and prompt.
@@ -86,6 +86,9 @@ export const HL_OP_INSERT_AFTER = "↓";
export const HL_OP_REPLACE = ":";
export const HL_OP_DELETE = "!";
/** Prefix for payload continuation lines. The prefix itself is not written. */
export const HL_PAYLOAD_PREFIX = "+";
/** 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}${HL_OP_DELETE}`;
@@ -88,7 +88,6 @@ function replaySessionChainOnCurrent(
};
}
function buildSparseOverlayText(currentText: string, snapshotLines: ReadonlyMap<number, string>): string {
const overlaid = currentText.split("\n");
let maxCachedLine = 0;
@@ -7,6 +7,7 @@ import {
HL_OP_INSERT_AFTER,
HL_OP_INSERT_BEFORE,
HL_OP_REPLACE,
HL_PAYLOAD_PREFIX,
} from "./hash";
import type { Anchor, HashlineCursor } from "./types";
@@ -20,6 +21,7 @@ const CHAR_SPACE = 32;
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 FILE_HASH_LENGTH = 4;
function isDigitCode(code: number): boolean {
@@ -31,7 +33,7 @@ function isNonZeroDigitCode(code: number): boolean {
}
function isDecorationCode(code: number): boolean {
return code === 42 || code === 43 || code === 45 || code === 62;
return code === 42 || code === 45 || code === 62;
}
function isHexDigitCode(code: number): boolean {
@@ -91,7 +93,7 @@ export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
}
// Leniently accept anchors copied from read/search output:
// - optional leading line-marker decoration (`*`, `>`, `+`, `-`)
// - optional leading line-marker decoration (`*`, `>`, `-`)
// - the required bare line number
function skipDecoratedAnchorPrefix(line: string, end = trimEndIndex(line)): number {
let index = skipWhitespace(line, 0, end);
@@ -339,7 +341,8 @@ export type HashlineToken =
| (TokenBase & { kind: "op-insert"; cursor: HashlineCursor; inlineBody: string | undefined })
| (TokenBase & { kind: "op-replace"; range: ParsedRange; inlineBody: string | undefined })
| (TokenBase & { kind: "op-delete"; range: ParsedRange; trailingPayload: boolean })
| (TokenBase & { kind: "payload"; text: string });
| (TokenBase & { kind: "payload"; text: string })
| (TokenBase & { kind: "raw"; text: string });
function classifyLine(line: string, lineNum: number): HashlineToken {
if (isEmptyLine(line)) return { kind: "blank", lineNum };
@@ -356,6 +359,9 @@ function classifyLine(line: string, lineNum: number): HashlineToken {
}
}
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") {
@@ -367,7 +373,7 @@ function classifyLine(line: string, lineNum: number): HashlineToken {
return { kind: "op-delete", lineNum, range: op.range, trailingPayload: op.trailingPayload };
}
return { kind: "payload", lineNum, text: line };
return { kind: "raw", lineNum, text: line };
}
/**
@@ -3,9 +3,9 @@ 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 prefix payload with diff syntax.
- NEVER restate unchanged lines "for context".
- Payload indentation is literal.
- Inline payload after an op is literal. Additional payload lines MUST start with `+`; that delimiter is stripped.
- Payload indentation after the op sigil or after `+` is literal.
</payload>
<ops>
@@ -13,10 +13,12 @@ LINE↑PAYLOAD insert before (or BOF↑)
LINE↓PAYLOAD insert after (or EOF↓)
A-B:PAYLOAD replace A..B (or A: == A..A)
A-B! delete A..B (or A! == A..A)
+PAYLOAD continuation payload line; leading `+` is not written
</ops>
<rules>
- **Payload is only what's NEW.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat anchor lines or neighbors.
- **Continuation lines require `+`.** Use `+` for a blank payload line; use `++text` to write a line starting with `+text`.
- **Go small.** Add → `↑`/`↓`; replace → `:`; delete → `!`.
- **Line numbers are frozen references to what you have seen.** Later ops still use original line numbers.
</rules>
@@ -33,11 +35,12 @@ A-B! delete A..B (or A! == A..A)
2:export function f() { return X; }
```
# replace, insert after, delete
# replace with a continuation line, insert after, delete
```
¶a.ts#1a2b
1:const X = "b";
1↓const Y = "c";
+export const Y = X;
1↓const Z = Y;
2!
```
</example>
@@ -48,12 +51,13 @@ A-B! delete A..B (or A! == A..A)
# WRONG — echoing read-style lines as context before the real op
1:const X = "a";
1-2:const X = "b";
export const Y = X;
export const Y = X; # raw continuation line missing required `+`
</anti-pattern>
<critical>
- One op per range, ever.
- Pick op precisely. Update: `:`, add: `↑`/`↓`, remove: `!`.
- Payload is only what's NEW; never repeat anchor lines or neighbors.
- Continuation payload lines after the op line must start with `+`.
- Anchor exactly; don't anchor neighbors.
</critical>
+103 -100
View File
@@ -27,6 +27,7 @@ beforeAll(async () => {
});
const pl = (text: string): string => text;
const extra = (text: string): string => `+${text}`;
const outputSep = ":";
const outputSepRe = ":";
@@ -111,14 +112,18 @@ describe("hashline parser — suffix-op syntax", () => {
const single = `${tag(2, "bbb")}:BBB`;
expect(applyDiff(content, single)).toBe("aaa\nBBB\nccc");
const range = [`${tag(2, "bbb")}-${tag(3, "ccc")}:BBB`, pl("CCC")].join("\n");
const range = [`${tag(2, "bbb")}-${tag(3, "ccc")}:BBB`, extra("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}:\nBBB`).edits).toEqual(parseHashline(`${anchor}-${anchor}:\nBBB`).edits);
expect(applyDiff(content, `${anchor}:\nBBB`)).toBe(applyDiff(content, `${anchor}-${anchor}:\nBBB`));
expect(parseHashline(`${anchor}:BBB\n${extra("CCC")}`).edits).toEqual(
parseHashline(`${anchor}-${anchor}:BBB\n${extra("CCC")}`).edits,
);
expect(applyDiff(content, `${anchor}:BBB\n${extra("CCC")}`)).toBe(
applyDiff(content, `${anchor}-${anchor}:BBB\n${extra("CCC")}`),
);
});
it("accepts an inline payload on the op line as the first/only payload line", () => {
@@ -130,16 +135,23 @@ describe("hashline parser — suffix-op syntax", () => {
it("combines an inline payload with subsequent payload lines on insert ops", () => {
const anchor = tag(2, "bbb");
const diff = [`${anchor}↓first inline`, pl("second from next line")].join("\n");
const diff = [`${anchor}↓first inline`, extra("second from next line")].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nfirst inline\nsecond from next line\nccc");
});
it("combines an inline payload with subsequent payload lines on the replace op", () => {
const anchor = tag(2, "bbb");
const diff = [`${anchor}:FIRST`, pl("SECOND")].join("\n");
const diff = [`${anchor}:FIRST`, extra("SECOND")].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nFIRST\nSECOND\nccc");
});
it("rejects unprefixed payload continuation lines", () => {
const anchor = tag(2, "bbb");
expect(() => parseHashline(`${anchor}:FIRST\nSECOND`).edits).toThrow(
/payload continuation lines must start with \+/,
);
});
it("preserves whitespace-bearing inline payload exactly", () => {
const anchor = tag(2, "bbb");
const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;";
@@ -149,28 +161,28 @@ describe("hashline parser — suffix-op syntax", () => {
it("auto-absorbs duplicated multiline prefix boundaries during replacement", () => {
const source = ["// one", "// two", "old();"].join("\n");
const diff = [`${sameLineRange(tag(3, "old();"))}:// one`, pl("// two"), pl("new();")].join("\n");
const diff = [`${sameLineRange(tag(3, "old();"))}:// one`, extra("// two"), extra("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();"))}:new();`, pl("// one"), pl("// two")].join("\n");
const diff = [`${sameLineRange(tag(1, "old();"))}:new();`, extra("// one"), extra("// 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();"))}:new();`, pl("};")].join("\n");
const diff = [`${sameLineRange(tag(1, "old();"))}:new();`, extra("};")].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();"))}:};`, pl("new();")].join("\n");
const diff = [`${sameLineRange(tag(2, "old();"))}:};`, extra("new();")].join("\n");
expect(applyDiff(source, diff)).toBe(["};", "new();"].join("\n"));
});
@@ -180,14 +192,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();"))}:if ok {`, pl("}")].join("\n");
const diff = [`${sameLineRange(tag(1, "old();"))}:if ok {`, extra("}")].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();"))}:keep`, pl("new();")].join("\n");
const diff = [`${sameLineRange(tag(2, "old();"))}:keep`, extra("new();")].join("\n");
expect(applyDiff(source, diff)).toBe(["keep", "keep", "new();"].join("\n"));
});
@@ -197,14 +209,14 @@ describe("hashline parser — suffix-op syntax", () => {
// is also the anchor of a separate insert. Absorbing it would silently
// steal that anchor and turn the insert into a replacement.
const source = ["A", "B", "X", "Y", "Z"].join("\n");
const diff = [`${tag(1, "A")}-${tag(2, "B")}:alpha`, pl("X"), pl("Y"), `${tag(4, "Y")}↑extra`].join("\n");
const diff = [`${tag(1, "A")}-${tag(2, "B")}:alpha`, extra("X"), extra("Y"), `${tag(4, "Y")}↑extra`].join("\n");
expect(applyDiff(source, diff)).toBe(["alpha", "X", "Y", "X", "extra", "Y", "Z"].join("\n"));
});
it("surfaces a warning when boundary duplicates are auto-absorbed", () => {
const source = ["// one", "// two", "old();"].join("\n");
const diff = [`${sameLineRange(tag(3, "old();"))}:// one`, pl("// two"), pl("new();")].join("\n");
const diff = [`${sameLineRange(tag(3, "old();"))}:// one`, extra("// two"), extra("new();")].join("\n");
const result = applyHashlineEdits(source, parseHashline(diff).edits);
expect(result.lines).toBe(["// one", "// two", "new();"].join("\n"));
@@ -257,20 +269,20 @@ 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")}↓aaa`, pl("bbb"), pl("NEW")].join("\n");
const diff = [`${tag(2, "bbb")}↓aaa`, extra("bbb"), extra("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, " }")}↑ added();`, pl(" }")].join("\n");
const diff = [`${tag(3, " }")}↑ added();`, extra(" }")].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, " });")}↓ });`, pl("added();")].join("\n");
const diff = [`${tag(1, " });")}↓ });`, extra("added();")].join("\n");
const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true });
expect(result.lines).toBe([" });", " });", "added();", "next();"].join("\n"));
@@ -279,21 +291,21 @@ describe("hashline parser — suffix-op syntax", () => {
it("preserves an intentional non-structural anchor duplicate for `ANCHOR↓` by default", () => {
const source = ["aaa", "bbb", "ccc"].join("\n");
const diff = [`${tag(2, "bbb")}↓bbb`, pl("NEW")].join("\n");
const diff = [`${tag(2, "bbb")}↓bbb`, extra("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", () => {
const source = ["aaa", "bbb", "ccc"].join("\n");
const diff = [`${tag(2, "bbb")}↑NEW`, pl("bbb")].join("\n");
const diff = [`${tag(2, "bbb")}↑NEW`, extra("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, "}")}↑if inner {`, pl("}")].join("\n");
const diff = [`${tag(2, "}")}↑if inner {`, extra("}")].join("\n");
expect(applyDiff(source, diff)).toBe(["if outer {", "if inner {", "}", "}"].join("\n"));
});
@@ -302,7 +314,7 @@ describe("hashline parser — suffix-op syntax", () => {
// 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")}↓aaa`, pl("bbb"), pl("NEW")].join("\n");
const diff = [`${tag(2, "bbb")}↓aaa`, extra("bbb"), extra("NEW")].join("\n");
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc");
});
@@ -310,7 +322,7 @@ describe("hashline parser — suffix-op syntax", () => {
// 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")}↓aaa`, pl("bbb"), pl("NEW"), pl("ccc"), pl("ddd")].join("\n");
const diff = [`${tag(2, "bbb")}↓aaa`, extra("bbb"), extra("NEW"), extra("ccc"), extra("ddd")].join("\n");
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd");
});
@@ -318,34 +330,34 @@ describe("hashline parser — suffix-op syntax", () => {
// 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")}↑NEW`, pl("ccc"), pl("ddd")].join("\n");
const diff = [`${tag(3, "ccc")}↑NEW`, extra("ccc"), extra("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↓bbb", pl("ccc"), pl("NEW")].join("\n");
const diff = ["EOF↓bbb", extra("ccc"), extra("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↑NEW", pl("aaa"), pl("bbb")].join("\n");
const diff = ["BOF↑NEW", extra("aaa"), extra("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")}↓bbb`, pl("NEW")].join("\n");
const diff = [`${tag(2, "bbb")}↓bbb`, extra("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")}↓aaa`, pl("bbb"), pl("NEW")].join("\n");
const diff = [`${tag(2, "bbb")}↓aaa`, extra("bbb"), extra("NEW")].join("\n");
const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true });
expect(result.lines).toBe("aaa\nbbb\nNEW\nccc");
expect(result.warnings).toBeDefined();
@@ -355,28 +367,28 @@ describe("hashline parser — suffix-op syntax", () => {
});
it("preserves payload text exactly", () => {
const diff = [`${sameLineRange(tag(2, "bbb"))}:`, pl("# not a header"), pl("+ not an op"), pl(" spaced")].join(
"\n",
);
const diff = [
`${sameLineRange(tag(2, "bbb"))}:`,
extra(""),
extra("# not a header"),
extra("+ not an op"),
extra(" spaced"),
].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\n\n# not a header\n+ not an op\n spaced\nccc");
});
it("treats blank lines inside a payload run as empty payload lines", () => {
// Truly blank lines inside an active payload run are verbatim empty
// payload lines as long as more payload follows.
const diff = [`${sameLineRange(tag(2, "bbb"))}:first`, "", "", pl("after")].join("\n");
it("treats plus-only payload lines as empty payload lines", () => {
const diff = [`${sameLineRange(tag(2, "bbb"))}:first`, extra(""), extra(""), extra("after")].join("\n");
expect(applyDiff(content, diff)).toBe("aaa\nfirst\n\n\nafter\nccc");
});
it("appends blank lines between ops to the previous payload", () => {
// Blank lines while a payload run is open are payload, never silent
// separators — every blank in `1:AAA\n\n\n3:CCC` ends up on disk.
it("ignores raw blank separators between ops", () => {
const diff = [`${sameLineRange(tag(1, "aaa"))}:AAA`, "", "", `${sameLineRange(tag(3, "ccc"))}:CCC`].join("\n");
expect(applyDiff(content, diff)).toBe("AAA\n\n\nbbb\nCCC");
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 (same as `LINE↑\n\n`).
// `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 },
@@ -387,7 +399,7 @@ describe("hashline parser — suffix-op syntax", () => {
});
it("rejects orphan payload lines with no preceding op", () => {
expect(() => parseHashline(pl("orphan")).edits).toThrow(/payload line has no preceding/);
expect(() => parseHashline(extra("orphan")).edits).toThrow(/payload line has no preceding/);
});
it("rejects op sigils written in prefix position", () => {
@@ -399,7 +411,7 @@ describe("hashline parser — suffix-op syntax", () => {
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")}:\nBBB`).edits).toThrow(
expect(() => parseHashline(`${tag(2, "bbb")}..${tag(3, "ccc")}:\n${extra("BBB")}`).edits).toThrow(
/payload line has no preceding/,
);
});
@@ -430,7 +442,7 @@ describe("hashline parser — suffix-op syntax", () => {
it("treats `LINE:TEXT` as replace syntax even when TEXT contains ↑ / ↓", () => {
const anchor = tag(2, "bbb");
expect(applyDiff(content, `${anchor}:bbb↓`)).toBe("aaa\nbbb↓\nccc");
expect(applyDiff(content, `${anchor}:bbb↑\nX`)).toBe("aaa\nbbb↑\nX\nccc");
expect(applyDiff(content, `${anchor}:bbb↑\n${extra("X")}`)).toBe("aaa\nbbb↑\nX\nccc");
});
it("uses inline payload for BOF/EOF inserts", () => {
@@ -451,7 +463,7 @@ describe("hashline parser — suffix-op syntax", () => {
});
it("coalesces two replace ops covering the same range (before/after-block pattern, last wins)", () => {
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:OLD\nOLD2\n${tag(2, "bbb")}-${tag(3, "ccc")}:NEW\nNEW2`;
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:OLD\n${extra("OLD2")}\n${tag(2, "bbb")}-${tag(3, "ccc")}:NEW\n${extra("NEW2")}`;
const { edits, warnings } = parseHashline(diff);
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd", edits).lines).toBe("aaa\nNEW\nNEW2\nddd");
expect(warnings).toEqual([
@@ -467,29 +479,21 @@ describe("hashline parser — suffix-op syntax", () => {
expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by the .+ op on line 1/);
});
it("demotes a single-line `N:` op inside a pending `A-B:` to a payload line", () => {
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:line one\n${tag(3, "ccc")}:line two\n${tag(4, "ddd")}:line three`;
it("uses `+` payload lines inside a multi-line replacement", () => {
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:line one\n${extra("line two")}\n${extra("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",
);
expect(warnings).toEqual([
"Detected one or more `LINE:TEXT` lines whose anchors fell inside the pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside a multi-line `A-B:` block, payload lines after the first do not need a line-number prefix.",
]);
expect(warnings).toEqual([]);
});
it("demotes a sub-range `A-B:` inside a pending outer `A-B:` to a payload line", () => {
const diff = `${tag(2, "bbb")}-${tag(5, "eee")}:line one\n${tag(3, "ccc")}-${tag(4, "ddd")}:collapsed pair`;
const { edits, warnings } = parseHashline(diff);
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee\nfff", edits).lines).toBe(
"aaa\nline one\ncollapsed pair\nfff",
);
expect(warnings).toEqual([
"Detected one or more `LINE:TEXT` lines whose anchors fell inside the pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside a multi-line `A-B:` block, payload lines after the first do not need a line-number prefix.",
]);
it("rejects read-output `N:` lines inside a pending `A-B:` as overlapping ops", () => {
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:line one\n${tag(3, "ccc")}:line two`;
expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by the .+ op on line 1/);
});
it("treats `N:` outside the pending range as a separate op (no demote)", () => {
it("treats `N:` outside the pending range as a separate op", () => {
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:line one\n${tag(5, "eee")}:line five`;
const { edits, warnings } = parseHashline(diff);
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee\nfff", edits).lines).toBe(
@@ -527,7 +531,7 @@ describe("hashline parser — suffix-op syntax", () => {
describe("hashline — file hash binding", () => {
it("rejects line-hash anchors as unrecognized payload lines", () => {
expect(() => parseHashline("2ab:\nBBB").edits).toThrow(/payload line has no preceding/);
expect(() => parseHashline(`2ab:\n${extra("BBB")}`).edits).toThrow(/payload line has no preceding/);
});
it("applies line-number edits without per-anchor hash validation", () => {
@@ -538,59 +542,59 @@ describe("hashline — file hash binding", () => {
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"))}:`, pl("BBB")].join("\n");
const input = [`¶src/foo.ts#1a2b`, `${sameLineRange(tag(2, "bbb"))}:`, extra("BBB")].join("\n");
expect(splitHashlineInput(input)).toEqual({
path: "src/foo.ts",
fileHash: "1a2b",
diff: `${sameLineRange(tag(2, "bbb"))}:\n${pl("BBB")}`,
diff: `${sameLineRange(tag(2, "bbb"))}:\n${extra("BBB")}`,
});
});
it("strips leading blank lines", () => {
expect(splitHashlineInput(`\n¶foo.ts\nBOF↓\n${pl("x")}`)).toEqual({
expect(splitHashlineInput(`\n¶foo.ts\nBOF↓\n${extra("x")}`)).toEqual({
path: "foo.ts",
diff: `BOF↓\n${pl("x")}`,
diff: `BOF↓\n${extra("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${pl("x")}`, { cwd }).path).toBe("src/foo.ts");
expect(splitHashlineInput(`${absolute}\nBOF↓\n${extra("x")}`, { cwd }).path).toBe("src/foo.ts");
});
it("uses explicit fallback path only when input has recognizable operations", () => {
expect(splitHashlineInput(`BOF↓\n${pl("x")}`, { path: "a.ts" })).toEqual({
expect(splitHashlineInput(`BOF↓\n${extra("x")}`, { path: "a.ts" })).toEqual({
path: "a.ts",
diff: `BOF↓\n${pl("x")}`,
diff: `BOF↓\n${extra("x")}`,
});
expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/);
});
it("splits multiple edit sections", () => {
const input = ["¶a.ts", "BOF↓", pl("a"), "¶b.ts", "EOF↓", pl("b")].join("\n");
const input = ["¶a.ts", "BOF↓", extra("a"), "¶b.ts", "EOF↓", extra("b")].join("\n");
expect(splitHashlineInputs(input)).toEqual([
{ path: "a.ts", diff: `BOF↓\n${pl("a")}` },
{ path: "b.ts", diff: `EOF↓\n${pl("b")}` },
{ path: "a.ts", diff: `BOF↓\n${extra("a")}` },
{ path: "b.ts", diff: `EOF↓\n${extra("b")}` },
]);
});
it("tolerates extra ¶ chars on the section header", () => {
const input = ["¶¶a.ts", "BOF↓", pl("a"), "¶¶¶b.ts", "EOF↓", pl("b")].join("\n");
const input = ["¶¶a.ts", "BOF↓", extra("a"), "¶¶¶b.ts", "EOF↓", extra("b")].join("\n");
expect(splitHashlineInputs(input)).toEqual([
{ path: "a.ts", diff: `BOF↓\n${pl("a")}` },
{ path: "b.ts", diff: `EOF↓\n${pl("b")}` },
{ path: "a.ts", diff: `BOF↓\n${extra("a")}` },
{ path: "b.ts", diff: `EOF↓\n${extra("b")}` },
]);
});
it("silently drops a duplicate header with no operations between them", () => {
const input = ["¶¶src/foo.ts", "¶¶src/foo.ts", `BOF↓`, pl("x")].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "src/foo.ts", diff: `BOF↓\n${pl("x")}` }]);
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")}` }]);
});
it("silently drops a trailing header with no operations", () => {
const input = ["¶¶a.ts", "BOF↓", pl("a"), "¶¶b.ts"].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF↓\n${pl("a")}` }]);
const input = ["¶¶a.ts", "BOF↓", extra("a"), "¶¶b.ts"].join("\n");
expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF↓\n${extra("a")}` }]);
});
});
@@ -608,7 +612,7 @@ describe("hashline executor", () => {
await withTempDir(async tempDir => {
const filePath = path.join(tempDir, "a.ts");
const source = ["aaa", "bbb", "ccc"].join("\n");
const input = `${header("a.ts", source)}\n${tag(2, "bbb")}↓${pl("aaa")}\n${pl("bbb")}\n${pl("NEW")}\n`;
const input = `${header("a.ts", source)}\n${tag(2, "bbb")}↓${pl("aaa")}\n${extra("bbb")}\n${extra("NEW")}\n`;
await Bun.write(filePath, source);
await executeHashlineSingle(hashlineExecuteOptions(tempDir, input));
@@ -658,14 +662,14 @@ describe("hashline executor", () => {
const input = [
header("a.ts", `${original}\n`),
`${sameLineRange(tag(2, "L2"))}:L2a`,
pl("L2b"),
pl("L2c"),
pl("L2d"),
pl("L2e"),
pl("L2f"),
pl("L2g"),
pl("L2h"),
pl("L2i"),
extra("L2b"),
extra("L2c"),
extra("L2d"),
extra("L2e"),
extra("L2f"),
extra("L2g"),
extra("L2h"),
extra("L2i"),
header("a.ts", `${original}\n`),
`${tag(8, "L8")}↓INSERTED`,
].join("\n");
@@ -702,7 +706,9 @@ describe("hashline executor", () => {
describe("hashlineEditParamsSchema — extra-field tolerance", () => {
it("accepts extra `path` field alongside `input`", () => {
expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF↓\n${pl("x")}` }).success).toBe(true);
expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF↓\n${extra("x")}` }).success).toBe(
true,
);
});
it("still requires `input`", () => {
@@ -995,11 +1001,11 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
const input = [
`¶a.ts`,
`${tag(1, "alpha")}↓`,
pl("a-payload"),
extra("a-payload"),
sentinel,
`¶b.ts`,
`${tag(1, "beta")}↓`,
pl("never-emitted"),
extra("never-emitted"),
].join("\n");
const sections = splitHashlineInputs(input);
expect(sections).toHaveLength(1);
@@ -1071,34 +1077,31 @@ describe("hashline apply — brace-delete soft warning", () => {
});
});
describe("hashline parser — blank line extends the open payload", () => {
it("blank line between ops is appended to the previous payload", () => {
describe("hashline parser — plus-prefixed blank payload lines", () => {
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 { diff } = splitHashlineInput(ops);
// `1:A` payload becomes [A, ""]; line 3 is then replaced with C.
expect(applyDiff(text, diff)).toBe("A\n\nb\nC\nd\ne\n");
expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n");
});
it("multiple blank lines between ops are all appended as payload", () => {
it("plus-only continuation lines are appended as empty payload lines", () => {
const text = "a\nb\nc\nd\ne\n";
const ops = `${header("a.ts", text)}\n1:A\n\n\n\n3:C\n`;
const ops = `${header("a.ts", text)}\n1:A\n${extra("")}\n${extra("")}\n3:C\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("A\n\n\n\nb\nC\nd\ne\n");
expect(applyDiff(text, diff)).toBe("A\n\n\nb\nC\nd\ne\n");
});
it("bare A: followed by a blank line replaces the line with two blanks", () => {
// `2:` seeds payload with `[""]`, the standalone blank line appends
// another `""`, then `4:D` flushes the run.
it("bare A: followed by two plus-only lines replaces the line with two blanks", () => {
const text = "a\nb\nc\nd\ne\n";
const ops = `${header("a.ts", text)}\n2:\n\n4:D\n`;
const ops = `${header("a.ts", text)}\n2:\n${extra("")}\n${extra("")}\n4:D\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n");
});
it("blank line inside payload between two content lines is preserved", () => {
it("plus-only line inside payload between two content lines is preserved", () => {
const text = "a\nb\nc\n";
const ops = `${header("a.ts", text)}\n2:first\n\nsecond\n`;
const ops = `${header("a.ts", text)}\n2:first\n${extra("")}\n${extra("second")}\n`;
const { diff } = splitHashlineInput(ops);
expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n");
});