From f9866798c06147213958954ba20f0b620e01badc Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 26 May 2026 09:51:13 +0200 Subject: [PATCH] feat(coding-agent): added hashline grammar for anchor ranges and payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated hashline markers to `¶` headers and `^/v/->` operators across constants, prompts, docs, and tests. - Reworked hashline grammar and parser to support `ANCHOR[INLINE_PAYLOAD]` with optional inline bodies and `A-B` ranges. - Changed range and marker syntax from `..`/`"` to `-` and suffix `^/v/->` forms like `7v` and `A->`. - Aligned `sameLineRange()` output and BOF/EOF handling so `|TEXT` remains cosmetic and payload now follows the op line. - Centralized op-line detection by replacing local regex helpers with `isHashlineOpLineText` for payload terminator and bad-op checks. --- docs/tools/edit.md | 89 +++--- packages/coding-agent/CHANGELOG.md | 8 + packages/coding-agent/src/edit/renderer.ts | 2 +- packages/coding-agent/src/edit/streaming.ts | 7 +- .../coding-agent/src/hashline/grammar.lark | 13 +- packages/coding-agent/src/hashline/hash.ts | 15 +- packages/coding-agent/src/hashline/input.ts | 12 +- packages/coding-agent/src/hashline/parser.ts | 131 ++++---- .../src/prompts/tools/hashline.md | 86 ++--- .../coding-agent/test/core/hashline.test.ts | 300 +++++++++--------- packages/coding-agent/test/edit-diff.test.ts | 8 +- .../test/edit-streaming-preview.test.ts | 8 +- .../test/tools/edit-renderer.test.ts | 24 +- 13 files changed, 362 insertions(+), 341 deletions(-) diff --git a/docs/tools/edit.md b/docs/tools/edit.md index 13840de20..57b6eb020 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/coding-agent/src/hashline/grammar.lark` — custom-tool grammar for hashline mode - - `packages/coding-agent/src/hashline/input.ts` — splits `§PATH` sections + - `packages/coding-agent/src/hashline/input.ts` — splits `¶PATH` sections - `packages/coding-agent/src/hashline/parser.ts` — parses op-prefixed edits and verbatim payload lines - `packages/coding-agent/src/hashline/apply.ts` — validates anchors and applies edits - `packages/coding-agent/src/hashline/anchors.ts` — stale-anchor mismatch formatting @@ -26,17 +26,17 @@ | Field | Type | Required | Description | | --- | --- | --- | --- | -| `input` | `string` | Yes | One or more edit sections. First non-blank line must be `§PATH` unless the caller supplies the legacy fallback `path` outside the model schema and the body already looks like hashline ops (`packages/coding-agent/src/hashline/input.ts`). Optional `*** Begin Patch` / `*** End Patch` envelope is ignored if present. | +| `input` | `string` | Yes | One or more edit sections. First non-blank line must be `¶PATH` unless the caller supplies the legacy fallback `path` outside the model schema and the body already looks like hashline ops (`packages/coding-agent/src/hashline/input.ts`). Optional `*** Begin Patch` / `*** End Patch` envelope is ignored if present. | Patch language inside `input`: -- Section header: `§PATH` -- Insert after: `»ANCHOR` -- Insert before: `«ANCHOR` -- Replace/delete range: `≔A..B` -- Single-line replace/delete sugar: `≔A` means `≔A..A` -- `≔A..B` with no payload deletes the range. To keep a blank line, include one explicit empty payload line. -- Payload lines: verbatim file content after `»`, `«`, or `≔` +- Section header: `¶PATH` +- Insert after: `ANCHOR↓` +- Insert before: `ANCHOR↑` +- Replace/delete range: `A-B→` +- Single-line replace/delete sugar: `A→` means `A-A→` +- `A-B→` with no payload deletes the range. To keep a blank line, include one explicit empty payload line. +- Inline payload: content after `↓`/`↑`/`→` on the same line is the first payload line; subsequent lines append to it - Special anchors: `BOF`, `EOF` - Anchor token: `<2-char-hash>`, for example `41th` @@ -68,16 +68,16 @@ Warnings: ## Flow 1. `EditTool.execute()` in `packages/coding-agent/src/edit/index.ts` resolves the active mode. Default is `hashline`; `customFormat` exposes `packages/coding-agent/src/hashline/grammar.lark` with `$HFMT$` / `$HOP_INSERT_BEFORE$` / `$HOP_INSERT_AFTER$` / `$HOP_REPLACE$` / `$HOP_CHARS$` / `$HFILE$` placeholders filled from `packages/coding-agent/src/hashline/hash.ts`. -2. `executeHashlineSingle()` in `packages/coding-agent/src/hashline/execute.ts` splits the raw `input` into `§PATH` sections with `splitHashlineInputs()`. +2. `executeHashlineSingle()` in `packages/coding-agent/src/hashline/execute.ts` splits the raw `input` into `¶PATH` sections with `splitHashlineInputs()`. 3. If multiple sections target the same path, `mergeSamePathSections()` concatenates them before execution so every op still refers to the original file snapshot. 4. Multi-section calls run a preflight pass (`preflightHashlineSection()`): parse ops, enforce plan-mode write rules, load the current file, reject anchor-scoped edits against missing files, reject auto-generated files, apply edits in memory, and fail if the result is a no-op. This prevents partial batches. 5. `parseHashlineWithWarnings()` in `packages/coding-agent/src/hashline/parser.ts` tokenizes the diff body: - ignores blank lines and optional `*** Begin Patch` - stops at `*** End Patch` - stops at `*** Abort` and emits `ABORT_WARNING` - - turns `»` / `«` payload runs into one `insert` edit per payload line - - turns `≔A..B` with payload into inserts before `A`, then deletes for `A..B` - - turns `≔A..B` with no payload into one `delete` edit per line in the range; a blank-in-place edit requires one explicit empty payload line + - turns `↓` / `↑` payload runs into one `insert` edit per payload line + - turns `A-B→` with payload into inserts before `A`, then deletes for `A-B` + - turns `A-B→` with no payload into one `delete` edit per line in the range; a blank-in-place edit requires one explicit empty payload line 6. `applyHashlineEdits()` in `packages/coding-agent/src/hashline/apply.ts` validates every referenced anchor before mutating anything. Each anchor hash is recomputed from current file content with `computeLineHash()`. 7. If any anchor hash differs, `applyHashlineEdits()` throws `HashlineMismatchError`. `execute.ts` catches only that class and calls `tryRecoverHashlineWithCache()`. 8. Recovery replays the edits against the most recent cached read/search snapshot for that path (`packages/coding-agent/src/edit/file-read-cache.ts`), then 3-way merges the result onto current disk content using `Diff.applyPatch(..., { fuzzFactor: 3 })` in `packages/coding-agent/src/hashline/recovery.ts`. On success the edit proceeds with a warning; on failure the original mismatch error is re-thrown. @@ -103,25 +103,25 @@ Warnings: Hashline op examples: ```text -§src/a.ts -»4fb +¶src/a.ts +4fb↓ const added = true; ``` ```text -§src/a.ts -«4fb +¶src/a.ts +4fb↑ const addedBefore = true; ``` ```text -§src/a.ts -≔4fb..6qx +¶src/a.ts +4fb-6qx→ ``` ```text -§src/a.ts -≔4fb..5dm +¶src/a.ts +4fb-5dm→ const clean = (name || DEF).trim(); return clean.length === 0 ? DEF : clean.toUpperCase(); ``` @@ -129,29 +129,29 @@ return clean.length === 0 ? DEF : clean.toUpperCase(); BOF/EOF examples: ```text -§src/a.ts -»BOF +¶src/a.ts +BOF↓ const HEADER = true; ``` ```text -§src/a.ts -»EOF +¶src/a.ts +EOF↓ export const done = true; ``` Delete / blank examples: ```text -§src/a.ts -≔4fb +¶src/a.ts +4fb→ ``` ```text -§src/a.ts -≔4fb +¶src/a.ts +4fb→ -»EOF +EOF↓ export const done = true; ``` @@ -178,28 +178,27 @@ export const done = true; - Stale-anchor recovery uses `fuzzFactor: 3` (`HASHLINE_RECOVERY_FUZZ_FACTOR`) in `packages/coding-agent/src/hashline/recovery.ts`. - The per-session read cache keeps at most 30 paths (`MAX_PATHS_PER_SESSION`) in `packages/coding-agent/src/edit/file-read-cache.ts`. - Hashline streaming chunk defaults are 200 lines or 64 KiB per chunk (`packages/coding-agent/src/hashline/types.ts`, consumed by `packages/coding-agent/src/hashline/stream.ts`). -- `HL_OP_INSERT_BEFORE` is `«`, `HL_OP_INSERT_AFTER` is `»`, `HL_OP_REPLACE` is `≔`, `HL_OP_CHARS` is `«»≔`, `HL_FILE_PREFIX` is `§`, and `HL_BODY_SEP` is `|` (`packages/coding-agent/src/hashline/hash.ts`). +- `HL_OP_INSERT_BEFORE` is `↑`, `HL_OP_INSERT_AFTER` is `↓`, `HL_OP_REPLACE` is `→`, `HL_OP_CHARS` is `↑↓→`, `HL_FILE_PREFIX` is `¶`, and `HL_BODY_SEP` is `|` (`packages/coding-agent/src/hashline/hash.ts`). ## Errors - Missing section header: - - `input must begin with "§PATH" on the first non-blank line; got: ... Example: "§src/foo.ts" then edit ops.` + - `input must begin with "¶PATH" on the first non-blank line; got: ... Example: "¶src/foo.ts" then edit ops.` - Empty header: - - `Input header "§" is empty; provide a file path.` + - `Input header "¶" is empty; provide a file path.` - Bad anchor token: - `line N: expected a full anchor such as "119sr"; got "...".` - Bad range syntax: - - `line N: explicit ranges are required for replacement...` - - `line N: range must include exactly two full anchors separated by "..".` - - `line N: range A..B ends before it starts.` - - `line N: range A..B uses two different hashes for the same line.` -- Missing payload for `»` / `«`: - - `line N: » and « operations require at least one verbatim payload line.` + - `line N: range must be ANCHOR or ANCHOR-ANCHOR (one dash, no spaces); got ...` + - `line N: range A-B ends before it starts.` + - `line N: range A-B uses two different hashes for the same line.` +- Missing payload for `↓` / `↑`: + - `line N: ↑ and ↓ operations require at least one verbatim payload line.` - Stray payload line: - - `line N: payload line has no preceding », «, or ≔ operation.` + - `line N: payload line has no preceding ↑, ↓, or → operation.` - Unknown op: - - `line N: unrecognized op. Use «ANCHOR..., »ANCHOR..., ≔A..B...` + - `line N: unrecognized op. Use ANCHOR↑ (insert before), ANCHOR↓ (insert after), or A-B→ (replace/delete).` - Delete vs blank: - - `≔A..B` with no payload deletes. To blank in place, include one explicit empty payload line before the next op/header/EOF. + - `A-B→` with no payload deletes. To blank in place, include one explicit empty payload line before the next op/header/EOF. - Missing file for anchor-scoped edits: - `File not found: ` - Out-of-range anchor: @@ -212,12 +211,12 @@ export const done = true; ## Notes - `read` and `search` are the authoritative source of anchors. The edit parser does not want the trailing `|TEXT`; copy only the `LINEhh` token. - Multi-op patches are parsed against the original file snapshot. Do not renumber later anchors after earlier ops; `applyHashlineEdits()` buckets and applies them bottom-up. -- `≔A..B` is not a primitive replace in the parser. With payload, it expands to inserts before `A` plus deletes for `A..B`; with no payload, it only deletes `A..B`. To blank in place, include one explicit empty payload line. Stale-anchor checking still happens on the original range lines. +- `A-B→` is not a primitive replace in the parser. With payload, it expands to inserts before `A` plus deletes for `A-B`; with no payload, it only deletes `A-B`. To blank in place, include one explicit empty payload line. Stale-anchor checking still happens on the original range lines. - Interior lines of a multi-line range use hash `**` (`RANGE_INTERIOR_HASH`) and are not individually verified; only the first and last anchor hashes are checked. - `computeLineHash()` trims trailing whitespace before hashing. Anchors survive line-ending changes and trailing-space-only changes, but not substantive line edits. - For punctuation-only lines, the hash mixes in the line number; identical `}` lines on different lines intentionally get different anchors. -- `splitHashlineInputs()` normalizes absolute `§PATH` headers back to a cwd-relative path when the file is inside the current working tree. Headers with any run of leading `§` chars (e.g. `§foo.ts`, `§§foo.ts`, `§§§foo.ts`) are accepted; the canonical form is `§PATH`. -- Optional `*** Begin Patch` / `*** End Patch` markers are accepted in hashline mode, but the file sections are still `§PATH`-based, not Codex `*** Update File:` hunks. +- `splitHashlineInputs()` normalizes absolute `¶PATH` headers back to a cwd-relative path when the file is inside the current working tree. Headers with any run of leading `¶` chars (e.g. `¶foo.ts`, `¶¶foo.ts`, `¶¶¶foo.ts`) are accepted; the canonical form is `¶PATH`. +- Optional `*** Begin Patch` / `*** End Patch` markers are accepted in hashline mode, but the file sections are still `¶PATH`-based, not Codex `*** Update File:` hunks. - `*** Abort` terminates parsing early and returns `ABORT_WARNING`; ops parsed before the marker still apply. - File-read cache invalidation is conflict-based, not write-through invalidation. If `read` later records content for a line that disagrees with the cached snapshot, the entire snapshot for that path is replaced with the newly observed lines (`packages/coding-agent/src/edit/file-read-cache.ts`). - There is no resolve-style apply/discard phase for hashline edits. The only preview path is the transient TUI diff preview in `packages/coding-agent/src/edit/streaming.ts`. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cb87c24d7..7a6fab6e3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] + +### Breaking Changes + +- Replaced the hashline patch format from `§`, `«`, `»`, `≔`, and `..` to `¶`, `↑`, `↓`, `→` with range separators written as `A-B`, requiring users to migrate hashline edit inputs + ### Added - Added `codex` and `gemini` to the web search provider settings so users can configure OpenAI and Gemini web search directly from provider selection @@ -8,6 +13,8 @@ ### Changed +- Updated hashline operation syntax to support inline payload text on the same op line for insert and replace (`ANCHOR↑/↓/...→`) while still accepting payload lines that follow +- Updated hashline anchor parsing so copied `|TEXT` decorations remain cosmetic and payload must be provided on or after the operator - Unified subagent output-schema validation into a single shared module (`tools/output-schema-validator.ts`) used by both the in-process `yield` tool (validates before the subagent yields) and the executor's post-mortem `finalizeSubprocessOutput` path (validates after subprocess exit). Previously each side ran its own `normalizeSchema` → `jtdToJsonSchema` → `validateJsonSchemaValue` chain in parallel, which was semantically equivalent but invited drift: a future tweak on one side could silently disagree with the other and cause yields that pass in-tool to fail post-mortem (or vice versa). The unification preserves both call sites' existing behavior (yield throws an actionable per-issue error for the model; executor produces a `schema_violation` outcome with the first issue and missing-required fields) by exposing two output formatters (`formatAllValidationIssues` for retries, `formatValidationIssueHeadline` for headlines). - Changed web search provider credential lookup to use the shared `AuthStorage` pipeline (`getApiKey`/`getOAuthAccess`) for API-key and OAuth auth instead of direct `AgentStorage` access - Changed the `codex` web search provider display label from `Codex` to `OpenAI` @@ -15,6 +22,7 @@ ### Fixed +- Fixed hashline inline payload parsing so same-line payloads containing whitespace, including tab-indented text, are preserved instead of being rejected - Fixed Bun HTTP/2 transport errors (`HTTP2StreamReset`, `HTTP2RefusedStream`, and `HTTP2EnhanceYourCalm`) to be treated as transient so the assistant now retries automatically instead of stopping on these recoverable failures - Fixed web search OAuth-backed providers (including Codex and Gemini) to use broker-managed token retrieval and account metadata, avoiding direct token-store refresh behavior that could cause search authentication failures - Updated Tavily missing-credential feedback to prompt users to configure an API-key provider setting instead of referencing `agent.db` directly diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 542c0c789..5c0020575 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -344,7 +344,7 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string { function parseHashlineInputPreviewHeader(line: string): string | null { if (!line.startsWith(HL_FILE_PREFIX)) return null; // Mirror hashline/input.ts: strip every leading file marker so canonical - // `§ PATH` headers and stray `§§ PATH` / `§§§PATH` runs render clean paths. + // `¶ PATH` headers and stray `¶¶ PATH` / `¶¶¶PATH` runs render clean paths. let prefixEnd = 0; while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++; const body = line.slice(prefixEnd).trim(); diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index 03754d43d..76463f658 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -23,7 +23,7 @@ import { END_PATCH_MARKER, type HashlineInputSection, HL_FILE_PREFIX, - HL_OP_CHARS, + isHashlineOpLineText, splitHashlineInputs, } from "../hashline"; import type { Theme } from "../modes/theme/theme"; @@ -90,8 +90,7 @@ function parseHashlineHeaderPath(line: string): string { } function isHashlineOpLine(line: string): boolean { - const first = line[0]; - return first !== undefined && HL_OP_CHARS.includes(first); + return isHashlineOpLineText(line); } function isHashlineEnvelopeMarkerLine(line: string): boolean { @@ -431,7 +430,7 @@ const hashlineStrategy: EditStreamingStrategy = { sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path }); } catch { // Single-section fallback keeps the original error rendering for the - // "haven't typed `§ PATH` yet" case. + // "haven't typed `¶ PATH` yet" case. const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, { autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates, }); diff --git a/packages/coding-agent/src/hashline/grammar.lark b/packages/coding-agent/src/hashline/grammar.lark index 9d4fa3f7d..1d7c74ecd 100644 --- a/packages/coding-agent/src/hashline/grammar.lark +++ b/packages/coding-agent/src/hashline/grammar.lark @@ -8,14 +8,17 @@ update_hunk: "$HFILE$" filename LF line_op* filename: /(.+)/ line_op: insert_before | insert_after | replace | blank -insert_before: "$HOP_INSERT_BEFORE$" anchor LF payload+ -insert_after: "$HOP_INSERT_AFTER$" anchor LF payload+ -replace: "$HOP_REPLACE$" range LF payload* -payload: /[^$HOP_CHARS$$HFILE$\n][^\n]*/ LF | LF +insert_before: anchor "$HOP_INSERT_BEFORE$" inline_body LF payload* + | anchor "$HOP_INSERT_BEFORE$" LF payload+ +insert_after: anchor "$HOP_INSERT_AFTER$" inline_body LF payload* + | anchor "$HOP_INSERT_AFTER$" LF payload+ +replace: range "$HOP_REPLACE$" inline_body? LF payload* +inline_body: /[^\n]+/ +payload: /[^$HFILE$\n][^\n]*/ LF | LF blank: LF anchor: LID | "EOF" | "BOF" -range: LID (".." LID)? +range: LID ("-" LID)? LID: /[1-9]\d*$HFMT$/ %import common.LF diff --git a/packages/coding-agent/src/hashline/hash.ts b/packages/coding-agent/src/hashline/hash.ts index c0a169b2e..d86f83787 100644 --- a/packages/coding-agent/src/hashline/hash.ts +++ b/packages/coding-agent/src/hashline/hash.ts @@ -91,22 +91,23 @@ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, /** * Hashline edit input markers. File section headers start with {@link HL_FILE_PREFIX}; - * op lines start with a direction/action sigil: {@link HL_OP_INSERT_BEFORE}, - * {@link HL_OP_INSERT_AFTER}, or {@link HL_OP_REPLACE}. Payload lines are - * verbatim file content and have no per-line marker. + * op lines have an `ANCHOR[INLINE_PAYLOAD]` shape, where SIGIL is one of + * {@link HL_OP_INSERT_BEFORE}, {@link HL_OP_INSERT_AFTER}, or {@link HL_OP_REPLACE}. + * Multi-line payloads follow on subsequent lines as verbatim file content with no + * per-line marker. * * These constants are the single source of truth for the edit parser, grammar, * renderer, and prompt. */ -export const HL_OP_INSERT_BEFORE = "«"; -export const HL_OP_INSERT_AFTER = "»"; -export const HL_OP_REPLACE = "≔"; +export const HL_OP_INSERT_BEFORE = "↑"; +export const HL_OP_INSERT_AFTER = "↓"; +export const HL_OP_REPLACE = "→"; /** All hashline edit op sigils, concatenated for fast membership tests. */ export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}`; /** Hashline edit file section header marker. */ -export const HL_FILE_PREFIX = "§"; +export const HL_FILE_PREFIX = "¶"; /** Stable separator for read/search/hashline display output. Intentionally not configurable. */ export const HL_BODY_SEP = "|"; diff --git a/packages/coding-agent/src/hashline/input.ts b/packages/coding-agent/src/hashline/input.ts index d2167d0af..81faf6c52 100644 --- a/packages/coding-agent/src/hashline/input.ts +++ b/packages/coding-agent/src/hashline/input.ts @@ -1,11 +1,9 @@ import * as path from "node:path"; import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./constants"; -import { HL_FILE_PREFIX, HL_OP_CHARS } from "./hash"; +import { HL_FILE_PREFIX } from "./hash"; +import { isHashlineOpLineText } from "./parser"; import type { SplitHashlineOptions } from "./types"; -const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -const HASHLINE_OP_LINE_RE = new RegExp(`^[${regexEscape(HL_OP_CHARS)}]`); - export interface HashlineInputSection { path: string; diff: string; @@ -30,8 +28,8 @@ function normalizeHashlinePath(rawPath: string, cwd?: string): string { function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null { const trimmed = line.trimEnd(); if (!trimmed.startsWith(HL_FILE_PREFIX)) return null; - // Strip a run of leading header markers so canonical `§PATH` and - // runaway-prefix forms like `§§PATH` / `§§§PATH` route to the same file. + // Strip a run of leading header markers so canonical `¶PATH` and + // runaway-prefix forms like `¶¶PATH` / `¶¶¶PATH` route to the same file. let prefixEnd = 0; while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++; const rest = trimmed.slice(prefixEnd); @@ -66,7 +64,7 @@ function stripLeadingBlankLines(input: string): string { export function containsRecognizableHashlineOperations(input: string): boolean { for (const line of input.split(/\r?\n/)) { - if (HASHLINE_OP_LINE_RE.test(line)) return true; + if (isHashlineOpLineText(line)) return true; } return false; } diff --git a/packages/coding-agent/src/hashline/parser.ts b/packages/coding-agent/src/hashline/parser.ts index 7148e67e4..eae14a6d7 100644 --- a/packages/coding-agent/src/hashline/parser.ts +++ b/packages/coding-agent/src/hashline/parser.ts @@ -1,10 +1,9 @@ import { ABORT_MARKER, ABORT_WARNING, BEGIN_PATCH_MARKER, END_PATCH_MARKER, RANGE_INTERIOR_HASH } from "./constants"; import { - computeLineHash, describeAnchorExamples, - HL_BODY_SEP_RE_RAW, HL_FILE_PREFIX, HL_HASH_CAPTURE_RE_RAW, + HL_HASH_RE_RAW, HL_OP_CHARS, HL_OP_INSERT_AFTER, HL_OP_INSERT_BEFORE, @@ -12,13 +11,41 @@ import { } from "./hash"; import type { Anchor, HashlineCursor, HashlineEdit } from "./types"; +const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const OP_CHARS_ESCAPED = regexEscape(HL_OP_CHARS); + // Leniently accept anchors copied from read/search output: // - optional leading line-marker decoration (`*`, `>`, `+`, `-`) // - the required `LINE+HASH` -// - an optional trailing `|TEXT` body (or anything after the hash) so users -// can paste a full `LINE+HASH|TEXT` line verbatim. +// - an optional trailing `|TEXT` body so users can paste a full +// `LINE+HASH|TEXT` line verbatim. const LID_CAPTURE_RE = new RegExp(`^\\s*[>+\\-*]*\\s*${HL_HASH_CAPTURE_RE_RAW}(?:\\|.*)?\\s*$`); -const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// Pre-op anchor part for insert ops: leading decoration, then a LID or +// BOF/EOF, then optional `|TEXT` paste decoration. The decoration MUST NOT +// contain any op sigil so the op-line regex below knows where the anchor part +// ends. Trailing `\s*` allows space between the anchor and the op sigil. +const INSERT_ANCHOR_PART_RE_RAW = `\\s*[>+\\-*]*\\s*(?:${HL_HASH_RE_RAW}|BOF|EOF)(?:\\|[^${OP_CHARS_ESCAPED}\\n]*)?\\s*`; + +// Pre-op range part for the replace op: optional decoration + LID, then an +// optional `-LID` end, then optional trailing `|TEXT` paste decoration. The +// `-` is the range separator; `|TEXT` between bounds is unsupported (TEXT may +// contain `-`), trailing decoration after the full range is still tolerated. +const RANGE_PART_RE_RAW = `\\s*[>+\\-*]*\\s*${HL_HASH_RE_RAW}(?:-${HL_HASH_RE_RAW})?(?:\\|[^${OP_CHARS_ESCAPED}\\n]*)?\\s*`; + +// Op lines place the operator AFTER the anchor/range. Group 1 captures the +// anchor (or range) part; group 2 captures the optional inline payload that +// follows the op sigil on the same line, with trailing whitespace eaten. +const INSERT_BEFORE_OP_RE = new RegExp(`^(${INSERT_ANCHOR_PART_RE_RAW})${regexEscape(HL_OP_INSERT_BEFORE)}(.*?)\\s*$`); +const INSERT_AFTER_OP_RE = new RegExp(`^(${INSERT_ANCHOR_PART_RE_RAW})${regexEscape(HL_OP_INSERT_AFTER)}(.*?)\\s*$`); +const REPLACE_OP_RE = new RegExp(`^(${RANGE_PART_RE_RAW})${regexEscape(HL_OP_REPLACE)}(.*?)\\s*$`); + +// Range parser: a bare `LINE+HASH` or `LINE+HASH-LINE+HASH` with optional +// leading decoration and optional trailing `|TEXT` paste decoration. Captures +// 1/2 = start line/hash, 3/4 = optional end line/hash. +const RANGE_PARSE_RE = new RegExp( + `^\\s*[>+\\-*]*\\s*${HL_HASH_CAPTURE_RE_RAW}(?:-${HL_HASH_CAPTURE_RE_RAW})?(?:\\|.*)?\\s*$`, +); function parseLid(raw: string, lineNum: number): Anchor { const match = LID_CAPTURE_RE.exec(raw); @@ -37,24 +64,24 @@ interface ParsedRange { } function parseRange(raw: string, lineNum: number): ParsedRange { - if (!raw.includes("..")) { - const start = parseLid(raw, lineNum); - return { start, end: { ...start } }; - } - const [startRaw, endRaw, extra] = raw.split(".."); - if (extra !== undefined || !startRaw || !endRaw) { + const match = RANGE_PARSE_RE.exec(raw); + if (!match) { throw new Error( - `line ${lineNum}: range must include exactly two full anchors separated by "..". ` + - `For a one-line edit, repeat the same anchor on both sides.`, + `line ${lineNum}: range must be ANCHOR or ANCHOR-ANCHOR (one dash, no spaces); ` + + `got ${JSON.stringify(raw)}.`, ); } - const start = parseLid(startRaw, lineNum); - const end = parseLid(endRaw, lineNum); + const start: Anchor = { line: Number.parseInt(match[1], 10), hash: match[2] }; + const end: Anchor = match[3] !== undefined ? { line: Number.parseInt(match[3], 10), hash: match[4] } : { ...start }; if (end.line < start.line) { - throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} ends before it starts.`); + throw new Error( + `line ${lineNum}: range ${start.line}${start.hash}-${end.line}${end.hash} ends before it starts.`, + ); } if (end.line === start.line && end.hash !== start.hash) { - throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} uses two different hashes for the same line.`); + throw new Error( + `line ${lineNum}: range ${start.line}${start.hash}-${end.line}${end.hash} uses two different hashes for the same line.`, + ); } return { start, end }; } @@ -69,57 +96,31 @@ function expandRange(range: ParsedRange): Anchor[] { return anchors; } +// `BOF`/`EOF` with optional leading decoration and optional `|TEXT` trailing +// paste decoration. The token is recognized verbatim; any `|TEXT` is discarded. +const BOF_RE = /^\s*[>+\-*]*\s*BOF(?:\|[^\n]*)?\s*$/; +const EOF_RE = /^\s*[>+\-*]*\s*EOF(?:\|[^\n]*)?\s*$/; + function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor { - if (raw === "BOF") return { kind: "bof" }; - if (raw === "EOF") return { kind: "eof" }; + if (BOF_RE.test(raw)) return { kind: "bof" }; + if (EOF_RE.test(raw)) return { kind: "eof" }; const cursorKind = kind === "before" ? "before_anchor" : "after_anchor"; return { kind: cursorKind, anchor: parseLid(raw, lineNum) }; } -/** - * Decide how to interpret the optional `|TEXT` body captured on an insert - * op line: - * - For BOF/EOF cursors the body is always treated as an inline payload - * line (there's no anchor hash to compare against). - * - For anchored cursors, compute the hash of TEXT at the anchor's line - * number. If it matches the anchor's hash, the body is just a verbatim - * copy of the anchored line — discard it, payload must come from the - * following lines as usual. - * - Otherwise the body is the first (or only) payload line for this op. - */ -function resolveInlineInsertBody(cursor: HashlineCursor, body: string | undefined): string | undefined { - if (body === undefined) return undefined; - if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") return body; - const { line, hash } = cursor.anchor; - if (computeLineHash(line, body) === hash) return undefined; - return body; -} - -// Insert ops leniently accept a trailing `|TEXT` body on the op line itself -// (e.g. `»502zk|\tconst foo = ...`). The anchor token excludes `|` so the body -// is captured separately; resolveInlineInsertBody decides whether to treat the -// captured text as a verbatim anchor decoration (when its hash matches the -// anchor's) or as an inline payload line. -const INSERT_BEFORE_OP_RE = new RegExp( - `^${regexEscape(HL_OP_INSERT_BEFORE)}\\s*([^|\\s]+)(?:${HL_BODY_SEP_RE_RAW}(.*))?\\s*$`, -); -const INSERT_AFTER_OP_RE = new RegExp( - `^${regexEscape(HL_OP_INSERT_AFTER)}\\s*([^|\\s]+)(?:${HL_BODY_SEP_RE_RAW}(.*))?\\s*$`, -); -const REPLACE_OP_RE = new RegExp(`^${regexEscape(HL_OP_REPLACE)}\\s*([^\\s+<\\-=]\\S*)\\s*$`); - function isEnvelopeOrAbortMarkerLine(line: string): boolean { const trimmed = line.trimEnd(); return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER || trimmed === ABORT_MARKER; } +export function isHashlineOpLineText(line: string): boolean { + return INSERT_BEFORE_OP_RE.test(line) || INSERT_AFTER_OP_RE.test(line) || REPLACE_OP_RE.test(line); +} + function isPayloadTerminatorLine(line: string): boolean { - const first = line[0]; - return ( - first === HL_FILE_PREFIX || - (first !== undefined && HL_OP_CHARS.includes(first)) || - isEnvelopeOrAbortMarkerLine(line) - ); + if (line.startsWith(HL_FILE_PREFIX)) return true; + if (isHashlineOpLineText(line)) return true; + return isEnvelopeOrAbortMarkerLine(line); } export function cloneCursor(cursor: HashlineCursor): HashlineCursor { @@ -188,7 +189,7 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[] const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line); if (insertBeforeMatch) { const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before"); - const inlineBody = resolveInlineInsertBody(cursor, insertBeforeMatch[2]); + const inlineBody = insertBeforeMatch[2].length > 0 ? insertBeforeMatch[2] : undefined; const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, inlineBody === undefined); if (inlineBody !== undefined) pushInsert(cursor, inlineBody, lineNum); for (const text of payload) pushInsert(cursor, text, lineNum); @@ -199,7 +200,7 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[] const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line); if (insertAfterMatch) { const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after"); - const inlineBody = resolveInlineInsertBody(cursor, insertAfterMatch[2]); + const inlineBody = insertAfterMatch[2].length > 0 ? insertAfterMatch[2] : undefined; const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, inlineBody === undefined); if (inlineBody !== undefined) pushInsert(cursor, inlineBody, lineNum); for (const text of payload) pushInsert(cursor, text, lineNum); @@ -210,9 +211,11 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[] const replaceMatch = REPLACE_OP_RE.exec(line); if (replaceMatch) { const range = parseRange(replaceMatch[1], lineNum); + const inlineBody = replaceMatch[2].length > 0 ? replaceMatch[2] : undefined; const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false); - if (payload.length > 0) { - for (const text of payload) { + const allPayload = inlineBody !== undefined ? [inlineBody, ...payload] : payload; + if (allPayload.length > 0) { + for (const text of allPayload) { edits.push({ kind: "insert", cursor: { kind: "before_anchor", anchor: { ...range.start } }, @@ -229,9 +232,11 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[] continue; } - if (isPayloadTerminatorLine(line) || /^[-@\u00B6]/u.test(line)) { + const firstChar = line[0]; + const startsWithOp = firstChar !== undefined && HL_OP_CHARS.includes(firstChar); + if (startsWithOp || /^[-@«»\u2254\u00A7]/u.test(line)) { throw new Error( - `line ${lineNum}: unrecognized op. Use ${HL_OP_INSERT_BEFORE}ANCHOR (insert before), ${HL_OP_INSERT_AFTER}ANCHOR (insert after), or ${HL_OP_REPLACE}A..B (replace/delete). ` + + `line ${lineNum}: unrecognized op. Use ANCHOR${HL_OP_INSERT_BEFORE} (insert before), ANCHOR${HL_OP_INSERT_AFTER} (insert after), or A-B${HL_OP_REPLACE} (replace/delete). ` + `Got ${JSON.stringify(line)}.`, ); } diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index f158b7046..f56d0a4c6 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -1,36 +1,38 @@ Your patch language is a compact, line-anchored edit format. -A patch contains one or more file sections. The first non-blank line of every edit section MUST be `§PATH`. +A patch contains one or more file sections. The first non-blank line of every edit section MUST be `¶PATH`. Operations reference lines in the file by their line number and hash, called "Anchors", e.g. `5th`, `123ab`. You MUST copy them verbatim from the latest output for the file you're editing. Purely textual format. The tool has NO awareness of language, indentation, brackets, fences, or table widths. You MUST emit valid syntax in replacements/insertions. -§PATH header: subsequent ops apply to PATH +¶PATH header: subsequent ops apply to PATH Each op line is ONE of: -»ANCHOR insert lines AFTER the anchored line (or EOF); payload follows on subsequent lines -«ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows on subsequent lines -≔A..B replace the inclusive range A..B with payload; delete the range if no payload follows -≔A shorthand for ≔A..A +ANCHOR↑ insert ABOVE the anchored line (or BOF); payload may follow inline after `↑` and/or on subsequent lines +ANCHOR↓ insert BELOW the anchored line (or EOF); payload may follow inline after `↓` and/or on subsequent lines +A-B→ replace the inclusive range A..B with payload; delete the range if no payload follows +A→ shorthand for A-A→ +- The arrow points to where the content lands relative to the anchor: `↑` above, `↓` below, `→` overwrite. - Payload text is verbatim — NEVER escape unicode. -- Payload ends at the next `»`, `«`, `≔`, `§`, envelope marker, or EOF. -- `≔A..B` with no payload deletes the range. To keep a blank line, include one explicit empty payload line. +- An op line is `ANCHOR[INLINE_PAYLOAD]`. Anything after the sigil on the same line is the first payload line; subsequent payload lines follow on the next lines. +- A payload run ends at the next op line, the next `¶PATH`, an envelope marker, or EOF. +- `A-B→` with no payload deletes the range. To keep a blank line, include one explicit empty payload line on the next line. - **Payload is only what's NEW relative to your range:** - - `≔` replaces inside; NEVER include lines outside. - - `»`/`«` adds at the anchor; NEVER repeat line A or neighbors. + - `→` replaces inside; NEVER include lines outside. + - `↑`/`↓` adds at the anchor; NEVER repeat line A or neighbors. - Payload matching nearby content duplicates — drop it or widen. - **Pick a self-contained unit first.** Touching a multiline construct? Widen to the whole thing. -- Then smallest op: add → `»`/`«`; delete/replace → `≔`. +- Then smallest op: add → `↑`/`↓`; delete/replace → `→`. When braces bound your edit, you SHOULD prefer these shapes: - **Whole block**: range spans `{` through matching `}`. -- **Signature only**: one-line `≔` on the opener; body untouched. +- **Signature only**: one-line `→` on the opener; body untouched. - **Insert inside**: anchor on `{` or last interior line; NEVER repeat the braces. - **End on `}`**: only when that `}` is part of the change. Otherwise extend or stop earlier. @@ -41,7 +43,7 @@ When braces bound your edit, you SHOULD prefer these shapes: - **Anchor only inside the visible region.** B+1 truncated? Re-`read` first. - **You SHOULD prefer the narrowest self-contained edit.** Narrow range beats wide range. - **Anchors reference the file as last read.** NEVER shift for prior ops. -- **One `»`/`«` op per block, NOT per line.** N lines = ONE op, N payloads. Collapse adjacent ops. +- **One `↓`/`↑` op per block, NOT per line.** N lines = ONE op, N payloads. Collapse adjacent ops. - **NEVER fabricate anchor hashes.** Missing? Re-`read`. @@ -57,62 +59,62 @@ When braces bound your edit, you SHOULD prefer these shapes: # Replace one line (the payload must re-emit the original indentation) -§mod.ts -≔{{hrefr 1}} +¶mod.ts +{{hrefr 1}}→ const TITLE = "Mrs"; # Replace a full multiline statement (widen to a self-contained boundary) -§mod.ts -≔{{hrefr 3}}..{{hrefr 6}} +¶mod.ts +{{hrefr 3}}-{{hrefr 6}}→ return [ "Mrs", name?.trim() || "guest", ].join(" "); -# Insert AFTER/BEFORE a line -§mod.ts -»{{hrefr 4}} +# Insert ABOVE/BELOW a line +¶mod.ts +{{hrefr 4}}↓ "Dr", -«{{hrefr 5}} +{{hrefr 5}}↑ "Dr", # Append to file -§mod.ts -»EOF +¶mod.ts +EOF↓ export const done = true; # Delete a line -§mod.ts -≔{{hrefr 5}} +¶mod.ts +{{hrefr 5}}→ -# Blank a line (replace with LF: the empty payload is the blank line before `»EOF`) -§mod.ts -≔{{hrefr 5}} +# Blank a line (replace with LF: the empty payload is the blank line before `EOF↓`) +¶mod.ts +{{hrefr 5}}→ -»EOF +EOF↓ export const done = true; # WRONG — replaces 2 lines just to add one. -§mod.ts -≔{{hrefr 1}}..{{hrefr 2}} +¶mod.ts +{{hrefr 1}}-{{hrefr 2}}→ const TITLE = "Mr"; const DEBUG = false; export function greet(name) { # RIGHT — same effect, one-line insert -§mod.ts -»{{hrefr 1}} +¶mod.ts +{{hrefr 1}}↓ const DEBUG = false; # WRONG — replace from the middle of a larger statement (error-prone) -§mod.ts -≔{{hrefr 4}}..{{hrefr 5}} +¶mod.ts +{{hrefr 4}}-{{hrefr 5}}→ "Dr", name?.trim() || "guest", # RIGHT — widen to the full statement -§mod.ts -≔{{hrefr 3}}..{{hrefr 6}} +¶mod.ts +{{hrefr 3}}-{{hrefr 6}}→ return [ "Dr", name?.trim() || "guest", @@ -121,10 +123,10 @@ const DEBUG = false; - Copy anchors verbatim (line number + 2-char hash); NEVER include the `|TEXT` body. -- NEVER write unified diff syntax. Headers are `§PATH`; ops are `»`/`«`/`≔`. -- `≔A..B` deletes the range when no payload follows. To keep a blank line, include one explicit empty payload line. -- `≔A..B` with payload writes exactly that payload. Edge line matches just outside? Widen, or it duplicates. -- Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `≔`. - - Before `≔A..B`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct. +- NEVER write unified diff syntax. Headers are `¶PATH`; ops put `↑`/`↓`/`→` AFTER the anchor. +- `A-B→` deletes the range when no payload follows. To keep a blank line, include one explicit empty payload line. +- `A-B→` with payload writes exactly that payload. Edge line matches just outside? Widen, or it duplicates. +- Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `→`. + - Before `A-B→`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct. - NEVER use this tool to reformat code (indentation, whitespace, line wrapping, style). Run the project's formatter instead. diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 228f187e5..0cdf84fbd 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -38,7 +38,7 @@ function tag(line: number, content: string): string { } function sameLineRange(anchor: string): string { - return `${anchor}..${anchor}`; + return `${anchor}-${anchor}`; } function mistag(line: number, content: string): string { @@ -88,76 +88,102 @@ function hashlineExecuteOptions( }; } -describe("hashline parser — block op syntax", () => { +describe("hashline parser — suffix-op syntax", () => { const content = "aaa\nbbb\nccc"; it("inserts payload before/after a Lid, and at BOF/EOF", () => { const diff = [ - `«${tag(2, "bbb")}`, + `${tag(2, "bbb")}↑`, pl("before b"), - `»${tag(2, "bbb")}`, + `${tag(2, "bbb")}↓`, pl("after b"), - "»BOF", + "BOF↓", pl("top"), - "»EOF", + "EOF↓", pl("tail"), ].join("\n"); expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail"); }); - it("inserts after the final line via `»ANCHOR` instead of falling off the file", () => { - const diff = [`»${tag(3, "ccc")}`, pl("tail")].join("\n"); + it("inserts after the final line via `ANCHOR↓` instead of falling off the file", () => { + const diff = [`${tag(3, "ccc")}↓`, pl("tail")].join("\n"); expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\ntail"); }); - it("deletes one line or an inclusive range when `≔A..B` has no payload", () => { - expect(applyDiff(content, `≔${sameLineRange(tag(2, "bbb"))}`)).toBe("aaa\nccc"); - expect(applyDiff(content, `≔${tag(2, "bbb")}..${tag(3, "ccc")}`)).toBe("aaa"); + it("deletes one line or an inclusive range when `A-B→` has no payload", () => { + expect(applyDiff(content, `${sameLineRange(tag(2, "bbb"))}→`)).toBe("aaa\nccc"); + expect(applyDiff(content, `${tag(2, "bbb")}-${tag(3, "ccc")}→`)).toBe("aaa"); }); it("blanks a line in place with an explicit empty payload line", () => { - const diff = `≔${sameLineRange(tag(2, "bbb"))}\n\n`; + const diff = `${sameLineRange(tag(2, "bbb"))}→\n\n`; expect(applyDiff(content, diff)).toBe("aaa\n\nccc"); }); it("replaces one line or an inclusive range with payload lines", () => { - const single = [`≔${tag(2, "bbb")}`, pl("BBB")].join("\n"); + const single = [`${tag(2, "bbb")}→`, pl("BBB")].join("\n"); expect(applyDiff(content, single)).toBe("aaa\nBBB\nccc"); - const range = [`≔${tag(2, "bbb")}..${tag(3, "ccc")}`, pl("BBB"), pl("CCC")].join("\n"); + const range = [`${tag(2, "bbb")}-${tag(3, "ccc")}→`, pl("BBB"), pl("CCC")].join("\n"); expect(applyDiff(content, range)).toBe("aaa\nBBB\nCCC"); }); it("treats single-anchor replace sugar as equivalent to an explicit one-line range", () => { const anchor = tag(2, "bbb"); - expect(parseHashline(`≔${anchor}\nBBB`)).toEqual(parseHashline(`≔${anchor}..${anchor}\nBBB`)); - expect(applyDiff(content, `≔${anchor}\nBBB`)).toBe(applyDiff(content, `≔${anchor}..${anchor}\nBBB`)); + expect(parseHashline(`${anchor}→\nBBB`)).toEqual(parseHashline(`${anchor}-${anchor}→\nBBB`)); + expect(applyDiff(content, `${anchor}→\nBBB`)).toBe(applyDiff(content, `${anchor}-${anchor}→\nBBB`)); + }); + + it("accepts an inline payload on the op line as the first/only payload line", () => { + const anchor = tag(2, "bbb"); + expect(applyDiff(content, `${anchor}↓NEW`)).toBe("aaa\nbbb\nNEW\nccc"); + expect(applyDiff(content, `${anchor}↑NEW`)).toBe("aaa\nNEW\nbbb\nccc"); + expect(applyDiff(content, `${anchor}→NEW`)).toBe("aaa\nNEW\nccc"); + }); + + it("combines an inline payload with subsequent payload lines on insert ops", () => { + const anchor = tag(2, "bbb"); + const diff = [`${anchor}↓first inline`, pl("second from next line")].join("\n"); + expect(applyDiff(content, diff)).toBe("aaa\nbbb\nfirst inline\nsecond from next line\nccc"); + }); + + it("combines an inline payload with subsequent payload lines on the replace op", () => { + const anchor = tag(2, "bbb"); + const diff = [`${anchor}→FIRST`, pl("SECOND")].join("\n"); + expect(applyDiff(content, diff)).toBe("aaa\nFIRST\nSECOND\nccc"); + }); + + it("preserves whitespace-bearing inline payload exactly", () => { + const anchor = tag(2, "bbb"); + const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;"; + expect(applyDiff(content, `${anchor}↓${payload}`)).toBe(`aaa\nbbb\n${payload}\nccc`); + expect(applyDiff(content, `${anchor}↑${payload}`)).toBe(`aaa\n${payload}\nbbb\nccc`); }); it("auto-absorbs duplicated multiline prefix boundaries during replacement", () => { const source = ["// one", "// two", "old();"].join("\n"); - const diff = [`≔${sameLineRange(tag(3, "old();"))}`, pl("// one"), pl("// two"), pl("new();")].join("\n"); + const diff = [`${sameLineRange(tag(3, "old();"))}→`, pl("// one"), pl("// two"), pl("new();")].join("\n"); expect(applyDiff(source, diff)).toBe(["// one", "// two", "new();"].join("\n")); }); it("auto-absorbs duplicated multiline suffix boundaries during replacement", () => { const source = ["old();", "// one", "// two"].join("\n"); - const diff = [`≔${sameLineRange(tag(1, "old();"))}`, pl("new();"), pl("// one"), pl("// two")].join("\n"); + const diff = [`${sameLineRange(tag(1, "old();"))}→`, pl("new();"), pl("// one"), pl("// two")].join("\n"); expect(applyDiff(source, diff)).toBe(["new();", "// one", "// two"].join("\n")); }); it("auto-absorbs a duplicated single structural suffix during replacement", () => { const source = ["old();", "};"].join("\n"); - const diff = [`≔${sameLineRange(tag(1, "old();"))}`, pl("new();"), pl("};")].join("\n"); + const diff = [`${sameLineRange(tag(1, "old();"))}→`, pl("new();"), pl("};")].join("\n"); expect(applyDiff(source, diff)).toBe(["new();", "};"].join("\n")); }); it("auto-absorbs a duplicated single structural prefix during replacement", () => { const source = ["};", "old();"].join("\n"); - const diff = [`≔${sameLineRange(tag(2, "old();"))}`, pl("};"), pl("new();")].join("\n"); + const diff = [`${sameLineRange(tag(2, "old();"))}→`, pl("};"), pl("new();")].join("\n"); expect(applyDiff(source, diff)).toBe(["};", "new();"].join("\n")); }); @@ -167,14 +193,14 @@ describe("hashline parser — block op syntax", () => { // `}` is a legitimate part of the new block, not a duplicate of the file's // existing `}`. The single-line structural absorb must NOT fire here. const source = ["old();", "}"].join("\n"); - const diff = [`≔${sameLineRange(tag(1, "old();"))}`, pl("if ok {"), pl("}")].join("\n"); + const diff = [`${sameLineRange(tag(1, "old();"))}→`, pl("if ok {"), pl("}")].join("\n"); expect(applyDiff(source, diff)).toBe(["if ok {", "}", "}"].join("\n")); }); it("does not auto-absorb a single duplicated boundary line", () => { const source = ["keep", "old();"].join("\n"); - const diff = [`≔${sameLineRange(tag(2, "old();"))}`, pl("keep"), pl("new();")].join("\n"); + const diff = [`${sameLineRange(tag(2, "old();"))}→`, pl("keep"), pl("new();")].join("\n"); expect(applyDiff(source, diff)).toBe(["keep", "keep", "new();"].join("\n")); }); @@ -185,11 +211,11 @@ describe("hashline parser — block op syntax", () => { // steal that anchor and turn the insert into a replacement. const source = ["A", "B", "X", "Y", "Z"].join("\n"); const diff = [ - `≔${tag(1, "A")}..${tag(2, "B")}`, + `${tag(1, "A")}-${tag(2, "B")}→`, pl("alpha"), pl("X"), pl("Y"), - `«${tag(4, "Y")}`, + `${tag(4, "Y")}↑`, pl("extra"), ].join("\n"); @@ -198,7 +224,7 @@ describe("hashline parser — block op syntax", () => { it("surfaces a warning when boundary duplicates are auto-absorbed", () => { const source = ["// one", "// two", "old();"].join("\n"); - const diff = [`≔${sameLineRange(tag(3, "old();"))}`, pl("// one"), pl("// two"), pl("new();")].join("\n"); + const diff = [`${sameLineRange(tag(3, "old();"))}→`, pl("// one"), pl("// two"), pl("new();")].join("\n"); const result = applyHashlineEdits(source, parseHashline(diff)); expect(result.lines).toBe(["// one", "// two", "new();"].join("\n")); @@ -213,93 +239,93 @@ describe("hashline parser — block op syntax", () => { // `autoDropPureInsertDuplicates` opt-in, unlike the single-line // structural absorb covered by the test below. const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`»${tag(2, "bbb")}`, pl("aaa"), pl("bbb"), pl("NEW")].join("\n"); + const diff = [`${tag(2, "bbb")}↓`, pl("aaa"), pl("bbb"), pl("NEW")].join("\n"); expect(applyDiff(source, diff)).toBe("aaa\nbbb\naaa\nbbb\nNEW\nccc"); }); it("auto-drops a duplicated single structural suffix for pure insert by default", () => { const source = ["if ok {", " keep();", " }"].join("\n"); - const diff = [`«${tag(3, " }")}`, pl(" added();"), pl(" }")].join("\n"); + const diff = [`${tag(3, " }")}↑`, pl(" added();"), pl(" }")].join("\n"); expect(applyDiff(source, diff)).toBe(["if ok {", " keep();", " added();", " }"].join("\n")); }); it("auto-drops a duplicated single structural prefix for pure insert by default", () => { const source = [" });", "next();"].join("\n"); - const diff = [`»${tag(1, " });")}`, pl(" });"), pl("added();")].join("\n"); + const diff = [`${tag(1, " });")}↓`, pl(" });"), pl("added();")].join("\n"); expect(applyDiff(source, diff)).toBe([" });", "added();", "next();"].join("\n")); }); - it("preserves an intentional non-structural anchor duplicate for `»ANCHOR` by default", () => { + it("preserves an intentional non-structural anchor duplicate for `ANCHOR↓` by default", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`»${tag(2, "bbb")}`, pl("bbb"), pl("NEW")].join("\n"); + const diff = [`${tag(2, "bbb")}↓`, pl("bbb"), pl("NEW")].join("\n"); expect(applyDiff(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc"); }); - it("preserves an intentional non-structural anchor duplicate for `«ANCHOR` by default", () => { + it("preserves an intentional non-structural anchor duplicate for `ANCHOR↑` by default", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`«${tag(2, "bbb")}`, pl("NEW"), pl("bbb")].join("\n"); + const diff = [`${tag(2, "bbb")}↑`, pl("NEW"), pl("bbb")].join("\n"); expect(applyDiff(source, diff)).toBe("aaa\nNEW\nbbb\nbbb\nccc"); }); it("does not drop a single structural pure-insert suffix when it preserves balance", () => { const source = ["if outer {", "}"].join("\n"); - const diff = [`«${tag(2, "}")}`, pl("if inner {"), pl("}")].join("\n"); + const diff = [`${tag(2, "}")}↑`, pl("if inner {"), pl("}")].join("\n"); expect(applyDiff(source, diff)).toBe(["if outer {", "if inner {", "}", "}"].join("\n")); }); - it("auto-absorbs duplicated leading payload of a pure `»ANCHOR` insert", () => { + it("auto-absorbs duplicated leading payload of a pure `ANCHOR↓` insert", () => { // Payload echoes the two file lines AT/ABOVE the insertion point // (aaa, bbb), then adds NEW. The leading echo is absorbed. const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`»${tag(2, "bbb")}`, pl("aaa"), pl("bbb"), pl("NEW")].join("\n"); + const diff = [`${tag(2, "bbb")}↓`, pl("aaa"), pl("bbb"), pl("NEW")].join("\n"); expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc"); }); - it("auto-absorbs context-wrap echo (leading-above + trailing-below) on `»ANCHOR`", () => { + it("auto-absorbs context-wrap echo (leading-above + trailing-below) on `ANCHOR↓`", () => { // Payload wraps NEW with context above (aaa, bbb) AND below (ccc, ddd). // Both ends should be absorbed, leaving only NEW inserted after bbb. const source = ["aaa", "bbb", "ccc", "ddd"].join("\n"); - const diff = [`»${tag(2, "bbb")}`, pl("aaa"), pl("bbb"), pl("NEW"), pl("ccc"), pl("ddd")].join("\n"); + const diff = [`${tag(2, "bbb")}↓`, pl("aaa"), pl("bbb"), pl("NEW"), pl("ccc"), pl("ddd")].join("\n"); expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd"); }); - it("auto-absorbs duplicated trailing payload of a pure `«ANCHOR` insert", () => { + it("auto-absorbs duplicated trailing payload of a pure `ANCHOR↑` insert", () => { // Insert before line 3 ("ccc"). Trailing payload echoes the anchor and the // line after it. Drop the trailing duplicates. const source = ["aaa", "bbb", "ccc", "ddd"].join("\n"); - const diff = [`«${tag(3, "ccc")}`, pl("NEW"), pl("ccc"), pl("ddd")].join("\n"); + const diff = [`${tag(3, "ccc")}↑`, pl("NEW"), pl("ccc"), pl("ddd")].join("\n"); expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd"); }); it("auto-absorbs duplicated leading payload at EOF insert", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - // `»EOF` payload echoes the last two file lines, then adds NEW. - const diff = ["»EOF", pl("bbb"), pl("ccc"), pl("NEW")].join("\n"); + // `EOF↓` payload echoes the last two file lines, then adds NEW. + const diff = ["EOF↓", pl("bbb"), pl("ccc"), pl("NEW")].join("\n"); expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nccc\nNEW"); }); it("auto-absorbs duplicated trailing payload at BOF insert", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - // `«BOF` payload prepends NEW but trails with the first two file lines. - const diff = ["«BOF", pl("NEW"), pl("aaa"), pl("bbb")].join("\n"); + // `BOF↑` payload prepends NEW but trails with the first two file lines. + const diff = ["BOF↑", pl("NEW"), pl("aaa"), pl("bbb")].join("\n"); expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("NEW\naaa\nbbb\nccc"); }); it("auto-drops a single duplicated anchor line in a pure insert when generic duplicate absorption is enabled", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`»${tag(2, "bbb")}`, pl("bbb"), pl("NEW")].join("\n"); + const diff = [`${tag(2, "bbb")}↓`, pl("bbb"), pl("NEW")].join("\n"); expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc"); }); it("surfaces a warning when pure-insert duplicates are auto-dropped", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`»${tag(2, "bbb")}`, pl("aaa"), pl("bbb"), pl("NEW")].join("\n"); + const diff = [`${tag(2, "bbb")}↓`, pl("aaa"), pl("bbb"), pl("NEW")].join("\n"); const result = applyHashlineEdits(source, parseHashline(diff), { autoDropPureInsertDuplicates: true }); expect(result.lines).toBe("aaa\nbbb\nNEW\nccc"); expect(result.warnings).toBeDefined(); @@ -310,7 +336,7 @@ describe("hashline parser — block op syntax", () => { it("preserves payload text exactly", () => { const diff = [ - `≔${sameLineRange(tag(2, "bbb"))}`, + `${sameLineRange(tag(2, "bbb"))}→`, pl(""), pl("# not a header"), pl("+ not an op"), @@ -322,116 +348,96 @@ describe("hashline parser — block op syntax", () => { it("treats blank lines inside a payload run as empty payload lines", () => { // Truly blank lines inside an active payload run are verbatim empty // payload lines as long as more payload follows. - const diff = [`≔${sameLineRange(tag(2, "bbb"))}`, pl("first"), "", "", pl("after")].join("\n"); + const diff = [`${sameLineRange(tag(2, "bbb"))}→`, pl("first"), "", "", pl("after")].join("\n"); expect(applyDiff(content, diff)).toBe("aaa\nfirst\n\n\nafter\nccc"); }); it("treats blank lines before the next op as payload", () => { const diff = [ - `≔${sameLineRange(tag(1, "aaa"))}`, + `${sameLineRange(tag(1, "aaa"))}→`, pl("AAA"), "", "", - `≔${sameLineRange(tag(3, "ccc"))}`, + `${sameLineRange(tag(3, "ccc"))}→`, pl("CCC"), ].join("\n"); expect(applyDiff(content, diff)).toBe("AAA\n\n\nbbb\nCCC"); }); it("rejects missing payloads and orphan payload lines", () => { - expect(() => parseHashline(`»${tag(1, "aaa")}`)).toThrow(/require at least one/); + expect(() => parseHashline(`${tag(1, "aaa")}↓`)).toThrow(/require at least one/); expect(() => parseHashline(pl("orphan"))).toThrow(/payload line has no preceding/); }); - it("leniently treats a bare blank line after « / » as an empty payload", () => { + it("leniently treats a bare blank line after ↑ / ↓ as an empty payload", () => { const hash = computeLineHash(5, "aaa"); const anchor = { line: 5, hash }; - expect(parseHashline(`«${tag(5, "aaa")}\n\n`)).toEqual([ + expect(parseHashline(`${tag(5, "aaa")}↑\n\n`)).toEqual([ { kind: "insert", cursor: { kind: "before_anchor", anchor }, text: "", lineNum: 1, index: 0 }, ]); - expect(parseHashline(`»${tag(5, "aaa")}\n\n`)).toEqual([ + expect(parseHashline(`${tag(5, "aaa")}↓\n\n`)).toEqual([ { kind: "insert", cursor: { kind: "after_anchor", anchor }, text: "", lineNum: 1, index: 0 }, ]); }); - it("rejects old cursor and equals-inline syntax after cutover", () => { - expect(() => parseHashline(`@${tag(1, "aaa")}\n+old`)).toThrow(/unrecognized op/); - expect(() => parseHashline(`${tag(1, "aaa")}=AAA`)).toThrow(/payload line has no preceding/); + it("rejects op sigils written in prefix position (legacy syntax)", () => { + expect(() => parseHashline(`↑${tag(1, "aaa")}\nold`)).toThrow(/unrecognized op/); + expect(() => parseHashline(`↓${tag(1, "aaa")}\nold`)).toThrow(/unrecognized op/); + expect(() => parseHashline(`→${tag(1, "aaa")}\nold`)).toThrow(/unrecognized op/); }); - it("rejects the retired delete op", () => { - expect(() => parseHashline(`≔-${sameLineRange(tag(2, "bbb"))}`)).toThrow(/unrecognized op/); + it("rejects ranges with `..` separator (legacy syntax)", () => { + // `..` is no longer the range separator; the line is treated as orphan + // payload because `2yy..3yy→` does not match the new range pattern. + expect(() => parseHashline(`${tag(2, "bbb")}..${tag(3, "ccc")}→\nBBB`)).toThrow(/payload line has no preceding/); }); - it("describes current sigils for unknown op syntax", () => { - expect(() => parseHashline(`-${sameLineRange(tag(2, "bbb"))}`)).toThrow(/Use «ANCHOR.*»ANCHOR.*≔A\.\.B/); + it("describes the new sigil shape on unknown-op lines", () => { + expect(() => parseHashline(`-${sameLineRange(tag(2, "bbb"))}`)).toThrow(/Use ANCHOR↑.*ANCHOR↓.*A-B→/); }); it("leniently tolerates a trailing `|TEXT` body on anchors copied verbatim from read output", () => { const anchor = tag(2, "bbb"); - // Bare trailing `|`, full `|TEXT` body, and both sides of a range. - expect(applyDiff(content, [`≔${anchor}|`, pl("BBB")].join("\n"))).toBe("aaa\nBBB\nccc"); - expect(applyDiff(content, [`≔${anchor}|bbb`, pl("BBB")].join("\n"))).toBe("aaa\nBBB\nccc"); - expect(applyDiff(content, [`«${anchor}|bbb`, pl("X")].join("\n"))).toBe("aaa\nX\nbbb\nccc"); - expect(applyDiff(content, [`»${anchor}|bbb`, pl("X")].join("\n"))).toBe("aaa\nbbb\nX\nccc"); - expect(applyDiff(content, `≔${anchor}|bbb..${tag(3, "ccc")}|ccc`)).toBe("aaa"); + // Bare trailing `|`, full `|TEXT` body, and trailing decoration after a range. + expect(applyDiff(content, [`${anchor}|→`, pl("BBB")].join("\n"))).toBe("aaa\nBBB\nccc"); + expect(applyDiff(content, [`${anchor}|bbb→`, pl("BBB")].join("\n"))).toBe("aaa\nBBB\nccc"); + expect(applyDiff(content, [`${anchor}|bbb↑`, pl("X")].join("\n"))).toBe("aaa\nX\nbbb\nccc"); + expect(applyDiff(content, [`${anchor}|bbb↓`, pl("X")].join("\n"))).toBe("aaa\nbbb\nX\nccc"); + // Trailing `|TEXT` after the full range is also tolerated. + expect(applyDiff(content, `${anchor}-${tag(3, "ccc")}|ccc→`)).toBe("aaa"); }); it("leniently strips `*`/`>` line-marker decoration from anchors", () => { const anchor = tag(2, "bbb"); - expect(applyDiff(content, [`≔*${anchor}`, pl("BBB")].join("\n"))).toBe("aaa\nBBB\nccc"); - expect(applyDiff(content, [`«>${anchor}`, pl("X")].join("\n"))).toBe("aaa\nX\nbbb\nccc"); + expect(applyDiff(content, [`*${anchor}→`, pl("BBB")].join("\n"))).toBe("aaa\nBBB\nccc"); + expect(applyDiff(content, [`>${anchor}↑`, pl("X")].join("\n"))).toBe("aaa\nX\nbbb\nccc"); }); - it("treats a non-matching `|TEXT` body on »/« as an inline payload line (single-line insert)", () => { - // content is "aaa\nbbb\nccc"; "NEW" does not match line 2 ("bbb"), so the - // body becomes the only payload line — no following payload required. + it("anchor paste decoration `|TEXT` before the op is cosmetic; real payload comes after", () => { + // `|bbb` between the anchor and the op is just paste decoration and is + // discarded. Payload must come inline after the op or on the next lines. const anchor = tag(2, "bbb"); - expect(applyDiff(content, `»${anchor}|NEW`)).toBe("aaa\nbbb\nNEW\nccc"); - expect(applyDiff(content, `«${anchor}|NEW`)).toBe("aaa\nNEW\nbbb\nccc"); - }); - - it("accepts inline `|TEXT` payload containing whitespace on »/«", () => { - // Regression: the prior regex required `\S+` after the op sigil, so an - // inline payload like `»2bx|\tconst foo = bar` failed with "unrecognized - // op" the moment the body contained any whitespace. - const anchor = tag(2, "bbb"); - const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;"; - expect(applyDiff(content, `»${anchor}|${payload}`)).toBe(`aaa\nbbb\n${payload}\nccc`); - expect(applyDiff(content, `«${anchor}|${payload}`)).toBe(`aaa\n${payload}\nbbb\nccc`); - }); - - it("prepends a non-matching inline `|TEXT` body before subsequent payload lines", () => { - const anchor = tag(2, "bbb"); - const diff = [`»${anchor}|first inline`, pl("second from next line")].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\nbbb\nfirst inline\nsecond from next line\nccc"); - }); - - it("discards an inline `|TEXT` body that matches the anchored line content", () => { - // When `|TEXT` reproduces line 2 verbatim it's just visual decoration — - // require a real payload from the following lines. - const anchor = tag(2, "bbb"); - const diff = [`»${anchor}|bbb`, pl("X"), pl("Y")].join("\n"); + const diff = [`${anchor}|bbb↓`, pl("X"), pl("Y")].join("\n"); expect(applyDiff(content, diff)).toBe("aaa\nbbb\nX\nY\nccc"); - // Matching body with no follow-up payload is still an error. - expect(() => parseHashline(`»${anchor}|bbb`)).toThrow(/require at least one/); + // No inline payload after the op and no follow-up: error. + expect(() => parseHashline(`${anchor}|bbb↓`)).toThrow(/require at least one/); }); - it("treats `|TEXT` after BOF/EOF as a payload line (no anchor hash to compare)", () => { - expect(applyDiff(content, `»BOF|HEAD`)).toBe("HEAD\naaa\nbbb\nccc"); - expect(applyDiff(content, `»EOF|TAIL`)).toBe("aaa\nbbb\nccc\nTAIL"); + it("treats `|TEXT` after BOF/EOF as cosmetic decoration", () => { + expect(applyDiff(content, `BOF|head↓HEAD`)).toBe("HEAD\naaa\nbbb\nccc"); + expect(applyDiff(content, `EOF|tail↓TAIL`)).toBe("aaa\nbbb\nccc\nTAIL"); }); }); describe("hashline — stale anchors", () => { it("throws HashlineMismatchError when a Lid hash no longer matches", () => { - const diff = [`≔${sameLineRange(mistag(2, "bbb"))}`, pl("BBB")].join("\n"); + const diff = [`${sameLineRange(mistag(2, "bbb"))}→`, pl("BBB")].join("\n"); expect(() => applyDiff("aaa\nbbb\nccc", diff)).toThrow(HashlineMismatchError); }); it("rejects when an anchor's stored line shifted (no auto-rebase)", () => { const stale = tag(2, "bbb"); - const diff = [`≔${sameLineRange(stale)}`, pl("BBB")].join("\n"); + const diff = [`${sameLineRange(stale)}→`, pl("BBB")].join("\n"); expect(() => applyDiff("aaa\nINSERTED\nbbb\nccc", diff)).toThrow(HashlineMismatchError); }); @@ -443,72 +449,72 @@ describe("hashline — stale anchors", () => { const collidingHash = computeLineHash(1, "x = 1"); // User points at line 4 (`z = 3`) with the colliding hash; without auto- // rebase, this is a plain mismatch. - const diff = [`≔${sameLineRange(`4${collidingHash}`)}`, pl("REPLACED")].join("\n"); + const diff = [`${sameLineRange(`4${collidingHash}`)}→`, pl("REPLACED")].join("\n"); expect(() => applyDiff(file, diff)).toThrow(HashlineMismatchError); }); }); -describe("splitHashlineInput — § headers", () => { - it("extracts path and diff body from §path header", () => { - const input = [`§src/foo.ts`, `≔${sameLineRange(tag(2, "bbb"))}`, pl("BBB")].join("\n"); +describe("splitHashlineInput — ¶ headers", () => { + it("extracts path and diff body from ¶path header", () => { + const input = [`¶src/foo.ts`, `${sameLineRange(tag(2, "bbb"))}→`, pl("BBB")].join("\n"); expect(splitHashlineInput(input)).toEqual({ path: "src/foo.ts", - diff: `≔${sameLineRange(tag(2, "bbb"))}\n${pl("BBB")}`, + diff: `${sameLineRange(tag(2, "bbb"))}→\n${pl("BBB")}`, }); }); it("strips leading blank lines and unquotes matching path quotes", () => { - expect(splitHashlineInput(`\n§"foo bar.ts"\n»BOF\n${pl("x")}`)).toEqual({ + expect(splitHashlineInput(`\n¶"foo bar.ts"\nBOF↓\n${pl("x")}`)).toEqual({ path: "foo bar.ts", - diff: `»BOF\n${pl("x")}`, + diff: `BOF↓\n${pl("x")}`, }); }); it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => { const cwd = process.cwd(); const absolute = path.join(cwd, "src", "foo.ts"); - expect(splitHashlineInput(`§${absolute}\n»BOF\n${pl("x")}`, { cwd }).path).toBe("src/foo.ts"); + expect(splitHashlineInput(`¶${absolute}\nBOF↓\n${pl("x")}`, { cwd }).path).toBe("src/foo.ts"); }); it("uses explicit fallback path only when input has recognizable operations", () => { - expect(splitHashlineInput(`»BOF\n${pl("x")}`, { path: "a.ts" })).toEqual({ + expect(splitHashlineInput(`BOF↓\n${pl("x")}`, { path: "a.ts" })).toEqual({ path: "a.ts", - diff: `»BOF\n${pl("x")}`, + diff: `BOF↓\n${pl("x")}`, }); expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/); }); it("splits multiple edit sections", () => { - const input = ["§a.ts", "»BOF", pl("a"), "§b.ts", "»EOF", pl("b")].join("\n"); + const input = ["¶a.ts", "BOF↓", pl("a"), "¶b.ts", "EOF↓", pl("b")].join("\n"); expect(splitHashlineInputs(input)).toEqual([ - { path: "a.ts", diff: `»BOF\n${pl("a")}` }, - { path: "b.ts", diff: `»EOF\n${pl("b")}` }, + { path: "a.ts", diff: `BOF↓\n${pl("a")}` }, + { path: "b.ts", diff: `EOF↓\n${pl("b")}` }, ]); }); - it("tolerates extra § chars on the section header", () => { - const input = ["§§a.ts", "»BOF", pl("a"), "§§§b.ts", "»EOF", pl("b")].join("\n"); + it("tolerates extra ¶ chars on the section header", () => { + const input = ["¶¶a.ts", "BOF↓", pl("a"), "¶¶¶b.ts", "EOF↓", pl("b")].join("\n"); expect(splitHashlineInputs(input)).toEqual([ - { path: "a.ts", diff: `»BOF\n${pl("a")}` }, - { path: "b.ts", diff: `»EOF\n${pl("b")}` }, + { path: "a.ts", diff: `BOF↓\n${pl("a")}` }, + { path: "b.ts", diff: `EOF↓\n${pl("b")}` }, ]); }); it("silently drops a duplicate header with no operations between them", () => { - const input = ["§§src/foo.ts", "§§src/foo.ts", `»BOF`, pl("x")].join("\n"); - expect(splitHashlineInputs(input)).toEqual([{ path: "src/foo.ts", diff: `»BOF\n${pl("x")}` }]); + const input = ["¶¶src/foo.ts", "¶¶src/foo.ts", `BOF↓`, pl("x")].join("\n"); + expect(splitHashlineInputs(input)).toEqual([{ path: "src/foo.ts", diff: `BOF↓\n${pl("x")}` }]); }); it("silently drops a trailing header with no operations", () => { - const input = ["§§a.ts", "»BOF", pl("a"), "§§b.ts"].join("\n"); - expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `»BOF\n${pl("a")}` }]); + const input = ["¶¶a.ts", "BOF↓", pl("a"), "¶¶b.ts"].join("\n"); + expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF↓\n${pl("a")}` }]); }); }); describe("hashline executor", () => { it("creates a missing file with a file-scoped insert", async () => { await withTempDir(async tempDir => { - const input = `§new.ts\n»BOF\n${pl("export const x = 1;")}\n`; + const input = `¶new.ts\nBOF↓\n${pl("export const x = 1;")}\n`; const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input)); expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain("new.ts:"); expect(await Bun.file(path.join(tempDir, "new.ts")).text()).toBe("export const x = 1;"); @@ -519,7 +525,7 @@ describe("hashline executor", () => { await withTempDir(async tempDir => { const filePath = path.join(tempDir, "a.ts"); const source = ["aaa", "bbb", "ccc"].join("\n"); - const input = `§a.ts\n»${tag(2, "bbb")}\n${pl("aaa")}\n${pl("bbb")}\n${pl("NEW")}\n`; + const input = `¶a.ts\n${tag(2, "bbb")}↓\n${pl("aaa")}\n${pl("bbb")}\n${pl("NEW")}\n`; await Bun.write(filePath, source); await executeHashlineSingle(hashlineExecuteOptions(tempDir, input)); @@ -540,11 +546,11 @@ describe("hashline executor", () => { await Bun.write(aPath, "aaa\n"); await Bun.write(bPath, "bbb\n"); const input = [ - "§a.ts", - `≔${sameLineRange(tag(1, "aaa"))}`, + "¶a.ts", + `${sameLineRange(tag(1, "aaa"))}→`, pl("AAA"), - "§b.ts", - `≔${sameLineRange(mistag(1, "bbb"))}`, + "¶b.ts", + `${sameLineRange(mistag(1, "bbb"))}→`, pl("BBB"), ].join("\n"); @@ -568,8 +574,8 @@ describe("hashline executor", () => { // A naive sequential apply reads the modified disk and fails anchor // validation outright. const input = [ - "§a.ts", - `≔${sameLineRange(tag(2, "L2"))}`, + "¶a.ts", + `${sameLineRange(tag(2, "L2"))}→`, pl("L2a"), pl("L2b"), pl("L2c"), @@ -579,8 +585,8 @@ describe("hashline executor", () => { pl("L2g"), pl("L2h"), pl("L2i"), - "§a.ts", - `»${tag(8, "L8")}`, + "¶a.ts", + `${tag(8, "L8")}↓`, pl("INSERTED"), ].join("\n"); @@ -616,7 +622,7 @@ describe("hashline executor", () => { describe("hashlineEditParamsSchema — extra-field tolerance", () => { it("accepts extra `path` field alongside `input`", () => { - expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `§x.ts\n»BOF\n${pl("x")}` }).success).toBe(true); + expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF↓\n${pl("x")}` }).success).toBe(true); }); it("still requires `input`", () => { @@ -685,7 +691,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { await Bun.write(filePath, `${v1Lines.join("\n")}\n`); // Model authors anchor against V0 — line 2 is "L2" in V0. - const input = `§a.ts\n≔${sameLineRange(tag(2, "L2"))}\n${pl("L2-MODEL")}\n`; + const input = `¶a.ts\n${sameLineRange(tag(2, "L2"))}→\n${pl("L2-MODEL")}\n`; const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session)); const finalLines = (await Bun.file(filePath).text()).replace(/\n$/, "").split("\n"); @@ -716,7 +722,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { v1Lines[5] = "L6-CHANGED"; await Bun.write(filePath, `${v1Lines.join("\n")}\n`); - const input = `§a.ts\n≔${sameLineRange(tag(6, "L6"))}\n${pl("L6-MODEL")}\n`; + const input = `¶a.ts\n${sameLineRange(tag(6, "L6"))}→\n${pl("L6-MODEL")}\n`; await expect( executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session)), ).rejects.toThrow(HashlineMismatchError); @@ -733,7 +739,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { // Live file is completely different — patch context cannot match even // with fuzz tolerance. const currentText = "totally\nunrelated\ncontent\nhere\nnow\n"; - const edits = parseHashline(`≔${sameLineRange(tag(2, "beta"))}\n${pl("BETA-MODEL")}`); + const edits = parseHashline(`${sameLineRange(tag(2, "beta"))}→\n${pl("BETA-MODEL")}`); const recovered = tryRecoverHashlineWithCache({ cache, @@ -766,7 +772,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { // First edit: change line 2 → BETA. After the write, the cache should // reflect V1 (post-edit), not V0. - const firstInput = `§a.ts\n≔${sameLineRange(tag(2, "beta"))}\n${pl("BETA")}\n`; + const firstInput = `¶a.ts\n${sameLineRange(tag(2, "beta"))}→\n${pl("BETA")}\n`; await executeHashlineSingle(hashlineExecuteOptions(tempDir, firstInput, undefined, session)); const v1Lines = ["alpha", "BETA", "gamma", "delta", "epsilon"]; expect(await Bun.file(filePath).text()).toBe(`${v1Lines.join("\n")}\n`); @@ -782,7 +788,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { const v2Lines = ["H1", "H2", "H3", "H4", "H5", "H6", "H7", ...v1Lines]; await Bun.write(filePath, `${v2Lines.join("\n")}\n`); - const secondInput = `§a.ts\n≔${sameLineRange(tag(3, "gamma"))}\n${pl("GAMMA")}\n`; + const secondInput = `¶a.ts\n${sameLineRange(tag(3, "gamma"))}→\n${pl("GAMMA")}\n`; const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, secondInput, undefined, session)); const finalLines = (await Bun.file(filePath).text()).replace(/\n$/, "").split("\n"); @@ -833,7 +839,7 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => const sentinel = "*** Abort"; it("parser breaks at *** Abort and surfaces a warning", () => { - const diff = [`»${tag(1, "alpha")}`, pl("HELLO"), sentinel, `»${tag(99, "junk")}`, pl("never")].join("\n"); + const diff = [`${tag(1, "alpha")}↓`, pl("HELLO"), sentinel, `${tag(99, "junk")}↓`, pl("never")].join("\n"); const { edits, warnings } = parseHashlineWithWarnings(diff); expect(edits).toHaveLength(1); expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" }); @@ -843,7 +849,7 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => it("appended sentinel from harmony-leak truncation: ops above are preserved", () => { // Mirrors the exact shape harmony-leak emits inside a single section. - const diff = `»${tag(1, "alpha")}\n${pl("KEPT")}\n*** Abort\n`; + const diff = `${tag(1, "alpha")}↓\n${pl("KEPT")}\n*** Abort\n`; const { edits, warnings } = parseHashlineWithWarnings(diff); expect(edits).toHaveLength(1); expect(edits[0]).toMatchObject({ text: "KEPT" }); @@ -852,12 +858,12 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => it("splitter respects *** Abort like *** End Patch", () => { const input = [ - `§a.ts`, - `»${tag(1, "alpha")}`, + `¶a.ts`, + `${tag(1, "alpha")}↓`, pl("a-payload"), sentinel, - `§b.ts`, - `»${tag(1, "beta")}`, + `¶b.ts`, + `${tag(1, "beta")}↓`, pl("never-emitted"), ].join("\n"); const sections = splitHashlineInputs(input); @@ -867,7 +873,7 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => }); it("clean input without sentinel produces no warning", () => { - const diff = `»${tag(1, "alpha")}\n${pl("PAYLOAD")}\n`; + const diff = `${tag(1, "alpha")}↓\n${pl("PAYLOAD")}\n`; const { warnings } = parseHashlineWithWarnings(diff); expect(warnings).toEqual([]); }); diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index 872a9934e..d80ee724c 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -236,10 +236,10 @@ describe("computeHashlineDiff", () => { const line = "unchanged content"; await Bun.write(sourcePath, `${line}\n`); - // `≔1` with the same line as payload is a true no-op: the edit + // `1→` with the same line as payload is a true no-op: the edit // fires through computeHashlineDiff but produces identical content. const anchor = formatLineHash(1, line); - const input = `§${sourcePath}\n≔${anchor}\n${line}\n`; + const input = `¶${sourcePath}\n${anchor}→\n${line}\n`; const result = await computeHashlineDiff({ input }, tempDir); expect("error" in result).toBe(true); if ("error" in result) { @@ -251,14 +251,14 @@ describe("computeHashlineDiff", () => { const sourcePath = path.join(tempDir, "source.txt"); await Bun.write(sourcePath, "first\n"); - const result = await computeHashlineDiff({ input: `§${sourcePath}\n»EOF\nsecond` }, tempDir); + const result = await computeHashlineDiff({ input: `¶${sourcePath}\nEOF↓\nsecond` }, tempDir); expect("diff" in result).toBe(true); if ("diff" in result) { expect(result.diff).toContain("second"); } }); test("returns a handled error when the source path is a local URL", async () => { - const result = await computeHashlineDiff({ input: "§local://PLAN.md\n»EOF\n" }, tempDir); + const result = await computeHashlineDiff({ input: "¶local://PLAN.md\nEOF↓\n" }, tempDir); expect("error" in result).toBe(true); if ("error" in result) { diff --git a/packages/coding-agent/test/edit-streaming-preview.test.ts b/packages/coding-agent/test/edit-streaming-preview.test.ts index 49da0503a..b2d210f52 100644 --- a/packages/coding-agent/test/edit-streaming-preview.test.ts +++ b/packages/coding-agent/test/edit-streaming-preview.test.ts @@ -54,7 +54,7 @@ describe("hashline streaming preview (multi-section)", () => { const ctx = (cwd: string) => ({ cwd, signal: new AbortController().signal }); test("keeps section A's preview when section B's header just arrived", async () => { - const input = ["§a.ts", "»BOF", "// new", "§b.ts"].join("\n"); + const input = ["¶a.ts", "BOF↓", "// new", "¶b.ts"].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).not.toBeNull(); expect(previews).toHaveLength(1); @@ -64,8 +64,8 @@ describe("hashline streaming preview (multi-section)", () => { }); test("ignores parse errors from the trailing in-progress section", async () => { - // `»7` is a malformed anchor — the trailing section is still being typed. - const input = ["§a.ts", "»BOF", "// new", "§b.ts", "»7"].join("\n"); + // `7↓` is a malformed anchor — the trailing section is still being typed. + const input = ["¶a.ts", "BOF↓", "// new", "¶b.ts", "7↓"].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).not.toBeNull(); expect(previews).toHaveLength(1); @@ -74,7 +74,7 @@ describe("hashline streaming preview (multi-section)", () => { }); test("renders both sections once each has at least one valid op", async () => { - const input = ["§a.ts", "»BOF", "// new a", "§b.ts", "»BOF", "// new b"].join("\n"); + const input = ["¶a.ts", "BOF↓", "// new a", "¶b.ts", "BOF↓", "// 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"]); diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index 453d9b10f..3232122cf 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -38,7 +38,7 @@ describe("editToolRenderer", () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderCall( { - input: "§packages/coding-agent/src/edit/renderer.ts\n»EOF\n// preview", + input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF↓\n// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -56,7 +56,7 @@ describe("editToolRenderer", () => { const component = new ToolExecutionComponent( "edit", { - input: ["*** Begin Patch", "§crates/pi-natives/src/shell.rs", "»EOF", "pub fn streaming_preview() {"].join( + input: ["*** Begin Patch", "¶crates/pi-natives/src/shell.rs", "EOF↓", "pub fn streaming_preview() {"].join( "\n", ), }, @@ -67,7 +67,7 @@ describe("editToolRenderer", () => { const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("crates/pi-natives/src/shell.rs"); - expect(rendered).toContain("»EOF"); + expect(rendered).toContain("EOF↓"); expect(rendered).toContain("pub fn streaming_preview() {"); expect(rendered).not.toContain("*** Begin Patch"); }); @@ -76,7 +76,7 @@ describe("editToolRenderer", () => { const uiTheme = await getUiTheme(); const compactComponent = editToolRenderer.renderCall( { - input: "§foo bar.ts\n»BOF\n// preview", + input: "¶foo bar.ts\nBOF↓\n// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -84,7 +84,7 @@ describe("editToolRenderer", () => { const quotedComponent = editToolRenderer.renderCall( { - input: "§'baz qux.ts'\n»BOF\n// preview", + input: "¶'baz qux.ts'\nBOF↓\n// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -96,14 +96,14 @@ describe("editToolRenderer", () => { expect(quotedRendered).toContain("baz qux.ts"); }); - it("strips canonical `§` and longer `§` runs from hashline input headers", async () => { + it("strips canonical `¶` and longer `¶` runs from hashline input headers", async () => { const uiTheme = await getUiTheme(); - // Canonical `§PATH` form — the parser strips the marker and the + // Canonical `¶PATH` form — the parser strips the marker and the // renderer keeps the title clean. const canonical = editToolRenderer.renderCall( { - input: "§packages/coding-agent/src/slash-commands/builtin-registry.ts\n»BOF\n// preview", + input: "¶packages/coding-agent/src/slash-commands/builtin-registry.ts\nBOF↓\n// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -111,7 +111,7 @@ describe("editToolRenderer", () => { // Even longer runs should still produce the clean path. const triple = editToolRenderer.renderCall( - { input: "§§§a/b/c.ts\n»BOF\n// preview" }, + { input: "¶¶¶a/b/c.ts\nBOF↓\n// preview" }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); @@ -120,9 +120,9 @@ describe("editToolRenderer", () => { const tripleRendered = Bun.stripANSI(triple.render(160).join("\n")); expect(canonicalRendered).toContain("packages/coding-agent/src/slash-commands/builtin-registry.ts"); - expect(canonicalRendered).not.toMatch(/§packages\/coding-agent/); + expect(canonicalRendered).not.toMatch(/¶packages\/coding-agent/); expect(tripleRendered).toContain("a/b/c.ts"); - expect(tripleRendered).not.toMatch(/§+a\/b\/c\.ts/); + expect(tripleRendered).not.toMatch(/¶+a\/b\/c\.ts/); }); it("uses hashline input headers for completed single-file result path", async () => { @@ -138,7 +138,7 @@ describe("editToolRenderer", () => { { expanded: false, isPartial: false, renderContext: { editMode: "hashline" } }, uiTheme, { - input: "§packages/coding-agent/src/edit/renderer.ts\n»EOF\n// preview", + input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF↓\n// preview", }, );