From 7e42c281f8974494b9e79eae47fd72238608c674 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 6 Jun 2026 01:37:05 +0000 Subject: [PATCH] fix(hashline): rejected line suffixes after snapshot tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rejected headers such as ¶src/file.ts#1A2B:42 as malformed headers instead of treating the whole tail as a hashless path and failing later in body parsing. Added strict and apply_patch-recovery regression coverage for line-suffixed snapshot tags. --- packages/hashline/src/input.ts | 9 +++++---- packages/hashline/src/tokenizer.ts | 13 ++++++++----- packages/hashline/test/leniency.test.ts | 4 ++++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/hashline/src/input.ts b/packages/hashline/src/input.ts index 1a413b47b..a281093aa 100644 --- a/packages/hashline/src/input.ts +++ b/packages/hashline/src/input.ts @@ -73,10 +73,11 @@ function tryParseRecoveryHeader(line: string, cwd?: string): RawSection | null { } // Same anti-junk rule as the strict tokenizer: a `#XXXX` token followed - // by whitespace+content inside the path body is a malformed header (e.g. - // stale-tag copy-paste like `src/a.ts#1A2B copied from read`), not a - // path with an embedded hex fragment. - if (new RegExp(`#[0-9A-Fa-f]{${HL_FILE_HASH_LENGTH}}\\s`).test(pathText)) return null; + // by whitespace+content or a line suffix inside the path body is a + // malformed header (e.g. stale-tag copy-paste like + // `src/a.ts#1A2B copied from read` or `src/a.ts#1A2B:42`), not a path + // with an embedded hex fragment. + if (new RegExp(`#[0-9A-Fa-f]{${HL_FILE_HASH_LENGTH}}(?:\\s|:)`).test(pathText)) return null; const path = normalizeHashlinePath(pathText, cwd); if (path.length === 0) return null; diff --git a/packages/hashline/src/tokenizer.ts b/packages/hashline/src/tokenizer.ts index 0ba87905a..7458f6061 100644 --- a/packages/hashline/src/tokenizer.ts +++ b/packages/hashline/src/tokenizer.ts @@ -334,10 +334,11 @@ function tryParseHeader(line: string): { path: string; fileHash?: string } | nul } } - // Reject stale-tag copy-paste such as `¶src/a.ts#1A2B copied from read`: - // a `#XXXX` token followed by whitespace+more content in the path body - // is a malformed header, not a path-with-embedded-hash. Surface the - // focused diagnostic instead of silently mis-routing the edit. + // Reject stale-tag copy-paste such as `¶src/a.ts#1A2B copied from read` + // and line-suffixed tags such as `¶src/a.ts#1A2B:42`: a `#XXXX` + // token followed by tag-tail junk in the path body is a malformed header, + // not a path-with-embedded-hash. Surface the focused diagnostic instead + // of silently mis-routing the edit. for (let i = FILE_PREFIX_LENGTH; i + HL_FILE_HASH_LENGTH < pathEnd; i++) { if (line.charCodeAt(i) !== CHAR_HASH) continue; let allHex = true; @@ -349,7 +350,9 @@ function tryParseHeader(line: string): { path: string; fileHash?: string } | nul } if (!allHex) continue; const after = i + HL_FILE_HASH_LENGTH + 1; - if (after < pathEnd && isWhitespaceCode(line.charCodeAt(after))) return null; + if (after < pathEnd && (isWhitespaceCode(line.charCodeAt(after)) || line.charCodeAt(after) === CHAR_COLON)) { + return null; + } } if (pathEnd === FILE_PREFIX_LENGTH) return null; diff --git a/packages/hashline/test/leniency.test.ts b/packages/hashline/test/leniency.test.ts index cab0600f0..b74f68618 100644 --- a/packages/hashline/test/leniency.test.ts +++ b/packages/hashline/test/leniency.test.ts @@ -28,12 +28,16 @@ describe("hashline section headers", () => { expect(() => Patch.parse("¶src/a.ts#1A2B copied from read\nreplace 1..1:\n+after")).toThrow( /Input header must be/, ); + expect(() => Patch.parse("¶src/a.ts#1A2B:812\nreplace 1..1:\n+after")).toThrow(/Input header must be/); }); it("rejects trailing junk after a snapshot tag even with apply_patch noise", () => { expect(() => Patch.parse("¶Update File: src/a.ts#1A2B copied from read\nreplace 1..1:\n+after")).toThrow( /Input header must be/, ); + expect(() => Patch.parse("¶Update File: src/a.ts#1A2B:812\nreplace 1..1:\n+after")).toThrow( + /Input header must be/, + ); }); });