diff --git a/docs/tools/edit.md b/docs/tools/edit.md index 061e45e26..7e02ca064 100644 --- a/docs/tools/edit.md +++ b/docs/tools/edit.md @@ -56,6 +56,7 @@ The canonical grammar is strict, but the hand parser accepts a few non-dangerous - Missing trailing colon on `SWAP` or `INS` — accepted. - `SWAP N-M:`, `SWAP N…M:`, `SWAP N M:`, and legacy `SWAP N..M:` — accepted as `SWAP N.=M:`. - Bare body rows with no `+` prefix are auto-prepended with `+` and a `BARE_BODY_AUTO_PIPED_WARNING` is appended. +- Bare `-` body rows are judged once the whole hunk body is known: when every `-` row is Markdown-bullet-shaped (`- item`) and the body is either fully bare or contains an explicit `+- item` sibling, the rows are kept as literal content and `MINUS_BULLET_AUTO_PIPED_WARNING` is appended; otherwise they are rejected as unified-diff contamination (see Errors). - `*** 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. @@ -63,7 +64,7 @@ The canonical grammar is strict, but the hand parser accepts a few non-dangerous - 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 `INS` / `SWAP.BLK` hunks are rejected; an empty `SWAP N.=M:` (no body rows) is treated as `DEL N.=M`. -- `-` body rows are rejected with `MINUS_ROW_REJECTED`. +- `-` body rows are rejected with `MINUS_ROW_REJECTED` unless the hunk is unambiguously a Markdown bullet list (see Tolerated input shapes). - `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) rejects a `SWAP.BLK` / `DEL.BLK` on the apply/final-preview path (the streaming preview silently drops it instead). `INS.BLK.POST N:` is never rejected this way — it is lowered to plain `INS.POST N:` with a warning: a closing-delimiter-anchor warning when line N is a pure closer (inserting after that end is exactly what the plain form does), a generic unresolved-anchor warning otherwise. ## Outputs @@ -164,7 +165,7 @@ DEL 20 - `Missing hashline snapshot tag for ; use \`[#tag]\` from your latest read/search output. To create a new file, use the write tool.` - Stray payload line: - `line N: payload line has no preceding hunk header. Use \`SWAP N.=M:\`, \`DEL N.=M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got "...".` -- Minus row: +- Minus row (unless auto-piped as an unambiguous Markdown bullet — see Tolerated input shapes): - ``line N: `-` rows are not valid; the range already names the lines being changed. For Markdown bullets or other literal `-` lines, prefix the literal row with `+`: `+- item`.`` - Empty body-bearing hunk: - `line N: \`INS\` needs at least one \`+TEXT\` body row.` @@ -195,4 +196,5 @@ DEL 20 ## Warnings - `Auto-prefixed bare body row(s) with +. Body rows must be +TEXT literal lines …` (`BARE_BODY_AUTO_PIPED_WARNING`) +- `Auto-prefixed bare `- ` bullet row(s) as literal content …` (`MINUS_BULLET_AUTO_PIPED_WARNING`) - Recovery banners: `RECOVERY_EXTERNAL_WARNING`, `RECOVERY_SESSION_CHAIN_WARNING`, `RECOVERY_SESSION_REPLAY_WARNING` (`packages/hashline/src/messages.ts`). diff --git a/packages/ai/test/leaked-thinking-stream.test.ts b/packages/ai/test/leaked-thinking-stream.test.ts index bec61e43f..769066aa4 100644 --- a/packages/ai/test/leaked-thinking-stream.test.ts +++ b/packages/ai/test/leaked-thinking-stream.test.ts @@ -330,7 +330,11 @@ describe("wrapLeakedThinkingStream", () => { const withCall = msg({ content: [signedBlock, call] }); inner.push({ type: "toolcall_start", contentIndex: 1, partial: withCall }); inner.push({ type: "toolcall_end", contentIndex: 1, toolCall: call, partial: withCall }); - inner.push({ type: "done", reason: "toolUse", message: msg({ content: [signedBlock, call], stopReason: "toolUse" }) }); + inner.push({ + type: "done", + reason: "toolUse", + message: msg({ content: [signedBlock, call], stopReason: "toolUse" }), + }); }); expect(result.content.map(b => b.type)).toEqual(["thinking", "toolCall"]); @@ -350,9 +354,7 @@ describe("wrapLeakedThinkingStream", () => { inner.push({ type: "done", reason: "stop", message: msg({ content: [block] }) }); }); - expect(thinks(result).map(b => [b.thinking, b.thinkingSignature])).toEqual([ - ["recovered reasoning", signature], - ]); + expect(thinks(result).map(b => [b.thinking, b.thinkingSignature])).toEqual([["recovered reasoning", signature]]); }); it("preserves native tool-call ids and streamed partial JSON while healing", async () => { diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 89aff6898..0cea5bb02 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Bare `- ` bullet body rows are now auto-accepted as literal content with a warning when the hunk is unambiguously a Markdown bullet list (every `-` row bullet-shaped and no plain `+new` diff counterpart); ambiguous `-` rows still fail with the teaching error. + ## [17.0.8] - 2026-07-22 ### Changed diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index 458585143..7ee16c6ca 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -52,6 +52,14 @@ export const REPLACE_PAIR_COALESCED_WARNING = `Two hunks targeted the same range export const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines."; +/** + * Bare `-` body rows accepted as literal Markdown bullets. Only emitted when + * the hunk is unambiguously a bullet list: every `-` row is bullet-shaped + * (`- item`) and the body has no unified-diff `+new` counterpart rows. + */ +export const MINUS_BULLET_AUTO_PIPED_WARNING = + "Auto-prefixed bare `- ` bullet row(s) as literal content. `-` rows never remove lines — the range does that; always prefix literal body rows with `+`: `+- item`."; + /** Unified-diff-style `-` row in a hunk body. */ export const MINUS_ROW_REJECTED = "`-` rows are not valid; the range already names the lines being changed. For Markdown bullets or other literal `-` lines, prefix the literal row with `+`: `+- item`."; diff --git a/packages/hashline/src/parser.ts b/packages/hashline/src/parser.ts index 27ba9951d..59e9a6d5d 100644 --- a/packages/hashline/src/parser.ts +++ b/packages/hashline/src/parser.ts @@ -10,6 +10,7 @@ import { DELETE_TAKES_NO_BODY, EMPTY_BLOCK, EMPTY_INSERT, + MINUS_BULLET_AUTO_PIPED_WARNING, MINUS_ROW_REJECTED, MOVE_TAKES_NO_BODY, REM_TAKES_NO_BODY, @@ -43,6 +44,13 @@ function isSkippableCommentLine(line: string): boolean { */ const BARE_LITERAL_VALUE_RE = /^\s*(?:"[^"]*"|'[^']*'|[-+]?\d+(?:\.\d+)?)\s*,?\s*$/; +/** + * Markdown-bullet shape: optional indent, `-`, exactly one space, then + * content. Unified-diff `-` rows almost never match — code lines get the `-` + * glued on (`-old()`) and indented deletions carry multiple spaces (`- x`). + */ +const MD_BULLET_ROW_RE = /^\s*- \S/; + function detectApplyPatchContamination(text: string, _hasPending: boolean): string | null { const trimmed = text.trimStart(); if (trimmed.length === 0) return null; @@ -93,7 +101,7 @@ interface PendingComment { text: string; } -type PayloadRow = { kind: "literal"; text: string; lineNum: number; bare?: boolean }; +type PayloadRow = { kind: "literal"; text: string; lineNum: number; bare?: boolean; minus?: boolean }; interface Pending { target: BlockTarget; @@ -284,8 +292,12 @@ export class Executor { if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`); if (this.#pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`); - if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`); - if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING); + const row: PayloadRow = { kind: "literal", text, lineNum, bare: true }; + // `-` rows are held and judged at flush time by #resolveMinusRows, + // once the whole body is visible. + if (text.trimStart().charCodeAt(0) === 45 /* - */) row.minus = true; + else if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) + this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING); this.#commitDeferredBlanks(this.#pending); // Defer read-output line-number stripping to #flushPending: a bare // "N:text" row is only a copy-paste artifact from snapshot output @@ -294,7 +306,7 @@ export class Executor { // with "digits:" (YAML ports "42:hello", timestamps "12:30") when it // sits next to an unprefixed sibling. Rows with an explicit "+" go // through #handleLiteralPayload and are never bare, never stripped. - this.#pending.payloads.push({ kind: "literal", text, lineNum, bare: true }); + this.#pending.payloads.push(row); return; } if (text.trim().length === 0) return; @@ -326,6 +338,38 @@ export class Executor { pending.deferredBlanks = []; } + /** + * Judge bare `-` body rows once the whole hunk body is known. They are + * usually unified-diff contamination (`-old` next to `+new`) and inserting + * them would corrupt the file, so they are rejected — EXCEPT when the body + * is unambiguously a Markdown bullet list: every `-` row is bullet-shaped + * (`- item`) and the body is either fully bare or already contains an + * explicit `+- item` sibling. Those rows are kept as literal content with a + * warning instead of failing the patch. + */ + #resolveMinusRows(payloads: readonly PayloadRow[]): void { + let firstMinus: PayloadRow | undefined; + let allBulletShaped = true; + let hasExplicit = false; + let hasExplicitBullet = false; + for (const row of payloads) { + if (row.minus) { + firstMinus ??= row; + allBulletShaped &&= MD_BULLET_ROW_RE.test(row.text); + } else if (!row.bare) { + hasExplicit = true; + hasExplicitBullet ||= MD_BULLET_ROW_RE.test(row.text); + } + } + if (firstMinus === undefined) return; + if (allBulletShaped && (!hasExplicit || hasExplicitBullet)) { + if (!this.#warnings.includes(MINUS_BULLET_AUTO_PIPED_WARNING)) + this.#warnings.push(MINUS_BULLET_AUTO_PIPED_WARNING); + return; + } + throw new Error(`line ${firstMinus.lineNum}: ${MINUS_ROW_REJECTED}`); + } + /** * Strip a single read-output line-number prefix (`N:`) from every bare body * row, but only when *all* bare rows carry one. A uniform set of prefixes is @@ -388,6 +432,7 @@ export class Executor { const pending = this.#pending; if (!pending) return; const { target, lineNum, payloads } = pending; + this.#resolveMinusRows(payloads); this.#stripBarePrefixesIfUniform(payloads); this.#pending = undefined; if (target.kind === "delete") { diff --git a/packages/hashline/test/leniency.test.ts b/packages/hashline/test/leniency.test.ts index 70af46634..2138a2ce9 100644 --- a/packages/hashline/test/leniency.test.ts +++ b/packages/hashline/test/leniency.test.ts @@ -171,6 +171,25 @@ describe("hashline body contracts", () => { /Markdown bullets or other literal `-` lines.*`\+- item`/, ); }); + it("auto-pipes a fully bare Markdown bullet body with a warning", () => { + const result = parsePatch("SWAP 2.=2:\n- item\n - nested"); + expect(applyEdits(FILE, result.edits).text).toBe("a\n- item\n - nested\nc\nd\ne"); + expect(result.warnings.some(w => /bullet row/.test(w))).toBe(true); + }); + + it("auto-pipes a bare bullet row next to explicit `+- item` siblings", () => { + const result = parsePatch("SWAP 2.=2:\n+### Fixed\n+- one\n- two"); + expect(applyEdits(FILE, result.edits).text).toBe("a\n### Fixed\n- one\n- two\nc\nd\ne"); + expect(result.warnings.some(w => /bullet row/.test(w))).toBe(true); + }); + + it("still rejects non-bullet bare `-` rows even in a fully bare body", () => { + expect(() => parsePatch("SWAP 2.=2:\n-old()")).toThrow(/`-` rows are not valid/); + }); + + it("still rejects bullet-shaped `-` rows beside a plain `+new` row (diff paste)", () => { + expect(() => parsePatch("SWAP 2.=2:\n- x\n+new()")).toThrow(/`-` rows are not valid/); + }); it("allows literal Markdown bullets and plus-prefixed text when prefixed with `+`", () => { expect(applyPatch(FILE, "SWAP 2.=2:\n+- item\n+ - nested\n++plus")).toBe(