Skip to content

0.9.5

Choose a tag to compare

@DirazCoder DirazCoder released this 11 Sep 22:38
· 22 commits to main since this release

temporal-fmt v0.9.5

Security

  • .tfmod mod archives: mod.json's "main" field and the mod name are now
    path-validated before either one touches the filesystem. A malicious archive
    could previously declare "main": "../../../some/file.mjs" and have the
    loader import() code from OUTSIDE its extraction directory — breaking the
    "archive contents == executed code" containment the mod system documents
    (the mod name had the same traversal shape via config reads). Absolute
    paths, .. segments, and symlink escapes are all rejected before
    stat/import/read now; extraction itself was never the gap (the system tar
    binary already refuses .. members on this platform) — these checks close
    off the manifest-controlled paths tar can't see.
  • formatDistance/formatDistanceToNow reject years outside
    [-271821, 275760] (Temporal's representable range) instead of walking a
    year-per-iteration day-count loop toward them. A hostile duck-typed field
    bag ({year: 2e8} from parsed user data, say) turned that loop into a cheap
    CPU-denial primitive — about 380ms/call at 2e8, scaling roughly linearly
    past that. The day-count itself is now O(1) via the Hinnant closed form, so
    this isn't just a range check bolted on top of the slow path.
  • Out-of-range month/day in formatDistance field bags now throw the typed
    InvalidDateError. Month 0 used to hit a V8-internal Intl error with no
    useful message, and Feb 31 silently compared as "now."

Fixed

  • interval: contains() had its two half-open bound labels swapped —
    'half-open-end' excluded its start and included its end, which is exactly
    what 'half-open-start' is supposed to do, and vice versa. intersects /
    intersection / union / difference all derive endpoint ownership from
    that same logic, so they inherited the bug: two point-disjoint open
    intervals could come back "overlapping" with a non-null empty intersection.
    splitInterval now produces a true partition instead of dropping the
    original end point and double-covering interior boundaries, and
    mergeIntervals no longer aliases caller input objects.
  • round/floor/ceil/truncate: floor and ceil were literally swapped
    for every pre-1970 date. floor(1969-12-31T12:00, 'day') returned Jan 1
    1970 — the opposite of what floor means — and 'nearest' broke ties
    asymmetrically across the epoch. roundDuration floor/ceil on negative
    durations now matches Temporal semantics, and a fractional
    roundingIncrement throws the typed InvalidDurationError instead of a raw
    V8 BigInt RangeError.
  • Proleptic-Gregorian day counts: the Math.floor((y2 >= 0 ? y2 : y2-399) / 400) era form — Hinnant's truncating-division idiom, ported straight into
    JS's flooring division without adjusting for the difference — double-
    corrected for negative years, shifting every pre-year-0 date by one day
    across difference, splitInterval, and anything in the compare family.
    Replaced with the actual closed form and verified against Temporal over
    34,320 round-trip cases.
  • add()-family weekday recomputation used Date.UTC(year, month-1, day),
    which remaps years 0-99 to 1900-1999 per the ECMAScript spec. First-century
    dates — including genuine Temporal.PlainDate input — got the wrong
    dayOfWeek out of that, which meant isBusinessDay was counting Sundays as
    business days. All four call sites now use an O(1) proleptic formula
    instead of routing through Date.
  • Month arithmetic across year 0 double-borrowed the year:
    add(PlainDate('0001-01-15'), -13, 'months') returned year -2 / month 12
    instead of Temporal's -0001-12-15.
  • Recurrence, several bugs in the same area: FREQ=WEEKLY;BYDAY=… never
    matched when the start date wasn't itself on a BYDAY weekday, since whole-
    week stepping is supposed to preserve the weekday and didn't; RRULE UNTIL
    was stored as the raw string, so the first next() call threw; BYDAY
    ordinals (2MO, -1FR) were silently dropped; negative BYMONTHDAY never
    matched anything; monthly rules drifted day-of-month after the first
    short-month clamp (Jan 31 → 28, forever — RFC 5545 says skip the month
    instead); skip()'s skip phase and between() both had unbounded
    iteration; a rule interval of undefined poisoned candidates with NaN
    even though 1 is the documented default; programmatic rules now validate
    their BY* lists instead of accepting garbage silently.
  • isDST assumed northern hemisphere and was inverted year-round for
    Australia/NZ/South America/southern Africa — standard offset is now
    min(Jan, Jul) instead of always-January. getNextTransition/
    getPreviousTransition now bisect to the actual transition minute; they
    used to return the day reconstructed at the input's wall-clock time, which
    was the right day up to 10 hours late. getTransitions validates its end
    argument now too.
  • Codemod tables: date-fns S/SS (fractional seconds) and y existed in
    temporal-fmt already but were missing from the mapping table, so they
    silently translated to quoted literal text instead of the real token, or
    threw. ww locale-vs-ISO week mismatches now refuse with an explanatory
    note instead of silently printing two different numbers depending on which
    week system got picked. A large batch of real-but-unmapped tokens now fail
    loudly instead of quietly literalizing.
  • analyzeFormat token positions now track actual source indices — quoted
    spans and '' escapes were under-counted before, which mis-positioned
    every token that came after any quote in the string. previewFormat's
    default sample is now a real PlainDateTime, so locale-aware tokens
    actually render in the preview. ww/RRRR/D-family tokens validate all
    the fields they read instead of producing NaN silently, and the token
    metadata no longer claims Instant supports time tokens or
    PlainMonthDay supports day-of-year.
  • compileFormat().pieces is now served frozen. A caller mutating the
    (shared, cached) array was poisoning every subsequent format() call for
    that string, process-wide. DEFAULT_CONFIG is frozen too, for the same
    reason — one import site mutating it could change every later
    createConfig()'s defaults. registerRelativeGrammar now validates that
    matchers are actually functions and isolates ones that throw;
    parseRelative rejects contradictory "in N units ago" input instead of
    silently resolving it as future; the CLI rejects prototype-chain subcommand
    lookups (temporal-fmt toString … used to crash with a raw TypeError);
    formatISODuration emits valid ISO for negative durations (-PT30S
    instead of the unparseable PT-30S) and parseISODuration accepts that
    leading-minus form on the way back in; the holiday calendar's per-year
    cache is bounded now (1000 entries, FIFO); format(null, …) throws the
    typed error instead of a raw TypeError.
  • interval: the end-inclusivity ownership checks inside intersection()
    and union() compared in the wrong direction, which made the "other
    interval strictly extends further" branch unreachable and computed end
    bounds from the wrong interval entirely. union((1,5], [3,10)) reported an
    inclusive end even though 10 is in neither interval; the mirror bug made
    intersect([1,10), [3,5]) exclude 5 even though 5 is in both. Found this
    during a line-by-line coverage audit — an unreachable branch on a live code
    path is usually a bug report wearing a disguise.
  • timezone: isDST()'s string-offset fallback (used when the installed
    Temporal implementation doesn't expose offsetNanoseconds) was comparing
    seconds against nanoseconds, so the comparison could never come out equal.
    Every DST-observing zone read as "in DST" year-round on implementations
    that hit this fallback. Both arms are normalized to seconds now.
  • recurrence: the next-match safety cap wasn't counting iterations that
    hit a continue — empty period, candidate at or before the start — so a
    rule whose every period is empty (FREQ=MONTHLY;BYDAY=-6MO, which no month
    can satisfy) spun forever instead of terminating with "no more
    occurrences." The cap counts every iteration now.
  • recurrence: parseRRule()'s BYDAY pattern only accepted single-digit
    ordinals, but RFC 5545 ordinals run -53..53 — a valid rule like
    BYDAY=53MO threw "invalid weekday" and never even reached the range check
    that already existed for it. Two-digit ordinals parse now.
  • recurrence: formatRRule() emitted INTERVAL=undefined for
    programmatic rules that omit interval (a documented default), which
    parseRRule() itself then rejects on the way back in — a broken round
    trip. Omitted interval is now omitted from the output, matching what
    parseRRule() expects.
  • recurrence: a plain field-bag start without dayOfWeek made every
    BYDAY-dependent rule silently yield nothing — Temporal starts worked fine
    because their dayOfWeek getter seeds the chain automatically. The
    iterator now derives dayOfWeek for bag starts too, so both start shapes
    behave the same.

Tests

  • npm run test:coverage is back to 100% lines/branches/functions/
    statements — the audit above had knocked it down to 98.94%. ~60 new tests
    in test/cov-audit.test.js cover the paths the audit surfaced, and the
    handful of genuinely unreachable defensive arms (each documented at its own
    site) carry /* c8 ignore ... @preserve */ hints so they survive the
    esbuild bundle, matching the repo's existing convention.