feat(hashline): enabled leniency rule for bare bullet minus rows
- Add leniency rule in parser to auto-accept bare `-` rows as literal content when hunks represent Markdown bullet lists. - Emit MINUS_BULLET_AUTO_PIPED_WARNING when bullet-shaped rows lack explicit plus prefixes. - Reject ambiguous or non-bullet minus rows to prevent unified-diff contamination.
This commit is contained in:
+4
-2
@@ -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 <path>; use \`[<path>#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`).
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.";
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user