fix: reject a year Date.UTC would silently move

`iso()` read back the month and the day to catch February 30 rolling over to
March 2, and took the year on trust. `Date.UTC` maps years 0–99 into the 1900s,
so a four-digit "0050" came back as 1950 with the month and day intact — past
both existing checks, because nothing about the resulting date is impossible.

Same class of failure as the rollover the guard was written for: a boundary the
source never stated, published as though it had. Reading the year back too is
the skip-rather-than-guess rule this module runs on, applied to the one field
that was not checked. It narrows only — `parseShortSlashRange`'s two-digit pivot
produces full years and is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 06:40:58 +02:00
co-authored by Claude Opus 5
parent 0b5aa12173
commit 23ad2ad797
2 changed files with 27 additions and 2 deletions
+14
View File
@@ -43,6 +43,20 @@ describe("parseMonthDayYear", () => {
test("rejects an unknown month name", () => {
expect(parseMonthDayYear("Smarch 3, 2026")).toBeNull();
});
test("rejects a year that Date.UTC would silently move", () => {
// `Date.UTC` maps years 099 into the 1900s, so "0050" came back as 1950
// with the month and day intact — which the impossible-date guard cannot
// see, because nothing about the date is impossible. It is the same class of
// failure as February 30 rolling over to March 2: a boundary the source
// never stated, published as though it had.
expect(parseMonthDayYear("August 12, 0050")).toBeNull();
expect(parseMonthDayYear("August 12, 0099")).toBeNull();
// And a real four-digit year is untouched.
expect(parseMonthDayYear("August 12, 2026")?.iso).toBe(
"2026-08-12T00:00:00.000Z",
);
});
});
describe("parseMonthDayRange", () => {