fix(recurring): reject calendar-impossible startDate, not just wrongly-shaped (C1 residual)

requireStartDateGranularity (store/actions.ts) and its import-path twin
(export/json.ts) checked date SHAPE with a regex, not date VALIDITY, so
"2026-09-31" (September has 30 days) passed both and threw later out of
foldBalances instead of at the write boundary. isValidStartDate
(RecurringSection.tsx) had the same hole for a day-shaped value under
everyNMonths. Separately, monthOf's regex (domain/months.ts) was
unanchored at the end, so "2026-091" was silently read as September 2026.

Route day-shaped values through toDayOrdinal and month-shaped values
through monthOf (now anchored to accept only "YYYY-MM" or "YYYY-MM-DD" in
full, never a prefix) at all three write boundaries, converting whatever
they throw into each file's own error style.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-04 08:16:03 +02:00
1 parent 405cf0bf2d
commit 042bc8597d
8 files changed
+183 -8

No files matched your search

+7 -1
View File
@@ -23,8 +23,14 @@ function fromOrdinal(ordinal: number): MonthId {
return format(Math.floor(ordinal / 12), (ordinal % 12) + 1);
}
// Anchored at both ends: "YYYY-MM" or "YYYY-MM-DD" only, never a prefix of
// something longer. Dual acceptance (with or without a day) is deliberate and
// load-bearing — do not narrow it to one form. An unanchored match once let
// "2026-091" through as September 2026, silently dropping the trailing "1".
const MONTH_OF_DATE = /^(\d{4})-(\d{2})(?:-\d{2})?$/;
export function monthOf(date: IsoDate): MonthId {
const match = /^(\d{4})-(\d{2})/.exec(date);
const match = MONTH_OF_DATE.exec(date);
if (!match) throw new Error(`Invalid IsoDate: ${date}`);
const month = Number(match[2]);
if (month < 1 || month > 12) throw new Error(`Invalid IsoDate: ${date}`);