fix(hashline): rejected line suffixes after snapshot tags

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.
This commit is contained in:
roboomp
2026-06-06 01:37:05 +00:00
parent 52f86e9445
commit 7e42c281f8
3 changed files with 17 additions and 9 deletions
+5 -4
View File
@@ -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;
+8 -5
View File
@@ -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;
+4
View File
@@ -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/,
);
});
});