While loops discarded every loop-carried fact, so the escape-time counter
in `while (x * x + y * y <= 4 && it < MAX) { ...; it++ }` stayed a double:
its increment, its bound compare and the `it === MAX` test after the loop
all ran in floating point.
guardedLoopCounterFacts bounds a counter that the loop changes only by
increments of positive integer literals outside nested loops, switch and
exception regions, when a conjunct of the condition compares it below a
literal or a binding the loop never writes, and the condition itself never
writes it. The body runs only after that conjunct held, so every pass
starts at most at the bound and climbs by at most the sum of the body's
increments; the counter starts at its entry fact and only grows. The range
holds at every read inside and after the loop, so it also feeds the
loop-carried closure as an invariant.
The mandelbrot counter becomes an i32 slot. Corpus 5333 pins escape-time,
reversed, inclusive, multi-increment, early-exit and module-level counters,
and every case that must stay double (counters written by their condition,
decrements, fractional and unknown steps, nested increments, written
bounds, disjunctions, fractional, -0, NaN and infinite starts, limits past
2^31, 2^32 and 2^53) against Node.
Every write of the module-level checksum in module-byte-loops.ts is a proven uint32, so its initializer now keeps it in an i32 local instead of a double global with an integer view. The emission test now pins the local and the absence of double loads; state keeps its integer view.
A module-level number that only its file's top-level code reads and
writes (an initializer binding) was already analyzed like a local, so
its loop arithmetic was proven int32, but it kept its double global. A
loop-carried `v = step(v, n)` at top level therefore converted v to a
double and back on every iteration: LLVM promotes the global to a
register, but cannot fold fptosi(sitofp) through the double phi.
When every write is a proven int32 (or uint32), the initializer now
keeps the binding in an i32 local that starts at the global's zero
value. Nothing else can observe the global: no other function reads or
writes it, the guard runs the body at most once, library builds have no
initializer bindings, and import-cycle bindings (checked through their
initialization flag elsewhere) keep their global. Debug builds keep the
global for the variable view.
The top-level closure kernel's v becomes an i32 local. Corpus 5324 pins
localized call results, inline remainders, uint32 hashes, labeled exits
and ++/--, and the bindings that must stay doubles or globals (growth
past int32 and 2^53, -0, fractions, NaN, reads by other functions and
closures) against Node.
Parameter and result facts stopped at their level: non-negative int32 or
int32. A remainder by a parameter therefore never proved its result
(the divisor may be 0), and `(x * 31 + n) % m` kept i64 arithmetic in
the callee because x might be as large as 2^31 - 1, even when every call
passes a divisor of 1000003 and a fed-back result below it.
narrowSlotRanges runs after the optimistic fixpoint as a verified
descending iteration. Each round assumes a range per parameter and
result slot, reanalyzes the functions that read a changed assumption,
and joins every write into each slot. The assumptions are kept only when
they are inductive: every write lands inside its slot's assumed range.
A failing slot falls back to its last verified range, a reanalyzed
function must still prove its field and box writes, and each bound
narrows at most once, so recursion that shifts its argument cannot walk
a bound down one step per round. Results the level dropped are revived
when every return becomes an int32 under the narrowed parameters. The
first round reuses each function's last fixpoint analysis.
`hash(n, m)` with `h = (h * 33 + i) % m` now keeps h in an i32 slot with
urem i32, and `step(x, n)` multiplies in i32. Corpus 5332 pins narrowed
and revived facts and every case that must stay double (zero, negative,
fractional and runtime divisors, -0 remainders, doubling and
counting-down recursion, mutual recursion, results past int32) against
Node.
The optimistic int32-slots fixpoint assumed exactly int32 for parameter,
result and captured-box candidates. That is too weak for remainders:
with x and n only known to be int32, `(x * 31 + n) % m` may have a
negative dividend (and a -0 result), so a kernel called as
`v = step(v, n)` could not prove step's result, v stayed a double, and
the parameters fell back to doubles too.
Unpinned candidates now start one level lower, at non-negative int32. A
write that is an int32 but may be negative demotes the slot to int32 and
reanalyzes its readers (a parameter that seeds a box demotes the box as
well); any other write drops it as before. Slots only ever widen, so the
fixpoint still ends with every remaining assumption proven at every
write. Int32 contract pins keep their int32 facts. The call-site range
retry is unchanged for slots that fail both levels.
The direct-call kernel now passes i32 to step.i32 and keeps v an i32
slot (sandbox: 2563 ms -> 964 ms, node 1256 ms). Corpus 5323 pins the
non-negative facts and every widening path (negative arguments, results
and box writes, -0 remainders, count-down recursion) against Node.
The self-hosted compiler lowers nested functions as closure values, and
closure values with optional or defaulted parameters are refused
(SC1090), so the closure's defaulted `invariant` parameter broke the
self-hosting integer-range, emission, LLVM emitter and native driver
builds. closedLocals now takes the invariant ranges explicitly, havoc
keeps its three parameters, and havocWith passes a loop counter's range.
The self-hosting range cases pin the narrowed closed range: x enters as 3
and the region writes 11, so the closures keep [3, 11]; the for loop's
`x = x` initializer still evaluates to the whole assumed bound.
boundedIntegerLoopFacts only accepted constant positive steps, so the
sieve's inner loop `for (let j = i * 2; j <= LIMIT; j += i)` kept j a
double: addsd, a floating bound compare, and an fptosi/sitofp exactness
check on every typed-array index.
A step may now be a binding the loop never writes (stable: not boxed,
TDZ or captured) whose entry fact is a positive integer range up to
2^31 - 1, added (or a negative range subtracted). Each update then adds
an exact integer in that range, so the counter climbs from its start to
at most the limit plus the largest step; the iteration budget for
secondary cursors uses the smallest step. j becomes an i32 slot updated
with add nsw i32.
Corpus 5322 pins zero, -0, negative, fractional, NaN and infinite steps,
steps written by the body, early exits, and counters crossing 2^31, 2^32
and 2^53 against Node.
analyzeIntegerRanges discarded loop-carried facts except for closed locals,
whose writes only qualified syntactically (bitwise results, literals,
imul/clz32). A local like `v = (v * 31 + n) % 1000003` stayed a double, so
every iteration paid fmul/fadd plus the checked floating remainder
(fptosi, exactness and 2^53 range checks, srem, -0 select, sitofp).
The closure now evaluates each write by the same interval rules as
`expr` (exact safe integers, never -0: `*` refuses a zero times a
negative, `%` needs a non-negative dividend and positive divisor, unary
minus refuses ranges containing 0) while every candidate is assumed
inside a fixed bound: int32, uint32, or the new non-negative int32 bound
that `%` closes under. The greatest fixpoint keeps candidates whose every
write lands inside the bound; their range then narrows to the entry fact
joined with the writes' values (sound by induction over the region), and
the closures intersect. A for loop's counter, which only the update
writes, contributes its header range to reads inside the region, so
`h = (h * 131 + i) % 65521` closes too.
The kernel's v becomes an i32 slot with mul/add nsw i32 and urem i32.
Corpus 5321 pins the closed forms and every case that must stay double
(negative dividends and -0, products above 2^53, NaN/Infinity/fraction
entries, zero and fractional divisors) against Node.
LLVM does not narrow the i64 urem of two extended int32s, so a proven
non-negative int32 remainder by a constant took the 64-bit magic-number
sequence (with its fixup). Emit urem i32 when both operands are proven
int32 ranges.
docs/content/docs/limitations.mdx now holds the frontmatter, the
headings, the anchor spans and one <include> line per partial. The text
moves unchanged to docs/content/limitations/: one file per labeled
bullet (<section>/<label>.mdx) and one per section's other text
(<section>.mdx). Edits to different bullets no longer touch adjacent
lines of one file.
remarkDocsConventions splices the included partials into the page and
joins the lists that consecutive bullet partials contribute, so the page,
its Markdown twin and the search index render as before. The route tests
read the page with its partials inlined. A page that includes partials
takes its last-modified date, used by the sitemap, from the newest commit
to the page or any of its partials.
docs/scripts/limitations-partials.mjs joins the partials back into one
file, splits a single-file page, and merges a branch's edits to the
single-file page into the partials.
indexOf and includes on arrays of class instances, records, arrays, maps,
sets, typed arrays and strings call an internal helper that LLVM inlines.
When the array's hole-free prefix covers its whole length, the helper
scans the dense slots itself: references compare as 64-bit slots, four
per branch, and strings compare by pointer, then length, then bytes, with
strings of up to 16 bytes compared as two words and longer ones through
scr_str_eq. Arrays with holes or sparse entries, and every other element
type, keep the runtime search.
randomFillSync(new Uint16Array(32), 8.5, 8) fills bytes 17 to 32, so the
last 4-byte group the check prints holds one random byte, which is zero
1 time in 256, in Node as well. The check now ORs the bytes of eight
fills before it reports the groups. The expected output is unchanged.
libuv's uv_fs_scandir sorts entries with strcmp on POSIX hosts, so Node's
readdirSync, readdir and fs/promises readdir list names in byte order
(with and without withFileTypes). The runtime returned the directory's
raw order, which differs from Node on most filesystems. The POSIX
scandir listing now sorts its names and entry kinds together, and
scr_fs_readdir reads names from that listing as the Windows path already
did. Windows keeps the directory's own order, as libuv does.
Corpus 5447 checks the listings against Node.
scr_runtime.h now includes 48 module headers (scr_core.h, scr_rc.h,
scr_string.h, scr_dyn.h, scr_http.h, ...) in dependency order and holds no
declarations. Each module header holds whole sections of the former file,
includes the headers whose names it uses, and compiles on its own.
Declarations, macros, struct layouts and inline bodies are unchanged. Five
blocks move to join their module: the class-hierarchy vtable header (into
scr_error.h, after the string core), the node:timers/promises pair (into
scr_async.h), the JSON.parse entry points and the JSON output buffer (into
scr_json.h, after the dyn declarations) and the EventTarget block (after
scr_json.h). No moved block uses or declares a name that a block it moves
past uses or declares. The string methods keep their position after the
class objects in scr_string_methods.h, because their struct ScrDyn
parameters rely on the file-scope struct ScrDyn declaration there.
The runtime ABI scan reads scr_runtime.h with its module headers expanded
in include order. A runtime test compiles every module header on its own.
scripts/runtime-header-hunks.mjs retargets a diff of the single-file
header onto the module headers.
The single-pass ASCII comparison now reads both strings a word at a time
when the covered ASCII bytes are TAB..CR and 0x20..0x7E, as the generated
table has them. A word of bytes 0x20..0x7E needs no per-byte coverage
check, equal words have equal primaries, and the rest of both strings is
validated by words after the first differing primary. A non-ASCII byte
answers at once that the strings need the general path; a word with a
tab, line break or another control goes to the per-byte pass. Runs
shorter than a word are read as the eight bytes ending at their last
byte, which stay inside the string object because the header precedes
the data.
The byte-equality check moves after the primary pass on the ASCII path,
where it only runs when every primary ties, and the scan and element-list
path moves out of line, so the ASCII path no longer sets up the element
buffers' stack frame.
scr_exc_alert, the process-wide exception alert of worker executables on
targets without the ELF per-thread pending word, was a tentative
definition. That branch compiles only for Mach-O, COFF and WASI, so the
macOS runtime build emitted it as a COMMON symbol, which library
localization keeps global, and the COMMON-symbol test failed on macOS
while passing on Linux.
Initialize it explicitly, and have the test also compile every runtime
source for aarch64-macos through zig cc when zig is on PATH and the host
is not macOS, so declarations in __APPLE__ branches are checked on Linux
too.
crypto.createSecretKey and generateKey/generateKeySync for "hmac" and
"aes" return SecretKeyObject values, native dyn handles over a copy of
the key bytes. The type and symmetricKeySize getters, export() as a
Buffer or a JWK, equals(), instanceof KeyObject, String(),
JSON.stringify and util.inspect follow Node 24.22, as do the argument
checks, their order and their errors. createHmac and hkdf/hkdfSync
accept these keys. toCryptoKey() and KeyObject.from() need WebCrypto
values and are refused with SC2020.
Dyn handle ops gain a @@toStringTag that differs from the class name
and a flag for views with no properties, which util.inspect prints at
any depth. KeyObject method calls dispatch through dynInvoke.
The localeCompare table now covers U+0363-U+036F, U+00BC-U+00BE and
U+2044 FRACTION SLASH, so strings containing them follow Node's collation
instead of code-point order. A combining Latin letter is its letter's
primary at the letter's secondary, with a tertiary weight between the
letter's case forms where Node places it, and the combining class it
shares with the diacritics it reorders with. A vulgar fraction is the
elements of its compatibility decomposition (1, U+2044, 4), each with the
tertiary weight Node places the fraction at among that position's forms,
so it orders after both 1⁄4 and ¹⁄4. The generator's check adds the
fractions' slash and superscript spellings and the combining letters
around each diacritic. SC6003 and the limitations page describe the new
coverage.
The generated lookup indexes one dense table for every code point below
U+0370, with the gaps between ranges marked uncovered, and checks the
ranges above it (now U+2044) after that, so a Latin lookup is one
comparison instead of up to four.
crypto.timingSafeEqual accepts every TypedArray, DataView, Buffer,
ArrayBuffer and SharedArrayBuffer and compares their bytes. Values of
other types throw Node 24.22's ERR_INVALID_ARG_TYPE for buf1 and then
buf2, before the byte lengths are compared. Views of every element kind
keep the typed call; other arguments take the checks at run time.
randomFillSync accepts every TypedArray, DataView, Buffer, ArrayBuffer and
SharedArrayBuffer and returns its argument at its own type. Offsets and
sizes count elements and are checked in bytes with Node 24.22's
ERR_OUT_OF_RANGE and ERR_INVALID_ARG_TYPE errors. randomFill fills the
same buffers and calls back in a later macrotask, or synchronously when
nothing is filled, as Node does.
randomInt takes a callback in both forms, and its errors carry Node's
received values and the max or "max - min" label. Values come from a
6 KiB cache, as in Node, so a callback runs on the next tick while the
cache has data and after an asynchronous refill when it is empty.
randomUUID and the new randomUUIDv7 validate options.disableEntropyCache
and draw from a 128-UUID batch unless it is set.
A non-u8 view passed to randomFillSync no longer needs a Uint8Array
type, and the bytes callback adapters pass views of any element kind
and dyn values through.
scr_lib.c calls the digest functions in scr_hash.c since #1006, and sort.test.ts copied array.test.ts's source list from before that change, so the test binary failed to link (undefined scr_ripemd160_init and others). The list now matches array.test.ts's.
The generated localeCompare table now agrees with Node on spellings that
tie at the primary and secondary levels. "l·" and "L·" collate as one
unit, equal to U+0140 and U+013F, through a contraction table. Precomposed
characters take their decompositions' elements after the case weights are
assigned, so U+0130 equals I + U+0307. Ligatures whose spelled-out letters
tie with them (ij and ij) get the case weight Node places them at among
their first letter's case forms, and ligatures with their own secondary
(Æ) keep their own case. The generator's check now also compares every
covered character's decompositions, case forms and ligature spellings in
four contexts. A per-thread class table lets the lazy primary comparison
find characters that need element lists with one lookup.
util.stripVTControlCharacters ran Node's ANSI pattern through the
regex replace loop, which tried the OSC branch's lazy scan at every
introducer. Text with many unterminated OSC introducers took quadratic
time (about 200 s for 100,000 of them).
The function now scans once and gives the same result as Node 24.22's
pattern: the OSC branch ends at the first terminator after ESC ], and
that position is cached across introducers; the CSI branch resolves its
first match without backtracking. A removal that brings lone surrogates
together keeps canonical storage.
Corpus programs 5470-5474 cover OSC and CSI forms, seeded random input,
long unterminated runs and surrogates, and strip-vt-linear.test.ts checks
that 400,000 unterminated introducers finish quickly.