diff --git a/src/ingest/dates.ts b/src/ingest/dates.ts index d98b207..0a7e4e0 100644 --- a/src/ingest/dates.ts +++ b/src/ingest/dates.ts @@ -40,8 +40,19 @@ function iso( return null; } const date = new Date(Date.UTC(y, m - 1, d, hh, mm, ss, 0)); - // Rejects impossible calendar dates such as February 30. - if (date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null; + // Rejects impossible calendar dates such as February 30 — and a year that did + // not survive the round trip. `Date.UTC` maps 0–99 into the 1900s, so a + // four-digit "0050" becomes 1950 with the month and day intact, which the two + // checks beside this one cannot see. Reading the year back is the same + // skip-rather-than-guess rule the rest of this module runs on, applied to the + // one field that was taken on trust. + if ( + date.getUTCFullYear() !== y || + date.getUTCMonth() !== m - 1 || + date.getUTCDate() !== d + ) { + return null; + } return date.toISOString(); } diff --git a/test/dates.test.ts b/test/dates.test.ts index 577a54d..e5b7c18 100644 --- a/test/dates.test.ts +++ b/test/dates.test.ts @@ -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 0–99 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", () => {