Repository navigation
0.9.5
temporal-fmt v0.9.5
Security
.tfmodmod 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
loaderimport()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/formatDistanceToNowreject 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
formatDistancefield bags now throw the typed
InvalidDateError. Month 0 used to hit a V8-internalIntlerror 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/differenceall 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.
splitIntervalnow produces a true partition instead of dropping the
original end point and double-covering interior boundaries, and
mergeIntervalsno 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.roundDurationfloor/ceil on negative
durations now matches Temporal semantics, and a fractional
roundingIncrementthrows the typedInvalidDurationErrorinstead of a raw
V8 BigIntRangeError.- 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
acrossdifference,splitInterval, and anything in thecomparefamily.
Replaced with the actual closed form and verified against Temporal over
34,320 round-trip cases. add()-family weekday recomputation usedDate.UTC(year, month-1, day),
which remaps years 0-99 to 1900-1999 per the ECMAScript spec. First-century
dates — including genuineTemporal.PlainDateinput — got the wrong
dayOfWeekout of that, which meantisBusinessDaywas counting Sundays as
business days. All four call sites now use an O(1) proleptic formula
instead of routing throughDate.- 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; RRULEUNTIL
was stored as the raw string, so the firstnext()call threw; BYDAY
ordinals (2MO,-1FR) were silently dropped; negativeBYMONTHDAYnever
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 andbetween()both had unbounded
iteration; a ruleintervalofundefinedpoisoned candidates with NaN
even though 1 is the documented default; programmatic rules now validate
their BY* lists instead of accepting garbage silently. isDSTassumed 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/
getPreviousTransitionnow 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.getTransitionsvalidates itsend
argument now too.- Codemod tables: date-fns
S/SS(fractional seconds) andyexisted 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.wwlocale-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. analyzeFormattoken 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 realPlainDateTime, 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 claimsInstantsupports time tokens or
PlainMonthDaysupports day-of-year.compileFormat().piecesis now served frozen. A caller mutating the
(shared, cached) array was poisoning every subsequentformat()call for
that string, process-wide.DEFAULT_CONFIGis frozen too, for the same
reason — one import site mutating it could change every later
createConfig()'s defaults.registerRelativeGrammarnow validates that
matchers are actually functions and isolates ones that throw;
parseRelativerejects 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 rawTypeError);
formatISODurationemits valid ISO for negative durations (-PT30S
instead of the unparseablePT-30S) andparseISODurationaccepts 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 rawTypeError.interval: the end-inclusivity ownership checks insideintersection()
andunion()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 exposeoffsetNanoseconds) 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 acontinue— 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=53MOthrew "invalid weekday" and never even reached the range check
that already existed for it. Two-digit ordinals parse now.recurrence:formatRRule()emittedINTERVAL=undefinedfor
programmatic rules that omitinterval(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 withoutdayOfWeekmade every
BYDAY-dependent rule silently yield nothing — Temporal starts worked fine
because theirdayOfWeekgetter seeds the chain automatically. The
iterator now derivesdayOfWeekfor bag starts too, so both start shapes
behave the same.
Tests
npm run test:coverageis back to 100% lines/branches/functions/
statements — the audit above had knocked it down to 98.94%. ~60 new tests
intest/cov-audit.test.jscover 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.