diff --git a/docs/tools/edit.md b/docs/tools/edit.md index a831d5d60..cb3831bc8 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 (`[`, `]`, `#`, `+`, `XCHG`, `DEL`, `INS`) + - `packages/hashline/src/format.ts` — sigils and header constants (`[`, `]`, `#`, `+`, `SWAP`, `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,10 +28,10 @@ Patch language inside `input`: - **File header**: `[PATH#TAG]`. `TAG` is four uppercase-hex chars — a content-derived hash of the whole normalized file (`computeFileHash()`), recorded in the session snapshot store. - **Operations**: - - `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. + - `SWAP N..M:` — replace original lines N..M with the body rows below. + - `SWAP.BLK N:` — replace the whole tree-sitter block beginning on line N (its header line through its closing line) with the body rows. The line span is resolved at apply time from the file's parse tree; point N at the line that opens the construct. The resolved span is exactly the node that begins on line N — a leading decorator, attribute, or doc-comment is a separate node and is not included; point N at the first decorator line (Python wraps `@dec` + `def` as one block) or fall back to `SWAP N..M:` to take a leading line-comment that parses as its own node (e.g. Rust `///`). On success the result echoes the matched span (`SWAP.BLK N → resolved lines A-B`). Errors (and steers to `SWAP N..M:`) when the language is unsupported, line N is blank or a closing delimiter, no node begins there, or the resolved block has a syntax error. - `DEL N..M` — delete original lines N..M. No body. - - `DEL.BLK N` — delete the whole tree-sitter block beginning on line N (resolved like `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. + - `DEL.BLK N` — delete the whole tree-sitter block beginning on line N (resolved like `SWAP.BLK N`, with the same decorator/comment caveat). No body. On success the result echoes the matched span (`DEL.BLK N → resolved lines A-B`). Same resolution failure modes and `DEL N..M` fallback. - `INS.PRE N:` — insert body rows immediately before line N. - `INS.POST N:` — insert body rows immediately after line N. - `INS.BLK.POST N:` — insert body rows after the last line of the tree-sitter block beginning on line N. Point N at the line that opens the construct, never its closing delimiter / last visible line; if you can see the last line already, use plain `INS.POST M:`. Same resolution failure modes and `INS.POST M:` fallback. @@ -50,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: -- `XCHG N:` — accepted as `XCHG N..N:`. +- `SWAP N:` — accepted as `SWAP 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:`. +- Missing trailing colon on `SWAP` or `INS` — accepted. +- `SWAP N-M:`, `SWAP N…M:`, and `SWAP N M:` — accepted as `SWAP N..M:`. - Bare body rows with no `+` prefix are auto-prepended with `+` and a `BARE_BODY_AUTO_PIPED_WARNING` is appended. - `*** Begin Patch` / `*** End Patch` envelopes are silently consumed. `*** Abort` terminates parsing silently — ops parsed before the marker still apply, no warning surfaced. - Some malformed bracketed headers are recovered after stripping apply-patch path noise such as `Update File:` / `Add File:` and extra `***`, but the recovered header still needs a valid four-hex tag for the patcher to apply it. - `*** Update File:` / `*** Add File:` / `*** Delete File:` / `*** Move to:` apply_patch sentinels inside the diff body throw an `apply_patch sentinel … is not valid in hashline` error. - `@@`-bracketed hunk headers are rejected with guidance to write a verb header. -- Bare `N` and bare `N M` / `N..M` headers are rejected with guidance to write `XCHG` or `DEL`. +- Bare `N` and bare `N M` / `N..M` headers are rejected with guidance to write `SWAP` or `DEL`. - `DEL N..M:` and any body rows under `DEL` / `DEL.BLK` are rejected. -- Empty `XCHG` / `INS` / `XCHG.BLK` hunks are rejected. +- Empty `SWAP` / `INS` / `SWAP.BLK` hunks are rejected. - `-` body rows are rejected with `MINUS_ROW_REJECTED`. -- `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. +- `SWAP.BLK N:` / `DEL.BLK N` / `INS.BLK.POST N:` require a wired tree-sitter resolver; `SWAP.BLK` and `INS.BLK.POST` additionally need at least one `+TEXT` body row, while `DEL.BLK` takes none. An unresolvable block (unsupported language, blank/closing-delimiter line, no node beginning on N, or a syntax error in the resolved block) is rejected on the apply/final-preview path; the streaming preview silently drops it instead. Exception: `INS.BLK.POST N:` anchored on a pure closing-delimiter line is lowered to plain `INS.POST N:` with a warning — line N is the end of a block, and inserting after that end is exactly what the plain form does. ## 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 `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. +- When the patch used `SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` ops (and the apply matched the tagged content), one `SWAP.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] -XCHG 1..1: +SWAP 1..1: +const X = "b"; +export const Y = X; ``` @@ -144,7 +144,7 @@ INS.TAIL: Multi-file: ```text [src/a.ts#0A3B] -XCHG 4..4: +SWAP 4..4: +const enabled = true; [src/b.ts#1F7C] DEL 20 @@ -154,7 +154,7 @@ DEL 20 - File snapshot tags are exactly four uppercase-hex chars — content-derived hashes (`computeFileHash()`) recorded in the per-session snapshot store. - The visible mismatch report shows 2 lines of context on each side (`MISMATCH_CONTEXT`) in `packages/hashline/src/messages.ts`. - Stale-anchor recovery uses `fuzzFactor: 0` in `packages/hashline/src/recovery.ts`. -- `HL_FILE_PREFIX` is `[`, `HL_FILE_SUFFIX` is `]`, `HL_PAYLOAD_REPLACE` is `+`, `HL_RANGE_SEP` is `..`, `HL_FILE_HASH_SEP` is `#`, and hunk keyword constants are `XCHG` / `DEL` / `INS` (`packages/hashline/src/format.ts`). +- `HL_FILE_PREFIX` is `[`, `HL_FILE_SUFFIX` is `]`, `HL_PAYLOAD_REPLACE` is `+`, `HL_RANGE_SEP` is `..`, `HL_FILE_HASH_SEP` is `#`, and hunk keyword constants are `SWAP` / `DEL` / `INS` (`packages/hashline/src/format.ts`). ## Errors - Missing section header: @@ -162,29 +162,29 @@ DEL 20 - Missing tag for any section: - `Missing hashline snapshot tag for edit to ; use \`[#tag]\` from your latest read/search output. To create a new file, use the write tool.` - Stray payload line: - - `line N: payload line has no preceding hunk header. Use \`XCHG N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got "...".` + - `line N: payload line has no preceding hunk header. Use \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got "...".` - Minus row: - ``line N: `-` rows are not valid; hashline ranges already name the lines being changed. To insert a literal line starting with `-`, write `+-…`.`` - Empty body-bearing hunk: - - `line N: \`XCHG N..M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`DEL N..M\`.` + - `line N: \`SWAP N..M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`DEL N..M\`.` - `line N: \`INS\` needs at least one \`+TEXT\` body row.` - - `line N: \`XCHG.BLK N:\` needs at least one \`+TEXT\` body row. To delete a block, use \`DEL.BLK N\`.` + - `line N: \`SWAP.BLK N:\` needs at least one \`+TEXT\` body row. To delete a block, use \`DEL.BLK N\`.` - Unresolvable block anchor (apply / final-preview path only): - - `line N: \`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: \`SWAP.BLK X:\` could not resolve a syntactic block beginning on line X. The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. Use \`SWAP X..M:\` with the block's explicit end line instead.` — followed by a blank line and numbered `*`-marked context rows around line X (same shape as the mismatch preview). - `line N: \`INS.BLK.POST X:\` could not resolve a syntactic block beginning on line X. The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. Use \`INS.POST M:\` with the block's explicit last line instead.` — same context preview. - Delete with body: - - `line N: \`DEL N..M\` does not take body rows. Remove the body, or use \`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.` + - `line N: \`DEL N..M\` does not take body rows. Remove the body, or use \`SWAP N..M:\`.` + - `line N: \`DEL.BLK N\` does not take body rows. Remove the body, or use \`SWAP.BLK N:\` to replace the block.` - Range out of order: - `line N: range A..B ends before it starts.` - 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 \`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]}\`.` + - `line N: apply_patch sentinel "*** …" is not valid in hashline. File sections start with \`[path#HASH]\` (no \`Update File:\` / \`Add File:\` keyword). Use \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.` + - `line N: unified-diff hunk header (\`@@ -N,M +N,M @@\`) is not valid in hashline. Use \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.` + - `line N: \`@@\`-bracketed hunk header "@@ …" is not valid in hashline. Drop the \`@@ ... @@\` brackets and write a verb header such as \`SWAP N..M:\`.` + - `line N: hunk headers need a verb. Use \`SWAP N..N:\` to replace, or \`DEL N\` to delete.` + - `line N: bare range hunk header "N M" is not valid. Hunk headers need a verb: write \`SWAP ${bareRange[1]}..${bareRange[2]}:\` or \`DEL ${bareRange[1]}..${bareRange[2]}\`.` - Out-of-range anchor: - `Line N does not exist (file has M lines)` - Stale snapshot tag: the `Patcher` first attempts snapshot-based recovery. When recovery cannot prove a valid result it throws `MismatchError`, which distinguishes recognized-but-drifted hashes from never-recorded hashes. The error includes the current file hash plus context around each anchor. diff --git a/packages/ai/test/inband-tools.test.ts b/packages/ai/test/inband-tools.test.ts index c621dc623..6819f5e47 100644 --- a/packages/ai/test/inband-tools.test.ts +++ b/packages/ai/test/inband-tools.test.ts @@ -167,7 +167,7 @@ describe("in-band tool grammars", () => { assistant([ { type: "text", - text: 'rubygems.ts:85-93\n\n\nread\n[rubygems.ts#A1B2]\n\n\n[rubygems.ts#A1B2]\nXCHG 89..89:\n+ fake', + text: 'rubygems.ts:85-93\n\n\nread\n[rubygems.ts#A1B2]\n\n\n[rubygems.ts#A1B2]\nSWAP 89..89:\n+ fake', }, ]), "anthropic", diff --git a/packages/coding-agent/src/edit/hashline/execute.ts b/packages/coding-agent/src/edit/hashline/execute.ts index 0bdf4699f..3eac7d257 100644 --- a/packages/coding-agent/src/edit/hashline/execute.ts +++ b/packages/coding-agent/src/edit/hashline/execute.ts @@ -98,7 +98,7 @@ interface RenderedSection { } function formatBlockResolution(resolution: BlockResolution): string { - const op = resolution.op === "delete" ? "DEL.BLK" : resolution.op === "insert_after" ? "INS.BLK.POST" : "XCHG.BLK"; + const op = resolution.op === "delete" ? "DEL.BLK" : resolution.op === "insert_after" ? "INS.BLK.POST" : "SWAP.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 2524074b8..9379fa9dc 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("XCHG.BLK — native tree-sitter resolution end-to-end", () => { +describe("SWAP.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}\nXCHG.BLK 2:\n+ if (y || z) {\n+ }`; + const input = `${header}\nSWAP.BLK 2:\n+ if (y || z) {\n+ }`; await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -80,7 +80,7 @@ describe("XCHG.BLK — 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}\nXCHG.BLK 1:\n+function x() {\n+ return 42;\n+}`; + const input = `${header}\nSWAP.BLK 1:\n+function x() {\n+ return 42;\n+}`; await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -104,7 +104,7 @@ describe("XCHG.BLK — 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}\nXCHG.BLK 2:\n+ if (y || z) {\n+ }`; + const input = `${header}\nSWAP.BLK 2:\n+ if (y || z) {\n+ }`; const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -113,17 +113,17 @@ describe("XCHG.BLK — native tree-sitter resolution end-to-end", () => { }); }); - it("echoes the resolved span in the result text for XCHG.BLK", async () => { + it("echoes the resolved span in the result text for SWAP.BLK", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nXCHG.BLK 1:\n+function x() {\n+ return 42;\n+}`; + const input = `${header}\nSWAP.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("XCHG.BLK 1 → resolved lines 1-4 (4 lines)"); + expect(text).toContain("SWAP.BLK 1 → resolved lines 1-4 (4 lines)"); }); }); @@ -141,16 +141,16 @@ describe("XCHG.BLK — native tree-sitter resolution end-to-end", () => { }); }); - it("rejects a lone closing delimiter (no block begins there) and steers to `XCHG N..M:`", async () => { + it("rejects a lone closing delimiter (no block begins there) and steers to `SWAP N..M:`", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); // Line 3 is ` }` — a closing delimiter, not a block opener. - const input = `${header}\nXCHG.BLK 3:\n+ }`; + const input = `${header}\nSWAP.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.*XCHG 3\.\.M:.*^ 1:function x\(\) \{$.*^\*3: {2}\}$/ms, + /could not resolve a syntactic block beginning on line 3.*SWAP 3\.\.M:.*^ 1:function x\(\) \{$.*^\*3: {2}\}$/ms, ); // Disk untouched — refusal never leaves a partial write. expect(await Bun.file(filePath).text()).toBe(TS_SOURCE); @@ -162,7 +162,7 @@ describe("XCHG.BLK — 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}\nXCHG.BLK 1:\n+ALPHA`; + const input = `${header}\nSWAP.BLK 1:\n+ALPHA`; await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow( /could not resolve a syntactic block/, diff --git a/packages/coding-agent/test/core/hashline-loop-guard.test.ts b/packages/coding-agent/test/core/hashline-loop-guard.test.ts index e00f93fdf..c0dcc4866 100644 --- a/packages/coding-agent/test/core/hashline-loop-guard.test.ts +++ b/packages/coding-agent/test/core/hashline-loop-guard.test.ts @@ -54,7 +54,7 @@ async function withTempDir(fn: (tempDir: string) => Promise): Promise { @@ -144,7 +144,7 @@ describe("hashline noop loop guard", () => { // from the model's perspective. const source = "aaa\nbbb\nccc\n"; const tag = getFileReadCache(session).record(filePath, source); - const realEdit = `${formatHashlineHeader("a.ts", tag)}\nreplace 2..2:\n+BBB\n`; + const realEdit = `${formatHashlineHeader("a.ts", tag)}\nSWAP 2..2:\n+BBB\n`; const editResult = await executeHashlineSingle(execOptions(realEdit, session)); expect(editResult.content[0]?.type === "text" ? editResult.content[0].text : "").not.toContain( "byte-identical to the file", @@ -156,7 +156,7 @@ describe("hashline noop loop guard", () => { // soft-hint regime. const newSource = "aaa\nBBB\nccc\n"; const newTag = getFileReadCache(session).record(filePath, newSource); - const newNoop = `${formatHashlineHeader("a.ts", newTag)}\nreplace 2..2:\n+BBB\n`; + const newNoop = `${formatHashlineHeader("a.ts", newTag)}\nSWAP 2..2:\n+BBB\n`; const result = await executeHashlineSingle(execOptions(newNoop, session)); expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain( "byte-identical to the file", diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 89f44889d..28474ba1b 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -45,7 +45,7 @@ function header(filePath: string, tag: string): string { } function sameLineRange(anchor: string): string { - return `replace ${anchor}..${anchor}:`; + return `SWAP ${anchor}..${anchor}:`; } async function withTempDir(fn: (tempDir: string) => Promise): Promise { @@ -85,7 +85,7 @@ function hashlineExecuteOptions( describe("hashline executor", () => { it("rejects file creation and directs to the write tool", async () => { await withTempDir(async tempDir => { - const input = `[new.ts]\ninsert head:\n${repl("export const x = 1;")}\n`; + const input = `[new.ts]\nINS.HEAD:\n${repl("export const x = 1;")}\n`; await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow(/write tool/); expect(await Bun.file(path.join(tempDir, "new.ts")).exists()).toBe(false); }); @@ -98,7 +98,7 @@ describe("hashline executor", () => { await Bun.write(filePath, source); const sourceTag = recordFullSnapshot(getFileReadCache(session), filePath, source); - const input = `${header("a.ts", sourceTag)}\ninsert tail:\n${repl("bbb")}\n${repl("ccc")}\n${repl("NEW")}\n`; + const input = `${header("a.ts", sourceTag)}\nINS.TAIL:\n${repl("bbb")}\n${repl("ccc")}\n${repl("NEW")}\n`; const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session)); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; @@ -203,7 +203,7 @@ describe("hashline executor", () => { repl("L2h"), repl("L2i"), header("a.ts", originalTag), - `insert after ${tag(8, "L8")}:`, + `INS.POST ${tag(8, "L8")}:`, repl("INSERTED"), ].join("\n"); @@ -250,14 +250,14 @@ describe("hashlineEditParamsSchema — payload shape", () => { it("tolerates provider extra fields without declaring `path`", () => { expect( - hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `[x.ts]\ninsert head:\n${repl("x")}` }).success, + hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `[x.ts]\nINS.HEAD:\n${repl("x")}` }).success, ).toBe(true); }); it("accepts `_input` as a provider-emitted alias for `input`", () => { - const parsed = hashlineEditParamsSchema.safeParse({ _input: `[x.ts]\ninsert head:\n${repl("x")}` }); + const parsed = hashlineEditParamsSchema.safeParse({ _input: `[x.ts]\nINS.HEAD:\n${repl("x")}` }); expect(parsed.success).toBe(true); - if (parsed.success) expect(parsed.data.input).toBe(`[x.ts]\ninsert head:\n${repl("x")}`); + if (parsed.success) expect(parsed.data.input).toBe(`[x.ts]\nINS.HEAD:\n${repl("x")}`); }); it("still requires `input`", () => { diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index e82fa3ac4..d5739d27c 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -236,12 +236,12 @@ describe("computeHashlineDiff", () => { const line = "unchanged content"; await Bun.write(sourcePath, `${line}\n`); - // `replace 1..1:` with the same line in the body is a true no-op: the edit + // `SWAP 1..1:` with the same line in the body is a true no-op: the edit // fires through computeHashlineDiff but produces identical content. const text = `${line}\n`; const snapshotStore = new InMemorySnapshotStore(); const tag = snapshotStore.record(sourcePath, text); - const input = `${formatHashlineHeader(sourcePath, tag)}\nreplace 1..1:\n+${line}\n`; + const input = `${formatHashlineHeader(sourcePath, tag)}\nSWAP 1..1:\n+${line}\n`; const result = await computeHashlineDiff({ input }, tempDir, snapshotStore); expect("error" in result).toBe(true); if ("error" in result) { @@ -257,7 +257,7 @@ describe("computeHashlineDiff", () => { const snapshotStore = new InMemorySnapshotStore(); const tag = snapshotStore.record(sourcePath, text); const result = await computeHashlineDiff( - { input: `${formatHashlineHeader(sourcePath, tag)}\ninsert tail:\n+second` }, + { input: `${formatHashlineHeader(sourcePath, tag)}\nINS.TAIL:\n+second` }, tempDir, snapshotStore, ); @@ -271,12 +271,12 @@ describe("computeHashlineDiff", () => { const relativePath = "source.txt"; await Bun.write(path.join(tempDir, relativePath), "first\n"); - // A tagless `insert tail:` carries no anchored edit, yet the apply path + // A tagless `INS.TAIL:` carries no anchored edit, yet the apply path // (Patcher.prepare) rejects it for the missing mandatory tag. The // preview/diff path MUST emit the SAME rejection so a successful preview // never precedes a failing apply. const result = await computeHashlineDiff( - { input: `[${relativePath}]\ninsert tail:\n+second` }, + { input: `[${relativePath}]\nINS.TAIL:\n+second` }, tempDir, new InMemorySnapshotStore(), ); @@ -287,7 +287,7 @@ describe("computeHashlineDiff", () => { }); test("returns a handled error when the source path is a local URL", async () => { const result = await computeHashlineDiff( - { input: "[local://PLAN.md]\ninsert tail:\n+x" }, + { input: "[local://PLAN.md]\nINS.TAIL:\n+x" }, tempDir, new InMemorySnapshotStore(), ); diff --git a/packages/coding-agent/test/edit-streaming-preview.test.ts b/packages/coding-agent/test/edit-streaming-preview.test.ts index 8eb4c6795..9294c55b5 100644 --- a/packages/coding-agent/test/edit-streaming-preview.test.ts +++ b/packages/coding-agent/test/edit-streaming-preview.test.ts @@ -66,7 +66,7 @@ describe("hashline streaming preview (multi-section)", () => { const ctx = (cwd: string) => ({ cwd, signal: new AbortController().signal, snapshots }); test("keeps section A's preview when section B's header just arrived", async () => { - const input = [headerA, "insert head:", "+// new", headerB].join("\n"); + const input = [headerA, "INS.HEAD:", "+// new", headerB].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).not.toBeNull(); expect(previews).toHaveLength(1); @@ -77,7 +77,7 @@ describe("hashline streaming preview (multi-section)", () => { test("ignores parse errors from the trailing in-progress section", async () => { // `7:bad` has invalid payload — the trailing section is still being typed. - const input = [headerA, "insert head:", "+// new", headerB, "7:bad"].join("\n"); + const input = [headerA, "INS.HEAD:", "+// new", headerB, "7:bad"].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).not.toBeNull(); expect(previews).toHaveLength(1); @@ -86,7 +86,7 @@ describe("hashline streaming preview (multi-section)", () => { }); test("renders both sections once each has at least one valid op", async () => { - const input = [headerA, "insert head:", "+// new a", headerB, "insert head:", "+// new b"].join("\n"); + const input = [headerA, "INS.HEAD:", "+// new a", headerB, "INS.HEAD:", "+// new b"].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toHaveLength(2); expect(previews?.map(p => p.path).sort()).toEqual(["a.ts", "b.ts"]); @@ -128,7 +128,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { // The `+` payload has no trailing newline — the common single-op case // the trailing-line trim used to erase, collapsing the preview to a // "No changes" error that rendered as a blank box for the whole stream. - const input = `${header}\nreplace 2..2:\n+const b = 22`; + const input = `${header}\nSWAP 2..2:\n+const b = 22`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toBeUndefined(); @@ -136,7 +136,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { }); test("does not surface stale hash errors while streaming", async () => { - const input = "[a.ts#FFFF]\nreplace 2..2:\n+const b = 22"; + const input = "[a.ts#FFFF]\nSWAP 2..2:\n+const b = 22"; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toBeUndefined(); @@ -145,7 +145,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { test("final preview accepts a live content hash even when the snapshot store has no history", async () => { const liveHeader = formatHashlineHeader("a.ts", computeFileHash(text)); - const input = `${liveHeader}\nreplace 2..2:\n+const b = 22\n`; + const input = `${liveHeader}\nSWAP 2..2:\n+const b = 22\n`; const previews = await strategy.computeDiffPreview( { input } as never, { @@ -162,7 +162,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { test("final preview recovers a stale tag from snapshot history", async () => { await Bun.write(file, `// external\n${text}`); - const input = `${header}\nreplace 2..2:\n+const b = 22\n`; + const input = `${header}\nSWAP 2..2:\n+const b = 22\n`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toBeUndefined(); @@ -170,7 +170,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { }); test("surfaces stale hash errors once streaming is complete", async () => { - const input = "[a.ts#FFFF]\nreplace 2..2:\n+const b = 22\n"; + const input = "[a.ts#FFFF]\nSWAP 2..2:\n+const b = 22\n"; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toContain("not from this session"); @@ -180,7 +180,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { // Op header typed, payload still empty: applyPartialTo drops the // payload-less op so nothing changes yet. The preview must report null // (preserving any prior frame), never a 'No changes' error that wipes it. - const input = `${header}\nreplace 2..2:\n`; + const input = `${header}\nSWAP 2..2:\n`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toBeNull(); }); @@ -217,7 +217,7 @@ describe("hashline streaming preview (monotonic growth)", () => { const ctx = (cwd: string) => ({ cwd, signal: new AbortController().signal, snapshots, isStreaming: true }); // Replace the 20-line body (lines 2..21) with the first `n` payload rows. const buildInput = (n: number) => - `${header}\nreplace 2..21:\n${payload + `${header}\nSWAP 2..21:\n${payload .slice(0, n) .map(l => `+${l}`) .join("\n")}`; @@ -304,12 +304,12 @@ describe("apply_patch streaming preview (trailing partial line)", () => { describe("matcherDigest", () => { test("hashline: digests stripped `+` body rows only, never headers or op lines", () => { - const input = ["[a.ts#AB12]", "replace 1..2:", "+const x = 1;", "+const y = 2;", "delete 5", ""].join("\n"); + const input = ["[a.ts#AB12]", "SWAP 1..2:", "+const x = 1;", "+const y = 2;", "DEL 5", ""].join("\n"); expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input })).toBe("const x = 1;\nconst y = 2;"); }); test("hashline: grammar-only payload digests to empty, missing input to undefined", () => { - expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: "[a.ts#AB12]\ndelete 3\n" })).toBe(""); + expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: "[a.ts#AB12]\nDEL 3\n" })).toBe(""); expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({})).toBeUndefined(); }); diff --git a/packages/coding-agent/test/edit/seen-line-guard.test.ts b/packages/coding-agent/test/edit/seen-line-guard.test.ts index 7c192658f..e5f345d46 100644 --- a/packages/coding-agent/test/edit/seen-line-guard.test.ts +++ b/packages/coding-agent/test/edit/seen-line-guard.test.ts @@ -93,7 +93,7 @@ describe("read → edit seen-line guard", () => { const tag = tagFromOutput(resultText(read)); await expect( - executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nreplace 12..12:\n+EDITED`, session)), + executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 12..12:\n+EDITED`, session)), ).rejects.toThrow(/were not shown in the read\/search output/); // The reject left the file untouched. expect(await Bun.file(file).text()).toBe(CONTENT); @@ -107,7 +107,7 @@ describe("read → edit seen-line guard", () => { const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` }); const tag = tagFromOutput(resultText(read)); - await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nreplace 2..2:\n+EDITED`, session)); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 2..2:\n+EDITED`, session)); expect(await Bun.file(file).text()).toContain("EDITED"); }); }); @@ -154,7 +154,7 @@ describe("search → edit seen-line guard", () => { expect(seen?.has(8)).toBe(false); // The matched line is in the seen set, so editing it applies. - await executeHashlineSingle(execOptions(`[code.txt#${tag}]\nreplace 4..4:\n+NEEDLE edited`, session)); + await executeHashlineSingle(execOptions(`[code.txt#${tag}]\nSWAP 4..4:\n+NEEDLE edited`, session)); expect(await Bun.file(file).text()).toContain("NEEDLE edited"); }); @@ -167,7 +167,7 @@ describe("search → edit seen-line guard", () => { const search = await new SearchTool(session).execute("s1", { pattern: "NEEDLE", paths: [file] }); const tag = tagFromOutput(resultText(search)); - await expect(executeHashlineSingle(execOptions(`[code.txt#${tag}]\nreplace 8..8:\n+X`, session))).rejects.toThrow( + await expect(executeHashlineSingle(execOptions(`[code.txt#${tag}]\nSWAP 8..8:\n+X`, session))).rejects.toThrow( /were not shown in the read\/search output/, ); expect(await Bun.file(file).text()).toBe(`${lines.join("\n")}\n`); diff --git a/packages/coding-agent/test/read-column-truncation-snapshot.test.ts b/packages/coding-agent/test/read-column-truncation-snapshot.test.ts index e98c22091..a5f973390 100644 --- a/packages/coding-agent/test/read-column-truncation-snapshot.test.ts +++ b/packages/coding-agent/test/read-column-truncation-snapshot.test.ts @@ -175,7 +175,7 @@ describe("read tool column truncation vs hashline snapshot", () => { tmpDir, filePath, header, - patchBody: "replace 3..3:\n+epilogue\n", + patchBody: "SWAP 3..3:\n+epilogue\n", }); const after = await fs.readFile(filePath, "utf8"); diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index 23f4207c0..12e20cdfc 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -87,7 +87,7 @@ describe("editToolRenderer", () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderCall( { - input: "[packages/coding-agent/src/edit/renderer.ts]\ninsert tail:\n+// preview", + input: "[packages/coding-agent/src/edit/renderer.ts]\nINS.TAIL:\n+// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -108,7 +108,7 @@ describe("editToolRenderer", () => { input: [ "*** Begin Patch", "[crates/pi-natives/src/shell.rs]", - "insert tail:", + "INS.TAIL:", "+pub fn streaming_preview() {", ].join("\n"), }, @@ -119,7 +119,7 @@ describe("editToolRenderer", () => { const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("crates/pi-natives/src/shell.rs"); - expect(rendered).not.toContain("insert tail:"); + expect(rendered).not.toContain("INS.TAIL:"); expect(rendered).not.toContain("+pub fn streaming_preview() {"); expect(rendered).not.toContain("*** Begin Patch"); }); @@ -128,7 +128,7 @@ describe("editToolRenderer", () => { const uiTheme = await getUiTheme(); const compactComponent = editToolRenderer.renderCall( { - input: "[foo bar.ts]\ninsert head:\n+// preview", + input: "[foo bar.ts]\nINS.HEAD:\n+// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -136,7 +136,7 @@ describe("editToolRenderer", () => { const quotedComponent = editToolRenderer.renderCall( { - input: "['baz qux.ts']\ninsert head:\n+// preview", + input: "['baz qux.ts']\nINS.HEAD:\n+// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -155,7 +155,7 @@ describe("editToolRenderer", () => { // renderer keeps the title clean. const canonical = editToolRenderer.renderCall( { - input: "[packages/coding-agent/src/slash-commands/builtin-registry.ts]\ninsert head:\n+// preview", + input: "[packages/coding-agent/src/slash-commands/builtin-registry.ts]\nINS.HEAD:\n+// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -163,7 +163,7 @@ describe("editToolRenderer", () => { // While streaming, the closing bracket may not have arrived yet. const partial = editToolRenderer.renderCall( - { input: "[a/b/c.ts\ninsert head:\n+// preview" }, + { input: "[a/b/c.ts\nINS.HEAD:\n+// preview" }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); @@ -190,7 +190,7 @@ describe("editToolRenderer", () => { { expanded: false, isPartial: false, renderContext: { editMode: "hashline" } }, uiTheme, { - input: "[packages/coding-agent/src/edit/renderer.ts]\ninsert tail:\n+// preview", + input: "[packages/coding-agent/src/edit/renderer.ts]\nINS.TAIL:\n+// preview", }, ); @@ -277,7 +277,7 @@ describe("editToolRenderer", () => { // The trailing payload line carries no newline — the common shape for a // single-line edit. The streaming pass trims that in-flight line, so the // preview only becomes computable once args are marked complete. - const input = `[memory.ts#${tag}]\nreplace 2..2:\n+export const b = 22;`; + const input = `[memory.ts#${tag}]\nSWAP 2..2:\n+export const b = 22;`; const component = new ToolExecutionComponent("edit", { input }, { snapshots }, hashlineTool, uiStub, tmpDir); component.setArgsComplete(); @@ -304,7 +304,7 @@ describe("editToolRenderer", () => { const snapshots = new InMemorySnapshotStore(); const tag = snapshots.record(filePath, content); - const input = `[memory.ts#${tag}]\nreplace 2..2:\n+export const b = 22;\n`; + const input = `[memory.ts#${tag}]\nSWAP 2..2:\n+export const b = 22;\n`; const component = new ToolExecutionComponent( "edit", { __partialJson: input }, diff --git a/packages/coding-agent/test/tools/eval-timeout.test.ts b/packages/coding-agent/test/tools/eval-timeout.test.ts index 2f3dd7fcc..3f215bc82 100644 --- a/packages/coding-agent/test/tools/eval-timeout.test.ts +++ b/packages/coding-agent/test/tools/eval-timeout.test.ts @@ -31,7 +31,7 @@ describe("EvalTool timeout semantics", () => { // 1s budget; the cell idles for 5s and emits no status, so nothing extends // the budget — it must be cut off at the wall-clock limit. const result = await tool.execute("call-compute-timeout", { - cells: [{ language: "js", code: "await Bun.sleep(5000); return 'never';", timeout: 1 }], + cells: [{ language: "js", code: "await Bun.sleep(2000); return 'never';", timeout: 1 }], }); const text = result.content diff --git a/packages/coding-agent/test/write-hashline-header.test.ts b/packages/coding-agent/test/write-hashline-header.test.ts index ea8f0e362..7774c230f 100644 --- a/packages/coding-agent/test/write-hashline-header.test.ts +++ b/packages/coding-agent/test/write-hashline-header.test.ts @@ -82,7 +82,7 @@ describe("write tool hashline header", () => { // Apply a hashline patch immediately, using only the tag the write tool // returned — no intervening `read`. - const patchInput = `${headerLine}\nreplace 1..1:\n+export const enabled = true;\n`; + const patchInput = `${headerLine}\nSWAP 1..1:\n+export const enabled = true;\n`; const patch = Patch.parse(patchInput, { cwd: tmpDir }); expect(patch.sections).toHaveLength(1); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index db4d032ce..b65566cab 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -5,10 +5,10 @@ ### Breaking Changes - Renamed all hashline DSL operators to concise abbreviated keywords: - - `replace` -> `XCHG` + - `replace` -> `SWAP` - `delete` -> `DEL` - `insert before`/`after`/`head`/`tail` -> `INS.PRE`/`POST`/`HEAD`/`TAIL` - - `replace_block` -> `XCHG.BLK` + - `replace_block` -> `SWAP.BLK` - `delete_block` -> `DEL.BLK` - `insert_after_block` -> `INS.BLK.POST` diff --git a/packages/hashline/README.md b/packages/hashline/README.md index 537eafecd..cf0e8e952 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}] -XCHG 1..1: +SWAP 1..1: +const greeting = "hello";`); const result = await patcher.apply(patch); @@ -47,8 +47,8 @@ still matches the recorded content hash, and refusing or attempting session-aware recovery on mismatch. Inside a section: -- `XCHG A..B:` — replace lines A..B with following `+TEXT` body rows. -- `XCHG.BLK A:` — replace the syntactic block beginning on line A. +- `SWAP A..B:` — replace lines A..B with following `+TEXT` body rows. +- `SWAP.BLK A:` — replace the syntactic block beginning on line A. - `DEL A..B` / `DEL.BLK A` — delete concrete lines or a resolved block. - `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. diff --git a/packages/hashline/src/format.ts b/packages/hashline/src/format.ts index 315c2a4ef..a1a5c2571 100644 --- a/packages/hashline/src/format.ts +++ b/packages/hashline/src/format.ts @@ -14,7 +14,7 @@ export const HL_FILE_SUFFIX = "]"; export const HL_PAYLOAD_REPLACE = "+"; /** Hunk-header keyword for concrete line replacement. */ -export const HL_REPLACE_KEYWORD = "XCHG"; +export const HL_REPLACE_KEYWORD = "SWAP"; /** Hunk-header keyword for concrete line deletion. */ export const HL_DELETE_KEYWORD = "DEL"; /** Hunk-header keyword for insertion operations. */ @@ -27,8 +27,8 @@ export const HL_INSERT_AFTER = "POST"; 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 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: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */ +export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.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. */ diff --git a/packages/hashline/src/grammar.lark b/packages/hashline/src/grammar.lark index ae19f7b1f..772acdd71 100644 --- a/packages/hashline/src/grammar.lark +++ b/packages/hashline/src/grammar.lark @@ -14,8 +14,8 @@ insert_hunk: insert_anchor LF emit_op+ insert_block_hunk: insert_block_anchor LF emit_op+ delete_hunk: "DEL " header_range LF delete_block_hunk: "DEL.BLK " LID LF -replace_anchor: "XCHG " header_range ":" -replace_block_anchor: "XCHG.BLK " LID ":" +replace_anchor: "SWAP " header_range ":" +replace_block_anchor: "SWAP.BLK " LID ":" insert_anchor: "INS." insert_pos ":" insert_block_anchor: "INS.BLK.POST " LID ":" insert_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL" diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index 69e37554f..b8e37b191 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -44,10 +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 `XCHG 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 `SWAP N..M:` hunk per range — the body is the final content, never old+new."; /** Bare bodyless hunk followed by an overlapping concrete hunk. */ -("Dropped a bare hunk overlapped by the concrete hunk after it. One `XCHG N..M:` hunk per range — the body is the final content, never old+new."); +("Dropped a bare hunk overlapped by the concrete hunk after it. One `SWAP N..M:` hunk per range — the body is the final content, never old+new."); /** Bare body rows auto-converted to literal `+` rows. */ export const BARE_BODY_AUTO_PIPED_WARNING = @@ -58,10 +58,10 @@ 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 = "`XCHG N..M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N..M`."; +export const EMPTY_REPLACE = "`SWAP 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 = "`XCHG.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`."; +export const EMPTY_BLOCK = "`SWAP.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 @@ -76,8 +76,8 @@ export function blockUnresolvedMessage( op: "replace" | "delete" = "replace", fileLines?: readonly string[], ): string { - const phrase = op === "delete" ? `DEL.BLK ${line}` : `XCHG.BLK ${line}:`; - const fallback = op === "delete" ? `DEL ${line}..M` : `XCHG ${line}..M:`; + const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`; + const fallback = op === "delete" ? `DEL ${line}..M` : `SWAP ${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.`; @@ -90,7 +90,7 @@ export function blockUnresolvedMessage( /** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */ export const BLOCK_RESOLVER_UNAVAILABLE = - "`XCHG.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range."; + "`SWAP.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 @@ -115,13 +115,13 @@ export function insertAfterBlockUnresolvedLoweredWarning(line: number): string { * edit; `resolveBlockEdits` must run first. Wiring bug, not authored input. */ export const UNRESOLVED_BLOCK_INTERNAL = - "internal error: unresolved `XCHG.BLK` edit reached the applier (resolveBlockEdits was not run)."; + "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run)."; /** Delete hunk received a body row. */ -export const DELETE_TAKES_NO_BODY = "`DEL N..M` does not take body rows. Remove the body, or use `XCHG N..M:`."; +export const DELETE_TAKES_NO_BODY = "`DEL N..M` does not take body rows. Remove the body, or use `SWAP N..M:`."; /** `delete_block N` hunk received a body row. */ -export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `XCHG.BLK N:`."; +export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`."; /** Insert hunk with no body. */ export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row."; @@ -224,9 +224,9 @@ export type BlockOp = "replace" | "delete" | "insert_after"; * see. Reject and point at both fixes. */ export function blockSingleLineMessage(line: number, op: BlockOp): string { - const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "XCHG.BLK"; + const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK"; const plainForm = - op === "insert_after" ? `INS.POST ${line}:` : op === "delete" ? `DEL ${line}` : `XCHG ${line}..${line}:`; + op === "insert_after" ? `INS.POST ${line}:` : op === "delete" ? `DEL ${line}` : `SWAP ${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 bccf4bd24..c7dcc444d 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 `XCHG N..M:`, `DEL N..M`, or `INS.PRE|POST|HEAD|TAIL:` ops." + "Use `SWAP 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 `XCHG N..M:`, `DEL N..M`, or `INS.PRE|POST|HEAD|TAIL:` ops." + "Use `SWAP 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 `XCHG N..M:`." + "Drop the `@@ ... @@` brackets and write a verb header such as `SWAP N..M:`." ); } if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) { return "`DEL N..M` has no colon and no body. Remove the colon and body rows."; } if (/^[1-9]\d*\s*$/.test(trimmed)) { - return `hunk headers need a verb. Use \`XCHG ${trimmed}..${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`; + return `hunk headers need a verb. Use \`SWAP ${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 \`XCHG ${bareRange[1]}..${bareRange[2]}:\` or \`DEL ${bareRange[1]}..${bareRange[2]}\`.` + `Hunk headers need a verb: write \`SWAP ${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 \`XCHG N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`, + `Use \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`, ); } diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index 166bbe5ea..a4cf6a4c0 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -5,8 +5,8 @@ Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag fro -`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. +`SWAP N..M:` — replace original lines N..M with the body rows below. INCLUSIVE — line M is consumed too. +`SWAP.BLK N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below. `DEL N..M` — delete original lines N..M. No body. `DEL.BLK N` — delete the whole syntactic block that BEGINS on line N. `INS.PRE N:` — insert the body rows immediately before line N. @@ -14,7 +14,7 @@ Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag fro `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:`). +Single line: `SWAP N..N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N..N:`). @@ -34,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 → `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:`. +- Whole construct → `SWAP.BLK N` (tree-sitter resolves the end); lines inside it → `SWAP N..M`. +- `SWAP.BLK N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `SWAP N..M`. +- Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N..N` / `DEL N` / `INS.POST N`) for one line, or point N at the real opener. Saw the closer? Use plain `INS.POST M:`. - Non-adjacent changes = separate hunks; untouched lines stay out of every range. -- Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `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. +- Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. A multi-line `SWAP` whose body restates the line just outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair. - NEVER format/restyle code with this tool; run the project formatter instead. @@ -62,7 +62,7 @@ INS.POST 1: Replace line 2 with two lines: ``` [greet.py#A1B2] -XCHG 2..2: +SWAP 2..2: + greeting = "Hi" + msg = f"{greeting}, {name}" ``` @@ -82,18 +82,18 @@ INS.TAIL: +greet("everyone") ``` -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: +Replace the whole `greet` function block — `SWAP.BLK 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays: ``` [greet.py#A1B2] -XCHG.BLK 1: +SWAP.BLK 1: +def greet(name): + print(f"Hello, {name}") ``` -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`: +A decorator or doc-comment is a SEPARATE block — `SWAP.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] -XCHG.BLK 1: +SWAP.BLK 1: +@cache +def load(key): + return store[key] @@ -101,25 +101,25 @@ XCHG.BLK 1: -# WRONG — empty `XCHG` to delete. RIGHT: DEL 4 -XCHG 4..4: +# WRONG — empty `SWAP` to delete. RIGHT: DEL 4 +SWAP 4..4: -# WRONG — range describes post-edit size. RIGHT: XCHG 1..1: (body length is irrelevant) -XCHG 1..2: +# WRONG — range describes post-edit size. RIGHT: SWAP 1..1: (body length is irrelevant) +SWAP 1..2: +def greet(name): # WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content. -XCHG 3..3: +SWAP 3..3: msg = "Hello, " + name - print(msg) + return msg # RIGHT -XCHG 3..3: +SWAP 3..3: + return msg -# WRONG — a pure insertion done as a widened `XCHG`: you only want to add one line after 2, +# WRONG — a pure insertion done as a widened `SWAP`: you only want to add one line after 2, # but you replace 2..4, retype the keepers in the body, and drop one (here line 4, `greet("world")`). -XCHG 2..4: +SWAP 2..4: + msg = "Hello, " + name + extra = compute(name) + print(msg) @@ -138,6 +138,6 @@ INS.POST 3: 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 → `XCHG.BLK N`. +2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `SWAP.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/test/block.test.ts b/packages/hashline/test/block.test.ts index 497907399..a6eec72f3 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("XCHG.BLK parsing", () => { - it("parses `XCHG.BLK N:` into a single deferred block edit", () => { - const { edits } = parsePatch("XCHG.BLK 2:\n+A\n+B"); +describe("SWAP.BLK parsing", () => { + it("parses `SWAP.BLK N:` into a single deferred block edit", () => { + const { edits } = parsePatch("SWAP.BLK 2:\n+A\n+B"); expect(edits).toHaveLength(1); const edit = edits[0]; @@ -43,52 +43,52 @@ describe("XCHG.BLK parsing", () => { expect(edit.payloads).toEqual(["A", "B"]); }); - it("still parses a literal `XCHG N..M:` range (distinct from `XCHG.BLK`)", () => { - const { edits } = parsePatch("XCHG 2..3:\n+A"); + it("still parses a literal `SWAP N..M:` range (distinct from `SWAP.BLK`)", () => { + const { edits } = parsePatch("SWAP 2..3:\n+A"); expect(edits.some(edit => edit.kind === "block")).toBe(false); expect(edits.some(edit => edit.kind === "delete")).toBe(true); }); - it("rejects a `XCHG.BLK N:` hunk with no body row", () => { - expect(() => parsePatch("XCHG.BLK 2:")).toThrow("`XCHG.BLK N:` needs at least one"); + it("rejects a `SWAP.BLK N:` hunk with no body row", () => { + expect(() => parsePatch("SWAP.BLK 2:")).toThrow("`SWAP.BLK N:` needs at least one"); }); }); describe("resolveBlockEdits", () => { - it("expands a block edit exactly like the equivalent `XCHG start..end:`", () => { - const blockEdits = parsePatch("XCHG.BLK 2:\n+A\n+B").edits; + it("expands a block edit exactly like the equivalent `SWAP start..end:`", () => { + const blockEdits = parsePatch("SWAP.BLK 2:\n+A\n+B").edits; const resolved = resolveBlockEdits(blockEdits, "ignored", PATH, stubResolver); - const replaceEdits = parsePatch("XCHG 2..3:\n+A\n+B").edits; + const replaceEdits = parsePatch("SWAP 2..3:\n+A\n+B").edits; expect(resolved.some(edit => edit.kind === "block")).toBe(false); expect(normalizeEdits(resolved)).toEqual(normalizeEdits(replaceEdits)); }); it("returns the input untouched when there are no block edits (fast path)", () => { - const edits = parsePatch("XCHG 1..1:\n+X").edits; + const edits = parsePatch("SWAP 1..1:\n+X").edits; expect(resolveBlockEdits(edits, "ignored", PATH, stubResolver)).toBe(edits); }); it("throws (default) when no resolver is wired", () => { - const edits = parsePatch("XCHG.BLK 2:\n+X").edits; + const edits = parsePatch("SWAP.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("XCHG.BLK 2:\n+X").edits; + const edits = parsePatch("SWAP.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("XCHG.BLK 7:\n+X").edits; + const edits = parsePatch("SWAP.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("XCHG.BLK 3:\n+X").edits; + const edits = parsePatch("SWAP.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("XCHG.BLK 9:\n+X").edits; + const edits = parsePatch("SWAP.BLK 9:\n+X").edits; let error: Error | undefined; try { resolveBlockEdits(edits, "only\ntwo", PATH, () => null); @@ -119,7 +119,7 @@ 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("XCHG.BLK 2:\n+A\n+B").edits, "ignored", PATH, stubResolver, { + resolveBlockEdits(parsePatch("SWAP.BLK 2:\n+A\n+B").edits, "ignored", PATH, stubResolver, { onResolved: resolution => seen.push(resolution), }); resolveBlockEdits(parsePatch("DEL.BLK 5").edits, "ignored", PATH, stubResolver, { @@ -134,7 +134,7 @@ describe("resolveBlockEdits", () => { it("does not fire onResolved for a dropped unresolvable block", () => { const seen: BlockResolution[] = []; - resolveBlockEdits(parsePatch("XCHG.BLK 2:\n+X").edits, "ignored", PATH, () => null, { + resolveBlockEdits(parsePatch("SWAP.BLK 2:\n+X").edits, "ignored", PATH, () => null, { onUnresolved: "drop", onResolved: resolution => seen.push(resolution), }); @@ -147,8 +147,8 @@ describe("resolveBlockEdits", () => { // plain form rather than silently landing a body in the wrong scope. const singleLineResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line }); - it("rejects a `XCHG.BLK` that resolves to a single line", () => { - const edits = parsePatch("XCHG.BLK 2:\n+X").edits; + it("rejects a `SWAP.BLK` that resolves to a single line", () => { + const edits = parsePatch("SWAP.BLK 2:\n+X").edits; expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver)).toThrow( /resolved a single-line block/, ); @@ -160,7 +160,7 @@ describe("resolveBlockEdits", () => { }); it("drops a single-line block resolution on the lenient preview path", () => { - const edits = parsePatch("XCHG.BLK 2:\n+X").edits; + const edits = parsePatch("SWAP.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]\nXCHG.BLK 2:\n+ if (y || z) {\n+ }`); - const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nXCHG 2..3:\n+ if (y || z) {\n+ }`); + const blockSection = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP.BLK 2:\n+ if (y || z) {\n+ }`); + const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP 2..3:\n+ if (y || z) {\n+ }`); const 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]\nXCHG.BLK 2:\n+X`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP.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]\nXCHG.BLK 2:\n+X`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP.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}]\nXCHG.BLK 2:\n+ if (y || z) {\n+ }`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP.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}]\nXCHG.BLK 2:\n+ if (y || z) {\n+ }`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP.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}]\nXCHG.BLK 2:\n+NEW`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP.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}]\nXCHG.BLK 2:\n+NEW`))).rejects.toBeInstanceOf( + await expect(patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nSWAP.BLK 2:\n+NEW`))).rejects.toBeInstanceOf( MismatchError, ); expect(fs.get(PATH)).toBe(liveText); @@ -260,7 +260,7 @@ 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}]\nXCHG.BLK 2:\n+X`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP.BLK 2:\n+X`))).rejects.toThrow( "could not resolve a syntactic block", ); expect(fs.get(PATH)).toBe(text); diff --git a/packages/hashline/test/boundary-repair.test.ts b/packages/hashline/test/boundary-repair.test.ts index 8a50b8c68..c29c6be0a 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 = [ - "XCHG 7..16:", + "SWAP 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 = ["XCHG 2..3:", "+\tsetup2();", "+\trun2();", "+});"].join("\n"); + const diff = ["SWAP 2..3:", "+\tsetup2();", "+\trun2();", "+});"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["it('a', () => {", "\tsetup2();", "\trun2();", "});", "after();"].join("\n")); expect(warnings.some(w => /delimiter-balance/.test(w))).toBe(true); @@ -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 = [ - "XCHG 4..6:", + "SWAP 4..6:", "+\tplanRender(", "+\t\ta: string[],", "+\t\tb: boolean,", @@ -117,7 +117,7 @@ describe("boundary-balance repair", () => { const file = ["if (a) {", "\tfoo();", "}", "bar();"].join("\n"); // Payload duplicates `if (a) {` but is net +2 braces; dropping the one // opener cannot zero the delta, so nothing is repaired. - const diff = ["XCHG 2..2:", "+if (a) {", "+\tif (b) {", "+\t\tfoo();"].join("\n"); + const diff = ["SWAP 2..2:", "+if (a) {", "+\tif (b) {", "+\t\tfoo();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (a) {", "if (a) {", "\tif (b) {", "\t\tfoo();", "}", "bar();"].join("\n")); expect(warnings).toHaveLength(0); @@ -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 = ["XCHG 5..5:", "+\tb() {", "+\t\treturn 2;", "+\t},"].join("\n"); + const diff = ["SWAP 5..5:", "+\tb() {", "+\t\treturn 2;", "+\t},"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe( ["const handlers = {", "\ta() {", "\t\treturn 1;", "\t},", "\tb() {", "\t\treturn 2;", "\t},", "};"].join( @@ -143,7 +143,7 @@ describe("boundary-balance repair", () => { // keeping the deleted suffix would duplicate the closer outside the payload. it("does not spare a deleted closing line that the payload already restates", () => { const file = ["class Foo {", "\tok();", "\t}", "}"].join("\n"); - const diff = ["XCHG 1..4:", "+class Foo {", "+\tok();", "+}"].join("\n"); + const diff = ["SWAP 1..4:", "+class Foo {", "+\tok();", "+}"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["class Foo {", "\tok();", "}"].join("\n")); @@ -159,7 +159,7 @@ describe("boundary-balance repair", () => { "\tprint_status()", ].join("\n"); const diff = [ - "XCHG 2..3:", + "SWAP 2..3:", "+func _cmd_travel_homeworld():", "+\tvar destination = find_homeworld()", "+\ttravel_to(destination)", @@ -183,7 +183,7 @@ describe("boundary-balance repair", () => { it("preserves payloads where multi-line boundary echoes cover every line", () => { const file = ["A", "B", "old", "C", "D"].join("\n"); - const diff = ["XCHG 3..3:", "+A", "+B", "+C", "+D"].join("\n"); + const diff = ["SWAP 3..3:", "+A", "+B", "+C", "+D"].join("\n"); const { text, warnings } = apply(file, diff); @@ -193,7 +193,7 @@ describe("boundary-balance repair", () => { it("preserves payloads made only of lines matching both replacement neighbors", () => { const file = ["a", "old", "c"].join("\n"); - const diff = ["XCHG 2..2:", "+a", "+c"].join("\n"); + const diff = ["SWAP 2..2:", "+a", "+c"].join("\n"); const { text, warnings } = apply(file, diff); @@ -209,7 +209,7 @@ describe("boundary-balance repair", () => { // Payload deliberately opens with the same bare `}` that sits above the // range and closes with the same `}` that sits below it; the payload is // internally balanced (delta 0) while the dropped edges sum to -2 braces. - const diff = ["XCHG 2..2:", "+}", "+if (a) {", "+if (b) {", "+x();", "+}"].join("\n"); + const diff = ["SWAP 2..2:", "+}", "+if (a) {", "+if (b) {", "+x();", "+}"].join("\n"); const { text, warnings } = apply(file, diff); @@ -221,7 +221,7 @@ describe("boundary-balance repair", () => { // (opener + closer) that duplicate the surviving neighbors are dropped. it("still drops a balance-neutral wrapper echo", () => { const file = ["function f() {", "old();", "}"].join("\n"); - const diff = ["XCHG 2..2:", "+function f() {", "+fresh();", "+}"].join("\n"); + const diff = ["SWAP 2..2:", "+function f() {", "+fresh();", "+}"].join("\n"); const { text, warnings } = apply(file, diff); @@ -235,7 +235,7 @@ describe("boundary-balance repair", () => { const file = ["foo();", "bar();", "bar();", "baz();"].join("\n"); // Replace line 2 with two balanced statements; the tail `bar();` equals // the surviving line 3 but the payload is balanced — must NOT be dropped. - const diff = ["XCHG 2..2:", "+qux();", "+bar();"].join("\n"); + const diff = ["SWAP 2..2:", "+qux();", "+bar();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["foo();", "qux();", "bar();", "bar();", "baz();"].join("\n")); expect(warnings).toHaveLength(0); @@ -245,7 +245,7 @@ describe("boundary-balance repair", () => { // could discard intended content, and it does not break syntax. it("does not drop a balance-neutral duplicated statement", () => { const file = ["a = 1;", "b = 2;", "c = 3;"].join("\n"); - const diff = ["XCHG 1..1:", "+a = 1;", "+b = 2;"].join("\n"); + const diff = ["SWAP 1..1:", "+a = 1;", "+b = 2;"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["a = 1;", "b = 2;", "b = 2;", "c = 3;"].join("\n")); expect(warnings).toHaveLength(0); @@ -254,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 = ["XCHG 2..2:", '+const b = "}}}";'].join("\n"); + const diff = ["SWAP 2..2:", '+const b = "}}}";'].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(['const a = "}";', 'const b = "}}}";', 'const c = "y";'].join("\n")); expect(warnings).toHaveLength(0); @@ -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 = ["XCHG 2..3:", "+ a2();", "+ b2();", "+ const out = [];"].join("\n"); + const diff = ["SWAP 2..3:", "+ a2();", "+ b2();", "+ const out = [];"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["function f() {", " a2();", " b2();", " const out = [];", " return out;", "}"].join("\n")); expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); @@ -275,7 +275,7 @@ describe("boundary-balance repair", () => { // the multi-line range (range one line low instead of one short). it("drops a one-sided leading keeper echo in a multi-line rewrite", () => { const file = ["setup();", "a();", "b();", "c();"].join("\n"); - const diff = ["XCHG 3..4:", "+a();", "+B();", "+C();"].join("\n"); + const diff = ["SWAP 3..4:", "+a();", "+B();", "+C();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["setup();", "a();", "B();", "C();"].join("\n")); expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); @@ -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(["XCHG 4..5:", "+\tsetup2();", "+\trun2();", "+});"].join("\n")); + const { edits } = parsePatch(["SWAP 4..5:", "+\tsetup2();", "+\trun2();", "+});"].join("\n")); const recovered = new Recovery(store).tryRecover({ path: PATH, currentText, fileHash, edits }); expect(recovered).not.toBeNull(); diff --git a/packages/hashline/test/core-contracts.test.ts b/packages/hashline/test/core-contracts.test.ts index 14be3810f..22b090049 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 `XCHG ${anchor}..${anchor}:`; + return `SWAP ${anchor}..${anchor}:`; } function applyDiff(content: string, diff: string): string { @@ -123,14 +123,14 @@ describe("hashline parser — range-anchor contracts", () => { }); it("strips copied read-output prefixes only inside pasted bare body rows", () => { - const diff = `XCHG ${tag(2)}..${tag(4)}:\n${repl("line one")}\n${tag(3)}:line two`; + const diff = `SWAP ${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 = `XCHG ${tag(2)}..${tag(4)}:\n${repl("NEW1")}\nXCHG ${tag(3)}..${tag(5)}:\n${repl("NEW2")}`; + const diff = `SWAP ${tag(2)}..${tag(4)}:\n${repl("NEW1")}\nSWAP ${tag(3)}..${tag(5)}:\n${repl("NEW2")}`; expect(() => parsePatch(diff).edits).toThrow(/anchor line 3 is already targeted by another hunk on line 1/); }); @@ -247,7 +247,7 @@ describe("Recovery", () => { path: filePath, currentText, tag: v0Tag, - edits: parsePatch(`XCHG 10..10:\n${repl("L10-EDITED")}`).edits, + edits: parsePatch(`SWAP 10..10:\n${repl("L10-EDITED")}`).edits, }); expect(recovered).not.toBeNull(); @@ -289,7 +289,7 @@ describe("hashline parser — delete and blank payload semantics", () => { 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]\nXCHG 2..2:\n").diff)).toBe( + expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nSWAP 2..2:\n").diff)).toBe( "line1\nline3\n", ); }); @@ -301,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]\nXCHG 2..2:\n${repl("")}\n${repl("")}\nXCHG 4..4:\n${repl("D")}\n`; + const ops = `[a.ts]\nSWAP 2..2:\n${repl("")}\n${repl("")}\nSWAP 4..4:\n${repl("D")}\n`; expect(applyDiff(text, splitHashlineInput(ops).diff)).toBe("a\n\n\nc\nD\ne\n"); - const embedded = `[a.ts]\nXCHG 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; + const embedded = `[a.ts]\nSWAP 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; expect(applyDiff("a\nb\nc\n", splitHashlineInput(embedded).diff)).toBe("a\nfirst\n\nsecond\nc\n"); }); }); diff --git a/packages/hashline/test/format-v2.test.ts b/packages/hashline/test/format-v2.test.ts index 4e87b8c33..b541e85ba 100644 --- a/packages/hashline/test/format-v2.test.ts +++ b/packages/hashline/test/format-v2.test.ts @@ -8,7 +8,7 @@ function applyPatch(text: string, diff: string): string { describe("hashline format v4", () => { it("replaces a concrete range with literal body rows in textual order", () => { const text = "a\nb\nc"; - const diff = ["XCHG 2..2:", "+before", "+after"].join("\n"); + const diff = ["SWAP 2..2:", "+before", "+after"].join("\n"); expect(applyPatch(text, diff)).toBe("a\nbefore\nafter\nc"); }); @@ -37,7 +37,7 @@ describe("hashline format v4", () => { it("treats an empty replace hunk as a delete and still rejects empty inserts", () => { const text = "a\nb\nc"; - expect(applyPatch(text, "XCHG 2..2:")).toBe("a\nc"); + expect(applyPatch(text, "SWAP 2..2:")).toBe("a\nc"); expect(() => parsePatch("INS.HEAD:")).toThrow(/needs at least one/); }); @@ -47,8 +47,8 @@ describe("hashline format v4", () => { it("auto-pipes bare body rows as literal text", () => { const text = "a\nb\nc"; - expect(applyPatch(text, "XCHG 2..2:\nraw")).toBe("a\nraw\nc"); - const { warnings } = parsePatch("XCHG 2..2:\nraw"); + expect(applyPatch(text, "SWAP 2..2:\nraw")).toBe("a\nraw\nc"); + const { warnings } = parsePatch("SWAP 2..2:\nraw"); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); @@ -56,7 +56,7 @@ describe("hashline format v4", () => { const text = "a\nb\nc"; // Without this fix, "3:text" becomes literal "3:text" in the file. // With the fix, the "3:" prefix is stripped, yielding just "text". - const { edits, warnings } = parsePatch("XCHG 2..2:\n3:replaced"); + const { edits, warnings } = parsePatch("SWAP 2..2:\n3:replaced"); expect(applyEdits(text, edits).text).toBe("a\nreplaced\nc"); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); @@ -78,7 +78,7 @@ describe("hashline format v4", () => { }); it("treats a replace range ending at the trailing sentinel as ending at the last real line", () => { - const edits = parsePatch("XCHG 2..3:\n+B").edits; + const edits = parsePatch("SWAP 2..3:\n+B").edits; expect(applyEdits("a\nb\n", edits).text).toBe("a\nB\n"); }); @@ -94,12 +94,12 @@ describe("hashline format v4", () => { }); it("does not flush a trailing streaming pending empty replace hunk", () => { - const result = parsePatchStreaming("XCHG 5..5:\n"); + const result = parsePatchStreaming("SWAP 5..5:\n"); expect(result.edits).toEqual([]); }); it("flushes a streaming empty replace hunk when another hunk starts", () => { - const result = parsePatchStreaming("XCHG 2..2:\nINS.TAIL:\n"); + const result = parsePatchStreaming("SWAP 2..2:\nINS.TAIL:\n"); expect(result.edits).toEqual([{ kind: "delete", anchor: { line: 2 }, lineNum: 1, index: 0 }]); }); }); diff --git a/packages/hashline/test/landing-shift.test.ts b/packages/hashline/test/landing-shift.test.ts index 496f893be..b1f9eb287 100644 --- a/packages/hashline/test/landing-shift.test.ts +++ b/packages/hashline/test/landing-shift.test.ts @@ -207,7 +207,7 @@ describe("insert-after-block inward landing shift", () => { it("refuses to cross a closer targeted by another hunk", () => { const resolver: BlockResolver = (): BlockSpan => ({ start: 1, end: 5 }); const text = ["foo(() => {", " bar(() => {", " x();", " });", "});", ""].join("\n"); - const section = Patch.parseSingle("[x.ts#1A2B]\nXCHG 4..4:\n+ }); // bar\nINS.BLK.POST 1:\n+ y();"); + const section = Patch.parseSingle("[x.ts#1A2B]\nSWAP 4..4:\n+ }); // bar\nINS.BLK.POST 1:\n+ y();"); const result = section.applyTo(text, resolver); diff --git a/packages/hashline/test/leniency.test.ts b/packages/hashline/test/leniency.test.ts index 7360551d4..fde068fc7 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]\nXCHG 1..1:\n+after"); + const section = Patch.parseSingle("[dir with spaces/file.ts#1a2b]\nSWAP 1..1:\n+after"); expect(section.path).toBe("dir with spaces/file.ts"); expect(section.fileHash).toBe("1A2B"); @@ -17,7 +17,7 @@ describe("hashline section headers", () => { }); it("recovers apply_patch-contaminated headers whose paths contain spaces", () => { - const section = Patch.parseSingle("[*** Update File: dir with spaces/file.ts#1A2B]\nXCHG 1..1:\n+after"); + const section = Patch.parseSingle("[*** Update File: dir with spaces/file.ts#1A2B]\nSWAP 1..1:\n+after"); expect(section.path).toBe("dir with spaces/file.ts"); expect(section.fileHash).toBe("1A2B"); @@ -25,25 +25,25 @@ describe("hashline section headers", () => { }); it("rejects trailing junk after a snapshot tag", () => { - 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/); + expect(() => Patch.parse("[src/a.ts#1A2B copied from read]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B:812]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); }); it("rejects trailing junk after a snapshot tag even with apply_patch noise", () => { - expect(() => Patch.parse("[Update File: src/a.ts#1A2B copied from read]\nXCHG 1..1:\n+after")).toThrow( + expect(() => Patch.parse("[Update File: src/a.ts#1A2B copied from read]\nSWAP 1..1:\n+after")).toThrow( /Input header must be/, ); - expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nXCHG 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); }); it("rejects malformed snapshot tags", () => { - expect(() => Patch.parse("[src/a.ts#1A2]\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/); + expect(() => Patch.parse("[src/a.ts#1A2]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2G]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B5]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); }); it("rejects malformed snapshot tags even with apply_patch noise", () => { - expect(() => Patch.parse("[Update File: src/a.ts#1A2G]\nXCHG 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[Update File: src/a.ts#1A2G]\nSWAP 1..1:\n+after")).toThrow(/Input header must be/); }); it("reports bracket syntax with a 4-hex example when the header is missing", () => { @@ -69,7 +69,7 @@ describe("hashline core — verb header forms", () => { }); it("accepts canonical replace/delete/insert forms", () => { - expect(applyPatch(FILE, "XCHG 2..3:\n+X")).toBe("a\nX\nd\ne"); + expect(applyPatch(FILE, "SWAP 2..3:\n+X")).toBe("a\nX\nd\ne"); expect(applyPatch(FILE, "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"); @@ -78,15 +78,15 @@ describe("hashline core — verb header forms", () => { }); it("accepts single-number replace and delete shorthand", () => { - expect(applyPatch(FILE, "XCHG 2:\n+X")).toBe("a\nX\nc\nd\ne"); + expect(applyPatch(FILE, "SWAP 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, "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"); + expect(applyPatch(FILE, "SWAP 2-3:\n+X")).toBe("a\nX\nd\ne"); + expect(applyPatch(FILE, "SWAP 2\u20263:\n+X")).toBe("a\nX\nd\ne"); + expect(applyPatch(FILE, "SWAP 2 3:\n+X")).toBe("a\nX\nd\ne"); + expect(applyPatch(FILE, "SWAP 2..3\n+X")).toBe("a\nX\nd\ne"); }); it("accepts missing colon on insert headers", () => { @@ -97,69 +97,69 @@ describe("hashline core — verb header forms", () => { describe("hashline body contracts", () => { it("auto-pipes a bare body row while warning", () => { - const result = parsePatch("XCHG 2..2:\n hello"); + const result = parsePatch("SWAP 2..2:\n hello"); expect(applyEdits(FILE, result.edits).text).toBe("a\n hello\nc\nd\ne"); expect(result.warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); it("strips read-output line number prefix from auto-piped bare body rows", () => { - const result = parsePatch("XCHG 2..2:\n2:hello"); + const result = parsePatch("SWAP 2..2:\n2:hello"); expect(applyEdits(FILE, result.edits).text).toBe("a\nhello\nc\nd\ne"); expect(result.warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); it("preserves `+N:` literal payloads without stripping", () => { - const result = parsePatch("XCHG 2..2:\n+3:keep"); + const result = parsePatch("SWAP 2..2:\n+3:keep"); expect(applyEdits(FILE, result.edits).text).toBe("a\n3:keep\nc\nd\ne"); expect(result.warnings.some(w => /Auto-prefixed/.test(w))).toBe(false); }); it("strips only one N: prefix from bare body rows (preserves nested digits:colon)", () => { // "2:42:hello" → should yield "42:hello", NOT "hello" (recursive would over-strip) - const result = parsePatch("XCHG 2..2:\n2:42:hello"); + const result = parsePatch("SWAP 2..2:\n2:42:hello"); expect(applyEdits(FILE, result.edits).text).toBe("a\n42:hello\nc\nd\ne"); }); it("strips N: prefixes only when every bare body row carries one", () => { - const result = parsePatch("XCHG 2..3:\n2:foo\n3:bar"); + const result = parsePatch("SWAP 2..3:\n2:foo\n3:bar"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\nbar\nd\ne"); }); it("leaves bare body rows untouched when only some carry an N: prefix", () => { // "3:keep" looks like a snapshot prefix but "plain" does not, so the body // is genuine content (not a pasted snapshot) — strip nothing. - const result = parsePatch("XCHG 2..3:\n3:keep\nplain"); + const result = parsePatch("SWAP 2..3:\n3:keep\nplain"); expect(applyEdits(FILE, result.edits).text).toBe("a\n3:keep\nplain\nd\ne"); }); it("keeps interior blank rows in a bare replace body", () => { - const result = parsePatch("XCHG 2..3:\nfoo\n\nbar"); + const result = parsePatch("SWAP 2..3:\nfoo\n\nbar"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\n\nbar\nd\ne"); }); it("drops trailing blank rows between a bare body and the next hunk", () => { - const result = parsePatch("XCHG 2..2:\nfoo\n\nXCHG 4..4:\nbaz"); + const result = parsePatch("SWAP 2..2:\nfoo\n\nSWAP 4..4:\nbaz"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\nc\nbaz\ne"); }); it("skips blank rows when checking N: prefix uniformity", () => { - const result = parsePatch("XCHG 2..3:\n2:foo\n\n3:bar"); + const result = parsePatch("SWAP 2..3:\n2:foo\n\n3:bar"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\n\nbar\nd\ne"); }); it("leaves numeric-keyed literal bodies untouched (dict/YAML shape)", () => { - const result = parsePatch('XCHG 2..3:\n1: "one",\n2: "two",'); + const result = parsePatch('SWAP 2..3:\n1: "one",\n2: "two",'); expect(applyEdits(FILE, result.edits).text).toBe('a\n1: "one",\n2: "two",\nd\ne'); }); it("rejects `-` body rows with a teaching error", () => { - expect(() => parsePatch("XCHG 2..2:\n-old\n+new")).toThrow(/`-` rows are not valid/); + expect(() => parsePatch("SWAP 2..2:\n-old\n+new")).toThrow(/`-` rows are not valid/); }); it("allows literal text that begins with `-` or `+` when prefixed with `+`", () => { - expect(applyPatch(FILE, "XCHG 2..2:\n+-literal\n++plus")).toBe("a\n-literal\n+plus\nc\nd\ne"); + expect(applyPatch(FILE, "SWAP 2..2:\n+-literal\n++plus")).toBe("a\n-literal\n+plus\nc\nd\ne"); }); it("treats empty replace as delete and still rejects empty insert", () => { - expect(applyPatch(FILE, "XCHG 2..2:")).toBe("a\nc\nd\ne"); + expect(applyPatch(FILE, "SWAP 2..2:")).toBe("a\nc\nd\ne"); expect(() => parsePatch("INS.TAIL:")).toThrow(/`INS` needs/); }); @@ -174,23 +174,23 @@ describe("hashline body contracts", () => { describe("hashline — apply_patch / unified-diff contamination", () => { it("rejects apply_patch sentinels as contamination", () => { - expect(() => parsePatch("*** Update File: a.ts\nXCHG 2..2:\n+X")).toThrow(/apply_patch sentinel/); - expect(() => parsePatch("*** Add File: a.ts\nXCHG 2..2:\n+X")).toThrow(/apply_patch sentinel/); + expect(() => parsePatch("*** Update File: a.ts\nSWAP 2..2:\n+X")).toThrow(/apply_patch sentinel/); + expect(() => parsePatch("*** Add File: a.ts\nSWAP 2..2:\n+X")).toThrow(/apply_patch sentinel/); }); it("rejects unified-diff hunk headers as contamination", () => { - expect(() => parsePatch("@@ -1,3 +1,3 @@\nXCHG 2..2:\n+X")).toThrow(/unified-diff hunk header/); + expect(() => parsePatch("@@ -1,3 +1,3 @@\nSWAP 2..2:\n+X")).toThrow(/unified-diff hunk header/); }); it("treats top-level `+TEXT` as an orphan literal payload", () => { - expect(() => parsePatch("+const X = 1;\nXCHG 2..2:")).toThrow(/payload line has no preceding hunk header/); + expect(() => parsePatch("+const X = 1;\nSWAP 2..2:")).toThrow(/payload line has no preceding hunk header/); }); }); describe("hashline apply — duplicate boundary payloads", () => { it("keeps replacement boundary echoes literal unless balance repair applies", () => { const text = ["// one", "// two", "old();"].join("\n"); - const diff = "XCHG 3..3:\n+// one\n+// two\n+new();"; + const diff = "SWAP 3..3:\n+// one\n+// two\n+new();"; expect(applyPatch(text, diff)).toBe(["// one", "// two", "// one", "// two", "new();"].join("\n")); }); diff --git a/packages/hashline/test/patcher.test.ts b/packages/hashline/test/patcher.test.ts index 6ca5dffb2..4a2ddc8e7 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}]\nXCHG 1..1:\n+after`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 1..1:\n+after`)); expect(result.sections[0]?.op).toBe("update"); expect(result.sections[0]?.fileHash).toMatch(/^[0-9A-F]{4}$/); @@ -45,14 +45,14 @@ describe("Patcher snapshot tag integrity", () => { expect(snapshots.byHash(PATH, tag)).toBeNull(); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 3..3:\n+L3`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 3..3:\n+L3`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nl2\nL3\nl4\nl5\n"); }); it("normalizes lowercase section tags while parsing", () => { - const section = Patch.parseSingle(`[${PATH}#1a2b]\nXCHG 1..1:\n+after`); + const section = Patch.parseSingle(`[${PATH}#1a2b]\nSWAP 1..1:\n+after`); expect(section.fileHash).toBe("1A2B"); }); @@ -65,7 +65,7 @@ describe("Patcher snapshot tag integrity", () => { const patcher = new Patcher({ fs, snapshots }); try { - await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 1..1:\n+after`)); + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 1..1:\n+after`)); throw new Error("expected MismatchError"); } catch (error) { expect(error).toBeInstanceOf(MismatchError); @@ -89,7 +89,7 @@ describe("Patcher snapshot tag integrity", () => { const bogus = live === "FFFF" ? "0000" : "FFFF"; try { - await patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nXCHG 1..1:\n+after`)); + await patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nSWAP 1..1:\n+after`)); throw new Error("expected MismatchError"); } catch (error) { expect(error).toBeInstanceOf(MismatchError); @@ -120,7 +120,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[${PATH}]\nXCHG 1..1:\n+X`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}]\nSWAP 1..1:\n+X`))).rejects.toThrow( /Missing hashline snapshot tag/, ); }); @@ -176,7 +176,7 @@ describe("Patcher seen-line provenance", () => { const tag = snapshots.record(PATH, CONTENT, [1, 2]); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 4..4:\n+L4`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4..4:\n+L4`))).rejects.toThrow( /were not shown in the read\/search output/, ); expect(fs.get(PATH)).toBe(CONTENT); @@ -188,7 +188,7 @@ describe("Patcher seen-line provenance", () => { const tag = snapshots.record(PATH, CONTENT, [1, 2]); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 2..2:\n+L2`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2..2:\n+L2`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nL2\nl3\nl4\nl5\n"); @@ -202,7 +202,7 @@ describe("Patcher seen-line provenance", () => { snapshots.record(PATH, CONTENT, [4, 5]); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 4..4:\n+L4`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4..4:\n+L4`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nl2\nl3\nL4\nl5\n"); @@ -214,7 +214,7 @@ describe("Patcher seen-line provenance", () => { const tag = snapshots.record(PATH, CONTENT); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nXCHG 4..4:\n+L4`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4..4:\n+L4`)); expect(result.sections[0]?.op).toBe("update"); }); diff --git a/packages/hashline/test/recovery-session-chain.test.ts b/packages/hashline/test/recovery-session-chain.test.ts index 3193a8dbe..1c1930357 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("XCHG 5..5:\n|L5-MODEL"); + const { edits } = parsePatch("SWAP 5..5:\n|L5-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -51,7 +51,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { // merge fails (patch context includes the rewritten line 5), but the // replay fallback is safe because the model's anchor still names the // same logical content. - const { edits } = parsePatch("XCHG 3..3:\n|L3-MODEL"); + const { edits } = parsePatch("SWAP 3..3:\n|L3-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, diff --git a/scripts/session-stats/analyze.py b/scripts/session-stats/analyze.py index 10e468a95..55a5a08a1 100644 --- a/scripts/session-stats/analyze.py +++ b/scripts/session-stats/analyze.py @@ -375,7 +375,7 @@ _ANCHOR_BARE = re.compile(r"^[a-zA-Z]?[0-9]+[a-z]{2}$") _HASHLINE_OP = re.compile( - r"^(XCHG\.BLK|XCHG|DEL\.BLK|DEL|INS\.PRE|INS\.POST|INS\.HEAD|INS\.TAIL|" + r"^(SWAP\.BLK|SWAP|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, @@ -383,9 +383,11 @@ _HASHLINE_OP = re.compile( _HASHLINE_OP_ALIASES = { - "replace block": "XCHG.BLK", - "replace_block": "XCHG.BLK", - "replace": "XCHG", + "replace block": "SWAP.BLK", + "replace_block": "SWAP.BLK", + "replace": "SWAP", + "xchg.blk": "SWAP.BLK", + "xchg": "SWAP", "delete block": "DEL.BLK", "delete_block": "DEL.BLK", "delete": "DEL",