From 4e32fd006064ec1a004e19281611ba70745e5eab Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 27 May 2026 00:37:03 +0200 Subject: [PATCH] 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. --- docs/tools/edit.md | 16 +- packages/coding-agent/CHANGELOG.md | 9 + packages/coding-agent/src/edit/streaming.ts | 7 +- .../coding-agent/src/hashline/constants.ts | 11 - .../coding-agent/src/hashline/executor.ts | 87 ++++---- .../coding-agent/src/hashline/grammar.lark | 2 +- packages/coding-agent/src/hashline/hash.ts | 15 +- .../coding-agent/src/hashline/recovery.ts | 1 - .../coding-agent/src/hashline/tokenizer.ts | 14 +- .../src/prompts/tools/hashline.md | 14 +- .../coding-agent/test/core/hashline.test.ts | 203 +++++++++--------- 11 files changed, 200 insertions(+), 179 deletions(-) diff --git a/docs/tools/edit.md b/docs/tools/edit.md index 87fe43b95..f9024e6d1 100644 --- a/docs/tools/edit.md +++ b/docs/tools/edit.md @@ -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: diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1cbad4a7e..13b004bfb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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.` 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 diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index a0ad805c0..9dde960bb 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -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}`); diff --git a/packages/coding-agent/src/hashline/constants.ts b/packages/coding-agent/src/hashline/constants.ts index 61284f8d8..b09ff7aa3 100644 --- a/packages/coding-agent/src/hashline/constants.ts +++ b/packages/coding-agent/src/hashline/constants.ts @@ -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."; diff --git a/packages/coding-agent/src/hashline/executor.ts b/packages/coding-agent/src/hashline/executor.ts index 509966ea9..8e17f1d03 100644 --- a/packages/coding-agent/src/hashline/executor.ts +++ b/packages/coding-agent/src/hashline/executor.ts @@ -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) { diff --git a/packages/coding-agent/src/hashline/grammar.lark b/packages/coding-agent/src/hashline/grammar.lark index 417230a99..d1de51f8a 100644 --- a/packages/coding-agent/src/hashline/grammar.lark +++ b/packages/coding-agent/src/hashline/grammar.lark @@ -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)? diff --git a/packages/coding-agent/src/hashline/hash.ts b/packages/coding-agent/src/hashline/hash.ts index c04531a3e..fa143c228 100644 --- a/packages/coding-agent/src/hashline/hash.ts +++ b/packages/coding-agent/src/hashline/hash.ts @@ -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[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}`; diff --git a/packages/coding-agent/src/hashline/recovery.ts b/packages/coding-agent/src/hashline/recovery.ts index 3c89e9afb..30bcfc125 100644 --- a/packages/coding-agent/src/hashline/recovery.ts +++ b/packages/coding-agent/src/hashline/recovery.ts @@ -88,7 +88,6 @@ function replaySessionChainOnCurrent( }; } - function buildSparseOverlayText(currentText: string, snapshotLines: ReadonlyMap): string { const overlaid = currentText.split("\n"); let maxCachedLine = 0; diff --git a/packages/coding-agent/src/hashline/tokenizer.ts b/packages/coding-agent/src/hashline/tokenizer.ts index 93d8d217e..cc47ff8a3 100644 --- a/packages/coding-agent/src/hashline/tokenizer.ts +++ b/packages/coding-agent/src/hashline/tokenizer.ts @@ -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 }; } /** diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 62a2dca19..82da10a59 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -3,9 +3,9 @@ Your patch language is a compact, line-anchored edit format. 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. @@ -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 - **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. @@ -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! ``` @@ -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 `+` - 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. diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index d54f5d289..059086f15 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -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"); });