From d6c51fe69aa922bf366679ef8a6111411f9f6815 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 15 Jun 2026 10:44:51 +0200 Subject: [PATCH] feat: hashline .= as seperator --- docs/tools/edit.md | 46 ++++++------- packages/ai/test/issue-2080-repro.test.ts | 22 +++---- packages/coding-agent/CHANGELOG.md | 1 + .../test/core/block-replace.test.ts | 4 +- .../test/core/hashline-loop-guard.test.ts | 6 +- packages/coding-agent/test/edit-diff.test.ts | 4 +- .../test/edit-streaming-preview.test.ts | 14 ++-- .../test/edit/seen-line-guard.test.ts | 8 +-- .../read-column-truncation-snapshot.test.ts | 2 +- .../test/tools/edit-renderer.test.ts | 4 +- .../test/write-hashline-header.test.ts | 2 +- packages/hashline/CHANGELOG.md | 4 ++ packages/hashline/README.md | 6 +- packages/hashline/src/apply.ts | 4 +- packages/hashline/src/block.ts | 4 +- packages/hashline/src/format.ts | 4 +- packages/hashline/src/grammar.lark | 2 +- packages/hashline/src/messages.ts | 19 +++--- packages/hashline/src/parser.ts | 24 +++---- packages/hashline/src/prompt.md | 28 ++++---- packages/hashline/src/tokenizer.ts | 11 +++- packages/hashline/src/types.ts | 6 +- packages/hashline/test/block.test.ts | 12 ++-- .../hashline/test/boundary-repair.test.ts | 46 ++++++------- packages/hashline/test/core-contracts.test.ts | 12 ++-- packages/hashline/test/format-v2.test.ts | 20 +++--- packages/hashline/test/landing-shift.test.ts | 2 +- packages/hashline/test/leniency.test.ts | 65 ++++++++++--------- packages/hashline/test/patcher.test.ts | 20 +++--- .../test/recovery-session-chain.test.ts | 4 +- 30 files changed, 211 insertions(+), 195 deletions(-) diff --git a/docs/tools/edit.md b/docs/tools/edit.md index cb3831bc8..87cd9f8a3 100644 --- a/docs/tools/edit.md +++ b/docs/tools/edit.md @@ -28,10 +28,10 @@ Patch language inside `input`: - **File header**: `[PATH#TAG]`. `TAG` is four uppercase-hex chars — a content-derived hash of the whole normalized file (`computeFileHash()`), recorded in the session snapshot store. - **Operations**: - - `SWAP N..M:` — replace original lines N..M with the body rows below. - - `SWAP.BLK N:` — replace the whole tree-sitter block beginning on line N (its header line through its closing line) with the body rows. The line span is resolved at apply time from the file's parse tree; point N at the line that opens the construct. The resolved span is exactly the node that begins on line N — a leading decorator, attribute, or doc-comment is a separate node and is not included; point N at the first decorator line (Python wraps `@dec` + `def` as one block) or fall back to `SWAP N..M:` to take a leading line-comment that parses as its own node (e.g. Rust `///`). On success the result echoes the matched span (`SWAP.BLK N → resolved lines A-B`). Errors (and steers to `SWAP N..M:`) when the language is unsupported, line N is blank or a closing delimiter, no node begins there, or the resolved block has a syntax error. - - `DEL N..M` — delete original lines N..M. No body. - - `DEL.BLK N` — delete the whole tree-sitter block beginning on line N (resolved like `SWAP.BLK N`, with the same decorator/comment caveat). No body. On success the result echoes the matched span (`DEL.BLK N → resolved lines A-B`). Same resolution failure modes and `DEL N..M` fallback. + - `SWAP N.=M:` — replace original lines N.=M with the body rows below. + - `SWAP.BLK N:` — replace the whole tree-sitter block beginning on line N (its header line through its closing line) with the body rows. The line span is resolved at apply time from the file's parse tree; point N at the line that opens the construct. The resolved span is exactly the node that begins on line N — a leading decorator, attribute, or doc-comment is a separate node and is not included; point N at the first decorator line (Python wraps `@dec` + `def` as one block) or fall back to `SWAP N.=M:` to take a leading line-comment that parses as its own node (e.g. Rust `///`). On success the result echoes the matched span (`SWAP.BLK N → resolved lines A-B`). Errors (and steers to `SWAP N.=M:`) when the language is unsupported, line N is blank or a closing delimiter, no node begins there, or the resolved block has a syntax error. + - `DEL N.=M` — delete original lines N.=M. No body. + - `DEL.BLK N` — delete the whole tree-sitter block beginning on line N (resolved like `SWAP.BLK N`, with the same decorator/comment caveat). No body. On success the result echoes the matched span (`DEL.BLK N → resolved lines A-B`). Same resolution failure modes and `DEL N.=M` fallback. - `INS.PRE N:` — insert body rows immediately before line N. - `INS.POST N:` — insert body rows immediately after line N. - `INS.BLK.POST N:` — insert body rows after the last line of the tree-sitter block beginning on line N. Point N at the line that opens the construct, never its closing delimiter / last visible line; if you can see the last line already, use plain `INS.POST M:`. Same resolution failure modes and `INS.POST M:` fallback. @@ -50,17 +50,17 @@ Anchors come from `read`/`search` output. `read` emits a `[PATH#TAG]` header fro The canonical grammar is strict, but the hand parser accepts a few non-dangerous variants: -- `SWAP N:` — accepted as `SWAP N..N:`. +- `SWAP N:` — accepted as `SWAP N.=N:`. - `DEL N` — accepted as single-line delete. - Missing trailing colon on `SWAP` or `INS` — accepted. -- `SWAP N-M:`, `SWAP N…M:`, and `SWAP N M:` — accepted as `SWAP N..M:`. +- `SWAP N-M:`, `SWAP N…M:`, `SWAP N M:`, and legacy `SWAP N.=M:` — accepted as `SWAP N.=M:`. - Bare body rows with no `+` prefix are auto-prepended with `+` and a `BARE_BODY_AUTO_PIPED_WARNING` is appended. - `*** Begin Patch` / `*** End Patch` envelopes are silently consumed. `*** Abort` terminates parsing silently — ops parsed before the marker still apply, no warning surfaced. - Some malformed bracketed headers are recovered after stripping apply-patch path noise such as `Update File:` / `Add File:` and extra `***`, but the recovered header still needs a valid four-hex tag for the patcher to apply it. - `*** Update File:` / `*** Add File:` / `*** Delete File:` / `*** Move to:` apply_patch sentinels inside the diff body throw an `apply_patch sentinel … is not valid in hashline` error. - `@@`-bracketed hunk headers are rejected with guidance to write a verb header. -- Bare `N` and bare `N M` / `N..M` headers are rejected with guidance to write `SWAP` or `DEL`. -- `DEL N..M:` and any body rows under `DEL` / `DEL.BLK` are rejected. +- Bare `N` and bare `N M` / `N.=M` headers are rejected with guidance to write `SWAP` or `DEL`. +- `DEL N.=M:` and any body rows under `DEL` / `DEL.BLK` are rejected. - Empty `SWAP` / `INS` / `SWAP.BLK` hunks are rejected. - `-` body rows are rejected with `MINUS_ROW_REJECTED`. - `SWAP.BLK N:` / `DEL.BLK N` / `INS.BLK.POST N:` require a wired tree-sitter resolver; `SWAP.BLK` and `INS.BLK.POST` additionally need at least one `+TEXT` body row, while `DEL.BLK` takes none. An unresolvable block (unsupported language, blank/closing-delimiter line, no node beginning on N, or a syntax error in the resolved block) is rejected on the apply/final-preview path; the streaming preview silently drops it instead. Exception: `INS.BLK.POST N:` anchored on a pure closing-delimiter line is lowered to plain `INS.POST N:` with a warning — line N is the end of a block, and inserting after that end is exactly what the plain form does. @@ -103,7 +103,7 @@ Replace line 1 with two lines: ```text [a.ts#0A3B] -SWAP 1..1: +SWAP 1.=1: +const X = "b"; +export const Y = X; ``` @@ -124,11 +124,11 @@ INS.PRE 5: +console.log(X + Y); ``` -Delete lines 4..5 entirely: +Delete lines 4.=5 entirely: ```text [a.ts#0A3B] -DEL 4..5 +DEL 4.=5 ``` Insert at start and end of file: @@ -144,7 +144,7 @@ INS.TAIL: Multi-file: ```text [src/a.ts#0A3B] -SWAP 4..4: +SWAP 4.=4: +const enabled = true; [src/b.ts#1F7C] DEL 20 @@ -154,7 +154,7 @@ DEL 20 - File snapshot tags are exactly four uppercase-hex chars — content-derived hashes (`computeFileHash()`) recorded in the per-session snapshot store. - The visible mismatch report shows 2 lines of context on each side (`MISMATCH_CONTEXT`) in `packages/hashline/src/messages.ts`. - Stale-anchor recovery uses `fuzzFactor: 0` in `packages/hashline/src/recovery.ts`. -- `HL_FILE_PREFIX` is `[`, `HL_FILE_SUFFIX` is `]`, `HL_PAYLOAD_REPLACE` is `+`, `HL_RANGE_SEP` is `..`, `HL_FILE_HASH_SEP` is `#`, and hunk keyword constants are `SWAP` / `DEL` / `INS` (`packages/hashline/src/format.ts`). +- `HL_FILE_PREFIX` is `[`, `HL_FILE_SUFFIX` is `]`, `HL_PAYLOAD_REPLACE` is `+`, `HL_RANGE_SEP` is `.=`, `HL_FILE_HASH_SEP` is `#`, and hunk keyword constants are `SWAP` / `DEL` / `INS` (`packages/hashline/src/format.ts`). ## Errors - Missing section header: @@ -162,29 +162,29 @@ DEL 20 - Missing tag for any section: - `Missing hashline snapshot tag for edit to ; use \`[#tag]\` from your latest read/search output. To create a new file, use the write tool.` - Stray payload line: - - `line N: payload line has no preceding hunk header. Use \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got "...".` + - `line N: payload line has no preceding hunk header. Use \`SWAP N.=M:\`, \`DEL N.=M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got "...".` - Minus row: - ``line N: `-` rows are not valid; hashline ranges already name the lines being changed. To insert a literal line starting with `-`, write `+-…`.`` - Empty body-bearing hunk: - - `line N: \`SWAP N..M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`DEL N..M\`.` + - `line N: \`SWAP N.=M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`DEL N.=M\`.` - `line N: \`INS\` needs at least one \`+TEXT\` body row.` - `line N: \`SWAP.BLK N:\` needs at least one \`+TEXT\` body row. To delete a block, use \`DEL.BLK N\`.` - Unresolvable block anchor (apply / final-preview path only): - - `line N: \`SWAP.BLK X:\` could not resolve a syntactic block beginning on line X. The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. Use \`SWAP X..M:\` with the block's explicit end line instead.` — followed by a blank line and numbered `*`-marked context rows around line X (same shape as the mismatch preview). + - `line N: \`SWAP.BLK X:\` could not resolve a syntactic block beginning on line X. The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. Use \`SWAP X.=M:\` with the block's explicit end line instead.` — followed by a blank line and numbered `*`-marked context rows around line X (same shape as the mismatch preview). - `line N: \`INS.BLK.POST X:\` could not resolve a syntactic block beginning on line X. The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. Use \`INS.POST M:\` with the block's explicit last line instead.` — same context preview. - Delete with body: - - `line N: \`DEL N..M\` does not take body rows. Remove the body, or use \`SWAP N..M:\`.` + - `line N: \`DEL N.=M\` does not take body rows. Remove the body, or use \`SWAP N.=M:\`.` - `line N: \`DEL.BLK N\` does not take body rows. Remove the body, or use \`SWAP.BLK N:\` to replace the block.` - Range out of order: - - `line N: range A..B ends before it starts.` + - `line N: range A.=B ends before it starts.` - Overlapping hunks on the same anchor: - `line N: anchor line X is already targeted by another hunk on line Y. Issue ONE hunk per range; payload is only the final desired content, never a before/after pair.` - apply_patch / unified-diff contamination: - - `line N: apply_patch sentinel "*** …" is not valid in hashline. File sections start with \`[path#HASH]\` (no \`Update File:\` / \`Add File:\` keyword). Use \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.` - - `line N: unified-diff hunk header (\`@@ -N,M +N,M @@\`) is not valid in hashline. Use \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.` - - `line N: \`@@\`-bracketed hunk header "@@ …" is not valid in hashline. Drop the \`@@ ... @@\` brackets and write a verb header such as \`SWAP N..M:\`.` - - `line N: hunk headers need a verb. Use \`SWAP N..N:\` to replace, or \`DEL N\` to delete.` - - `line N: bare range hunk header "N M" is not valid. Hunk headers need a verb: write \`SWAP ${bareRange[1]}..${bareRange[2]}:\` or \`DEL ${bareRange[1]}..${bareRange[2]}\`.` + - `line N: apply_patch sentinel "*** …" is not valid in hashline. File sections start with \`[path#HASH]\` (no \`Update File:\` / \`Add File:\` keyword). Use \`SWAP N.=M:\`, \`DEL N.=M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.` + - `line N: unified-diff hunk header (\`@@ -N,M +N,M @@\`) is not valid in hashline. Use \`SWAP N.=M:\`, \`DEL N.=M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.` + - `line N: \`@@\`-bracketed hunk header "@@ …" is not valid in hashline. Drop the \`@@ ... @@\` brackets and write a verb header such as \`SWAP N.=M:\`.` + - `line N: hunk headers need a verb. Use \`SWAP N.=N:\` to replace, or \`DEL N\` to delete.` + - `line N: bare range hunk header "N M" is not valid. Hunk headers need a verb: write \`SWAP ${bareRange[1]}.=${bareRange[2]}:\` or \`DEL ${bareRange[1]}.=${bareRange[2]}\`.` - Out-of-range anchor: - `Line N does not exist (file has M lines)` - Stale snapshot tag: the `Patcher` first attempts snapshot-based recovery. When recovery cannot prove a valid result it throws `MismatchError`, which distinguishes recognized-but-drifted hashes from never-recorded hashes. The error includes the current file hash plus context around each anchor. diff --git a/packages/ai/test/issue-2080-repro.test.ts b/packages/ai/test/issue-2080-repro.test.ts index 88dd783cf..0517a624f 100644 --- a/packages/ai/test/issue-2080-repro.test.ts +++ b/packages/ai/test/issue-2080-repro.test.ts @@ -80,7 +80,7 @@ describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => { const fetchMock = createMockFetch([ toolCallChunk(model, { name: "edit", - arguments: { input: "[foo.ts#A1B2]\nreplace 91..91:\n+ " }, + arguments: { input: "[foo.ts#A1B2]\nSWAP 91.=91:\n+ " }, }), toolCallChunk(model, { arguments: { input: 'const out = await executeTool("nuke", { path: "x" }, ctx);' }, @@ -100,7 +100,7 @@ describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => { id: "call-minimax-1", name: "edit", arguments: { - input: '[foo.ts#A1B2]\nreplace 91..91:\n+ const out = await executeTool("nuke", { path: "x" }, ctx);', + input: '[foo.ts#A1B2]\nSWAP 91.=91:\n+ const out = await executeTool("nuke", { path: "x" }, ctx);', }, }, ]); @@ -115,10 +115,10 @@ describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => { const fetchMock = createMockFetch([ toolCallChunk(model, { name: "edit", - arguments: { input: "[foo.ts#A1B2]\nreplace 91..91:" }, + arguments: { input: "[foo.ts#A1B2]\nSWAP 91.=91:" }, }), toolCallChunk(model, { - arguments: { input: "[foo.ts#A1B2]\nreplace 91..91:\n+new" }, + arguments: { input: "[foo.ts#A1B2]\nSWAP 91.=91:\n+new" }, }), stopChunk(model), "[DONE]", @@ -134,7 +134,7 @@ describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => { type: "toolCall", id: "call-minimax-1", name: "edit", - arguments: { input: "[foo.ts#A1B2]\nreplace 91..91:\n+new" }, + arguments: { input: "[foo.ts#A1B2]\nSWAP 91.=91:\n+new" }, }, ]); }); @@ -142,7 +142,7 @@ describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => { it("preserves keys that only appear in earlier chunks instead of dropping them with later chunks", async () => { const model = getBundledModel<"openai-completions">("minimax-code-cn", "MiniMax-M3"); const fetchMock = createMockFetch([ - toolCallChunk(model, { name: "edit", arguments: { input: "[foo.ts#A1B2]\ndelete 5" } }), + toolCallChunk(model, { name: "edit", arguments: { input: "[foo.ts#A1B2]\nDEL 5" } }), toolCallChunk(model, { arguments: { dryRun: true } }), stopChunk(model), "[DONE]", @@ -158,7 +158,7 @@ describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => { type: "toolCall", id: "call-minimax-1", name: "edit", - arguments: { input: "[foo.ts#A1B2]\ndelete 5", dryRun: true }, + arguments: { input: "[foo.ts#A1B2]\nDEL 5", dryRun: true }, }, ]); }); @@ -175,7 +175,7 @@ describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => { const fetchMock = createMockFetch([ toolCallChunk(model, { name: "edit", - arguments: { input: "[foo.ts#A1B2]\nreplace 91..91:\n+ " }, + arguments: { input: "[foo.ts#A1B2]\nSWAP 91.=91:\n+ " }, }), toolCallChunk(model, { arguments: { input: 'const out = await executeTool("nuke", { path: "x" }, ctx);' }, @@ -193,7 +193,7 @@ describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => { } const expected = { - input: '[foo.ts#A1B2]\nreplace 91..91:\n+ const out = await executeTool("nuke", { path: "x" }, ctx);', + input: '[foo.ts#A1B2]\nSWAP 91.=91:\n+ const out = await executeTool("nuke", { path: "x" }, ctx);', }; // Source-side merged result (what `block.arguments` is set to in `finishToolCallBlock`). expect(toolCallEndArgs).toEqual(expected); @@ -208,7 +208,7 @@ describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => { // the proxy still concatenates ("" then the final delta) and parses to the same args. const model = getBundledModel<"openai-completions">("minimax-code-cn", "MiniMax-M3"); const fetchMock = createMockFetch([ - toolCallChunk(model, { name: "edit", arguments: { input: "[foo.ts#A1B2]\ndelete 5" } }), + toolCallChunk(model, { name: "edit", arguments: { input: "[foo.ts#A1B2]\nDEL 5" } }), stopChunk(model), "[DONE]", ]); @@ -218,6 +218,6 @@ describe("issue #2080 - MiniMax multi-chunk object tool arguments", () => { for await (const event of s) { if (event.type === "toolcall_delta") accumulated += event.delta; } - expect(JSON.parse(accumulated)).toEqual({ input: "[foo.ts#A1B2]\ndelete 5" }); + expect(JSON.parse(accumulated)).toEqual({ input: "[foo.ts#A1B2]\nDEL 5" }); }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 66cfb4ed8..dec1effd6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - Capped unexpected-stop auto-continuation to three retry attempts before giving up on repeated stops +- Updated the `edit` tool's hashline prompt, grammar, and docs to recommend the `.=` inclusive range separator (`SWAP 1.=3:`); the legacy `..` form still parses. ## [15.13.2] - 2026-06-15 diff --git a/packages/coding-agent/test/core/block-replace.test.ts b/packages/coding-agent/test/core/block-replace.test.ts index 9379fa9dc..88e94491e 100644 --- a/packages/coding-agent/test/core/block-replace.test.ts +++ b/packages/coding-agent/test/core/block-replace.test.ts @@ -141,7 +141,7 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { }); }); - it("rejects a lone closing delimiter (no block begins there) and steers to `SWAP N..M:`", async () => { + it("rejects a lone closing delimiter (no block begins there) and steers to `SWAP N.=M:`", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); @@ -150,7 +150,7 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { // Steers to the concrete form and previews the file around the anchor (`*`-marked). await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow( - /could not resolve a syntactic block beginning on line 3.*SWAP 3\.\.M:.*^ 1:function x\(\) \{$.*^\*3: {2}\}$/ms, + /could not resolve a syntactic block beginning on line 3.*SWAP 3\.=M:.*^ 1:function x\(\) \{$.*^\*3: {2}\}$/ms, ); // Disk untouched — refusal never leaves a partial write. expect(await Bun.file(filePath).text()).toBe(TS_SOURCE); diff --git a/packages/coding-agent/test/core/hashline-loop-guard.test.ts b/packages/coding-agent/test/core/hashline-loop-guard.test.ts index c0dcc4866..8cd70c8a9 100644 --- a/packages/coding-agent/test/core/hashline-loop-guard.test.ts +++ b/packages/coding-agent/test/core/hashline-loop-guard.test.ts @@ -54,7 +54,7 @@ async function withTempDir(fn: (tempDir: string) => Promise): Promise { @@ -144,7 +144,7 @@ describe("hashline noop loop guard", () => { // from the model's perspective. const source = "aaa\nbbb\nccc\n"; const tag = getFileReadCache(session).record(filePath, source); - const realEdit = `${formatHashlineHeader("a.ts", tag)}\nSWAP 2..2:\n+BBB\n`; + const realEdit = `${formatHashlineHeader("a.ts", tag)}\nSWAP 2.=2:\n+BBB\n`; const editResult = await executeHashlineSingle(execOptions(realEdit, session)); expect(editResult.content[0]?.type === "text" ? editResult.content[0].text : "").not.toContain( "byte-identical to the file", @@ -156,7 +156,7 @@ describe("hashline noop loop guard", () => { // soft-hint regime. const newSource = "aaa\nBBB\nccc\n"; const newTag = getFileReadCache(session).record(filePath, newSource); - const newNoop = `${formatHashlineHeader("a.ts", newTag)}\nSWAP 2..2:\n+BBB\n`; + const newNoop = `${formatHashlineHeader("a.ts", newTag)}\nSWAP 2.=2:\n+BBB\n`; const result = await executeHashlineSingle(execOptions(newNoop, session)); expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain( "byte-identical to the file", diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index d5739d27c..ff4dd440b 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -236,12 +236,12 @@ describe("computeHashlineDiff", () => { const line = "unchanged content"; await Bun.write(sourcePath, `${line}\n`); - // `SWAP 1..1:` with the same line in the body is a true no-op: the edit + // `SWAP 1.=1:` with the same line in the body is a true no-op: the edit // fires through computeHashlineDiff but produces identical content. const text = `${line}\n`; const snapshotStore = new InMemorySnapshotStore(); const tag = snapshotStore.record(sourcePath, text); - const input = `${formatHashlineHeader(sourcePath, tag)}\nSWAP 1..1:\n+${line}\n`; + const input = `${formatHashlineHeader(sourcePath, tag)}\nSWAP 1.=1:\n+${line}\n`; const result = await computeHashlineDiff({ input }, tempDir, snapshotStore); expect("error" in result).toBe(true); if ("error" in result) { diff --git a/packages/coding-agent/test/edit-streaming-preview.test.ts b/packages/coding-agent/test/edit-streaming-preview.test.ts index 7c2f93d22..0ad5bec3c 100644 --- a/packages/coding-agent/test/edit-streaming-preview.test.ts +++ b/packages/coding-agent/test/edit-streaming-preview.test.ts @@ -128,7 +128,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { // The `+` payload has no trailing newline — the common single-op case // the trailing-line trim used to erase, collapsing the preview to a // "No changes" error that rendered as a blank box for the whole stream. - const input = `${header}\nSWAP 2..2:\n+const b = 22`; + const input = `${header}\nSWAP 2.=2:\n+const b = 22`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toBeUndefined(); @@ -136,7 +136,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { }); test("does not surface stale hash errors while streaming", async () => { - const input = "[a.ts#FFFF]\nSWAP 2..2:\n+const b = 22"; + const input = "[a.ts#FFFF]\nSWAP 2.=2:\n+const b = 22"; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toBeUndefined(); @@ -145,7 +145,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { test("final preview accepts a live content hash even when the snapshot store has no history", async () => { const liveHeader = formatHashlineHeader("a.ts", computeFileHash(text)); - const input = `${liveHeader}\nSWAP 2..2:\n+const b = 22\n`; + const input = `${liveHeader}\nSWAP 2.=2:\n+const b = 22\n`; const previews = await strategy.computeDiffPreview( { input } as never, { @@ -162,7 +162,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { test("final preview recovers a stale tag from snapshot history", async () => { await Bun.write(file, `// external\n${text}`); - const input = `${header}\nSWAP 2..2:\n+const b = 22\n`; + const input = `${header}\nSWAP 2.=2:\n+const b = 22\n`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toBeUndefined(); @@ -170,7 +170,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { }); test("surfaces stale hash errors once streaming is complete", async () => { - const input = "[a.ts#FFFF]\nSWAP 2..2:\n+const b = 22\n"; + const input = "[a.ts#FFFF]\nSWAP 2.=2:\n+const b = 22\n"; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toContain("not from this session"); @@ -180,7 +180,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { // Op header typed, payload still empty: applyPartialTo drops the // payload-less op so nothing changes yet. The preview must report null // (preserving any prior frame), never a 'No changes' error that wipes it. - const input = `${header}\nSWAP 2..2:\n`; + const input = `${header}\nSWAP 2.=2:\n`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toBeNull(); }); @@ -217,7 +217,7 @@ describe("hashline streaming preview (monotonic growth)", () => { const ctx = (cwd: string) => ({ cwd, signal: new AbortController().signal, snapshots, isStreaming: true }); // Replace the 20-line body (lines 2..21) with the first `n` payload rows. const buildInput = (n: number) => - `${header}\nSWAP 2..21:\n${payload + `${header}\nSWAP 2.=21:\n${payload .slice(0, n) .map(l => `+${l}`) .join("\n")}`; diff --git a/packages/coding-agent/test/edit/seen-line-guard.test.ts b/packages/coding-agent/test/edit/seen-line-guard.test.ts index e5f345d46..131ba9e85 100644 --- a/packages/coding-agent/test/edit/seen-line-guard.test.ts +++ b/packages/coding-agent/test/edit/seen-line-guard.test.ts @@ -93,7 +93,7 @@ describe("read → edit seen-line guard", () => { const tag = tagFromOutput(resultText(read)); await expect( - executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 12..12:\n+EDITED`, session)), + executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 12.=12:\n+EDITED`, session)), ).rejects.toThrow(/were not shown in the read\/search output/); // The reject left the file untouched. expect(await Bun.file(file).text()).toBe(CONTENT); @@ -107,7 +107,7 @@ describe("read → edit seen-line guard", () => { const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` }); const tag = tagFromOutput(resultText(read)); - await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 2..2:\n+EDITED`, session)); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 2.=2:\n+EDITED`, session)); expect(await Bun.file(file).text()).toContain("EDITED"); }); }); @@ -154,7 +154,7 @@ describe("search → edit seen-line guard", () => { expect(seen?.has(8)).toBe(false); // The matched line is in the seen set, so editing it applies. - await executeHashlineSingle(execOptions(`[code.txt#${tag}]\nSWAP 4..4:\n+NEEDLE edited`, session)); + await executeHashlineSingle(execOptions(`[code.txt#${tag}]\nSWAP 4.=4:\n+NEEDLE edited`, session)); expect(await Bun.file(file).text()).toContain("NEEDLE edited"); }); @@ -167,7 +167,7 @@ describe("search → edit seen-line guard", () => { const search = await new SearchTool(session).execute("s1", { pattern: "NEEDLE", paths: [file] }); const tag = tagFromOutput(resultText(search)); - await expect(executeHashlineSingle(execOptions(`[code.txt#${tag}]\nSWAP 8..8:\n+X`, session))).rejects.toThrow( + await expect(executeHashlineSingle(execOptions(`[code.txt#${tag}]\nSWAP 8.=8:\n+X`, session))).rejects.toThrow( /were not shown in the read\/search output/, ); expect(await Bun.file(file).text()).toBe(`${lines.join("\n")}\n`); diff --git a/packages/coding-agent/test/read-column-truncation-snapshot.test.ts b/packages/coding-agent/test/read-column-truncation-snapshot.test.ts index a5f973390..9656c2de8 100644 --- a/packages/coding-agent/test/read-column-truncation-snapshot.test.ts +++ b/packages/coding-agent/test/read-column-truncation-snapshot.test.ts @@ -175,7 +175,7 @@ describe("read tool column truncation vs hashline snapshot", () => { tmpDir, filePath, header, - patchBody: "SWAP 3..3:\n+epilogue\n", + patchBody: "SWAP 3.=3:\n+epilogue\n", }); const after = await fs.readFile(filePath, "utf8"); diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index 12e20cdfc..8379fa71d 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -277,7 +277,7 @@ describe("editToolRenderer", () => { // The trailing payload line carries no newline — the common shape for a // single-line edit. The streaming pass trims that in-flight line, so the // preview only becomes computable once args are marked complete. - const input = `[memory.ts#${tag}]\nSWAP 2..2:\n+export const b = 22;`; + const input = `[memory.ts#${tag}]\nSWAP 2.=2:\n+export const b = 22;`; const component = new ToolExecutionComponent("edit", { input }, { snapshots }, hashlineTool, uiStub, tmpDir); component.setArgsComplete(); @@ -304,7 +304,7 @@ describe("editToolRenderer", () => { const snapshots = new InMemorySnapshotStore(); const tag = snapshots.record(filePath, content); - const input = `[memory.ts#${tag}]\nSWAP 2..2:\n+export const b = 22;\n`; + const input = `[memory.ts#${tag}]\nSWAP 2.=2:\n+export const b = 22;\n`; const component = new ToolExecutionComponent( "edit", { __partialJson: input }, diff --git a/packages/coding-agent/test/write-hashline-header.test.ts b/packages/coding-agent/test/write-hashline-header.test.ts index 7774c230f..a91edf1ec 100644 --- a/packages/coding-agent/test/write-hashline-header.test.ts +++ b/packages/coding-agent/test/write-hashline-header.test.ts @@ -82,7 +82,7 @@ describe("write tool hashline header", () => { // Apply a hashline patch immediately, using only the tag the write tool // returned — no intervening `read`. - const patchInput = `${headerLine}\nSWAP 1..1:\n+export const enabled = true;\n`; + const patchInput = `${headerLine}\nSWAP 1.=1:\n+export const enabled = true;\n`; const patch = Patch.parse(patchInput, { cwd: tmpDir }); expect(patch.sections).toHaveLength(1); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index b798ee70a..c1abd62e9 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Changed the recommended hashline range separator from `..` to `.=` (e.g. `SWAP 1.=3:`, `DEL 4.=5`) so the inclusive `<=`-style end is self-evident. `HL_RANGE_SEP` is now `.=`; the prompt, grammar, error messages, and emitted headers all use it. The lenient parser still accepts the legacy `..` (and `-`/`…`/space) forms. + ## [15.13.2] - 2026-06-15 ### Breaking Changes diff --git a/packages/hashline/README.md b/packages/hashline/README.md index cf0e8e952..f31741cd2 100644 --- a/packages/hashline/README.md +++ b/packages/hashline/README.md @@ -26,7 +26,7 @@ await fs.writeText("hello.ts", before); const tag = snapshots.record("hello.ts", before); const patcher = new Patcher({ fs, snapshots }); const patch = Patch.parse(String.raw`[hello.ts#${tag}] -SWAP 1..1: +SWAP 1.=1: +const greeting = "hello";`); const result = await patcher.apply(patch); @@ -47,9 +47,9 @@ still matches the recorded content hash, and refusing or attempting session-aware recovery on mismatch. Inside a section: -- `SWAP A..B:` — replace lines A..B with following `+TEXT` body rows. +- `SWAP A.=B:` — replace lines A.=B with following `+TEXT` body rows. - `SWAP.BLK A:` — replace the syntactic block beginning on line A. -- `DEL A..B` / `DEL.BLK A` — delete concrete lines or a resolved block. +- `DEL A.=B` / `DEL.BLK A` — delete concrete lines or a resolved block. - `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows. - `INS.BLK.POST A:` — insert following body rows after the resolved block's last line. - `+TEXT` — literal body row (use `+` alone for a blank line). diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index fab4b1194..18590f1a5 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -232,7 +232,7 @@ interface ReplacementGroup { * Detect a replacement group starting at `start`: a run of `before_anchor` * replacement inserts sharing one source op line, immediately followed by the * contiguous range deletes for that same op. Mirrors how the parser lowers an - * `replace N..M:` hunk with a body. + * `replace N.=M:` hunk with a body. */ function findReplacementGroup(edits: readonly AppliedEdit[], start: number): ReplacementGroup | undefined { const first = edits[start]; @@ -453,7 +453,7 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string * by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}. * * Scoped to multi-line ranges (a construct rewrite) on purpose: a single-line - * `replace N..N` expanding into several lines is an *expansion* where every + * `replace N.=N` expanding into several lines is an *expansion* where every * payload line is intentional new content, so a payload line that happens to * equal a neighbor stays — only a genuine block rewrite retypes a boundary * keeper by mistake. The dropped lines must be delimiter-neutral so removing the diff --git a/packages/hashline/src/block.ts b/packages/hashline/src/block.ts index dbe97d701..f81f6aecf 100644 --- a/packages/hashline/src/block.ts +++ b/packages/hashline/src/block.ts @@ -7,7 +7,7 @@ * runs at every apply/preview boundary that has text: it calls the injected * {@link BlockResolver} to resolve each block's `[start, end]` span, then emits * the exact same edits the concrete form produces in the parser: `replace - * start..end:` inserts + deletes for a replace, a pure range delete for a + * start.=end:` inserts + deletes for a replace, a pure range delete for a * delete, and plain `after_anchor` inserts at `end` for an insert-after. After * it runs, no `block` edits remain, so {@link applyEdits} (and recovery) only * ever see resolved edits. @@ -145,7 +145,7 @@ export function resolveBlockEdits( } continue; } - // Mirror the parser's `replace start..end:` expansion exactly: one + // Mirror the parser's `replace start.=end:` expansion exactly: one // `before_anchor` replacement insert per payload row at `span.start`, // then one delete per line across `[span.start, span.end]`. An empty // `payloads` (from `delete_block N`) emits no inserts — a pure deletion. diff --git a/packages/hashline/src/format.ts b/packages/hashline/src/format.ts index a1a5c2571..106e38991 100644 --- a/packages/hashline/src/format.ts +++ b/packages/hashline/src/format.ts @@ -38,8 +38,8 @@ export const HL_HEADER_COLON = ":"; /** Separator between a hashline file path and its opaque snapshot tag. */ export const HL_FILE_HASH_SEP = "#"; -/** Separator between two line numbers in a range, e.g. `5..10`. */ -export const HL_RANGE_SEP = ".."; +/** Separator between two line numbers in a range, e.g. `5.=10`. */ +export const HL_RANGE_SEP = ".="; /** Separator between a line number and displayed line content in hashline mode. */ export const HL_LINE_BODY_SEP = ":"; diff --git a/packages/hashline/src/grammar.lark b/packages/hashline/src/grammar.lark index 772acdd71..2e5cd7ac1 100644 --- a/packages/hashline/src/grammar.lark +++ b/packages/hashline/src/grammar.lark @@ -21,7 +21,7 @@ insert_block_anchor: "INS.BLK.POST " LID ":" insert_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL" emit_op: "+" /(.*)/ LF -header_range: LID ".." LID +header_range: LID ".=" LID LID: /[1-9]\d*/ %import common.LF diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index b8e37b191..2051a345f 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -1,6 +1,6 @@ /** Centralized error/warning text for the hashline parser, applier, and patcher. */ -import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format"; +import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX, HL_RANGE_SEP } from "./format"; /** Lines of context shown either side of a hash mismatch. */ export const MISMATCH_CONTEXT = 2; @@ -43,11 +43,10 @@ export const END_PATCH_MARKER = "*** End Patch"; export const ABORT_MARKER = "*** Abort"; /** Two consecutive hunks targeted the exact same concrete range. */ -export const REPLACE_PAIR_COALESCED_WARNING = - "Two hunks targeted the same range; kept only the second. One `SWAP N..M:` hunk per range — the body is the final content, never old+new."; +export const REPLACE_PAIR_COALESCED_WARNING = `Two hunks targeted the same range; kept only the second. One \`SWAP N${HL_RANGE_SEP}M:\` hunk per range — the body is the final content, never old+new.`; /** Bare bodyless hunk followed by an overlapping concrete hunk. */ -("Dropped a bare hunk overlapped by the concrete hunk after it. One `SWAP N..M:` hunk per range — the body is the final content, never old+new."); +`Dropped a bare hunk overlapped by the concrete hunk after it. One \`SWAP N${HL_RANGE_SEP}M:\` hunk per range — the body is the final content, never old+new.`; /** Bare body rows auto-converted to literal `+` rows. */ export const BARE_BODY_AUTO_PIPED_WARNING = @@ -58,7 +57,7 @@ export const MINUS_ROW_REJECTED = "`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-…`."; /** Replace hunk with no body. */ -export const EMPTY_REPLACE = "`SWAP N..M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N..M`."; +export const EMPTY_REPLACE = `\`SWAP N${HL_RANGE_SEP}M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`DEL N${HL_RANGE_SEP}M\`.`; /** `replace_block N:` hunk with no body. */ export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`."; @@ -77,7 +76,7 @@ export function blockUnresolvedMessage( fileLines?: readonly string[], ): string { const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`; - const fallback = op === "delete" ? `DEL ${line}..M` : `SWAP ${line}..M:`; + const fallback = op === "delete" ? `DEL ${line}${HL_RANGE_SEP}M` : `SWAP ${line}${HL_RANGE_SEP}M:`; let message = `\`${phrase}\` could not resolve a syntactic block beginning on line ${line} ` + `(unsupported language, blank/closer line, or parse error). Use \`${fallback}\` with explicit lines.`; @@ -118,7 +117,7 @@ export const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run)."; /** Delete hunk received a body row. */ -export const DELETE_TAKES_NO_BODY = "`DEL N..M` does not take body rows. Remove the body, or use `SWAP N..M:`."; +export const DELETE_TAKES_NO_BODY = `\`DEL N${HL_RANGE_SEP}M\` does not take body rows. Remove the body, or use \`SWAP N${HL_RANGE_SEP}M:\`.`; /** `delete_block N` hunk received a body row. */ export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`."; @@ -226,7 +225,11 @@ export type BlockOp = "replace" | "delete" | "insert_after"; export function blockSingleLineMessage(line: number, op: BlockOp): string { const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK"; const plainForm = - op === "insert_after" ? `INS.POST ${line}:` : op === "delete" ? `DEL ${line}` : `SWAP ${line}..${line}:`; + op === "insert_after" + ? `INS.POST ${line}:` + : op === "delete" + ? `DEL ${line}` + : `SWAP ${line}${HL_RANGE_SEP}${line}:`; return ( `\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` + `of a multi-line construct. For that one line use \`${plainForm}\`; to act on an enclosing construct, anchor ${blockForm} ` + diff --git a/packages/hashline/src/parser.ts b/packages/hashline/src/parser.ts index c7dcc444d..b71ff3d8c 100644 --- a/packages/hashline/src/parser.ts +++ b/packages/hashline/src/parser.ts @@ -3,7 +3,7 @@ * flat list of {@link Edit}s. Sits between the {@link Tokenizer} and the * applier. */ -import { HL_PAYLOAD_REPLACE } from "./format"; +import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format"; import { BARE_BODY_AUTO_PIPED_WARNING, DELETE_BLOCK_TAKES_NO_BODY, @@ -18,7 +18,9 @@ import type { Anchor, Cursor, Edit } from "./types"; function validateRangeOrder(range: ParsedRange, lineNum: number): void { if (range.end.line < range.start.line) { - throw new Error(`line ${lineNum}: range ${range.start.line}..${range.end.line} ends before it starts.`); + throw new Error( + `line ${lineNum}: range ${range.start.line}${HL_RANGE_SEP}${range.end.line} ends before it starts.`, + ); } } @@ -52,33 +54,33 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri return ( `apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` + "File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " + - "Use `SWAP N..M:`, `DEL N..M`, or `INS.PRE|POST|HEAD|TAIL:` ops." + `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.` ); } if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) { return ( "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " + - "Use `SWAP N..M:`, `DEL N..M`, or `INS.PRE|POST|HEAD|TAIL:` ops." + `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.` ); } if (trimmed.startsWith("@@")) { const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed; return ( `\`@@\`-bracketed hunk header ${JSON.stringify(preview)} is not valid in hashline. ` + - "Drop the `@@ ... @@` brackets and write a verb header such as `SWAP N..M:`." + `Drop the \`@@ ... @@\` brackets and write a verb header such as \`SWAP N${HL_RANGE_SEP}M:\`.` ); } - if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) { - return "`DEL N..M` has no colon and no body. Remove the colon and body rows."; + if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|\.=|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) { + return `\`DEL N${HL_RANGE_SEP}M\` has no colon and no body. Remove the colon and body rows.`; } if (/^[1-9]\d*\s*$/.test(trimmed)) { - return `hunk headers need a verb. Use \`SWAP ${trimmed}..${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`; + return `hunk headers need a verb. Use \`SWAP ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`; } - const bareRange = /^([1-9]\d*)\s*[-. …]+\s*([1-9]\d*)\s*:?$/.exec(trimmed); + const bareRange = /^([1-9]\d*)\s*[-. …=]+\s*([1-9]\d*)\s*:?$/.exec(trimmed); if (bareRange !== null) { return ( `bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` + - `Hunk headers need a verb: write \`SWAP ${bareRange[1]}..${bareRange[2]}:\` or \`DEL ${bareRange[1]}..${bareRange[2]}\`.` + `Hunk headers need a verb: write \`SWAP ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}:\` or \`DEL ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}\`.` ); } return null; @@ -253,7 +255,7 @@ export class Executor { if (text.trim().length === 0) return; throw new Error( `line ${lineNum}: payload line has no preceding hunk header. ` + - `Use \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`, + `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`, ); } diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index a4cf6a4c0..65c7ae32e 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -5,16 +5,16 @@ Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag fro -`SWAP N..M:` — replace original lines N..M with the body rows below. INCLUSIVE — line M is consumed too. +`SWAP N.=M:` — replace original lines N.=M with the body rows below. INCLUSIVE — line M is consumed too. `SWAP.BLK N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below. -`DEL N..M` — delete original lines N..M. No body. +`DEL N.=M` — delete original lines N.=M. No body. `DEL.BLK N` — delete the whole syntactic block that BEGINS on line N. `INS.PRE N:` — insert the body rows immediately before line N. `INS.POST N:` — insert the body rows immediately after line N. `INS.BLK.POST N:` — insert the body rows after the END of the block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `INS.POST`. `INS.HEAD:` — insert the body rows at the very start of the file. `INS.TAIL:` — insert the body rows at the very end of the file. -Single line: `SWAP N..N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N..N:`). +Single line: `SWAP N.=N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N.=N:`). @@ -34,9 +34,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k - On a stale-tag rejection or any surprising result: STOP and re-`read` before further edits. - One hunk per range; the body is the final content, never an old/new pair. - Ranges cover ONLY lines whose content changes. Never widen over unchanged lines — a stale wide range shreds everything it spans. -- Whole construct → `SWAP.BLK N` (tree-sitter resolves the end); lines inside it → `SWAP N..M`. -- `SWAP.BLK N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `SWAP N..M`. -- Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N..N` / `DEL N` / `INS.POST N`) for one line, or point N at the real opener. Saw the closer? Use plain `INS.POST M:`. +- Whole construct → `SWAP.BLK N` (tree-sitter resolves the end); lines inside it → `SWAP N.=M`. +- `SWAP.BLK N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `SWAP N.=M`. +- Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N.=N` / `DEL N` / `INS.POST N`) for one line, or point N at the real opener. Saw the closer? Use plain `INS.POST M:`. - Non-adjacent changes = separate hunks; untouched lines stay out of every range. - Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. A multi-line `SWAP` whose body restates the line just outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair. - NEVER format/restyle code with this tool; run the project formatter instead. @@ -62,7 +62,7 @@ INS.POST 1: Replace line 2 with two lines: ``` [greet.py#A1B2] -SWAP 2..2: +SWAP 2.=2: + greeting = "Hi" + msg = f"{greeting}, {name}" ``` @@ -102,24 +102,24 @@ SWAP.BLK 1: # WRONG — empty `SWAP` to delete. RIGHT: DEL 4 -SWAP 4..4: +SWAP 4.=4: -# WRONG — range describes post-edit size. RIGHT: SWAP 1..1: (body length is irrelevant) -SWAP 1..2: +# WRONG — range describes post-edit size. RIGHT: SWAP 1.=1: (body length is irrelevant) +SWAP 1.=2: +def greet(name): # WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content. -SWAP 3..3: +SWAP 3.=3: msg = "Hello, " + name - print(msg) + return msg # RIGHT -SWAP 3..3: +SWAP 3.=3: + return msg # WRONG — a pure insertion done as a widened `SWAP`: you only want to add one line after 2, -# but you replace 2..4, retype the keepers in the body, and drop one (here line 4, `greet("world")`). -SWAP 2..4: +# but you replace 2.=4, retype the keepers in the body, and drop one (here line 4, `greet("world")`). +SWAP 2.=4: + msg = "Hello, " + name + extra = compute(name) + print(msg) diff --git a/packages/hashline/src/tokenizer.ts b/packages/hashline/src/tokenizer.ts index b7786fb13..93f05901d 100644 --- a/packages/hashline/src/tokenizer.ts +++ b/packages/hashline/src/tokenizer.ts @@ -4,7 +4,7 @@ * Format shape: * ``` * [path/to/file.ts#1A2B] - * replace 5..7: + * replace 5.=7: * +literal new line * ``` */ @@ -40,6 +40,7 @@ const CHAR_SPACE = 32; const CHAR_DOT = 46; const CHAR_HYPHEN = 45; const CHAR_ELLIPSIS = 0x2026; +const CHAR_EQUALS = 61; const CHAR_UPPER_A = 65; const CHAR_UPPER_F = 70; @@ -167,7 +168,11 @@ function scanRangeSeparator(line: string, index: number, end: number): number | consumedSeparator = true; continue; } - if (code === CHAR_DOT && cursor + 1 < end && line.charCodeAt(cursor + 1) === CHAR_DOT) { + if ( + code === CHAR_DOT && + cursor + 1 < end && + (line.charCodeAt(cursor + 1) === CHAR_DOT || line.charCodeAt(cursor + 1) === CHAR_EQUALS) + ) { cursor += 2; consumedSeparator = true; continue; @@ -277,7 +282,7 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan | }; } // `delete_block N` — resolve N to a tree-sitter block range at apply time - // and delete its whole span. Like `delete N..M`, it takes no body and no + // and delete its whole span. Like `delete N.=M`, it takes no body and no // trailing colon. const deleteBlockEnd = scanKeyword(line, cursor, end, HL_DELETE_BLOCK_KEYWORD); if (deleteBlockEnd !== null) { diff --git a/packages/hashline/src/types.ts b/packages/hashline/src/types.ts index 5ab15fc4f..d23f94150 100644 --- a/packages/hashline/src/types.ts +++ b/packages/hashline/src/types.ts @@ -47,7 +47,7 @@ export type Edit = * time — it is computed by {@link resolveBlockEdits} once file text + * path (→ language) are available, then expanded into concrete edits: * a non-empty `payloads` without `mode` (from `replace_block`) becomes - * the same `replacement` inserts + deletes that `replace start..end:` + * the same `replacement` inserts + deletes that `replace start.=end:` * produces; an empty `payloads` (from `delete_block`) becomes a pure * range deletion; `mode: "insert_after"` becomes plain `after_anchor` * inserts at the block's last line. `applyEdits` never sees this @@ -78,7 +78,7 @@ export interface ApplyResult { blockResolutions?: BlockResolution[]; } -/** A parsed `[A..B]` line range. */ +/** A parsed `[A.=B]` line range. */ export interface ParsedRange { start: Anchor; end: Anchor; @@ -134,7 +134,7 @@ export interface BlockSpan { /** * One `replace_block N:` / `delete_block N` / `insert_after_block N:` anchor * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the - * host can echo "block N → lines start..end" and let the model catch a wrong + * host can echo "block N → lines start.=end" and let the model catch a wrong * opener — e.g. a decorator or doc-comment that sits in a separate node * outside the resolved block. */ diff --git a/packages/hashline/test/block.test.ts b/packages/hashline/test/block.test.ts index a6eec72f3..d6eb03b32 100644 --- a/packages/hashline/test/block.test.ts +++ b/packages/hashline/test/block.test.ts @@ -43,8 +43,8 @@ describe("SWAP.BLK parsing", () => { expect(edit.payloads).toEqual(["A", "B"]); }); - it("still parses a literal `SWAP N..M:` range (distinct from `SWAP.BLK`)", () => { - const { edits } = parsePatch("SWAP 2..3:\n+A"); + it("still parses a literal `SWAP N.=M:` range (distinct from `SWAP.BLK`)", () => { + const { edits } = parsePatch("SWAP 2.=3:\n+A"); expect(edits.some(edit => edit.kind === "block")).toBe(false); expect(edits.some(edit => edit.kind === "delete")).toBe(true); }); @@ -55,17 +55,17 @@ describe("SWAP.BLK parsing", () => { }); describe("resolveBlockEdits", () => { - it("expands a block edit exactly like the equivalent `SWAP start..end:`", () => { + it("expands a block edit exactly like the equivalent `SWAP start.=end:`", () => { const blockEdits = parsePatch("SWAP.BLK 2:\n+A\n+B").edits; const resolved = resolveBlockEdits(blockEdits, "ignored", PATH, stubResolver); - const replaceEdits = parsePatch("SWAP 2..3:\n+A\n+B").edits; + const replaceEdits = parsePatch("SWAP 2.=3:\n+A\n+B").edits; expect(resolved.some(edit => edit.kind === "block")).toBe(false); expect(normalizeEdits(resolved)).toEqual(normalizeEdits(replaceEdits)); }); it("returns the input untouched when there are no block edits (fast path)", () => { - const edits = parsePatch("SWAP 1..1:\n+X").edits; + const edits = parsePatch("SWAP 1.=1:\n+X").edits; expect(resolveBlockEdits(edits, "ignored", PATH, stubResolver)).toBe(edits); }); @@ -171,7 +171,7 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => { it("applyTo resolves a block edit and matches the equivalent `replace`", () => { const blockSection = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP.BLK 2:\n+ if (y || z) {\n+ }`); - const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP 2..3:\n+ if (y || z) {\n+ }`); + const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP 2.=3:\n+ if (y || z) {\n+ }`); const blockResult = blockSection.applyTo(text, stubResolver); const replaceResult = replaceSection.applyTo(text); diff --git a/packages/hashline/test/boundary-repair.test.ts b/packages/hashline/test/boundary-repair.test.ts index c29c6be0a..fc9deedef 100644 --- a/packages/hashline/test/boundary-repair.test.ts +++ b/packages/hashline/test/boundary-repair.test.ts @@ -9,7 +9,7 @@ function apply(text: string, diff: string): { text: string; warnings: string[] } describe("boundary-balance repair", () => { // The canonical incident: a range-replace whose payload restates the // fragment + paren close that still live just below the range, doubling - // `` and `);`. `replace 11..31:` covers `const …` through the second `/>`. + // `` and `);`. `replace 11.=31:` covers `const …` through the second `/>`. it("drops a duplicated multi-line closing block (the Root.tsx incident)", () => { const file = [ 'import type React from "react";', @@ -35,7 +35,7 @@ describe("boundary-balance repair", () => { // Range 7..16 = `const …` through the first `/>`; payload restates the // `` + `);` that survive at lines 17-18. const diff = [ - "SWAP 7..16:", + "SWAP 7.=16:", "+\treturn (", "+\t\t<>", "+\t\t\t { // the payload restates the `});` that survives just below it. it("drops a single duplicated structural closer (`});`)", () => { const file = ["it('a', () => {", "\tsetup();", "\trun();", "});", "after();"].join("\n"); - // `replace 2..3:` replaces the two body lines but the payload also restates the + // `replace 2.=3:` replaces the two body lines but the payload also restates the // `});` at line 4, which survives — a duplicate close. - const diff = ["SWAP 2..3:", "+\tsetup2();", "+\trun2();", "+});"].join("\n"); + const diff = ["SWAP 2.=3:", "+\tsetup2();", "+\trun2();", "+});"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["it('a', () => {", "\tsetup2();", "\trun2();", "});", "after();"].join("\n")); expect(warnings.some(w => /delimiter-balance/.test(w))).toBe(true); @@ -83,10 +83,10 @@ describe("boundary-balance repair", () => { "\t}", "}", ].join("\n"); - // `replace 4..6:` covers the params + return-type line, but the payload also + // `replace 4.=6:` covers the params + return-type line, but the payload also // restates the `planRender(` at line 3, which survives — a duplicate open. const diff = [ - "SWAP 4..6:", + "SWAP 4.=6:", "+\tplanRender(", "+\t\ta: string[],", "+\t\tb: boolean,", @@ -117,7 +117,7 @@ describe("boundary-balance repair", () => { const file = ["if (a) {", "\tfoo();", "}", "bar();"].join("\n"); // Payload duplicates `if (a) {` but is net +2 braces; dropping the one // opener cannot zero the delta, so nothing is repaired. - const diff = ["SWAP 2..2:", "+if (a) {", "+\tif (b) {", "+\t\tfoo();"].join("\n"); + const diff = ["SWAP 2.=2:", "+if (a) {", "+\tif (b) {", "+\t\tfoo();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (a) {", "if (a) {", "\tif (b) {", "\t\tfoo();", "}", "bar();"].join("\n")); expect(warnings).toHaveLength(0); @@ -126,9 +126,9 @@ describe("boundary-balance repair", () => { // Genuine missing-closer: payload omits the trailing `});`. it("spares the deleted closing line when the payload omits it", () => { const file = ["const handlers = {", "\ta() {", "\t\treturn 1;", "\t},", "};"].join("\n"); - // `replace 5..5:` is the final `};`. Model inserts a new method but forgets to + // `replace 5.=5:` is the final `};`. Model inserts a new method but forgets to // restate `};`; sparing it keeps the object literal balanced. - const diff = ["SWAP 5..5:", "+\tb() {", "+\t\treturn 2;", "+\t},"].join("\n"); + const diff = ["SWAP 5.=5:", "+\tb() {", "+\t\treturn 2;", "+\t},"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe( ["const handlers = {", "\ta() {", "\t\treturn 1;", "\t},", "\tb() {", "\t\treturn 2;", "\t},", "};"].join( @@ -143,7 +143,7 @@ describe("boundary-balance repair", () => { // keeping the deleted suffix would duplicate the closer outside the payload. it("does not spare a deleted closing line that the payload already restates", () => { const file = ["class Foo {", "\tok();", "\t}", "}"].join("\n"); - const diff = ["SWAP 1..4:", "+class Foo {", "+\tok();", "+}"].join("\n"); + const diff = ["SWAP 1.=4:", "+class Foo {", "+\tok();", "+}"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["class Foo {", "\tok();", "}"].join("\n")); @@ -159,7 +159,7 @@ describe("boundary-balance repair", () => { "\tprint_status()", ].join("\n"); const diff = [ - "SWAP 2..3:", + "SWAP 2.=3:", "+func _cmd_travel_homeworld():", "+\tvar destination = find_homeworld()", "+\ttravel_to(destination)", @@ -183,7 +183,7 @@ describe("boundary-balance repair", () => { it("preserves payloads where multi-line boundary echoes cover every line", () => { const file = ["A", "B", "old", "C", "D"].join("\n"); - const diff = ["SWAP 3..3:", "+A", "+B", "+C", "+D"].join("\n"); + const diff = ["SWAP 3.=3:", "+A", "+B", "+C", "+D"].join("\n"); const { text, warnings } = apply(file, diff); @@ -193,7 +193,7 @@ describe("boundary-balance repair", () => { it("preserves payloads made only of lines matching both replacement neighbors", () => { const file = ["a", "old", "c"].join("\n"); - const diff = ["SWAP 2..2:", "+a", "+c"].join("\n"); + const diff = ["SWAP 2.=2:", "+a", "+c"].join("\n"); const { text, warnings } = apply(file, diff); @@ -209,7 +209,7 @@ describe("boundary-balance repair", () => { // Payload deliberately opens with the same bare `}` that sits above the // range and closes with the same `}` that sits below it; the payload is // internally balanced (delta 0) while the dropped edges sum to -2 braces. - const diff = ["SWAP 2..2:", "+}", "+if (a) {", "+if (b) {", "+x();", "+}"].join("\n"); + const diff = ["SWAP 2.=2:", "+}", "+if (a) {", "+if (b) {", "+x();", "+}"].join("\n"); const { text, warnings } = apply(file, diff); @@ -221,7 +221,7 @@ describe("boundary-balance repair", () => { // (opener + closer) that duplicate the surviving neighbors are dropped. it("still drops a balance-neutral wrapper echo", () => { const file = ["function f() {", "old();", "}"].join("\n"); - const diff = ["SWAP 2..2:", "+function f() {", "+fresh();", "+}"].join("\n"); + const diff = ["SWAP 2.=2:", "+function f() {", "+fresh();", "+}"].join("\n"); const { text, warnings } = apply(file, diff); @@ -235,7 +235,7 @@ describe("boundary-balance repair", () => { const file = ["foo();", "bar();", "bar();", "baz();"].join("\n"); // Replace line 2 with two balanced statements; the tail `bar();` equals // the surviving line 3 but the payload is balanced — must NOT be dropped. - const diff = ["SWAP 2..2:", "+qux();", "+bar();"].join("\n"); + const diff = ["SWAP 2.=2:", "+qux();", "+bar();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["foo();", "qux();", "bar();", "bar();", "baz();"].join("\n")); expect(warnings).toHaveLength(0); @@ -245,7 +245,7 @@ describe("boundary-balance repair", () => { // could discard intended content, and it does not break syntax. it("does not drop a balance-neutral duplicated statement", () => { const file = ["a = 1;", "b = 2;", "c = 3;"].join("\n"); - const diff = ["SWAP 1..1:", "+a = 1;", "+b = 2;"].join("\n"); + const diff = ["SWAP 1.=1:", "+a = 1;", "+b = 2;"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["a = 1;", "b = 2;", "b = 2;", "c = 3;"].join("\n")); expect(warnings).toHaveLength(0); @@ -254,18 +254,18 @@ describe("boundary-balance repair", () => { // Brackets inside strings must not trigger a spurious balance mismatch. it("ignores brackets inside string literals", () => { const file = ['const a = "}";', 'const b = "x";', 'const c = "y";'].join("\n"); - const diff = ["SWAP 2..2:", '+const b = "}}}";'].join("\n"); + const diff = ["SWAP 2.=2:", '+const b = "}}}";'].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(['const a = "}";', 'const b = "}}}";', 'const c = "y";'].join("\n")); expect(warnings).toHaveLength(0); }); // A MULTI-line construct rewrite whose payload restates the keeper that - // survives just below the range — the att#1 `replace 639..644` shape where + // survives just below the range — the att#1 `replace 639.=644` shape where // the range was one line short of the `const changedFiles` it retyped. it("drops a one-sided trailing keeper echo in a multi-line rewrite", () => { const file = ["function f() {", " a();", " b();", " const out = [];", " return out;", "}"].join("\n"); - const diff = ["SWAP 2..3:", "+ a2();", "+ b2();", "+ const out = [];"].join("\n"); + const diff = ["SWAP 2.=3:", "+ a2();", "+ b2();", "+ const out = [];"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["function f() {", " a2();", " b2();", " const out = [];", " return out;", "}"].join("\n")); expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); @@ -275,7 +275,7 @@ describe("boundary-balance repair", () => { // the multi-line range (range one line low instead of one short). it("drops a one-sided leading keeper echo in a multi-line rewrite", () => { const file = ["setup();", "a();", "b();", "c();"].join("\n"); - const diff = ["SWAP 3..4:", "+a();", "+B();", "+C();"].join("\n"); + const diff = ["SWAP 3.=4:", "+a();", "+B();", "+C();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["setup();", "a();", "B();", "C();"].join("\n")); expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); @@ -315,9 +315,9 @@ describe("boundary-balance repair through stale-snapshot recovery", () => { const store = new InMemorySnapshotStore(); const fileHash = store.record(PATH, snapshotText); - // `replace 4..5:` replaces the body lines but the payload also restates the `});` + // `replace 4.=5:` replaces the body lines but the payload also restates the `});` // that survives at line 6 — the duplicate-closer mistake. - const { edits } = parsePatch(["SWAP 4..5:", "+\tsetup2();", "+\trun2();", "+});"].join("\n")); + const { edits } = parsePatch(["SWAP 4.=5:", "+\tsetup2();", "+\trun2();", "+});"].join("\n")); const recovered = new Recovery(store).tryRecover({ path: PATH, currentText, fileHash, edits }); expect(recovered).not.toBeNull(); diff --git a/packages/hashline/test/core-contracts.test.ts b/packages/hashline/test/core-contracts.test.ts index 22b090049..2d44ae747 100644 --- a/packages/hashline/test/core-contracts.test.ts +++ b/packages/hashline/test/core-contracts.test.ts @@ -247,7 +247,7 @@ describe("Recovery", () => { path: filePath, currentText, tag: v0Tag, - edits: parsePatch(`SWAP 10..10:\n${repl("L10-EDITED")}`).edits, + edits: parsePatch(`SWAP 10.=10:\n${repl("L10-EDITED")}`).edits, }); expect(recovered).not.toBeNull(); @@ -286,25 +286,25 @@ describe("hashline abort sentinel", () => { describe("hashline parser — delete and blank payload semantics", () => { it("applies inline delete and empty replace operations", () => { expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nDEL 2\n").diff)).toBe("line1\nline3\n"); - expect(applyDiff("line1\nline2\nline3\nline4\n", splitHashlineInput("[a.ts]\nDEL 2..3\n").diff)).toBe( + expect(applyDiff("line1\nline2\nline3\nline4\n", splitHashlineInput("[a.ts]\nDEL 2.=3\n").diff)).toBe( "line1\nline4\n", ); - expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nSWAP 2..2:\n").diff)).toBe( + expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nSWAP 2.=2:\n").diff)).toBe( "line1\nline3\n", ); }); it("treats old inline replacement syntax as orphan body", () => { - const { diff } = splitHashlineInput("[a.ts]\n2..2=replacement\n"); + const { diff } = splitHashlineInput("[a.ts]\n2.=2=replacement\n"); expect(() => parsePatch(diff)).toThrow(/payload line has no preceding hunk header/); }); it("preserves explicit blank replacement rows", () => { const text = "a\nb\nc\nd\ne\n"; - const ops = `[a.ts]\nSWAP 2..2:\n${repl("")}\n${repl("")}\nSWAP 4..4:\n${repl("D")}\n`; + const ops = `[a.ts]\nSWAP 2.=2:\n${repl("")}\n${repl("")}\nSWAP 4.=4:\n${repl("D")}\n`; expect(applyDiff(text, splitHashlineInput(ops).diff)).toBe("a\n\n\nc\nD\ne\n"); - const embedded = `[a.ts]\nSWAP 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; + const embedded = `[a.ts]\nSWAP 2.=2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; expect(applyDiff("a\nb\nc\n", splitHashlineInput(embedded).diff)).toBe("a\nfirst\n\nsecond\nc\n"); }); }); diff --git a/packages/hashline/test/format-v2.test.ts b/packages/hashline/test/format-v2.test.ts index b541e85ba..b19afb0de 100644 --- a/packages/hashline/test/format-v2.test.ts +++ b/packages/hashline/test/format-v2.test.ts @@ -8,7 +8,7 @@ function applyPatch(text: string, diff: string): string { describe("hashline format v4", () => { it("replaces a concrete range with literal body rows in textual order", () => { const text = "a\nb\nc"; - const diff = ["SWAP 2..2:", "+before", "+after"].join("\n"); + const diff = ["SWAP 2.=2:", "+before", "+after"].join("\n"); expect(applyPatch(text, diff)).toBe("a\nbefore\nafter\nc"); }); @@ -20,7 +20,7 @@ describe("hashline format v4", () => { it("deletes a concrete range", () => { const text = "a\nb\nc\nd"; - expect(applyPatch(text, "DEL 2..3")).toBe("a\nd"); + expect(applyPatch(text, "DEL 2.=3")).toBe("a\nd"); }); it("inserts before and after concrete anchors", () => { @@ -37,7 +37,7 @@ describe("hashline format v4", () => { it("treats an empty replace hunk as a delete and still rejects empty inserts", () => { const text = "a\nb\nc"; - expect(applyPatch(text, "SWAP 2..2:")).toBe("a\nc"); + expect(applyPatch(text, "SWAP 2.=2:")).toBe("a\nc"); expect(() => parsePatch("INS.HEAD:")).toThrow(/needs at least one/); }); @@ -47,8 +47,8 @@ describe("hashline format v4", () => { it("auto-pipes bare body rows as literal text", () => { const text = "a\nb\nc"; - expect(applyPatch(text, "SWAP 2..2:\nraw")).toBe("a\nraw\nc"); - const { warnings } = parsePatch("SWAP 2..2:\nraw"); + expect(applyPatch(text, "SWAP 2.=2:\nraw")).toBe("a\nraw\nc"); + const { warnings } = parsePatch("SWAP 2.=2:\nraw"); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); @@ -56,7 +56,7 @@ describe("hashline format v4", () => { const text = "a\nb\nc"; // Without this fix, "3:text" becomes literal "3:text" in the file. // With the fix, the "3:" prefix is stripped, yielding just "text". - const { edits, warnings } = parsePatch("SWAP 2..2:\n3:replaced"); + const { edits, warnings } = parsePatch("SWAP 2.=2:\n3:replaced"); expect(applyEdits(text, edits).text).toBe("a\nreplaced\nc"); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); @@ -73,12 +73,12 @@ describe("hashline format v4", () => { }); it("treats a delete range ending at the trailing sentinel as ending at the last real line", () => { - const edits = parsePatch("DEL 2..3").edits; + const edits = parsePatch("DEL 2.=3").edits; expect(applyEdits("a\nb\n", edits).text).toBe("a\n"); }); it("treats a replace range ending at the trailing sentinel as ending at the last real line", () => { - const edits = parsePatch("SWAP 2..3:\n+B").edits; + const edits = parsePatch("SWAP 2.=3:\n+B").edits; expect(applyEdits("a\nb\n", edits).text).toBe("a\nB\n"); }); @@ -94,12 +94,12 @@ describe("hashline format v4", () => { }); it("does not flush a trailing streaming pending empty replace hunk", () => { - const result = parsePatchStreaming("SWAP 5..5:\n"); + const result = parsePatchStreaming("SWAP 5.=5:\n"); expect(result.edits).toEqual([]); }); it("flushes a streaming empty replace hunk when another hunk starts", () => { - const result = parsePatchStreaming("SWAP 2..2:\nINS.TAIL:\n"); + const result = parsePatchStreaming("SWAP 2.=2:\nINS.TAIL:\n"); expect(result.edits).toEqual([{ kind: "delete", anchor: { line: 2 }, lineNum: 1, index: 0 }]); }); }); diff --git a/packages/hashline/test/landing-shift.test.ts b/packages/hashline/test/landing-shift.test.ts index b1f9eb287..1b41ddc09 100644 --- a/packages/hashline/test/landing-shift.test.ts +++ b/packages/hashline/test/landing-shift.test.ts @@ -207,7 +207,7 @@ describe("insert-after-block inward landing shift", () => { it("refuses to cross a closer targeted by another hunk", () => { const resolver: BlockResolver = (): BlockSpan => ({ start: 1, end: 5 }); const text = ["foo(() => {", " bar(() => {", " x();", " });", "});", ""].join("\n"); - const section = Patch.parseSingle("[x.ts#1A2B]\nSWAP 4..4:\n+ }); // bar\nINS.BLK.POST 1:\n+ y();"); + const section = Patch.parseSingle("[x.ts#1A2B]\nSWAP 4.=4:\n+ }); // bar\nINS.BLK.POST 1:\n+ y();"); const result = section.applyTo(text, resolver); diff --git a/packages/hashline/test/leniency.test.ts b/packages/hashline/test/leniency.test.ts index fde068fc7..402c22e65 100644 --- a/packages/hashline/test/leniency.test.ts +++ b/packages/hashline/test/leniency.test.ts @@ -9,7 +9,7 @@ const FILE = "a\nb\nc\nd\ne"; describe("hashline section headers", () => { it("accepts paths with spaces in anchored section headers", () => { - const section = Patch.parseSingle("[dir with spaces/file.ts#1a2b]\nSWAP 1..1:\n+after"); + const section = Patch.parseSingle("[dir with spaces/file.ts#1a2b]\nSWAP 1.=1:\n+after"); expect(section.path).toBe("dir with spaces/file.ts"); expect(section.fileHash).toBe("1A2B"); @@ -17,7 +17,7 @@ describe("hashline section headers", () => { }); it("recovers apply_patch-contaminated headers whose paths contain spaces", () => { - const section = Patch.parseSingle("[*** Update File: dir with spaces/file.ts#1A2B]\nSWAP 1..1:\n+after"); + const section = Patch.parseSingle("[*** Update File: dir with spaces/file.ts#1A2B]\nSWAP 1.=1:\n+after"); expect(section.path).toBe("dir with spaces/file.ts"); expect(section.fileHash).toBe("1A2B"); @@ -25,30 +25,30 @@ describe("hashline section headers", () => { }); it("rejects trailing junk after a snapshot tag", () => { - expect(() => Patch.parse("[src/a.ts#1A2B copied from read]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); - expect(() => Patch.parse("[src/a.ts#1A2B:812]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B copied from read]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B:812]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); }); it("rejects trailing junk after a snapshot tag even with apply_patch noise", () => { - expect(() => Patch.parse("[Update File: src/a.ts#1A2B copied from read]\nSWAP 1..1:\n+after")).toThrow( + expect(() => Patch.parse("[Update File: src/a.ts#1A2B copied from read]\nSWAP 1.=1:\n+after")).toThrow( /Input header must be/, ); - expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); }); it("rejects malformed snapshot tags", () => { - expect(() => Patch.parse("[src/a.ts#1A2]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); - expect(() => Patch.parse("[src/a.ts#1A2G]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); - expect(() => Patch.parse("[src/a.ts#1A2B5]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2G]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B5]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); }); it("rejects malformed snapshot tags even with apply_patch noise", () => { - expect(() => Patch.parse("[Update File: src/a.ts#1A2G]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[Update File: src/a.ts#1A2G]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); }); it("reports bracket syntax with a 4-hex example when the header is missing", () => { try { - Patch.parse("DEL 38..40"); + Patch.parse("DEL 38.=40"); throw new Error("expected missing-header error"); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -69,8 +69,8 @@ describe("hashline core — verb header forms", () => { }); it("accepts canonical replace/delete/insert forms", () => { - expect(applyPatch(FILE, "SWAP 2..3:\n+X")).toBe("a\nX\nd\ne"); - expect(applyPatch(FILE, "DEL 2..3")).toBe("a\nd\ne"); + expect(applyPatch(FILE, "SWAP 2.=3:\n+X")).toBe("a\nX\nd\ne"); + expect(applyPatch(FILE, "DEL 2.=3")).toBe("a\nd\ne"); expect(applyPatch(FILE, "INS.PRE 2:\n+X")).toBe("a\nX\nb\nc\nd\ne"); expect(applyPatch(FILE, "INS.POST 2:\n+X")).toBe("a\nb\nX\nc\nd\ne"); expect(applyPatch(FILE, "INS.HEAD:\n+X")).toBe("X\na\nb\nc\nd\ne"); @@ -86,7 +86,8 @@ describe("hashline core — verb header forms", () => { expect(applyPatch(FILE, "SWAP 2-3:\n+X")).toBe("a\nX\nd\ne"); expect(applyPatch(FILE, "SWAP 2\u20263:\n+X")).toBe("a\nX\nd\ne"); expect(applyPatch(FILE, "SWAP 2 3:\n+X")).toBe("a\nX\nd\ne"); - expect(applyPatch(FILE, "SWAP 2..3\n+X")).toBe("a\nX\nd\ne"); + expect(applyPatch(FILE, "SWAP 2..3:\n+X")).toBe("a\nX\nd\ne"); // legacy `..` still accepted + expect(applyPatch(FILE, "SWAP 2.=3\n+X")).toBe("a\nX\nd\ne"); // missing colon }); it("accepts missing colon on insert headers", () => { @@ -97,69 +98,69 @@ describe("hashline core — verb header forms", () => { describe("hashline body contracts", () => { it("auto-pipes a bare body row while warning", () => { - const result = parsePatch("SWAP 2..2:\n hello"); + const result = parsePatch("SWAP 2.=2:\n hello"); expect(applyEdits(FILE, result.edits).text).toBe("a\n hello\nc\nd\ne"); expect(result.warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); it("strips read-output line number prefix from auto-piped bare body rows", () => { - const result = parsePatch("SWAP 2..2:\n2:hello"); + const result = parsePatch("SWAP 2.=2:\n2:hello"); expect(applyEdits(FILE, result.edits).text).toBe("a\nhello\nc\nd\ne"); expect(result.warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); it("preserves `+N:` literal payloads without stripping", () => { - const result = parsePatch("SWAP 2..2:\n+3:keep"); + const result = parsePatch("SWAP 2.=2:\n+3:keep"); expect(applyEdits(FILE, result.edits).text).toBe("a\n3:keep\nc\nd\ne"); expect(result.warnings.some(w => /Auto-prefixed/.test(w))).toBe(false); }); it("strips only one N: prefix from bare body rows (preserves nested digits:colon)", () => { // "2:42:hello" → should yield "42:hello", NOT "hello" (recursive would over-strip) - const result = parsePatch("SWAP 2..2:\n2:42:hello"); + const result = parsePatch("SWAP 2.=2:\n2:42:hello"); expect(applyEdits(FILE, result.edits).text).toBe("a\n42:hello\nc\nd\ne"); }); it("strips N: prefixes only when every bare body row carries one", () => { - const result = parsePatch("SWAP 2..3:\n2:foo\n3:bar"); + const result = parsePatch("SWAP 2.=3:\n2:foo\n3:bar"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\nbar\nd\ne"); }); it("leaves bare body rows untouched when only some carry an N: prefix", () => { // "3:keep" looks like a snapshot prefix but "plain" does not, so the body // is genuine content (not a pasted snapshot) — strip nothing. - const result = parsePatch("SWAP 2..3:\n3:keep\nplain"); + const result = parsePatch("SWAP 2.=3:\n3:keep\nplain"); expect(applyEdits(FILE, result.edits).text).toBe("a\n3:keep\nplain\nd\ne"); }); it("keeps interior blank rows in a bare replace body", () => { - const result = parsePatch("SWAP 2..3:\nfoo\n\nbar"); + const result = parsePatch("SWAP 2.=3:\nfoo\n\nbar"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\n\nbar\nd\ne"); }); it("drops trailing blank rows between a bare body and the next hunk", () => { - const result = parsePatch("SWAP 2..2:\nfoo\n\nSWAP 4..4:\nbaz"); + const result = parsePatch("SWAP 2.=2:\nfoo\n\nSWAP 4.=4:\nbaz"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\nc\nbaz\ne"); }); it("skips blank rows when checking N: prefix uniformity", () => { - const result = parsePatch("SWAP 2..3:\n2:foo\n\n3:bar"); + const result = parsePatch("SWAP 2.=3:\n2:foo\n\n3:bar"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\n\nbar\nd\ne"); }); it("leaves numeric-keyed literal bodies untouched (dict/YAML shape)", () => { - const result = parsePatch('SWAP 2..3:\n1: "one",\n2: "two",'); + const result = parsePatch('SWAP 2.=3:\n1: "one",\n2: "two",'); expect(applyEdits(FILE, result.edits).text).toBe('a\n1: "one",\n2: "two",\nd\ne'); }); it("rejects `-` body rows with a teaching error", () => { - expect(() => parsePatch("SWAP 2..2:\n-old\n+new")).toThrow(/`-` rows are not valid/); + expect(() => parsePatch("SWAP 2.=2:\n-old\n+new")).toThrow(/`-` rows are not valid/); }); it("allows literal text that begins with `-` or `+` when prefixed with `+`", () => { - expect(applyPatch(FILE, "SWAP 2..2:\n+-literal\n++plus")).toBe("a\n-literal\n+plus\nc\nd\ne"); + expect(applyPatch(FILE, "SWAP 2.=2:\n+-literal\n++plus")).toBe("a\n-literal\n+plus\nc\nd\ne"); }); it("treats empty replace as delete and still rejects empty insert", () => { - expect(applyPatch(FILE, "SWAP 2..2:")).toBe("a\nc\nd\ne"); + expect(applyPatch(FILE, "SWAP 2.=2:")).toBe("a\nc\nd\ne"); expect(() => parsePatch("INS.TAIL:")).toThrow(/`INS` needs/); }); @@ -174,23 +175,23 @@ describe("hashline body contracts", () => { describe("hashline — apply_patch / unified-diff contamination", () => { it("rejects apply_patch sentinels as contamination", () => { - expect(() => parsePatch("*** Update File: a.ts\nSWAP 2..2:\n+X")).toThrow(/apply_patch sentinel/); - expect(() => parsePatch("*** Add File: a.ts\nSWAP 2..2:\n+X")).toThrow(/apply_patch sentinel/); + expect(() => parsePatch("*** Update File: a.ts\nSWAP 2.=2:\n+X")).toThrow(/apply_patch sentinel/); + expect(() => parsePatch("*** Add File: a.ts\nSWAP 2.=2:\n+X")).toThrow(/apply_patch sentinel/); }); it("rejects unified-diff hunk headers as contamination", () => { - expect(() => parsePatch("@@ -1,3 +1,3 @@\nSWAP 2..2:\n+X")).toThrow(/unified-diff hunk header/); + expect(() => parsePatch("@@ -1,3 +1,3 @@\nSWAP 2.=2:\n+X")).toThrow(/unified-diff hunk header/); }); it("treats top-level `+TEXT` as an orphan literal payload", () => { - expect(() => parsePatch("+const X = 1;\nSWAP 2..2:")).toThrow(/payload line has no preceding hunk header/); + expect(() => parsePatch("+const X = 1;\nSWAP 2.=2:")).toThrow(/payload line has no preceding hunk header/); }); }); describe("hashline apply — duplicate boundary payloads", () => { it("keeps replacement boundary echoes literal unless balance repair applies", () => { const text = ["// one", "// two", "old();"].join("\n"); - const diff = "SWAP 3..3:\n+// one\n+// two\n+new();"; + const diff = "SWAP 3.=3:\n+// one\n+// two\n+new();"; expect(applyPatch(text, diff)).toBe(["// one", "// two", "// one", "// two", "new();"].join("\n")); }); diff --git a/packages/hashline/test/patcher.test.ts b/packages/hashline/test/patcher.test.ts index 4a2ddc8e7..0f9d36ff1 100644 --- a/packages/hashline/test/patcher.test.ts +++ b/packages/hashline/test/patcher.test.ts @@ -25,7 +25,7 @@ describe("Patcher snapshot tag integrity", () => { const tag = snapshots.record(PATH, "before\n"); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 1..1:\n+after`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 1.=1:\n+after`)); expect(result.sections[0]?.op).toBe("update"); expect(result.sections[0]?.fileHash).toMatch(/^[0-9A-F]{4}$/); @@ -45,14 +45,14 @@ describe("Patcher snapshot tag integrity", () => { expect(snapshots.byHash(PATH, tag)).toBeNull(); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 3..3:\n+L3`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 3.=3:\n+L3`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nl2\nL3\nl4\nl5\n"); }); it("normalizes lowercase section tags while parsing", () => { - const section = Patch.parseSingle(`[${PATH}#1a2b]\nSWAP 1..1:\n+after`); + const section = Patch.parseSingle(`[${PATH}#1a2b]\nSWAP 1.=1:\n+after`); expect(section.fileHash).toBe("1A2B"); }); @@ -65,7 +65,7 @@ describe("Patcher snapshot tag integrity", () => { const patcher = new Patcher({ fs, snapshots }); try { - await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 1..1:\n+after`)); + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 1.=1:\n+after`)); throw new Error("expected MismatchError"); } catch (error) { expect(error).toBeInstanceOf(MismatchError); @@ -89,7 +89,7 @@ describe("Patcher snapshot tag integrity", () => { const bogus = live === "FFFF" ? "0000" : "FFFF"; try { - await patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nSWAP 1..1:\n+after`)); + await patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nSWAP 1.=1:\n+after`)); throw new Error("expected MismatchError"); } catch (error) { expect(error).toBeInstanceOf(MismatchError); @@ -120,7 +120,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[${PATH}]\nSWAP 1..1:\n+X`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}]\nSWAP 1.=1:\n+X`))).rejects.toThrow( /Missing hashline snapshot tag/, ); }); @@ -176,7 +176,7 @@ describe("Patcher seen-line provenance", () => { const tag = snapshots.record(PATH, CONTENT, [1, 2]); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4..4:\n+L4`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4.=4:\n+L4`))).rejects.toThrow( /were not shown in the read\/search output/, ); expect(fs.get(PATH)).toBe(CONTENT); @@ -188,7 +188,7 @@ describe("Patcher seen-line provenance", () => { const tag = snapshots.record(PATH, CONTENT, [1, 2]); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2..2:\n+L2`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+L2`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nL2\nl3\nl4\nl5\n"); @@ -202,7 +202,7 @@ describe("Patcher seen-line provenance", () => { snapshots.record(PATH, CONTENT, [4, 5]); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4..4:\n+L4`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4.=4:\n+L4`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nl2\nl3\nL4\nl5\n"); @@ -214,7 +214,7 @@ describe("Patcher seen-line provenance", () => { const tag = snapshots.record(PATH, CONTENT); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4..4:\n+L4`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4.=4:\n+L4`)); expect(result.sections[0]?.op).toBe("update"); }); diff --git a/packages/hashline/test/recovery-session-chain.test.ts b/packages/hashline/test/recovery-session-chain.test.ts index 1c1930357..1dffec42e 100644 --- a/packages/hashline/test/recovery-session-chain.test.ts +++ b/packages/hashline/test/recovery-session-chain.test.ts @@ -33,7 +33,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { // rewrote. Replaying onto current would overwrite "L5-CHANGED" with // payload the model authored against the stale "L5". That is // corruption, not recovery. - const { edits } = parsePatch("SWAP 5..5:\n|L5-MODEL"); + const { edits } = parsePatch("SWAP 5.=5:\n|L5-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -51,7 +51,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { // merge fails (patch context includes the rewritten line 5), but the // replay fallback is safe because the model's anchor still names the // same logical content. - const { edits } = parsePatch("SWAP 3..3:\n|L3-MODEL"); + const { edits } = parsePatch("SWAP 3.=3:\n|L3-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH,