diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 77504aff8..809b4a983 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Recovered dangling range separators in hunk headers (`PUT 244.=:`, `CUT 5.=`) as single-line ranges (`N.=N`) instead of rejecting the header as an orphan payload line. + ## [17.3.0] - 2026-08-13 ### Fixed diff --git a/packages/hashline/src/tokenizer.ts b/packages/hashline/src/tokenizer.ts index 3f88de01b..31372148c 100644 --- a/packages/hashline/src/tokenizer.ts +++ b/packages/hashline/src/tokenizer.ts @@ -182,6 +182,37 @@ function scanRangeSeparator(line: string, index: number, end: number): number | return cursor; } +/** + * Recover a dangling range separator: the run after `N` contains at least one + * non-whitespace separator char but no end number (`244.=:`, `5-`, `12.. @reg`). + * Models write this intending an open range; it collapses to `N.=N`. Returns + * the index past the run only when what follows is `:`, `@`, or end-of-header — + * anything else keeps the header on the strict rejection path. + */ +function scanDanglingSeparator(line: string, index: number, end: number): number | null { + let cursor = index; + let sawSeparatorChar = false; + while (cursor < end) { + const code = line.charCodeAt(cursor); + if (code === CHAR_HYPHEN || code === CHAR_DOT || code === CHAR_EQUALS || code === CHAR_ELLIPSIS) { + sawSeparatorChar = true; + cursor++; + continue; + } + if (isWhitespaceCode(code)) { + cursor++; + continue; + } + break; + } + if (!sawSeparatorChar) return null; + if (cursor < end) { + const code = line.charCodeAt(cursor); + if (code !== CHAR_COLON && code !== CHAR_AT) return null; + } + return cursor; +} + function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allowSingle = false): RangeScan | null { const numberStart = skipWhitespace(line, index, end); const start = scanLineNumber(line, numberStart, end); @@ -189,6 +220,14 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo const afterFirst = scanRangeSeparator(line, start.nextIndex, end); if (afterFirst === null) { if (!allowSingle) return null; + const dangling = scanDanglingSeparator(line, start.nextIndex, end); + if (dangling !== null) { + return { + range: { start: { line: start.line }, end: { line: start.line } }, + nextIndex: dangling, + hadSeparator: true, + }; + } return { range: { start: { line: start.line }, end: { line: start.line } }, nextIndex: skipWhitespace(line, start.nextIndex, end), diff --git a/packages/hashline/test/leniency.test.ts b/packages/hashline/test/leniency.test.ts index a932a8c0e..0f57084ba 100644 --- a/packages/hashline/test/leniency.test.ts +++ b/packages/hashline/test/leniency.test.ts @@ -82,6 +82,16 @@ describe("hashline core — verb header forms", () => { expect(applyPatch(FILE, "CUT 2")).toBe("a\nc\nd\ne"); }); + it("recovers a dangling range separator as a single-line range", () => { + expect(applyPatch(FILE, "PUT 2.=:\n+X")).toBe("a\nX\nc\nd\ne"); + expect(applyPatch(FILE, "PUT 2-:\n+X")).toBe("a\nX\nc\nd\ne"); + expect(applyPatch(FILE, "CUT 2.=")).toBe("a\nc\nd\ne"); + }); + + it("still rejects a dangling separator followed by junk", () => { + expect(() => parsePatch("PUT 2.= junk:\n+X")).toThrow(); + }); + it("recovers top-level numbered snapshot rows as single-line replacements", () => { for (const separator of [":", "|"]) { const result = parsePatch(`2${separator}B\n4${separator}D`);