From 907bc9979ebb76805c1e0bc97daa659f547d4ccb Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 15 Jun 2026 06:13:04 +0200 Subject: [PATCH] feat: renamed opcodes to simplified variants - Renamed line and block patch op verbs to XCHG, DEL, and INS in parsing and formatting. - Updated grammar and tokenizer to support XCHG.BLK, DEL.BLK, and INS.PRE/POST/HEAD/TAIL forms. - Updated diagnostics, docs, prompts, tests, and changelog to use XCHG/DEL/INS-based operators. - Expanded session-stats parsing to normalize legacy op aliases to compact IDs. --- docs/tools/edit.md | 85 ++++++----- .../src/edit/hashline/block-resolver.ts | 2 +- .../coding-agent/src/edit/hashline/execute.ts | 7 +- .../test/core/block-replace.test.ts | 32 ++-- packages/hashline/CHANGELOG.md | 10 ++ packages/hashline/README.md | 12 +- packages/hashline/src/apply.ts | 6 +- packages/hashline/src/block.ts | 12 +- packages/hashline/src/format.ts | 31 ++-- packages/hashline/src/grammar.lark | 14 +- packages/hashline/src/input.ts | 6 +- packages/hashline/src/messages.ts | 59 ++++---- packages/hashline/src/parser.ts | 16 +- packages/hashline/src/patcher.ts | 10 +- packages/hashline/src/prompt.md | 74 ++++----- packages/hashline/src/tokenizer.ts | 75 ++++----- packages/hashline/src/types.ts | 20 +-- packages/hashline/test/block.test.ts | 142 +++++++++--------- .../hashline/test/boundary-repair.test.ts | 34 ++--- packages/hashline/test/core-contracts.test.ts | 66 ++++---- packages/hashline/test/format-v2.test.ts | 40 ++--- packages/hashline/test/landing-shift.test.ts | 51 +++---- packages/hashline/test/leniency.test.ts | 98 ++++++------ packages/hashline/test/patcher.test.ts | 28 ++-- .../test/recovery-session-chain.test.ts | 4 +- scripts/session-stats/README.md | 2 +- scripts/session-stats/analyze.py | 24 ++- 27 files changed, 484 insertions(+), 476 deletions(-) diff --git a/docs/tools/edit.md b/docs/tools/edit.md index dde10c46d..a831d5d60 100644 --- a/docs/tools/edit.md +++ b/docs/tools/edit.md @@ -8,7 +8,7 @@ - Key collaborators: - `packages/coding-agent/src/utils/edit-mode.ts` — selects active edit mode - `packages/hashline/src/grammar.lark` — canonical constrained-decoding grammar - - `packages/hashline/src/format.ts` — sigils and header constants (`[`, `]`, `#`, `+`, `replace`, `delete`, `insert`) + - `packages/hashline/src/format.ts` — sigils and header constants (`[`, `]`, `#`, `+`, `XCHG`, `DEL`, `INS`) - `packages/hashline/src/input.ts` — parses `[PATH#TAG]` sections - `packages/hashline/src/tokenizer.ts` / `packages/hashline/src/parser.ts` — tokenizes and parses ops - `packages/hashline/src/apply.ts` — applies parsed edits to file text @@ -28,19 +28,19 @@ 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**: - - `replace N..M:` — replace original lines N..M with the body rows below. - - `replace block 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 `replace 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 (`replace block N → resolved lines A-B`). Errors (and steers to `replace 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. - - `delete N..M` — delete original lines N..M. No body. - - `delete block N` — delete the whole tree-sitter block beginning on line N (resolved like `replace block N`, with the same decorator/comment caveat). No body. On success the result echoes the matched span (`delete block N → resolved lines A-B`). Same resolution failure modes and `delete N..M` fallback. - - `insert before N:` — insert body rows immediately before line N. - - `insert after N:` — insert body rows immediately after line N. - - `insert after block 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 `insert after M:`. Same resolution failure modes and `insert after M:` fallback. - - `insert head:` — insert body rows at the start of the file. - - `insert tail:` — insert body rows at the end of the file. + - `XCHG N..M:` — replace original lines N..M with the body rows below. + - `XCHG.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 `XCHG 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 (`XCHG.BLK N → resolved lines A-B`). Errors (and steers to `XCHG 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 `XCHG.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. + - `INS.HEAD:` — insert body rows at the start of the file. + - `INS.TAIL:` — insert body rows at the end of the file. - **Body rows**: - Only body-bearing headers end in `:`. - Every body row is `+TEXT`; `+` alone adds a blank line. - - `delete` never has body rows. + - `DEL` never has body rows. - There is no repeat row kind. To keep a line, leave it out of every range; split edits into multiple hunks when needed. - `-` rows are invalid. Literal text beginning with `-` or `+` must be written as `+-text` / `++text`. @@ -50,25 +50,25 @@ 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: -- `replace N:` — accepted as `replace N..N:`. -- `delete N` — accepted as single-line delete. -- Missing trailing colon on `replace` or `insert` — accepted. -- `replace N-M:`, `replace N…M:`, and `replace N M:` — accepted as `replace N..M:`. +- `XCHG N:` — accepted as `XCHG N..N:`. +- `DEL N` — accepted as single-line delete. +- Missing trailing colon on `XCHG` or `INS` — accepted. +- `XCHG N-M:`, `XCHG N…M:`, and `XCHG N M:` — accepted as `XCHG 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 `replace` or `delete`. -- `delete N..M:` and any body rows under `delete` / `delete block` are rejected. -- Empty `replace` / `insert` / `replace block` hunks are rejected. +- Bare `N` and bare `N M` / `N..M` headers are rejected with guidance to write `XCHG` or `DEL`. +- `DEL N..M:` and any body rows under `DEL` / `DEL.BLK` are rejected. +- Empty `XCHG` / `INS` / `XCHG.BLK` hunks are rejected. - `-` body rows are rejected with `MINUS_ROW_REJECTED`. -- `replace block N:` / `delete block N` / `insert after block N:` require a wired tree-sitter resolver; `replace block` and `insert after block` additionally need at least one `+TEXT` body row, while `delete block` 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: `insert after block N:` anchored on a pure closing-delimiter line is lowered to plain `insert after N:` with a warning — line N is the end of a block, and inserting after that end is exactly what the plain form does. +- `XCHG.BLK N:` / `DEL.BLK N` / `INS.BLK.POST N:` require a wired tree-sitter resolver; `XCHG.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. ## Outputs - Single-shot tool result; hashline mode does not use a `resolve` preview/apply handshake. - `content` contains one text block per call. For a successful single-file edit it is the post-edit `[path#TAG]` section header (a fresh snapshot tag for the written content), followed by a compact diff preview from `packages/hashline/src/diff-preview.ts` when one is emitted. -- When the patch used `replace block`/`delete block`/`insert after block` ops (and the apply matched the tagged content), one `replace block N → resolved lines A-B (K lines)` line per block op (single-line spans render `resolved line A (1 line)`; insert-after appends `; body lands after line B`) is inserted between the `[PATH#TAG]` header and the diff preview, so the caller can confirm tree-sitter resolved the construct it intended. +- When the patch used `XCHG.BLK`/`DEL.BLK`/`INS.BLK.POST` ops (and the apply matched the tagged content), one `XCHG.BLK N → resolved lines A-B (K lines)` line per block op (single-line spans render `resolved line A (1 line)`; INS.BLK.POST appends `; body lands after line B`) is inserted between the `[PATH#TAG]` header and the diff preview, so the caller can confirm tree-sitter resolved the construct it intended. - Parse, apply, or recovery warnings are appended as: ```text @@ -103,7 +103,7 @@ Replace line 1 with two lines: ```text [a.ts#0A3B] -replace 1..1: +XCHG 1..1: +const X = "b"; +export const Y = X; ``` @@ -112,7 +112,7 @@ Insert below line 5: ```text [a.ts#0A3B] -insert after 5: +INS.POST 5: +console.log(X + Y); ``` @@ -120,7 +120,7 @@ Insert above line 5: ```text [a.ts#0A3B] -insert before 5: +INS.PRE 5: +console.log(X + Y); ``` @@ -128,34 +128,33 @@ Delete lines 4..5 entirely: ```text [a.ts#0A3B] -delete 4..5 +DEL 4..5 ``` Insert at start and end of file: ```text [a.ts#0A3B] -insert head: +INS.HEAD: +// header -insert tail: +INS.TAIL: +// trailer ``` Multi-file: - ```text [src/a.ts#0A3B] -replace 4..4: +XCHG 4..4: +const enabled = true; [src/b.ts#1F7C] -delete 20 +DEL 20 ``` ## Limits & Caps - 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 `replace` / `delete` / `insert` (`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 `XCHG` / `DEL` / `INS` (`packages/hashline/src/format.ts`). ## Errors - Missing section header: @@ -163,29 +162,29 @@ delete 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 \`replace N..M:\`, \`delete N..M\`, or \`insert before|after|head|tail:\` above the body. Got "...".` + - `line N: payload line has no preceding hunk header. Use \`XCHG 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: \`replace N..M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`delete N..M\`.` - - `line N: \`insert\` needs at least one \`+TEXT\` body row.` - - `line N: \`replace block N:\` needs at least one \`+TEXT\` body row. To delete a block, use \`delete N..M\` with the block's line range.` + - `line N: \`XCHG 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: \`XCHG.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: \`replace block 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 \`replace 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: \`insert after block 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 \`insert after M:\` with the block's explicit last line instead.` — same context preview. + - `line N: \`XCHG.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 \`XCHG 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: \`delete N..M\` does not take body rows. Remove the body, or use \`replace N..M:\`.` - - `line N: \`delete block N\` does not take body rows. Remove the body, or use \`replace block N:\` to replace the block.` + - `line N: \`DEL N..M\` does not take body rows. Remove the body, or use \`XCHG N..M:\`.` + - `line N: \`DEL.BLK N\` does not take body rows. Remove the body, or use \`XCHG.BLK N:\` to replace the block.` - Range out of order: - `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 \`replace N..M:\`, \`delete N..M\`, or \`insert before|after|head|tail:\` ops.` - - `line N: unified-diff hunk header (\`@@ -N,M +N,M @@\`) is not valid in hashline. Use \`replace N..M:\`, \`delete N..M\`, or \`insert before|after|head|tail:\` ops.` - - `line N: \`@@\`-bracketed hunk header "@@ …" is not valid in hashline. Drop the \`@@ ... @@\` brackets and write a verb header such as \`replace N..M:\`.` - - `line N: hunk headers need a verb. Use \`replace N..N:\` to replace, or \`delete N\` to delete.` - - `line N: bare range hunk header "N M" is not valid. Hunk headers need a verb: write \`replace N..M:\` or \`delete N..M\`.` + - `line N: apply_patch sentinel "*** …" is not valid in hashline. File sections start with \`[path#HASH]\` (no \`Update File:\` / \`Add File:\` keyword). Use \`XCHG 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 \`XCHG 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 \`XCHG N..M:\`.` + - `line N: hunk headers need a verb. Use \`XCHG 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 \`XCHG ${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/coding-agent/src/edit/hashline/block-resolver.ts b/packages/coding-agent/src/edit/hashline/block-resolver.ts index 4faa8bb06..433a0c153 100644 --- a/packages/coding-agent/src/edit/hashline/block-resolver.ts +++ b/packages/coding-agent/src/edit/hashline/block-resolver.ts @@ -1,5 +1,5 @@ /** - * Tree-sitter-backed {@link BlockResolver} for the hashline `replace block N:` + * Tree-sitter-backed {@link BlockResolver} for the hashline block replace * operator. Bridges the pure hashline seam to the native `blockRangeAt` * primitive in `@oh-my-pi/pi-natives`, which infers the language from the file * path and returns the 1-indexed line span of the syntactic block beginning on diff --git a/packages/coding-agent/src/edit/hashline/execute.ts b/packages/coding-agent/src/edit/hashline/execute.ts index 18fb606ec..0bdf4699f 100644 --- a/packages/coding-agent/src/edit/hashline/execute.ts +++ b/packages/coding-agent/src/edit/hashline/execute.ts @@ -98,12 +98,7 @@ interface RenderedSection { } function formatBlockResolution(resolution: BlockResolution): string { - const op = - resolution.op === "delete" - ? "delete block" - : resolution.op === "insert_after" - ? "insert after block" - : "replace block"; + const op = resolution.op === "delete" ? "DEL.BLK" : resolution.op === "insert_after" ? "INS.BLK.POST" : "XCHG.BLK"; const lines = resolution.end - resolution.start + 1; const span = resolution.start === resolution.end ? `line ${resolution.start}` : `lines ${resolution.start}-${resolution.end}`; diff --git a/packages/coding-agent/test/core/block-replace.test.ts b/packages/coding-agent/test/core/block-replace.test.ts index c247d17e6..2524074b8 100644 --- a/packages/coding-agent/test/core/block-replace.test.ts +++ b/packages/coding-agent/test/core/block-replace.test.ts @@ -63,12 +63,12 @@ async function seedFile( const TS_SOURCE = "function x() {\n if (y) {\n }\n}\n"; -describe("replace block — native tree-sitter resolution end-to-end", () => { +describe("XCHG.BLK — native tree-sitter resolution end-to-end", () => { it("resolves the inner `if` block (line 2) and replaces its full span", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nreplace block 2:\n+ if (y || z) {\n+ }`; + const input = `${header}\nXCHG.BLK 2:\n+ if (y || z) {\n+ }`; await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -80,7 +80,7 @@ describe("replace block — native tree-sitter resolution end-to-end", () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nreplace block 1:\n+function x() {\n+ return 42;\n+}`; + const input = `${header}\nXCHG.BLK 1:\n+function x() {\n+ return 42;\n+}`; await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -88,11 +88,11 @@ describe("replace block — native tree-sitter resolution end-to-end", () => { }); }); - it("deletes the resolved `if` block (line 2) end-to-end via `delete block`", async () => { + it("deletes the resolved `if` block (line 2) end-to-end via `DEL.BLK`", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\ndelete block 2`; + const input = `${header}\nDEL.BLK 2`; await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -104,7 +104,7 @@ describe("replace block — native tree-sitter resolution end-to-end", () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nreplace block 2:\n+ if (y || z) {\n+ }`; + const input = `${header}\nXCHG.BLK 2:\n+ if (y || z) {\n+ }`; const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -113,44 +113,44 @@ describe("replace block — native tree-sitter resolution end-to-end", () => { }); }); - it("echoes the resolved span in the result text for replace block", async () => { + it("echoes the resolved span in the result text for XCHG.BLK", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nreplace block 1:\n+function x() {\n+ return 42;\n+}`; + const input = `${header}\nXCHG.BLK 1:\n+function x() {\n+ return 42;\n+}`; const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); // `function x() {` opens on line 1; tree-sitter resolves the whole body (lines 1-4). - expect(text).toContain("replace block 1 → resolved lines 1-4 (4 lines)"); + expect(text).toContain("XCHG.BLK 1 → resolved lines 1-4 (4 lines)"); }); }); - it("echoes the resolved span in the result text for delete block", async () => { + it("echoes the resolved span in the result text for DEL.BLK", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\ndelete block 2`; + const input = `${header}\nDEL.BLK 2`; const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); // `if (y) {` opens on line 2; resolves lines 2-3. - expect(text).toContain("delete block 2 → resolved lines 2-3 (2 lines)"); + expect(text).toContain("DEL.BLK 2 → resolved lines 2-3 (2 lines)"); }); }); - it("rejects a lone closing delimiter (no block begins there) and steers to `replace N..M:`", async () => { + it("rejects a lone closing delimiter (no block begins there) and steers to `XCHG N..M:`", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); // Line 3 is ` }` — a closing delimiter, not a block opener. - const input = `${header}\nreplace block 3:\n+ }`; + const input = `${header}\nXCHG.BLK 3:\n+ }`; // 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.*replace 3\.\.M:.*^ 1:function x\(\) \{$.*^\*3: {2}\}$/ms, + /could not resolve a syntactic block beginning on line 3.*XCHG 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); @@ -162,7 +162,7 @@ describe("replace block — native tree-sitter resolution end-to-end", () => { const session = makeSession(tempDir); const source = "alpha\nbeta\ngamma\n"; const { filePath, header } = await seedFile(tempDir, session, "data.unknownext", source); - const input = `${header}\nreplace block 1:\n+ALPHA`; + const input = `${header}\nXCHG.BLK 1:\n+ALPHA`; await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow( /could not resolve a syntactic block/, diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index bb7e1f911..db4d032ce 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Breaking Changes + +- Renamed all hashline DSL operators to concise abbreviated keywords: + - `replace` -> `XCHG` + - `delete` -> `DEL` + - `insert before`/`after`/`head`/`tail` -> `INS.PRE`/`POST`/`HEAD`/`TAIL` + - `replace_block` -> `XCHG.BLK` + - `delete_block` -> `DEL.BLK` + - `insert_after_block` -> `INS.BLK.POST` + ## [15.13.1] - 2026-06-15 ### Breaking Changes diff --git a/packages/hashline/README.md b/packages/hashline/README.md index 3da433997..537eafecd 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}] -replace 1..1: +XCHG 1..1: +const greeting = "hello";`); const result = await patcher.apply(patch); @@ -47,11 +47,11 @@ still matches the recorded content hash, and refusing or attempting session-aware recovery on mismatch. Inside a section: -- `replace A..B:` — replace lines A..B with following `+TEXT` body rows. -- `replace block A:` — replace the syntactic block beginning on line A. -- `delete A..B` / `delete block A` — delete concrete lines or a resolved block. -- `insert before A:` / `insert after A:` / `insert head:` / `insert tail:` — insert following body rows. -- `insert after block A:` — insert following body rows after the resolved block's last line. +- `XCHG A..B:` — replace lines A..B with following `+TEXT` body rows. +- `XCHG.BLK A:` — replace the syntactic block beginning on line A. +- `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). ## Abstractions diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index 8fdea19cd..fab4b1194 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -592,7 +592,7 @@ function repairReplacementBoundaries( // content lines are never crossed) places the body at the depth its // indentation names. // -// Inward (block-lowered inserts only): `insert after block N:` anchors on the +// Inward (block-lowered inserts only): `insert_after_block N:` anchors on the // resolved block's closing line, but a body indented deeper than that closer // claims a depth inside the block — the common misreading of the op as // "append at the end of block N's body". Sliding the landing point backward @@ -630,7 +630,7 @@ interface AfterInsertGroup { anchor: number; /** Indices into the edit list, in patch order. */ members: number[]; - /** First line of the resolved block when lowered from `insert after block N:`. */ + /** First line of the resolved block when lowered from `insert_after_block N:`. */ blockStart?: number; } @@ -730,7 +730,7 @@ function resolveInwardLanding( /** * Slide mis-anchored after-insert hunks to the depth their body indentation * claims: outward past the structural closer lines that follow the anchor - * when the body is shallower, or — for `insert after block N:` lowerings — + * when the body is shallower, or — for `insert_after_block N:` lowerings — * inward across the block's trailing closers when the body is deeper than * the block's closing line. Returns the corrected edit list plus one warning * per shifted hunk. diff --git a/packages/hashline/src/block.ts b/packages/hashline/src/block.ts index 276ce1348..dbe97d701 100644 --- a/packages/hashline/src/block.ts +++ b/packages/hashline/src/block.ts @@ -1,6 +1,6 @@ /** - * Expand deferred block edits (`replace block N:` / `delete block N` / - * `insert after block N:`) into concrete inserts + deletes. + * Expand deferred block edits (`replace_block N:` / `delete_block N` / + * `insert_after_block N:`) into concrete inserts + deletes. * * The hashline parser cannot expand a block edit on its own — the line span is * unknown until file text + path (→ language) are available. This transform @@ -29,7 +29,7 @@ export interface ResolveBlockEditsOptions { * `blockUnresolvedMessage` error — used by the authoritative apply + final * preview paths. `"drop"` silently skips the edit — used by the streaming * preview, where a half-written file or transient parse error must not - * throw. Unresolvable `insert after block N:` edits never reach this: they + * throw. Unresolvable `insert_after_block N:` edits never reach this: they * are lowered to plain `insert after N:` with a warning. */ onUnresolved?: "throw" | "drop"; @@ -42,7 +42,7 @@ export interface ResolveBlockEditsOptions { onResolved?: (resolution: BlockResolution) => void; /** * Invoked once per diagnostic produced while resolving — currently the - * `insert after block N:` lowerings (closer anchor or unresolvable block). + * `insert_after_block N:` lowerings (closer anchor or unresolvable block). * Hosts should surface these on the apply result's `warnings`. */ onWarning?: (message: string) => void; @@ -82,7 +82,7 @@ export function resolveBlockEdits( const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace"; const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null; if (span === null) { - // `insert after block N:` never fails the patch — lower it to plain + // `insert_after_block N:` never fails the patch — lower it to plain // `insert after N:` with a warning instead. Two flavors: // - anchored on a pure closing-delimiter line: no block begins // there, but line N IS the end of one, and "after the end of the @@ -148,7 +148,7 @@ export function resolveBlockEdits( // 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. + // `payloads` (from `delete_block N`) emits no inserts — a pure deletion. for (const payload of edit.payloads) { const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } }; resolved.push({ diff --git a/packages/hashline/src/format.ts b/packages/hashline/src/format.ts index ef752aa97..315c2a4ef 100644 --- a/packages/hashline/src/format.ts +++ b/packages/hashline/src/format.ts @@ -14,22 +14,25 @@ export const HL_FILE_SUFFIX = "]"; export const HL_PAYLOAD_REPLACE = "+"; /** Hunk-header keyword for concrete line replacement. */ -export const HL_REPLACE_KEYWORD = "replace"; -/** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */ -export const HL_BLOCK_KEYWORD = "block"; +export const HL_REPLACE_KEYWORD = "XCHG"; /** Hunk-header keyword for concrete line deletion. */ -export const HL_DELETE_KEYWORD = "delete"; +export const HL_DELETE_KEYWORD = "DEL"; /** Hunk-header keyword for insertion operations. */ -export const HL_INSERT_KEYWORD = "insert"; +export const HL_INSERT_KEYWORD = "INS"; /** Insert position keyword for inserting before a concrete line. */ -export const HL_INSERT_BEFORE = "before"; +export const HL_INSERT_BEFORE = "PRE"; /** Insert position keyword for inserting after a concrete line. */ -export const HL_INSERT_AFTER = "after"; +export const HL_INSERT_AFTER = "POST"; /** Insert position keyword for inserting at the start of the file. */ -export const HL_INSERT_HEAD = "head"; +export const HL_INSERT_HEAD = "HEAD"; /** Insert position keyword for inserting at the end of the file. */ -export const HL_INSERT_TAIL = "tail"; -/** Hunk-header terminator for body-bearing operations. */ +export const HL_INSERT_TAIL = "TAIL"; +/** Hunk-header keyword: `XCHG.BLK N:` resolves N to a tree-sitter block range and replaces its span. */ +export const HL_REPLACE_BLOCK_KEYWORD = "XCHG.BLK"; +/** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */ +export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK"; +/** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */ +export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST"; export const HL_HEADER_COLON = ":"; /** Separator between a hashline file path and its opaque snapshot tag. */ @@ -65,13 +68,13 @@ export function formatDeleteHeader(start: number, end = start): string { export function formatInsertHeader(cursor: Cursor): string { switch (cursor.kind) { case "before_anchor": - return `${HL_INSERT_KEYWORD} ${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`; + return `${HL_INSERT_KEYWORD}.${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`; case "after_anchor": - return `${HL_INSERT_KEYWORD} ${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`; + return `${HL_INSERT_KEYWORD}.${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`; case "bof": - return `${HL_INSERT_KEYWORD} ${HL_INSERT_HEAD}${HL_HEADER_COLON}`; + return `${HL_INSERT_KEYWORD}.${HL_INSERT_HEAD}${HL_HEADER_COLON}`; case "eof": - return `${HL_INSERT_KEYWORD} ${HL_INSERT_TAIL}${HL_HEADER_COLON}`; + return `${HL_INSERT_KEYWORD}.${HL_INSERT_TAIL}${HL_HEADER_COLON}`; } } diff --git a/packages/hashline/src/grammar.lark b/packages/hashline/src/grammar.lark index a121d4e0a..ae19f7b1f 100644 --- a/packages/hashline/src/grammar.lark +++ b/packages/hashline/src/grammar.lark @@ -12,13 +12,13 @@ replace_hunk: replace_anchor LF emit_op* replace_block_hunk: replace_block_anchor LF emit_op+ insert_hunk: insert_anchor LF emit_op+ insert_block_hunk: insert_block_anchor LF emit_op+ -delete_hunk: "delete " header_range LF -delete_block_hunk: "delete block " LID LF -replace_anchor: "replace " header_range ":" -replace_block_anchor: "replace block " LID ":" -insert_anchor: "insert " insert_pos ":" -insert_block_anchor: "insert after block " LID ":" -insert_pos: "before " LID | "after " LID | "head" | "tail" +delete_hunk: "DEL " header_range LF +delete_block_hunk: "DEL.BLK " LID LF +replace_anchor: "XCHG " header_range ":" +replace_block_anchor: "XCHG.BLK " LID ":" +insert_anchor: "INS." insert_pos ":" +insert_block_anchor: "INS.BLK.POST " LID ":" +insert_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL" emit_op: "+" /(.*)/ LF header_range: LID ".." LID diff --git a/packages/hashline/src/input.ts b/packages/hashline/src/input.ts index 5962fe29a..976d23f1d 100644 --- a/packages/hashline/src/input.ts +++ b/packages/hashline/src/input.ts @@ -273,7 +273,7 @@ export class PatchSection { get hasAnchorScopedEdit(): boolean { return this.edits.some(edit => { if (edit.kind === "delete") return true; - // A `replace block N:` edit is anchored to concrete content on line N. + // A `replace_block N:` edit is anchored to concrete content on line N. if (edit.kind === "block") return true; return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; }); @@ -305,7 +305,7 @@ export class PatchSection { * method directly when you've already validated the file content and * just want the result. * - * `blockResolver` resolves any `replace block N:` edits against `text`; an + * `blockResolver` resolves any `replace_block N:` edits against `text`; an * unresolvable block throws (this is the final, authoritative preview path). */ applyTo(text: string, blockResolver?: BlockResolver): ApplyResult { @@ -331,7 +331,7 @@ export class PatchSection { * empty-payload edit. Intended for incremental diff previews; the writer * path should always use {@link applyTo}. * - * `blockResolver` resolves any `replace block N:` edits against `text`; an + * `blockResolver` resolves any `replace_block N:` edits against `text`; an * unresolvable block is silently dropped so a half-written file does not * throw mid-stream. */ diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index b770b97d5..69e37554f 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -44,11 +44,10 @@ 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 `replace N..M:` hunk per range — the body is the final content, never old+new."; + "Two hunks targeted the same range; kept only the second. One `XCHG N..M:` hunk per range — the body is the final content, never old+new."; /** Bare bodyless hunk followed by an overlapping concrete hunk. */ -export const REPLACE_PAIR_COALESCED_OVERLAP_WARNING = - "Dropped a bare hunk overlapped by the concrete hunk after it. One `replace 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 `XCHG N..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 = @@ -59,17 +58,16 @@ 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 = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`."; +export const EMPTY_REPLACE = "`XCHG N..M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N..M`."; -/** `replace block N:` hunk with no body. */ -export const EMPTY_BLOCK = - "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete block N`."; +/** `replace_block N:` hunk with no body. */ +export const EMPTY_BLOCK = "`XCHG.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`."; /** * Block-anchored replace/delete could not resolve to a syntactic block * (unsupported language, blank/out-of-range line, no node beginning on N, or * parse error). Appends a {@link formatAnchoredContext} preview when - * `fileLines` is given. `insert after block N:` never reaches this — it is + * `fileLines` is given. `insert_after_block N:` never reaches this — it is * lowered to plain `insert after N:` instead (see * {@link insertAfterBlockUnresolvedLoweredWarning}). */ @@ -78,8 +76,8 @@ export function blockUnresolvedMessage( op: "replace" | "delete" = "replace", fileLines?: readonly string[], ): string { - const phrase = op === "delete" ? `delete block ${line}` : `replace block ${line}:`; - const fallback = op === "delete" ? `delete ${line}..M` : `replace ${line}..M:`; + const phrase = op === "delete" ? `DEL.BLK ${line}` : `XCHG.BLK ${line}:`; + const fallback = op === "delete" ? `DEL ${line}..M` : `XCHG ${line}..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.`; @@ -92,42 +90,41 @@ export function blockUnresolvedMessage( /** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */ export const BLOCK_RESOLVER_UNAVAILABLE = - "`replace block`/`delete block`/`insert after block` are not available here (no block resolver configured). Use a concrete line range."; + "`XCHG.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range."; /** - * `insert after block N:` anchored on a closing-delimiter line, lowered to + * `insert_after_block N:` anchored on a closing-delimiter line, lowered to * plain `insert after N:` — the closer ends a block, and inserting after it * is exactly what the plain form does. */ export function insertAfterBlockCloserLoweredWarning(line: number): string { - return `\`insert after block ${line}:\` anchors on a closing delimiter, so it was applied as plain \`insert after ${line}:\`. Anchor on the line that OPENS the construct.`; + return `\`INS.BLK.POST ${line}:\` anchors on a closing delimiter, so it was applied as plain \`INS.POST ${line}:\`. Anchor on the line that OPENS the construct.`; } /** - * `insert after block N:` anchor unresolvable (unsupported language, blank + * `insert_after_block N:` anchor unresolvable (unsupported language, blank * line, parse error, or no resolver), lowered to plain `insert after N:` — * applying with a warning beats failing the patch. */ export function insertAfterBlockUnresolvedLoweredWarning(line: number): string { - return `\`insert after block ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`insert after ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`; + return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`; } /** - * Internal invariant: `applyEdits` received an unresolved `replace block N:` + * Internal invariant: `applyEdits` received an unresolved `replace_block N:` * edit; `resolveBlockEdits` must run first. Wiring bug, not authored input. */ export const UNRESOLVED_BLOCK_INTERNAL = - "internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run)."; + "internal error: unresolved `XCHG.BLK` edit reached the applier (resolveBlockEdits was not run)."; /** Delete hunk received a body row. */ -export const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`."; +export const DELETE_TAKES_NO_BODY = "`DEL N..M` does not take body rows. Remove the body, or use `XCHG N..M:`."; -/** `delete block N` hunk received a body row. */ -export const DELETE_BLOCK_TAKES_NO_BODY = - "`delete block N` does not take body rows. Remove the body, or use `replace block N:`."; +/** `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 `XCHG.BLK N:`."; /** Insert hunk with no body. */ -export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row."; +export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row."; /** * `insert after` body indented shallower than the anchor: the landing slid @@ -135,16 +132,16 @@ export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row."; * I read instead of after the block" mistake. */ export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string { - return `insert after ${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`; + return `INS.POST ${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`; } /** - * `insert after block N:` body indented deeper than the block's closer: the + * `insert_after_block N:` body indented deeper than the block's closer: the * landing was pulled inside the block — a deeper body almost always means * "append inside the block's body". */ export function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string { - return `insert after block ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`insert after block\` lands AFTER the block at sibling depth — if inside was intended, use plain \`insert after ${closerLine}:\`.`; + return `INS.BLK.POST ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`INS.BLK.POST\` lands AFTER the block at sibling depth — if inside was intended, use plain \`INS.POST ${closerLine}:\`.`; } /** `Recovery`: an external write matched a cached snapshot. */ @@ -170,7 +167,7 @@ export const RECOVERY_SESSION_REPLAY_WARNING = * onto live content and warn instead of hard-failing. */ export const HEADTAIL_DRIFT_WARNING = - "Applied the `insert head:`/`insert tail:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected."; + "Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected."; /** * Section omitted the mandatory snapshot tag. Shared by the apply @@ -220,20 +217,16 @@ export function unseenLinesMessage(sectionPath: string, unseenLines: readonly nu export type BlockOp = "replace" | "delete" | "insert_after"; /** - * A `replace block`/`delete block`/`insert after block` anchor resolved to a + * A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a * single line — almost always a bare statement the model mis-anchored, not a * multi-line construct. The plain op is unambiguous for one line; the block * form only earns its keep when it spares counting a closing line you cannot * see. Reject and point at both fixes. */ export function blockSingleLineMessage(line: number, op: BlockOp): string { - const blockForm = op === "insert_after" ? "insert after block" : op === "delete" ? "delete block" : "replace block"; + const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "XCHG.BLK"; const plainForm = - op === "insert_after" - ? `insert after ${line}:` - : op === "delete" - ? `delete ${line}` - : `replace ${line}..${line}:`; + op === "insert_after" ? `INS.POST ${line}:` : op === "delete" ? `DEL ${line}` : `XCHG ${line}..${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 dfeb38792..bccf4bd24 100644 --- a/packages/hashline/src/parser.ts +++ b/packages/hashline/src/parser.ts @@ -52,33 +52,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 `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops." + "Use `XCHG N..M:`, `DEL N..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 `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops." + "Use `XCHG N..M:`, `DEL N..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 `replace N..M:`." + "Drop the `@@ ... @@` brackets and write a verb header such as `XCHG N..M:`." ); } - if (/^delete\s+[1-9]\d*(?:\s*(?:\.\.|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) { - return "`delete 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..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 \`replace ${trimmed}..${trimmed}:\` to replace, or \`delete ${trimmed}\` to delete.`; + return `hunk headers need a verb. Use \`XCHG ${trimmed}..${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`; } 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 \`replace ${bareRange[1]}..${bareRange[2]}:\` or \`delete ${bareRange[1]}..${bareRange[2]}\`.` + `Hunk headers need a verb: write \`XCHG ${bareRange[1]}..${bareRange[2]}:\` or \`DEL ${bareRange[1]}..${bareRange[2]}\`.` ); } return null; @@ -253,7 +253,7 @@ export class Executor { if (text.trim().length === 0) return; throw new Error( `line ${lineNum}: payload line has no preceding hunk header. ` + - `Use \`replace N..M:\`, \`delete N..M\`, or \`insert before|after|head|tail:\` above the body. Got ${JSON.stringify(text)}.`, + `Use \`XCHG N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`, ); } diff --git a/packages/hashline/src/patcher.ts b/packages/hashline/src/patcher.ts index 4283251a1..3d7cda980 100644 --- a/packages/hashline/src/patcher.ts +++ b/packages/hashline/src/patcher.ts @@ -41,8 +41,8 @@ export interface PatcherOptions { /** Snapshot store that minted and resolves hashline section tags. Required. */ snapshots: SnapshotStore; /** - * Resolves `replace block N:` anchors to concrete line spans via tree-sitter. - * Optional: when omitted, any `replace block N:` edit throws on apply (the + * Resolves `replace_block N:` anchors to concrete line spans via tree-sitter. + * Optional: when omitted, any `replace_block N:` edit throws on apply (the * host did not wire a resolver). Plain line-range ops never need it. */ blockResolver?: BlockResolver; @@ -73,7 +73,7 @@ export interface PatchSectionResult { /** Warnings collected by the parser, applier, and (optionally) recovery. */ warnings: string[]; /** - * Resolved spans for any `replace block`/`delete block` ops, present when the + * Resolved spans for any `replace_block`/`delete_block` ops, present when the * apply matched the tagged content. Undefined for patches with no block ops * (and for resolutions routed through drift recovery, where numbers shift). */ @@ -112,7 +112,7 @@ export class PreparedSection { function hasAnchorScopedEdit(edits: readonly Edit[]): boolean { return edits.some(edit => { if (edit.kind === "delete") return true; - // A `replace block N:` edit anchors to concrete content on line N. + // A `replace_block N:` edit anchors to concrete content on line N. if (edit.kind === "block") return true; return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; }); @@ -386,7 +386,7 @@ export class Patcher { const expected = exists ? section.fileHash : undefined; const liveMatches = expected !== undefined && computeFileHash(normalized) === expected; - // Resolve `replace block N:` edits to concrete ranges before recovery + // Resolve `replace_block N:` edits to concrete ranges before recovery // runs. Block anchors are expressed against the snapshot the section tag // names, so resolve against that exact text: // - live content matches the tag (or there is no tag) → resolve against diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index 5dbd9b8fc..166bbe5ea 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -1,20 +1,20 @@ -Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `delete` has no body. +Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `DEL` has no body. Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist. -`replace N..M:` — replace original lines N..M with the body rows below. INCLUSIVE — line M is consumed too. -`replace block N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below. -`delete N..M` — delete original lines N..M. No body. -`delete block N` — delete the whole syntactic block that BEGINS on line N. -`insert before N:` — insert the body rows immediately before line N. -`insert after N:` — insert the body rows immediately after line N. -`insert after block 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 `insert after`. -`insert head:` — insert the body rows at the very start of the file. -`insert tail:` — insert the body rows at the very end of the file. -Single line: `replace N..N:` / `delete N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `replace N..N:`). +`XCHG N..M:` — replace original lines N..M with the body rows below. INCLUSIVE — line M is consumed too. +`XCHG.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.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: `XCHG N..N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `XCHG N..N:`). @@ -34,11 +34,11 @@ 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 → `replace block N` (tree-sitter resolves the end); lines inside it → `replace N..M`. -- `replace block 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 `replace N..M`. -- Block ops (`replace block`/`delete block`/`insert after block`) 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 (`replace N..N` / `delete N` / `insert after N`) for one line, or point N at the real opener. Saw the closer? Use plain `insert after M:`. +- Whole construct → `XCHG.BLK N` (tree-sitter resolves the end); lines inside it → `XCHG N..M`. +- `XCHG.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 `XCHG N..M`. +- Block ops (`XCHG.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 (`XCHG 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 `insert`, never a widened `replace` — retyped keepers are exactly what gets dropped. A multi-line `replace` 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. +- Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `XCHG` — retyped keepers are exactly what gets dropped. A multi-line `XCHG` 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. @@ -55,14 +55,14 @@ Original (the exact shape `read` returns): Insert a guard after line 1: ``` [greet.py#A1B2] -insert after 1: +INS.POST 1: + if not name: name = "stranger" ``` Replace line 2 with two lines: ``` [greet.py#A1B2] -replace 2..2: +XCHG 2..2: + greeting = "Hi" + msg = f"{greeting}, {name}" ``` @@ -70,30 +70,30 @@ replace 2..2: Delete line 3: ``` [greet.py#A1B2] -delete 3 +DEL 3 ``` Add a header and trailer: ``` [greet.py#A1B2] -insert head: +INS.HEAD: +# generated header -insert tail: +INS.TAIL: +greet("everyone") ``` -Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays: +Replace the whole `greet` function block — `XCHG.BLK 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays: ``` [greet.py#A1B2] -replace block 1: +XCHG.BLK 1: +def greet(name): + print(f"Hello, {name}") ``` -A decorator or doc-comment is a SEPARATE block — `replace block` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would resolve only the function and orphan `@cache`: +A decorator or doc-comment is a SEPARATE block — `XCHG.BLK` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would resolve only the function and orphan `@cache`: ``` [svc.py#C3D4] -replace block 1: +XCHG.BLK 1: +@cache +def load(key): + return store[key] @@ -101,43 +101,43 @@ replace block 1: -# WRONG — empty `replace` to delete. RIGHT: delete 4 -replace 4..4: +# WRONG — empty `XCHG` to delete. RIGHT: DEL 4 +XCHG 4..4: -# WRONG — range describes post-edit size. RIGHT: replace 1..1: (body length is irrelevant) -replace 1..2: +# WRONG — range describes post-edit size. RIGHT: XCHG 1..1: (body length is irrelevant) +XCHG 1..2: +def greet(name): # WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content. -replace 3..3: +XCHG 3..3: msg = "Hello, " + name - print(msg) + return msg # RIGHT -replace 3..3: +XCHG 3..3: + return msg -# WRONG — a pure insertion done as a widened `replace`: you only want to add one line after 2, +# WRONG — a pure insertion done as a widened `XCHG`: 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")`). -replace 2..4: +XCHG 2..4: + msg = "Hello, " + name + extra = compute(name) + print(msg) # RIGHT — touch nothing you keep; the new line is the whole body. -insert after 2: +INS.POST 2: + extra = compute(name) -# WRONG — `insert after block N:` anchored on a closing delimiter / last visible line. RIGHT: plain `insert after M:` -insert after block 3: +# WRONG — `INS.BLK.POST N:` anchored on a closing delimiter / last visible line. RIGHT: plain `INS.POST M:` +INS.BLK.POST 3: +after() # RIGHT -insert after 3: +INS.POST 3: +after() If you remember nothing else: 1. RE-GROUND AFTER EVERY EDIT. Every apply mints a fresh `#TAG` and renumbers — take the next edit's numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`. -2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `replace block N`. +2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `XCHG.BLK N`. 3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows; never `-old`/context lines. The range does the deleting. diff --git a/packages/hashline/src/tokenizer.ts b/packages/hashline/src/tokenizer.ts index d2eafbf21..b7786fb13 100644 --- a/packages/hashline/src/tokenizer.ts +++ b/packages/hashline/src/tokenizer.ts @@ -10,7 +10,7 @@ */ import { describeAnchorExamples, - HL_BLOCK_KEYWORD, + HL_DELETE_BLOCK_KEYWORD, HL_DELETE_KEYWORD, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, @@ -18,11 +18,13 @@ import { HL_FILE_SUFFIX, HL_HEADER_COLON, HL_INSERT_AFTER, + HL_INSERT_AFTER_BLOCK_KEYWORD, HL_INSERT_BEFORE, HL_INSERT_HEAD, HL_INSERT_KEYWORD, HL_INSERT_TAIL, HL_PAYLOAD_REPLACE, + HL_REPLACE_BLOCK_KEYWORD, HL_REPLACE_KEYWORD, } from "./format"; import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages"; @@ -218,7 +220,7 @@ function scanKeyword(line: string, index: number, end: number, keyword: string): const next = index + keyword.length; if (next < end) { const code = line.charCodeAt(next); - if (!isWhitespaceCode(code) && code !== CHAR_COLON) return null; + if (!isWhitespaceCode(code) && code !== CHAR_COLON && code !== CHAR_DOT) return null; } return next; } @@ -229,7 +231,8 @@ function consumeOptionalColon(line: string, index: number, end: number): number } function scanInsertTarget(line: string, index: number, end: number): TargetScan | null { - const cursor = skipWhitespace(line, index, end); + if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null; + const cursor = skipWhitespace(line, index + 1, end); const beforeEnd = scanKeyword(line, cursor, end, HL_INSERT_BEFORE); if (beforeEnd !== null) { const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end); @@ -239,16 +242,6 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan } const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER); if (afterEnd !== null) { - // `insert after block N:` — resolve N to a tree-sitter block range at - // apply time and insert after its last line. Try the `block` sub-keyword - // before falling back to a literal `insert after N:` anchor. - const blockEnd = scanKeyword(line, skipWhitespace(line, afterEnd, end), end, HL_BLOCK_KEYWORD); - if (blockEnd !== null) { - const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end); - if (anchor === null) return null; - const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end); - return { target: { kind: "insert_after_block", anchor: { line: anchor.line } }, nextIndex }; - } const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end); if (anchor === null) return null; const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end); @@ -263,20 +256,19 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan function scanHunkAnchor(line: string, start: number, end: number): TargetScan | null { const cursor = skipWhitespace(line, start, end); + + // `replace_block N:` — resolve N to a tree-sitter block range at apply time. + const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD); + if (replaceBlockEnd !== null) { + const anchor = scanLineNumber(line, skipWhitespace(line, replaceBlockEnd, end), end); + if (anchor === null) return null; + return { + target: { kind: "block", anchor: { line: anchor.line } }, + nextIndex: consumeOptionalColon(line, anchor.nextIndex, end), + }; + } const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD); if (replaceEnd !== null) { - // `replace block N:` — resolve N to a tree-sitter block range at apply - // time. Try the `block` sub-keyword before falling back to a literal - // `replace N..M:` range. - const blockEnd = scanKeyword(line, skipWhitespace(line, replaceEnd, end), end, HL_BLOCK_KEYWORD); - if (blockEnd !== null) { - const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end); - if (anchor === null) return null; - return { - target: { kind: "block", anchor: { line: anchor.line } }, - nextIndex: consumeOptionalColon(line, anchor.nextIndex, end), - }; - } const range = scanHeaderRange(line, replaceEnd, end, true); if (range === null) return null; return { @@ -284,25 +276,36 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan | nextIndex: consumeOptionalColon(line, range.nextIndex, end), }; } + // `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 + // trailing colon. + const deleteBlockEnd = scanKeyword(line, cursor, end, HL_DELETE_BLOCK_KEYWORD); + if (deleteBlockEnd !== null) { + const anchor = scanLineNumber(line, skipWhitespace(line, deleteBlockEnd, end), end); + if (anchor === null) return null; + const next = skipWhitespace(line, anchor.nextIndex, end); + if (next < end && line.charCodeAt(next) === CHAR_COLON) return null; + return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next }; + } const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD); if (deleteEnd !== null) { - // `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 trailing colon. - const blockEnd = scanKeyword(line, skipWhitespace(line, deleteEnd, end), end, HL_BLOCK_KEYWORD); - if (blockEnd !== null) { - const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end); - if (anchor === null) return null; - const next = skipWhitespace(line, anchor.nextIndex, end); - if (next < end && line.charCodeAt(next) === CHAR_COLON) return null; - return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next }; - } const range = scanHeaderRange(line, deleteEnd, end, true); if (range === null) return null; const next = skipWhitespace(line, range.nextIndex, end); if (next < end && line.charCodeAt(next) === CHAR_COLON) return null; return { target: { kind: "delete", range: range.range }, nextIndex: next }; } + // `insert_after_block N:` — insert after the last line of the tree-sitter + // block at N. + const insertAfterBlockEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER_BLOCK_KEYWORD); + if (insertAfterBlockEnd !== null) { + const anchor = scanLineNumber(line, skipWhitespace(line, insertAfterBlockEnd, end), end); + if (anchor === null) return null; + return { + target: { kind: "insert_after_block", anchor: { line: anchor.line } }, + nextIndex: consumeOptionalColon(line, anchor.nextIndex, end), + }; + } const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD); if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end); return null; diff --git a/packages/hashline/src/types.ts b/packages/hashline/src/types.ts index 71f78e4ee..5ab15fc4f 100644 --- a/packages/hashline/src/types.ts +++ b/packages/hashline/src/types.ts @@ -32,7 +32,7 @@ export type Edit = index: number; mode?: "replacement"; /** - * Present on inserts lowered from `insert after block N:`: the + * Present on inserts lowered from `insert_after_block N:`: the * resolved block's first line. Lets the applier slide a body that * claims a depth inside the block back across the block's trailing * closer lines (never above this line). @@ -42,13 +42,13 @@ export type Edit = | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string } | { /** - * Deferred block edit (`replace block N:` / `delete block N` / - * `insert after block N:`). The exact line span is unknown at parse + * Deferred block edit (`replace_block N:` / `delete_block N` / + * `insert_after_block N:`). The exact line span is unknown at parse * 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 + * a non-empty `payloads` without `mode` (from `replace_block`) becomes * the same `replacement` inserts + deletes that `replace start..end:` - * produces; an empty `payloads` (from `delete block`) becomes a pure + * 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 * variant. @@ -70,7 +70,7 @@ export interface ApplyResult { /** Diagnostic warnings collected by the parser, patcher, or recovery. */ warnings?: string[]; /** - * Resolved spans for each `replace block`/`delete block` op in this apply, + * Resolved spans for each `replace_block`/`delete_block` op in this apply, * in patch order. Present only when the apply matched the tagged content * (the common no-drift path), so the line numbers line up with what the * caller read. Absent when there were no block ops. @@ -122,7 +122,7 @@ export interface CompactDiffOptions { } /** - * Resolved 1-indexed inclusive line span of a `replace block N:` target. + * Resolved 1-indexed inclusive line span of a `replace_block N:` target. */ export interface BlockSpan { /** First line of the block (1-indexed, inclusive). */ @@ -132,7 +132,7 @@ export interface BlockSpan { } /** - * One `replace block N:` / `delete block N` / `insert after block N:` anchor + * 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 * opener — e.g. a decorator or doc-comment that sits in a separate node @@ -149,7 +149,7 @@ export interface BlockResolution { op: "replace" | "delete" | "insert_after"; } -/** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */ +/** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */ export interface BlockResolverRequest { /** Target file path (used to infer language by extension). */ path: string; @@ -160,7 +160,7 @@ export interface BlockResolverRequest { } /** - * Resolves a `replace block N:` anchor to the line span of the syntactic block + * Resolves a `replace_block N:` anchor to the line span of the syntactic block * that begins on line N. Returns `null` when no block can be resolved * (unrecognized language, blank/out-of-range line, no node begins there, or the * resolved subtree has a syntax error). Pure seam: the hashline core declares diff --git a/packages/hashline/test/block.test.ts b/packages/hashline/test/block.test.ts index 51ccbd8ef..497907399 100644 --- a/packages/hashline/test/block.test.ts +++ b/packages/hashline/test/block.test.ts @@ -31,9 +31,9 @@ function normalizeEdits(edits: readonly Edit[]): unknown[] { }); } -describe("replace block parsing", () => { - it("parses `replace block N:` into a single deferred block edit", () => { - const { edits } = parsePatch("replace block 2:\n+A\n+B"); +describe("XCHG.BLK parsing", () => { + it("parses `XCHG.BLK N:` into a single deferred block edit", () => { + const { edits } = parsePatch("XCHG.BLK 2:\n+A\n+B"); expect(edits).toHaveLength(1); const edit = edits[0]; @@ -43,52 +43,52 @@ describe("replace block parsing", () => { expect(edit.payloads).toEqual(["A", "B"]); }); - it("still parses a literal `replace N..M:` range (block sub-keyword is optional)", () => { - const { edits } = parsePatch("replace 2..3:\n+A"); + it("still parses a literal `XCHG N..M:` range (distinct from `XCHG.BLK`)", () => { + const { edits } = parsePatch("XCHG 2..3:\n+A"); expect(edits.some(edit => edit.kind === "block")).toBe(false); expect(edits.some(edit => edit.kind === "delete")).toBe(true); }); - it("rejects a `replace block N:` hunk with no body row", () => { - expect(() => parsePatch("replace block 2:")).toThrow("`replace block N:` needs at least one"); + it("rejects a `XCHG.BLK N:` hunk with no body row", () => { + expect(() => parsePatch("XCHG.BLK 2:")).toThrow("`XCHG.BLK N:` needs at least one"); }); }); describe("resolveBlockEdits", () => { - it("expands a block edit exactly like the equivalent `replace start..end:`", () => { - const blockEdits = parsePatch("replace block 2:\n+A\n+B").edits; + it("expands a block edit exactly like the equivalent `XCHG start..end:`", () => { + const blockEdits = parsePatch("XCHG.BLK 2:\n+A\n+B").edits; const resolved = resolveBlockEdits(blockEdits, "ignored", PATH, stubResolver); - const replaceEdits = parsePatch("replace 2..3:\n+A\n+B").edits; + const replaceEdits = parsePatch("XCHG 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("replace 1..1:\n+X").edits; + const edits = parsePatch("XCHG 1..1:\n+X").edits; expect(resolveBlockEdits(edits, "ignored", PATH, stubResolver)).toBe(edits); }); it("throws (default) when no resolver is wired", () => { - const edits = parsePatch("replace block 2:\n+X").edits; + const edits = parsePatch("XCHG.BLK 2:\n+X").edits; expect(() => resolveBlockEdits(edits, "ignored", PATH, undefined)).toThrow("not available here"); }); it("drops an unresolvable block edit in `drop` mode", () => { - const edits = parsePatch("replace block 2:\n+X").edits; + const edits = parsePatch("XCHG.BLK 2:\n+X").edits; const resolved = resolveBlockEdits(edits, "ignored", PATH, () => null, { onUnresolved: "drop" }); expect(resolved).toHaveLength(0); }); it("throws a block-unresolved error in `throw` mode when the resolver returns null", () => { - const edits = parsePatch("replace block 7:\n+X").edits; + const edits = parsePatch("XCHG.BLK 7:\n+X").edits; expect(() => resolveBlockEdits(edits, "ignored", PATH, () => null)).toThrow( "could not resolve a syntactic block beginning on line 7", ); }); it("includes a nearby-context preview in the block-unresolved error", () => { - const edits = parsePatch("replace block 3:\n+X").edits; + const edits = parsePatch("XCHG.BLK 3:\n+X").edits; const text = "alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot"; let error: Error | undefined; try { @@ -105,7 +105,7 @@ describe("resolveBlockEdits", () => { }); it("omits the context preview when the anchor line is out of range", () => { - const edits = parsePatch("replace block 9:\n+X").edits; + const edits = parsePatch("XCHG.BLK 9:\n+X").edits; let error: Error | undefined; try { resolveBlockEdits(edits, "only\ntwo", PATH, () => null); @@ -119,10 +119,10 @@ describe("resolveBlockEdits", () => { it("fires onResolved with the resolved span for replace and delete blocks", () => { const seen: BlockResolution[] = []; // stubResolver maps line N → span [N, N+1]. - resolveBlockEdits(parsePatch("replace block 2:\n+A\n+B").edits, "ignored", PATH, stubResolver, { + resolveBlockEdits(parsePatch("XCHG.BLK 2:\n+A\n+B").edits, "ignored", PATH, stubResolver, { onResolved: resolution => seen.push(resolution), }); - resolveBlockEdits(parsePatch("delete block 5").edits, "ignored", PATH, stubResolver, { + resolveBlockEdits(parsePatch("DEL.BLK 5").edits, "ignored", PATH, stubResolver, { onResolved: resolution => seen.push(resolution), }); @@ -134,7 +134,7 @@ describe("resolveBlockEdits", () => { it("does not fire onResolved for a dropped unresolvable block", () => { const seen: BlockResolution[] = []; - resolveBlockEdits(parsePatch("replace block 2:\n+X").edits, "ignored", PATH, () => null, { + resolveBlockEdits(parsePatch("XCHG.BLK 2:\n+X").edits, "ignored", PATH, () => null, { onUnresolved: "drop", onResolved: resolution => seen.push(resolution), }); @@ -142,25 +142,25 @@ describe("resolveBlockEdits", () => { }); // A single-line resolution means the anchor was a bare statement, not a - // multi-line construct opener (the att#1 `insert after block 678` shape, + // multi-line construct opener (the att#1 `insert_after_block 678` shape, // where line 678 was `options.mode = "check";`). Reject and point at the // plain form rather than silently landing a body in the wrong scope. const singleLineResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line }); - it("rejects a `replace block` that resolves to a single line", () => { - const edits = parsePatch("replace block 2:\n+X").edits; + it("rejects a `XCHG.BLK` that resolves to a single line", () => { + const edits = parsePatch("XCHG.BLK 2:\n+X").edits; expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver)).toThrow( /resolved a single-line block/, ); }); - it("rejects an `insert after block` that resolves to a single line", () => { - const edits = parsePatch("insert after block 2:\n+X").edits; + it("rejects an `INS.BLK.POST` that resolves to a single line", () => { + const edits = parsePatch("INS.BLK.POST 2:\n+X").edits; expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver)).toThrow(/single-line block/); }); it("drops a single-line block resolution on the lenient preview path", () => { - const edits = parsePatch("replace block 2:\n+X").edits; + const edits = parsePatch("XCHG.BLK 2:\n+X").edits; const resolved = resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver, { onUnresolved: "drop" }); expect(resolved).toHaveLength(0); }); @@ -170,8 +170,8 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => { const text = "function x() {\n if (y) {\n }\n}\n"; it("applyTo resolves a block edit and matches the equivalent `replace`", () => { - const blockSection = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+ if (y || z) {\n+ }`); - const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nreplace 2..3:\n+ if (y || z) {\n+ }`); + const blockSection = Patch.parseSingle(`[${PATH}#1A2B]\nXCHG.BLK 2:\n+ if (y || z) {\n+ }`); + const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nXCHG 2..3:\n+ if (y || z) {\n+ }`); const blockResult = blockSection.applyTo(text, stubResolver); const replaceResult = replaceSection.applyTo(text); @@ -181,12 +181,12 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => { }); it("applyTo throws when a block edit has no resolver", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+X`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nXCHG.BLK 2:\n+X`); expect(() => section.applyTo(text)).toThrow("no block resolver configured"); }); it("applyPartialTo drops an unresolvable block edit instead of throwing", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+X`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nXCHG.BLK 2:\n+X`); // No resolver → drop. The lone block edit vanishes, so the text is unchanged. const result = section.applyPartialTo(text); expect(result.text).toBe(text); @@ -202,7 +202,7 @@ describe("Patcher with a block resolver", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+ if (y || z) {\n+ }`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG.BLK 2:\n+ if (y || z) {\n+ }`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("function x() {\n if (y || z) {\n }\n}\n"); @@ -214,7 +214,7 @@ describe("Patcher with a block resolver", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+ if (y || z) {\n+ }`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG.BLK 2:\n+ if (y || z) {\n+ }`)); expect(result.sections[0]?.blockResolutions).toEqual([{ anchorLine: 2, start: 2, end: 3, op: "replace" }]); }); @@ -230,7 +230,7 @@ describe("Patcher with a block resolver", () => { // `block 2` resolves against the SNAPSHOT → span [2,3] → replace // "line1","line2"; recovery 3-way-merges the change onto the live file. - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+NEW`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG.BLK 2:\n+NEW`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("line0\nNEW\nline3\nline4\nline5\n"); @@ -248,7 +248,7 @@ describe("Patcher with a block resolver", () => { const bogus = live === "FFFF" ? "0000" : "FFFF"; const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - await expect(patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nreplace block 2:\n+NEW`))).rejects.toBeInstanceOf( + await expect(patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nXCHG.BLK 2:\n+NEW`))).rejects.toBeInstanceOf( MismatchError, ); expect(fs.get(PATH)).toBe(liveText); @@ -260,18 +260,18 @@ describe("Patcher with a block resolver", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: () => null }); - await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+X`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG.BLK 2:\n+X`))).rejects.toThrow( "could not resolve a syntactic block", ); expect(fs.get(PATH)).toBe(text); }); }); -describe("delete block", () => { +describe("DEL.BLK", () => { const text = "function x() {\n if (y) {\n }\n}\n"; - it("parses `delete block N` into a block edit with no payloads", () => { - const { edits } = parsePatch("delete block 2"); + it("parses `DEL.BLK N` into a block edit with no payloads", () => { + const { edits } = parsePatch("DEL.BLK 2"); expect(edits).toHaveLength(1); const edit = edits[0]; @@ -281,12 +281,12 @@ describe("delete block", () => { expect(edit.payloads).toEqual([]); }); - it("rejects body rows under `delete block N`", () => { - expect(() => parsePatch("delete block 2\n+X")).toThrow("`delete block N` does not take body rows"); + it("rejects body rows under `DEL.BLK N`", () => { + expect(() => parsePatch("DEL.BLK 2\n+X")).toThrow("`DEL.BLK N` does not take body rows"); }); it("resolveBlockEdits expands a delete-block edit into pure deletes", () => { - const edits = parsePatch("delete block 2").edits; + const edits = parsePatch("DEL.BLK 2").edits; const resolved = resolveBlockEdits(edits, "ignored", PATH, stubResolver); expect(resolved.every(edit => edit.kind === "delete")).toBe(true); @@ -294,13 +294,13 @@ describe("delete block", () => { }); it("applyTo deletes the resolved block span", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\ndelete block 2`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nDEL.BLK 2`); // stub span [2,3] → drop " if (y) {" and " }". expect(section.applyTo(text, stubResolver).text).toBe("function x() {\n}\n"); }); it("applyPartialTo drops an unresolvable delete-block edit instead of throwing", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\ndelete block 2`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nDEL.BLK 2`); expect(section.applyPartialTo(text).text).toBe(text); }); @@ -310,18 +310,18 @@ describe("delete block", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\ndelete block 2`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nDEL.BLK 2`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("function x() {\n}\n"); }); }); -describe("insert after block", () => { +describe("INS.BLK.POST", () => { const text = "function x() {\n if (y) {\n }\n}\n"; - it("parses `insert after block N:` into a deferred block edit with insert mode", () => { - const { edits } = parsePatch("insert after block 2:\n+A\n+B"); + it("parses `INS.BLK.POST N:` into a deferred block edit with insert mode", () => { + const { edits } = parsePatch("INS.BLK.POST 2:\n+A\n+B"); expect(edits).toHaveLength(1); const edit = edits[0]; @@ -332,20 +332,20 @@ describe("insert after block", () => { expect(edit.mode).toBe("insert_after"); }); - it("still parses a literal `insert after N:` anchor (block sub-keyword is optional)", () => { - const { edits } = parsePatch("insert after 2:\n+A"); + it("still parses a literal `INS.POST N:` anchor (distinct from `INS.BLK.POST`)", () => { + const { edits } = parsePatch("INS.POST 2:\n+A"); expect(edits.some(edit => edit.kind === "block")).toBe(false); }); - it("rejects an `insert after block N:` hunk with no body row", () => { - expect(() => parsePatch("insert after block 2:")).toThrow("`insert` needs at least one"); + it("rejects an `INS.BLK.POST N:` hunk with no body row", () => { + expect(() => parsePatch("INS.BLK.POST 2:")).toThrow("`INS` needs at least one"); }); it("resolveBlockEdits expands to the equivalent `insert after end:` lowering", () => { - const blockEdits = parsePatch("insert after block 2:\n+A\n+B").edits; + const blockEdits = parsePatch("INS.BLK.POST 2:\n+A\n+B").edits; // stub span [2,3] → after_anchor inserts at line 3. const resolved = resolveBlockEdits(blockEdits, "ignored", PATH, stubResolver); - const insertEdits = parsePatch("insert after 3:\n+A\n+B").edits; + const insertEdits = parsePatch("INS.POST 3:\n+A\n+B").edits; expect(resolved.some(edit => edit.kind === "block")).toBe(false); expect(normalizeEdits(resolved)).toEqual(normalizeEdits(insertEdits)); @@ -353,57 +353,57 @@ describe("insert after block", () => { it("fires onResolved with op insert_after", () => { const seen: BlockResolution[] = []; - resolveBlockEdits(parsePatch("insert after block 2:\n+A").edits, "ignored", PATH, stubResolver, { + resolveBlockEdits(parsePatch("INS.BLK.POST 2:\n+A").edits, "ignored", PATH, stubResolver, { onResolved: resolution => seen.push(resolution), }); expect(seen).toEqual([{ anchorLine: 2, start: 2, end: 3, op: "insert_after" }]); }); - it("lowers an unresolvable anchor to plain `insert after N:` with a warning", () => { - const edits = parsePatch("insert after block 7:\n+X").edits; + it("lowers an unresolvable anchor to plain `INS.POST N:` with a warning", () => { + const edits = parsePatch("INS.BLK.POST 7:\n+X").edits; const warnings: string[] = []; const resolved = resolveBlockEdits(edits, "ignored", PATH, () => null, { onWarning: warning => warnings.push(warning), }); - expect(normalizeEdits(resolved)).toEqual(normalizeEdits(parsePatch("insert after 7:\n+X").edits)); + expect(normalizeEdits(resolved)).toEqual(normalizeEdits(parsePatch("INS.POST 7:\n+X").edits)); expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain("applied as plain `insert after 7:`"); + expect(warnings[0]).toContain("applied as plain `INS.POST 7:`"); }); - it("lowers `insert after block` even when no resolver is wired", () => { - const edits = parsePatch("insert after block 2:\n+X").edits; + it("lowers `INS.BLK.POST` even when no resolver is wired", () => { + const edits = parsePatch("INS.BLK.POST 2:\n+X").edits; const warnings: string[] = []; const resolved = resolveBlockEdits(edits, "ignored", PATH, undefined, { onWarning: warning => warnings.push(warning), }); - expect(normalizeEdits(resolved)).toEqual(normalizeEdits(parsePatch("insert after 2:\n+X").edits)); + expect(normalizeEdits(resolved)).toEqual(normalizeEdits(parsePatch("INS.POST 2:\n+X").edits)); expect(warnings).toHaveLength(1); }); - it("lowers a closing-delimiter anchor to plain `insert after N:` with a warning", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\ninsert after block 3:\n+ done();`); + it("lowers a closing-delimiter anchor to plain `INS.POST N:` with a warning", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nINS.BLK.POST 3:\n+ done();`); const resolver: BlockResolver = ({ line }) => (line === 2 ? { start: 2, end: 3 } : null); const result = section.applyTo(text, resolver); // line 3 is ` }` — no block begins there, but it ends one; the body - // lands after it, exactly where `insert after block` would have put it. + // lands after it, exactly where `insert_after_block` would have put it. expect(result.text).toBe("function x() {\n if (y) {\n }\n done();\n}\n"); - expect(result.warnings?.some(w => /applied as plain `insert after 3:`/.test(w))).toBe(true); + expect(result.warnings?.some(w => /applied as plain `INS.POST 3:`/.test(w))).toBe(true); }); - it("lowers an unresolvable blank-line anchor to plain `insert after N:` instead of failing", () => { - const blankAnchored = Patch.parseSingle(`[notes.md#1A2B]\ninsert after block 2:\n+- new entry`); + it("lowers an unresolvable blank-line anchor to plain `INS.POST N:` instead of failing", () => { + const blankAnchored = Patch.parseSingle(`[notes.md#1A2B]\nINS.BLK.POST 2:\n+- new entry`); const result = blankAnchored.applyTo("### Changed\n\n- old entry\n", () => null); expect(result.text).toBe("### Changed\n\n- new entry\n- old entry\n"); expect( - result.warnings?.some(w => /could not resolve a syntactic block.*applied as plain `insert after 2:`/.test(w)), + result.warnings?.some(w => /could not resolve a syntactic block.*applied as plain `INS.POST 2:`/.test(w)), ).toBe(true); }); @@ -414,14 +414,14 @@ describe("insert after block", () => { const resolver: BlockResolver = ({ line }) => (line === 2 ? { start: 2, end: 3 } : null); const patcher = new Patcher({ fs, snapshots, blockResolver: resolver }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\ninsert after block 3:\n+ done();`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nINS.BLK.POST 3:\n+ done();`)); expect(fs.get(PATH)).toBe("function x() {\n if (y) {\n }\n done();\n}\n"); - expect(result.sections[0]?.warnings.some(w => /applied as plain `insert after 3:`/.test(w))).toBe(true); + expect(result.sections[0]?.warnings.some(w => /applied as plain `INS.POST 3:`/.test(w))).toBe(true); }); it("applyTo inserts the body after the resolved block's last line", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\ninsert after block 2:\n+ done();`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nINS.BLK.POST 2:\n+ done();`); // stub span [2,3] → body lands after " }" (line 3), before the final "}". expect(section.applyTo(text, stubResolver).text).toBe("function x() {\n if (y) {\n }\n done();\n}\n"); }); @@ -432,7 +432,7 @@ describe("insert after block", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\ninsert after block 2:\n+ done();`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nINS.BLK.POST 2:\n+ done();`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("function x() {\n if (y) {\n }\n done();\n}\n"); diff --git a/packages/hashline/test/boundary-repair.test.ts b/packages/hashline/test/boundary-repair.test.ts index bcec53fa6..8a50b8c68 100644 --- a/packages/hashline/test/boundary-repair.test.ts +++ b/packages/hashline/test/boundary-repair.test.ts @@ -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 = [ - "replace 7..16:", + "XCHG 7..16:", "+\treturn (", "+\t\t<>", "+\t\t\t { const file = ["it('a', () => {", "\tsetup();", "\trun();", "});", "after();"].join("\n"); // `replace 2..3:` replaces the two body lines but the payload also restates the // `});` at line 4, which survives — a duplicate close. - const diff = ["replace 2..3:", "+\tsetup2();", "+\trun2();", "+});"].join("\n"); + const diff = ["XCHG 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); @@ -86,7 +86,7 @@ describe("boundary-balance repair", () => { // `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 = [ - "replace 4..6:", + "XCHG 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 = ["replace 2..2:", "+if (a) {", "+\tif (b) {", "+\t\tfoo();"].join("\n"); + const diff = ["XCHG 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); @@ -128,7 +128,7 @@ describe("boundary-balance repair", () => { 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 // restate `};`; sparing it keeps the object literal balanced. - const diff = ["replace 5..5:", "+\tb() {", "+\t\treturn 2;", "+\t},"].join("\n"); + const diff = ["XCHG 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 = ["replace 1..4:", "+class Foo {", "+\tok();", "+}"].join("\n"); + const diff = ["XCHG 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 = [ - "replace 2..3:", + "XCHG 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 = ["replace 3..3:", "+A", "+B", "+C", "+D"].join("\n"); + const diff = ["XCHG 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 = ["replace 2..2:", "+a", "+c"].join("\n"); + const diff = ["XCHG 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 = ["replace 2..2:", "+}", "+if (a) {", "+if (b) {", "+x();", "+}"].join("\n"); + const diff = ["XCHG 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 = ["replace 2..2:", "+function f() {", "+fresh();", "+}"].join("\n"); + const diff = ["XCHG 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 = ["replace 2..2:", "+qux();", "+bar();"].join("\n"); + const diff = ["XCHG 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 = ["replace 1..1:", "+a = 1;", "+b = 2;"].join("\n"); + const diff = ["XCHG 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,7 +254,7 @@ 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 = ["replace 2..2:", '+const b = "}}}";'].join("\n"); + const diff = ["XCHG 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); @@ -265,7 +265,7 @@ describe("boundary-balance repair", () => { // 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 = ["replace 2..3:", "+ a2();", "+ b2();", "+ const out = [];"].join("\n"); + const diff = ["XCHG 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 = ["replace 3..4:", "+a();", "+B();", "+C();"].join("\n"); + const diff = ["XCHG 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); @@ -317,7 +317,7 @@ describe("boundary-balance repair through stale-snapshot recovery", () => { // `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(["replace 4..5:", "+\tsetup2();", "+\trun2();", "+});"].join("\n")); + const { edits } = parsePatch(["XCHG 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 bbd66d4ba..14be3810f 100644 --- a/packages/hashline/test/core-contracts.test.ts +++ b/packages/hashline/test/core-contracts.test.ts @@ -21,7 +21,7 @@ function tag(line: number): string { } function sameLineRange(anchor: string): string { - return `replace ${anchor}..${anchor}:`; + return `XCHG ${anchor}..${anchor}:`; } function applyDiff(content: string, diff: string): string { @@ -87,7 +87,7 @@ describe("hashline parser — range-anchor contracts", () => { const content = "aaa\nbbb\nccc"; it("keeps parsed sections reusable across target snapshots", () => { - const section = Patch.parseSingle(["[a.ts]", `insert after ${tag(2)}:`, repl("tail")].join("\n")); + const section = Patch.parseSingle(["[a.ts]", `INS.POST ${tag(2)}:`, repl("tail")].join("\n")); expect(section.applyTo("aaa\nbbb").text).toBe("aaa\nbbb\ntail"); expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc"); @@ -95,42 +95,42 @@ describe("hashline parser — range-anchor contracts", () => { it("applies replace/delete/insert operations against concrete anchors", () => { const diff = [ - `insert before ${tag(2)}:`, + `INS.PRE ${tag(2)}:`, repl("before b"), - `insert after ${tag(2)}:`, + `INS.POST ${tag(2)}:`, repl("after b"), - "insert head:", + "INS.HEAD:", repl("top"), - "insert tail:", + "INS.TAIL:", repl("tail"), ].join("\n"); expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail"); - expect(applyDiff(content, `delete ${tag(2)}`)).toBe("aaa\nccc"); - expect(applyDiff(content, `delete ${tag(2)}..${tag(3)}`)).toBe("aaa"); + expect(applyDiff(content, `DEL ${tag(2)}`)).toBe("aaa\nccc"); + expect(applyDiff(content, `DEL ${tag(2)}..${tag(3)}`)).toBe("aaa"); expect(applyDiff(content, `${sameLineRange(tag(2))}\n${repl("BBB")}`)).toBe("aaa\nBBB\nccc"); }); it("inserts after the final line without falling off the file", () => { - expect(applyDiff(content, `insert after ${tag(3)}:\n${repl("tail")}`)).toBe("aaa\nbbb\nccc\ntail"); + expect(applyDiff(content, `INS.POST ${tag(3)}:\n${repl("tail")}`)).toBe("aaa\nbbb\nccc\ntail"); }); it("preserves whitespace-bearing and sigil-leading payload exactly", () => { const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;"; - expect(applyDiff(content, `insert after ${tag(2)}:\n${repl(payload)}`)).toBe(`aaa\nbbb\n${payload}\nccc`); + expect(applyDiff(content, `INS.POST ${tag(2)}:\n${repl(payload)}`)).toBe(`aaa\nbbb\n${payload}\nccc`); expect( applyDiff(content, `${sameLineRange(tag(2))}\n${repl("|literal")}\n${repl("^literal")}\n${repl("↓literal")}`), ).toBe("aaa\n|literal\n^literal\n↓literal\nccc"); }); it("strips copied read-output prefixes only inside pasted bare body rows", () => { - const diff = `replace ${tag(2)}..${tag(4)}:\n${repl("line one")}\n${tag(3)}:line two`; + const diff = `XCHG ${tag(2)}..${tag(4)}:\n${repl("line one")}\n${tag(3)}:line two`; const { edits, warnings } = parsePatch(diff); expect(applyEdits("aaa\nbbb\nccc\nddd\neee", edits).text).toBe("aaa\nline one\nline two\neee"); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); it("rejects overlapping replacement ranges", () => { - const diff = `replace ${tag(2)}..${tag(4)}:\n${repl("NEW1")}\nreplace ${tag(3)}..${tag(5)}:\n${repl("NEW2")}`; + const diff = `XCHG ${tag(2)}..${tag(4)}:\n${repl("NEW1")}\nXCHG ${tag(3)}..${tag(5)}:\n${repl("NEW2")}`; expect(() => parsePatch(diff).edits).toThrow(/anchor line 3 is already targeted by another hunk on line 1/); }); @@ -151,34 +151,34 @@ describe("hashline input splitter", () => { }); it("normalizes leading blanks, cwd-relative paths, and explicit fallback paths", () => { - expect(splitHashlineInput(`\n[foo.ts]\ninsert head:\n${repl("x")}`)).toEqual({ + expect(splitHashlineInput(`\n[foo.ts]\nINS.HEAD:\n${repl("x")}`)).toEqual({ path: "foo.ts", - diff: `insert head:\n${repl("x")}`, + diff: `INS.HEAD:\n${repl("x")}`, }); const cwd = process.cwd(); const absolute = `${cwd}/src/foo.ts`; - expect(splitHashlineInput(`[${absolute}]\ninsert head:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); - expect(splitHashlineInput(`insert head:\n${repl("x")}`, { path: "a.ts" })).toEqual({ + expect(splitHashlineInput(`[${absolute}]\nINS.HEAD:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); + expect(splitHashlineInput(`INS.HEAD:\n${repl("x")}`, { path: "a.ts" })).toEqual({ path: "a.ts", - diff: `insert head:\n${repl("x")}`, + diff: `INS.HEAD:\n${repl("x")}`, }); expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/); }); it("splits multiple sections and drops a trailing header without operations", () => { - const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]", "insert tail:", repl("b")].join("\n"); + const input = ["[a.ts]", "INS.HEAD:", repl("a"), "[b.ts]", "INS.TAIL:", repl("b")].join("\n"); expect(splitHashlineInputs(input)).toEqual([ - { path: "a.ts", diff: `insert head:\n${repl("a")}` }, - { path: "b.ts", diff: `insert tail:\n${repl("b")}` }, + { path: "a.ts", diff: `INS.HEAD:\n${repl("a")}` }, + { path: "b.ts", diff: `INS.TAIL:\n${repl("b")}` }, ]); - expect(splitHashlineInputs(["[a.ts]", "insert head:", repl("a"), "[b.ts]"].join("\n"))).toEqual([ - { path: "a.ts", diff: `insert head:\n${repl("a")}` }, + expect(splitHashlineInputs(["[a.ts]", "INS.HEAD:", repl("a"), "[b.ts]"].join("\n"))).toEqual([ + { path: "a.ts", diff: `INS.HEAD:\n${repl("a")}` }, ]); }); it("rejects unified-diff hunk headers on the first line", () => { - const input = ["@@ -1,3 +1,3 @@", "insert head:", repl("x")].join("\n"); + const input = ["@@ -1,3 +1,3 @@", "INS.HEAD:", repl("x")].join("\n"); expect(() => splitHashlineInputs(input)).toThrow(/unified-diff hunk header/); }); }); @@ -247,7 +247,7 @@ describe("Recovery", () => { path: filePath, currentText, tag: v0Tag, - edits: parsePatch(`replace 10..10:\n${repl("L10-EDITED")}`).edits, + edits: parsePatch(`XCHG 10..10:\n${repl("L10-EDITED")}`).edits, }); expect(recovered).not.toBeNull(); @@ -259,9 +259,7 @@ describe("hashline abort sentinel", () => { const sentinel = "*** Abort"; it("terminates parsing without surfacing a warning", () => { - const diff = [`insert after ${tag(1)}:`, repl("HELLO"), sentinel, `insert after ${tag(99)}:`, repl("never")].join( - "\n", - ); + const diff = [`INS.POST ${tag(1)}:`, repl("HELLO"), sentinel, `INS.POST ${tag(99)}:`, repl("never")].join("\n"); const { edits, warnings } = parsePatch(diff); expect(edits).toHaveLength(1); expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" }); @@ -271,11 +269,11 @@ describe("hashline abort sentinel", () => { it("stops the input splitter before later sections", () => { const input = [ "[a.ts]", - `insert after ${tag(1)}:`, + `INS.POST ${tag(1)}:`, repl("a-payload"), sentinel, "[b.ts]", - `insert after ${tag(1)}:`, + `INS.POST ${tag(1)}:`, repl("never"), ].join("\n"); const sections = splitHashlineInputs(input); @@ -287,11 +285,11 @@ 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]\ndelete 2\n").diff)).toBe("line1\nline3\n"); - expect(applyDiff("line1\nline2\nline3\nline4\n", splitHashlineInput("[a.ts]\ndelete 2..3\n").diff)).toBe( + 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( "line1\nline4\n", ); - expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nreplace 2..2:\n").diff)).toBe( + expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nXCHG 2..2:\n").diff)).toBe( "line1\nline3\n", ); }); @@ -303,10 +301,10 @@ describe("hashline parser — delete and blank payload semantics", () => { it("preserves explicit blank replacement rows", () => { const text = "a\nb\nc\nd\ne\n"; - const ops = `[a.ts]\nreplace 2..2:\n${repl("")}\n${repl("")}\nreplace 4..4:\n${repl("D")}\n`; + const ops = `[a.ts]\nXCHG 2..2:\n${repl("")}\n${repl("")}\nXCHG 4..4:\n${repl("D")}\n`; expect(applyDiff(text, splitHashlineInput(ops).diff)).toBe("a\n\n\nc\nD\ne\n"); - const embedded = `[a.ts]\nreplace 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; + const embedded = `[a.ts]\nXCHG 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 b1d94f4e5..4e87b8c33 100644 --- a/packages/hashline/test/format-v2.test.ts +++ b/packages/hashline/test/format-v2.test.ts @@ -8,47 +8,47 @@ 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 = ["replace 2..2:", "+before", "+after"].join("\n"); + const diff = ["XCHG 2..2:", "+before", "+after"].join("\n"); expect(applyPatch(text, diff)).toBe("a\nbefore\nafter\nc"); }); it("deletes a single source line", () => { const text = "a\nb\nc"; - expect(applyPatch(text, "delete 2")).toBe("a\nc"); + expect(applyPatch(text, "DEL 2")).toBe("a\nc"); }); it("deletes a concrete range", () => { const text = "a\nb\nc\nd"; - expect(applyPatch(text, "delete 2..3")).toBe("a\nd"); + expect(applyPatch(text, "DEL 2..3")).toBe("a\nd"); }); it("inserts before and after concrete anchors", () => { const text = "a\nb\nc"; - const diff = ["insert before 2:", "+before", "insert after 2:", "+after"].join("\n"); + const diff = ["INS.PRE 2:", "+before", "INS.POST 2:", "+after"].join("\n"); expect(applyPatch(text, diff)).toBe("a\nbefore\nb\nafter\nc"); }); it("inserts at head and tail", () => { const text = "a\nb"; - expect(applyPatch(text, "insert head:\n+HEAD")).toBe("HEAD\na\nb"); - expect(applyPatch(text, "insert tail:\n+TAIL")).toBe("a\nb\nTAIL"); + expect(applyPatch(text, "INS.HEAD:\n+HEAD")).toBe("HEAD\na\nb"); + expect(applyPatch(text, "INS.TAIL:\n+TAIL")).toBe("a\nb\nTAIL"); }); it("treats an empty replace hunk as a delete and still rejects empty inserts", () => { const text = "a\nb\nc"; - expect(applyPatch(text, "replace 2..2:")).toBe("a\nc"); - expect(() => parsePatch("insert head:")).toThrow(/needs at least one/); + expect(applyPatch(text, "XCHG 2..2:")).toBe("a\nc"); + expect(() => parsePatch("INS.HEAD:")).toThrow(/needs at least one/); }); it("rejects body rows under delete", () => { - expect(() => parsePatch("delete 2\n+replacement")).toThrow(/does not take body rows/); + expect(() => parsePatch("DEL 2\n+replacement")).toThrow(/does not take body rows/); }); it("auto-pipes bare body rows as literal text", () => { const text = "a\nb\nc"; - expect(applyPatch(text, "replace 2..2:\nraw")).toBe("a\nraw\nc"); - const { warnings } = parsePatch("replace 2..2:\nraw"); + expect(applyPatch(text, "XCHG 2..2:\nraw")).toBe("a\nraw\nc"); + const { warnings } = parsePatch("XCHG 2..2:\nraw"); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); @@ -56,50 +56,50 @@ 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("replace 2..2:\n3:replaced"); + const { edits, warnings } = parsePatch("XCHG 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); }); it("validates insert anchors against file bounds", () => { - const edits = parsePatch("insert before 4:\n+x").edits; + const edits = parsePatch("INS.PRE 4:\n+x").edits; expect(() => applyEdits("a\nb", edits)).toThrow(/Line 4 does not exist/); }); it("ignores deleting the trailing blank sentinel of a newline-terminated file", () => { // "a\nb\n" splits into ["a", "b", ""]; line 3 is the phantom sentinel. - const edits = parsePatch("delete 3").edits; + const edits = parsePatch("DEL 3").edits; expect(applyEdits("a\nb\n", edits).text).toBe("a\nb\n"); }); it("treats a delete range ending at the trailing sentinel as ending at the last real line", () => { - const edits = parsePatch("delete 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("replace 2..3:\n+B").edits; + const edits = parsePatch("XCHG 2..3:\n+B").edits; expect(applyEdits("a\nb\n", edits).text).toBe("a\nB\n"); }); it("still allows inserts anchored on the trailing blank sentinel", () => { - const edits = parsePatch("insert after 3:\n+tail").edits; + const edits = parsePatch("INS.POST 3:\n+tail").edits; expect(applyEdits("a\nb\n", edits).text).toBe("a\nb\n\ntail"); }); it("still deletes a genuine empty last line of a non-newline-terminated file", () => { // "a\nb" has no sentinel; line 2 is real content. - const edits = parsePatch("delete 2").edits; + const edits = parsePatch("DEL 2").edits; expect(applyEdits("a\nb", edits).text).toBe("a"); }); it("does not flush a trailing streaming pending empty replace hunk", () => { - const result = parsePatchStreaming("replace 5..5:\n"); + const result = parsePatchStreaming("XCHG 5..5:\n"); expect(result.edits).toEqual([]); }); it("flushes a streaming empty replace hunk when another hunk starts", () => { - const result = parsePatchStreaming("replace 2..2:\ninsert tail:\n"); + const result = parsePatchStreaming("XCHG 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 dac6249b3..496f893be 100644 --- a/packages/hashline/test/landing-shift.test.ts +++ b/packages/hashline/test/landing-shift.test.ts @@ -27,13 +27,13 @@ function apply(text: string, patch: string): { text: string; warnings: string[] describe("after-insert landing shift", () => { it("slides a shallower body past the closing line and warns", () => { - const { text, warnings } = apply(FILE, "insert after 3:\n+ c();"); + const { text, warnings } = apply(FILE, "INS.POST 3:\n+ c();"); expect(text).toBe( ["function f() {", " if (x) {", " a();", " }", " c();", " b();", "}", ""].join("\n"), ); expect(warnings).toHaveLength(1); - expect(warnings[0]).toMatch(/insert after 3: .*moved past 1 closing line to after line 4/); + expect(warnings[0]).toMatch(/INS.POST 3: .*moved past 1 closing line to after line 4/); }); it("crosses multiple closer levels and stops when depth returns to the body's", () => { @@ -50,44 +50,42 @@ describe("after-insert landing shift", () => { ].join("\n"); // Body at depth 4 escapes both the `for` and the `if`. - const outer = apply(nested, "insert after 4:\n+ c();"); + const outer = apply(nested, "INS.POST 4:\n+ c();"); expect(outer.text.split("\n")[6]).toBe(" c();"); expect(outer.warnings[0]).toMatch(/moved past 2 closing lines to after line 6/); // Body at depth 8 escapes only the `for`, staying inside the `if`. - const inner = apply(nested, "insert after 4:\n+ c();"); + const inner = apply(nested, "INS.POST 4:\n+ c();"); expect(inner.text.split("\n")[5]).toBe(" c();"); expect(inner.warnings[0]).toMatch(/moved past 1 closing line to after line 5/); }); it("does not shift when the body matches the anchor's depth", () => { - const { text, warnings } = apply(FILE, "insert after 3:\n+ c();"); + const { text } = apply(FILE, "INS.POST 3:\n+ c();"); expect(text.split("\n")[3]).toBe(" c();"); - expect(warnings).toHaveLength(0); }); it("never crosses content lines (indentation-only languages stay put)", () => { const py = ["def f():", " if x:", " a()", " b()", ""].join("\n"); - const { text, warnings } = apply(py, "insert after 3:\n+ c()"); + const { text, warnings } = apply(py, "INS.POST 3:\n+ c()"); expect(text).toBe(["def f():", " if x:", " a()", " c()", " b()", ""].join("\n")); expect(warnings).toHaveLength(0); }); it("treats a body of pure closers as depth-neutral", () => { - const { text, warnings } = apply(FILE, "insert after 3:\n+ }"); + const { text, warnings } = apply(FILE, "INS.POST 3:\n+ }"); expect(text.split("\n")[3]).toBe(" }"); expect(warnings).toHaveLength(0); }); it("skips incomparable indentation styles (tabs file, spaces body)", () => { const tabs = ["function f() {", "\tif (x) {", "\t\ta();", "\t}", "\tb();", "}", ""].join("\n"); - const { text, warnings } = apply(tabs, "insert after 3:\n+ c();"); + const { text } = apply(tabs, "INS.POST 3:\n+ c();"); expect(text.split("\n")[3]).toBe(" c();"); - expect(warnings).toHaveLength(0); }); it("refuses to cross a line targeted by another hunk", () => { - const { text, warnings } = apply(FILE, "insert after 3:\n+ c();\ndelete 4"); + const { text, warnings } = apply(FILE, "INS.POST 3:\n+ c();\nDEL 4"); // The closer on line 4 is owned by the delete; the insert stays put. expect(text).toBe(["function f() {", " if (x) {", " a();", " c();", " b();", "}", ""].join("\n")); expect(warnings).toHaveLength(0); @@ -95,24 +93,24 @@ describe("after-insert landing shift", () => { it("looks past blank lines between the anchor and the closer", () => { const gapped = ["function f() {", " if (x) {", " a();", "", " }", " b();", "}", ""].join("\n"); - const { text, warnings } = apply(gapped, "insert after 3:\n+ c();"); + const { text, warnings } = apply(gapped, "INS.POST 3:\n+ c();"); expect(text).toBe( ["function f() {", " if (x) {", " a();", "", " }", " c();", " b();", "}", ""].join("\n"), ); expect(warnings[0]).toMatch(/after line 5/); }); - it("leaves `insert before N:` untouched", () => { - const { text, warnings } = apply(FILE, "insert before 4:\n+ c();"); + it("leaves `INS.PRE N:` untouched", () => { + const { text, warnings } = apply(FILE, "INS.PRE 4:\n+ c();"); expect(text.split("\n")[3]).toBe(" c();"); expect(warnings).toHaveLength(0); }); - it("composes with `insert after block N:` to escape enclosing closers", () => { + it("composes with `INS.BLK.POST N:` to escape enclosing closers", () => { // stub: block beginning on N spans [N, N+1] → `block 2` ends on line 3. const stubResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 1 }); const text = ["function f() {", " const t = mk({", " });", "}", "x();", ""].join("\n"); - const section = Patch.parseSingle("[x.ts#1A2B]\ninsert after block 2:\n+ref = t;"); + const section = Patch.parseSingle("[x.ts#1A2B]\nINS.BLK.POST 2:\n+ref = t;"); const result = section.applyTo(text, stubResolver); @@ -126,7 +124,7 @@ describe("after-insert landing shift", () => { }); /** - * Inward landing correction for `insert after block N:` — a body indented + * Inward landing correction for `insert_after_block N:` — a body indented * deeper than the block's closing line claims a depth INSIDE the block (the * "append at the end of the block's body" misreading), so the landing slides * back across the block's trailing closers. Contract under test: fires only @@ -146,8 +144,7 @@ describe("insert-after-block inward landing shift", () => { it("pulls a deeper body inside the block, after its last content line", () => { const resolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 2 }); - const section = Patch.parseSingle("[x.ts#1A2B]\ninsert after block 2:\n+ setup();"); - + const section = Patch.parseSingle("[x.ts#1A2B]\nINS.BLK.POST 2:\n+ setup();"); const result = section.applyTo(BLOCK_FILE, resolver); expect(result.text).toBe( @@ -155,15 +152,13 @@ describe("insert-after-block inward landing shift", () => { "\n", ), ); - expect(result.warnings?.some(w => /insert after block 2: .*placed inside the block, after line 3/.test(w))).toBe( - true, - ); + expect(result.warnings?.some(w => /INS.BLK.POST 2: .*placed inside the block, after line 3/.test(w))).toBe(true); }); it("lands right after the opener of an empty block", () => { const resolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 1 }); const text = ["function f() {", " afterEach(() => {", " });", "}", ""].join("\n"); - const section = Patch.parseSingle("[x.ts#1A2B]\ninsert after block 2:\n+ setup();"); + const section = Patch.parseSingle("[x.ts#1A2B]\nINS.BLK.POST 2:\n+ setup();"); const result = section.applyTo(text, resolver); @@ -176,7 +171,7 @@ describe("insert-after-block inward landing shift", () => { it("crosses nested trailing closers and stops at the body's claimed depth", () => { const resolver: BlockResolver = (): BlockSpan => ({ start: 1, end: 5 }); const text = ["foo(() => {", " bar(() => {", " x();", " });", "});", ""].join("\n"); - const section = Patch.parseSingle("[x.ts#1A2B]\ninsert after block 1:\n+ baz();"); + const section = Patch.parseSingle("[x.ts#1A2B]\nINS.BLK.POST 1:\n+ baz();"); const result = section.applyTo(text, resolver); @@ -190,7 +185,7 @@ describe("insert-after-block inward landing shift", () => { it("leaves a sibling-depth body after the block (the literal contract)", () => { const resolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 2 }); - const section = Patch.parseSingle("[x.ts#1A2B]\ninsert after block 2:\n+ cleanup();"); + const section = Patch.parseSingle("[x.ts#1A2B]\nINS.BLK.POST 2:\n+ cleanup();"); const result = section.applyTo(BLOCK_FILE, resolver); @@ -203,7 +198,7 @@ describe("insert-after-block inward landing shift", () => { }); it("never shifts a plain `insert after M:` anchored on a closer", () => { - const { text, warnings } = apply(BLOCK_FILE, "insert after 4:\n+ leak();"); + const { text, warnings } = apply(BLOCK_FILE, "INS.POST 4:\n+ leak();"); expect(text.split("\n")[4]).toBe(" leak();"); expect(warnings).toHaveLength(0); @@ -212,9 +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]\nreplace 4..4:\n+ }); // bar\ninsert after block 1:\n+ y();", - ); + const section = Patch.parseSingle("[x.ts#1A2B]\nXCHG 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 1bc76fd1a..7360551d4 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]\nreplace 1..1:\n+after"); + const section = Patch.parseSingle("[dir with spaces/file.ts#1a2b]\nXCHG 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]\nreplace 1..1:\n+after"); + const section = Patch.parseSingle("[*** Update File: dir with spaces/file.ts#1A2B]\nXCHG 1..1:\n+after"); expect(section.path).toBe("dir with spaces/file.ts"); expect(section.fileHash).toBe("1A2B"); @@ -25,34 +25,30 @@ describe("hashline section headers", () => { }); it("rejects trailing junk after a snapshot tag", () => { - expect(() => Patch.parse("[src/a.ts#1A2B copied from read]\nreplace 1..1:\n+after")).toThrow( - /Input header must be/, - ); - expect(() => Patch.parse("[src/a.ts#1A2B:812]\nreplace 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B copied from read]\nXCHG 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B:812]\nXCHG 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]\nreplace 1..1:\n+after")).toThrow( - /Input header must be/, - ); - expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nreplace 1..1:\n+after")).toThrow( + expect(() => Patch.parse("[Update File: src/a.ts#1A2B copied from read]\nXCHG 1..1:\n+after")).toThrow( /Input header must be/, ); + expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nXCHG 1..1:\n+after")).toThrow(/Input header must be/); }); it("rejects malformed snapshot tags", () => { - expect(() => Patch.parse("[src/a.ts#1A2]\nreplace 1..1:\n+after")).toThrow(/Input header must be/); - expect(() => Patch.parse("[src/a.ts#1A2G]\nreplace 1..1:\n+after")).toThrow(/Input header must be/); - expect(() => Patch.parse("[src/a.ts#1A2B5]\nreplace 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2]\nXCHG 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2G]\nXCHG 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B5]\nXCHG 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]\nreplace 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[Update File: src/a.ts#1A2G]\nXCHG 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("delete 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); @@ -73,134 +69,134 @@ describe("hashline core — verb header forms", () => { }); it("accepts canonical replace/delete/insert forms", () => { - expect(applyPatch(FILE, "replace 2..3:\n+X")).toBe("a\nX\nd\ne"); - expect(applyPatch(FILE, "delete 2..3")).toBe("a\nd\ne"); - expect(applyPatch(FILE, "insert before 2:\n+X")).toBe("a\nX\nb\nc\nd\ne"); - expect(applyPatch(FILE, "insert after 2:\n+X")).toBe("a\nb\nX\nc\nd\ne"); - expect(applyPatch(FILE, "insert head:\n+X")).toBe("X\na\nb\nc\nd\ne"); - expect(applyPatch(FILE, "insert tail:\n+X")).toBe("a\nb\nc\nd\ne\nX"); + expect(applyPatch(FILE, "XCHG 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"); + expect(applyPatch(FILE, "INS.TAIL:\n+X")).toBe("a\nb\nc\nd\ne\nX"); }); it("accepts single-number replace and delete shorthand", () => { - expect(applyPatch(FILE, "replace 2:\n+X")).toBe("a\nX\nc\nd\ne"); - expect(applyPatch(FILE, "delete 2")).toBe("a\nc\nd\ne"); + expect(applyPatch(FILE, "XCHG 2:\n+X")).toBe("a\nX\nc\nd\ne"); + expect(applyPatch(FILE, "DEL 2")).toBe("a\nc\nd\ne"); }); it("accepts alternate replace range separators and missing colon", () => { - expect(applyPatch(FILE, "replace 2-3:\n+X")).toBe("a\nX\nd\ne"); - expect(applyPatch(FILE, "replace 2\u20263:\n+X")).toBe("a\nX\nd\ne"); - expect(applyPatch(FILE, "replace 2 3:\n+X")).toBe("a\nX\nd\ne"); - expect(applyPatch(FILE, "replace 2..3\n+X")).toBe("a\nX\nd\ne"); + expect(applyPatch(FILE, "XCHG 2-3:\n+X")).toBe("a\nX\nd\ne"); + expect(applyPatch(FILE, "XCHG 2\u20263:\n+X")).toBe("a\nX\nd\ne"); + expect(applyPatch(FILE, "XCHG 2 3:\n+X")).toBe("a\nX\nd\ne"); + expect(applyPatch(FILE, "XCHG 2..3\n+X")).toBe("a\nX\nd\ne"); }); it("accepts missing colon on insert headers", () => { - expect(applyPatch(FILE, "insert before 2\n+X")).toBe("a\nX\nb\nc\nd\ne"); - expect(applyPatch(FILE, "insert head\n+X")).toBe("X\na\nb\nc\nd\ne"); + expect(applyPatch(FILE, "INS.PRE 2\n+X")).toBe("a\nX\nb\nc\nd\ne"); + expect(applyPatch(FILE, "INS.HEAD\n+X")).toBe("X\na\nb\nc\nd\ne"); }); }); describe("hashline body contracts", () => { it("auto-pipes a bare body row while warning", () => { - const result = parsePatch("replace 2..2:\n hello"); + const result = parsePatch("XCHG 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("replace 2..2:\n2:hello"); + const result = parsePatch("XCHG 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("replace 2..2:\n+3:keep"); + const result = parsePatch("XCHG 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("replace 2..2:\n2:42:hello"); + const result = parsePatch("XCHG 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("replace 2..3:\n2:foo\n3:bar"); + const result = parsePatch("XCHG 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("replace 2..3:\n3:keep\nplain"); + const result = parsePatch("XCHG 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("replace 2..3:\nfoo\n\nbar"); + const result = parsePatch("XCHG 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("replace 2..2:\nfoo\n\nreplace 4..4:\nbaz"); + const result = parsePatch("XCHG 2..2:\nfoo\n\nXCHG 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("replace 2..3:\n2:foo\n\n3:bar"); + const result = parsePatch("XCHG 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('replace 2..3:\n1: "one",\n2: "two",'); + const result = parsePatch('XCHG 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("replace 2..2:\n-old\n+new")).toThrow(/`-` rows are not valid/); + expect(() => parsePatch("XCHG 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, "replace 2..2:\n+-literal\n++plus")).toBe("a\n-literal\n+plus\nc\nd\ne"); + expect(applyPatch(FILE, "XCHG 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, "replace 2..2:")).toBe("a\nc\nd\ne"); - expect(() => parsePatch("insert tail:")).toThrow(/`insert` needs/); + expect(applyPatch(FILE, "XCHG 2..2:")).toBe("a\nc\nd\ne"); + expect(() => parsePatch("INS.TAIL:")).toThrow(/`INS` needs/); }); it("rejects delete with a body", () => { - expect(() => parsePatch("delete 2\n+X")).toThrow(/does not take body rows/); + expect(() => parsePatch("DEL 2\n+X")).toThrow(/does not take body rows/); }); it("rejects delete with a colon", () => { - expect(() => parsePatch("delete 2:\n+X")).toThrow(/has no colon/); + expect(() => parsePatch("DEL 2:\n+X")).toThrow(/has no colon/); }); }); describe("hashline — apply_patch / unified-diff contamination", () => { it("rejects apply_patch sentinels as contamination", () => { - expect(() => parsePatch("*** Update File: a.ts\nreplace 2..2:\n+X")).toThrow(/apply_patch sentinel/); - expect(() => parsePatch("*** Add File: a.ts\nreplace 2..2:\n+X")).toThrow(/apply_patch sentinel/); + expect(() => parsePatch("*** Update File: a.ts\nXCHG 2..2:\n+X")).toThrow(/apply_patch sentinel/); + expect(() => parsePatch("*** Add File: a.ts\nXCHG 2..2:\n+X")).toThrow(/apply_patch sentinel/); }); it("rejects unified-diff hunk headers as contamination", () => { - expect(() => parsePatch("@@ -1,3 +1,3 @@\nreplace 2..2:\n+X")).toThrow(/unified-diff hunk header/); + expect(() => parsePatch("@@ -1,3 +1,3 @@\nXCHG 2..2:\n+X")).toThrow(/unified-diff hunk header/); }); it("treats top-level `+TEXT` as an orphan literal payload", () => { - expect(() => parsePatch("+const X = 1;\nreplace 2..2:")).toThrow(/payload line has no preceding hunk header/); + expect(() => parsePatch("+const X = 1;\nXCHG 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 = "replace 3..3:\n+// one\n+// two\n+new();"; + const diff = "XCHG 3..3:\n+// one\n+// two\n+new();"; expect(applyPatch(text, diff)).toBe(["// one", "// two", "// one", "// two", "new();"].join("\n")); }); it("keeps pure-insert context echoes literal", () => { const text = ["aaa", "bbb", "ccc"].join("\n"); - const diff = "insert tail:\n+bbb\n+ccc\n+NEW"; + const diff = "INS.TAIL:\n+bbb\n+ccc\n+NEW"; expect(applyPatch(text, diff)).toBe("aaa\nbbb\nccc\nbbb\nccc\nNEW"); }); }); diff --git a/packages/hashline/test/patcher.test.ts b/packages/hashline/test/patcher.test.ts index a90cdd3b2..6ca5dffb2 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}]\nreplace 1..1:\n+after`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 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}]\nreplace 3..3:\n+L3`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 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]\nreplace 1..1:\n+after`); + const section = Patch.parseSingle(`[${PATH}#1a2b]\nXCHG 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}]\nreplace 1..1:\n+after`)); + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 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}]\nreplace 1..1:\n+after`)); + await patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nXCHG 1..1:\n+after`)); throw new Error("expected MismatchError"); } catch (error) { expect(error).toBeInstanceOf(MismatchError); @@ -109,7 +109,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[${PATH}]\ninsert tail:\n+c`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}]\nINS.TAIL:\n+c`))).rejects.toThrow( /Missing hashline snapshot tag.*use the write tool/s, ); expect(fs.get(PATH)).toBe("a\nb\n"); @@ -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}]\nreplace 1..1:\n+X`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}]\nXCHG 1..1:\n+X`))).rejects.toThrow( /Missing hashline snapshot tag/, ); }); @@ -130,7 +130,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[ghost.ts#1A2B]\ninsert tail:\n+c`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[ghost.ts#1A2B]\nINS.TAIL:\n+c`))).rejects.toThrow( /File not found.*use the write tool/is, ); }); @@ -143,7 +143,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const stale = live === "0000" ? "FFFF" : "0000"; const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${stale}]\ninsert tail:\n+c`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${stale}]\nINS.TAIL:\n+c`)); const section = result.sections[0]; expect(section?.op).toBe("update"); @@ -158,7 +158,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const tag = snapshots.record(PATH, content); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\ninsert tail:\n+c`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nINS.TAIL:\n+c`)); const section = result.sections[0]; expect(section?.op).toBe("update"); @@ -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}]\nreplace 4..4:\n+L4`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 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}]\nreplace 2..2:\n+L2`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 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}]\nreplace 4..4:\n+L4`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 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}]\nreplace 4..4:\n+L4`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 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 24bf947a2..3193a8dbe 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("replace 5..5:\n|L5-MODEL"); + const { edits } = parsePatch("XCHG 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("replace 3..3:\n|L3-MODEL"); + const { edits } = parsePatch("XCHG 3..3:\n|L3-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, diff --git a/scripts/session-stats/README.md b/scripts/session-stats/README.md index 62d0d2766..954e2a447 100644 --- a/scripts/session-stats/README.md +++ b/scripts/session-stats/README.md @@ -70,7 +70,7 @@ All three accept `-n N` / `--folder SUBSTR` to scope the query, plus (per-call `timestamp`, so it slices long sessions precisely). The `edits` audit reads each call's `is_error` flag as the authoritative success/failure signal and decodes hashline op kinds (`replace`, `insert after`, `delete`, -`replace block`, …) into the verb distribution. +`replace_block`, …) into the verb distribution. ## Usage audit (`audit.ts`) diff --git a/scripts/session-stats/analyze.py b/scripts/session-stats/analyze.py index e276e2d8a..10e468a95 100644 --- a/scripts/session-stats/analyze.py +++ b/scripts/session-stats/analyze.py @@ -375,12 +375,29 @@ _ANCHOR_BARE = re.compile(r"^[a-zA-Z]?[0-9]+[a-z]{2}$") _HASHLINE_OP = re.compile( - r"^(replace block|replace|delete block|delete|" - r"insert before|insert after|insert head|insert tail)\b", + r"^(XCHG\.BLK|XCHG|DEL\.BLK|DEL|INS\.PRE|INS\.POST|INS\.HEAD|INS\.TAIL|" + r"replace_block|replace block|replace|delete_block|delete block|delete|" + r"insert_after_block|insert after block|insert before|insert after|insert head|insert tail)\b", re.I, ) +_HASHLINE_OP_ALIASES = { + "replace block": "XCHG.BLK", + "replace_block": "XCHG.BLK", + "replace": "XCHG", + "delete block": "DEL.BLK", + "delete_block": "DEL.BLK", + "delete": "DEL", + "insert before": "INS.PRE", + "insert after": "INS.POST", + "insert head": "INS.HEAD", + "insert tail": "INS.TAIL", + "insert after block": "INS.BLK.POST", + "insert_after_block": "INS.BLK.POST", +} + + def _hashline_ops(inp: str) -> list[str]: """Op kinds in a hashline edit `input` payload (skips `+` body rows).""" ops: list[str] = [] @@ -389,7 +406,8 @@ def _hashline_ops(inp: str) -> list[str]: continue m = _HASHLINE_OP.match(line.lstrip()) if m: - ops.append(m.group(1).lower()) + op = m.group(1).lower() + ops.append(_HASHLINE_OP_ALIASES.get(op, op)) return ops