Skip to content

fix(util): parseDate rejects calendar-invalid dates (Feb 30, Feb 29 non-leap, Apr 31, ...)#178

Closed
sroussey wants to merge 1 commit into
mainfrom
claude/wonderful-hypatia-q5iltz-h2-parsedate
Closed

fix(util): parseDate rejects calendar-invalid dates (Feb 30, Feb 29 non-leap, Apr 31, ...)#178
sroussey wants to merge 1 commit into
mainfrom
claude/wonderful-hypatia-q5iltz-h2-parsedate

Conversation

@sroussey

@sroussey sroussey commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

src/util/parseDate.ts already range-checks month (1-12) and day (1-31), but that
still admits calendar-invalid inputs — 2025-02-30, 2025-02-29 in a non-leap
year, 2025-04-31, and the equivalents for June / September / November. The
returned { year, month, day } string is subsequently handed to
new Date(...) in the storage/history layer, which silently rolls forward
(2025-02-30 → March 2), corrupting the point-in-time semantics of
ChangeLog / spac_history / offering-history change_date rows and defeating
the as_of guards.

Fix

After the existing range check, probe the calendar via a UTC Date and reject
any input the calendar refuses to preserve verbatim:

const probe = new Date(Date.UTC(year, month - 1, day));
if (
  probe.getUTCFullYear() !== year ||
  probe.getUTCMonth() !== month - 1 ||
  probe.getUTCDate() !== day
) {
  throw new Error(`Invalid calendar date: ${dateStr}`);
}

The return shape is unchanged. Real dates flow through untouched.

Tests

Added to src/util/parseDate.test.ts:

  • Feb 30 rejected in any year (2024 and 2025).
  • Feb 29 rejected in 2025 / 2023 / 1900 (non-leap; 1900 exercises the
    divisible-by-100-but-not-400 rule) and accepted in 2024 and 2000 (the
    divisible-by-400 rule).
  • Parameterised: the impossible 31st of April / June / September / November.
  • Parameterised: month 0, month 13, day 0, day 32 still throw
    Invalid date format from the existing range check (regression fence — new
    probe must not swallow the pre-existing error message).
  • Regression fence: all five recognised input shapes (yyyy-MM-dd,
    yyyy/MM/dd, MM/dd/yyyy, MM-dd-yyyy, yyyyMMdd) still accept a
    well-formed real date (2024-02-29).

Verification

  • bun test src/util/parseDate.test.ts — 20 pass, 0 fail (7 new tests added).
  • bun test — no new failures introduced. The 7 remaining failures are
    pre-existing network timeouts on FetchQuarterlyIndexTask /
    FetchDailyIndexTask (verified by stashing the change and reproducing).
  • npx tsc --noEmit — clean.

Generated by Claude Code

Previously parseDate accepted range-valid but calendar-invalid inputs like
"2025-02-30", "2025-02-29" (non-leap), and "2025-04-31". The returned
{year, month, day} then flowed into downstream `new Date(...)` calls,
which silently rolled forward (Feb 30 -> Mar 2), shifting point-in-time
semantics of ChangeLog / spac_history / offering-history change_date
fields and defeating as_of guards.

Add a UTC-Date probe-back-check after the existing range check: if
`new Date(Date.UTC(y, m-1, d))` doesn't round-trip to the same y/m/d,
throw `Invalid calendar date: <input>` instead of returning a lie.

Tests cover Feb 30 (any year), Feb 29 leap-year rules (2024/2000 accept;
2025/2023/1900 reject), the impossible 31st of 30-day months (Apr/Jun/
Sep/Nov), out-of-range month/day, and a regression fence for all five
supported input shapes on a real date.

sroussey commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Integrated into #176. The parseDate calendar-validity probe is carried forward verbatim (clean merge). Verified compatible with the whole tree: the full suite there is 1577 pass / 7 pre-existing network fails — no real date path regressed from the stricter rejection. Closing in favor of #176.


Generated by Claude Code

@sroussey sroussey closed this Jul 2, 2026
sroussey pushed a commit that referenced this pull request Jul 2, 2026
Correctness:
- parseDate: restore the literal year via setUTCFullYear before the calendar
  probe. Date.UTC remaps years 0-99 to 1900-1999, so a valid 4-digit year like
  '0099-01-01' was wrongly rejected as an invalid calendar date. Regression test
  added; Feb-30-style rollover detection is unaffected.

Resilience regression from #178's stricter parseDate throw:
- FetchQuarterlyIndexTask / FetchQuarterlyFormIdxTask: wrap the per-row
  secDate() in try/catch so a single calendar-invalid EDGAR 'Date Filed' row
  is skipped+warned instead of aborting the whole quarter's batch (parseDate
  now throws where it previously rolled forward).

Cleanup:
- XbrlFactRepo.clearForAccession delegates to replaceForAccession(acc, [],
  { intentionalClear: true }), removing a byte-identical duplicate delete loop.

Skipped (documented in review): the 4-way junction-repo copy-paste (would need
a shared CanonicalJunctionRepo base — larger refactor beyond this diff); the
CLI --date throw (operator fail-fast is acceptable); wiring clearForAccession
into xbrlEnrichment (pre-existing behavior #177 deliberately left unchanged);
and 5 concurrency observations that are pre-existing #175 reap-path limitations
or documented single-process scope, not wave-2 regressions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants