Commit Graph
1276 Commits
Author SHA1 Message Date
Malte Ubl fac59cfeea Merge the clang 18 size-conversion fix from perf/length-to-double-nneg 2026-10-11 08:14:06 -07:00
Malte Ubl 2077bc9ee6 Spell non-negative size conversions as sitofp for clang 18
The `nneg` flag on `uitofp` is LLVM 19 syntax. Sanitized builds hand the textual module to the host clang, and Ubuntu 24.04's clang 18 rejects it ("expected type"), which failed every Linux sanitized Effect shard. These operands are size_t values below 2^63, so `sitofp` converts them identically, is accepted by every supported LLVM, and still lowers to one cvtsi2sd on x86-64 (LLVM 19+ canonicalizes it to `uitofp nneg` where it can prove the sign).
2026-10-11 08:13:58 -07:00
Malte Ubl fb291122f5 Expect only cycle members to be traced in the self-hosting analysis cases
Since #837 a type is traced only if it lies in a cycle-capable component or reaches an intrinsic reference; a type that merely holds a cycle cannot be reached back from it. In both backend-analysis cases `record:outer` only holds `record:cycle`, so the Node oracle and the native compiler agree on ["record:cycle"] and the expectations still listed `record:outer`.
2026-10-11 08:13:08 -07:00
Malte Ubl 5ed8842d43 Merge perf/typed-array-reads into perf/alloc-hugepages
Brings in cc7c6cb7 (inline truncation decided for the host when no triple is given), which this branch predates; without it the native bootstrap's LLVM comparison with the Node seed fails on x86-64 Linux.
2026-10-11 08:12:14 -07:00
Chris Tate 3afea88bfb Merge pull request #839 from vercel-labs/perf/length-to-double-nneg
Convert lengths and in-range indices to doubles as non-negative
2026-10-10 21:27:14 -05:00
Chris Tate e44f50b327 Merge pull request #837 from vercel-labs/perf/trace-cycle-candidates
Trace only types that can lie on a cycle
2026-10-10 21:26:29 -05:00
Chris Tate 0b0b7a8319 Merge pull request #835 from vercel-labs/fix/number-undefined-unions
Keep missing reads undefined through number arguments, narrowed slots and lambda results
2026-10-10 21:25:54 -05:00
Chris Tate 43bb23ee0a Merge pull request #833 from vercel-labs/perf/cycle-growth-cap
Let the cycle collector's growth fraction widen to 16
2026-10-10 21:25:20 -05:00
Chris Tate 972557b71b Merge pull request #832 from vercel-labs/fix/array-read-arguments
Keep guarded and in-bounds typed-array reads out of the optional ABI
2026-10-10 21:24:40 -05:00
Chris Tate 3c2eeac1d7 Merge pull request #830 from vercel-labs/perf/alloc-hugepages
Move busy small-object slices to transparent huge pages
2026-10-10 21:24:01 -05:00
Malte Ubl 44f4d0bf19 Prove more fields construction-only and trace only types on a potential cycle
The cycle analysis gave a header to every type that could reach a cycle,
and its construction-only facts gave up on a whole hierarchy as soon as a
constructor let `this` escape or a type could cross into checked-dynamic
code. Four refinements, all derived from the final IR:

- A constructor's stores stay construction-only up to the first top-level
  statement in which `this` escapes. Statements run in order, so earlier
  stores complete while the object is private; stores from the escaping
  statement on, and every store of a constructor whose ancestor let `this`
  escape, are writes. Compound-assignment receiver temporaries count as
  `this`. An all-immutable cycle is still impossible: each immutable edge
  a -> b is stored before a escapes while b has escaped already.
- Crossing into checked-dynamic code only makes types mutable when the
  program contains a runtime store into a dynamic value (int32-slots'
  audit, now shared as dynamicFieldStores): typed-ref capsules write native
  fields only through such a commit. Without one, hidden property bags
  stay empty and add no edges. FFI operands and intrinsics other than
  publish stay conservative.
- A type is traced iff it lies in a cycle-capable component or reaches an
  intrinsic reference. A type that merely holds a cycle cannot be reached
  back from it: its references are external counts.
- Reachability is per class: a slot of static type C reaches C's subtree,
  not the whole hierarchy; header presence stays uniform per hierarchy.

tsc-ts (e469e33): traced classes 603 -> 469 of 700, records 64 -> 10 of
202, unions 344 -> 93 of 482; scr_cyc_alloc sites 659 -> 471. Mutable
hierarchy units 243 -> 4. Collector candidates on the self-check
(single-threaded) 6.02M -> 5.55M.
2026-10-10 15:25:49 -07:00
Malte Ubl cc7c6cb730 Decide inline truncation for the host when no triple is given
The Node-hosted CLI emits with an empty target triple (the host), while
the native CLI passes its toolchain's triple. Inline truncation keyed on
the triple text alone, so on x86-64 Linux the Node seed emitted
llvm.trunc and the native compiler emitted the inline sequence, and the
native bootstrap's LLVM comparison with the Node seed failed
(self-hosting-native-driver, rebuild-emit). An empty triple now means
the host architecture, as it already does for executable TLS; WASI keeps
the intrinsic. The DataView emission test accepts either truncation
form, since it runs on hosts of both kinds.
2026-10-10 15:11:19 -07:00
Malte Ubl 67f9de094c Convert lengths and in-range indices to doubles as non-negative
Array, string and byte lengths, counts, capacities and range-checked indices are size_t values below 2^63, but they were converted with a plain uitofp. On x86-64 without AVX-512 an unsigned 64-bit conversion expands to a five-instruction sequence with two constant-pool loads (unpcklps/subpd/unpckhpd/addsd), and it showed up in the hottest tsc-ts checker loops (compareTypeLists, getPropertyOfTypeEx) between a length load and the conversion back to an integer. `uitofp nneg` states what is already true, so the backend emits one cvtsi2sd. arm64 converts unsigned values in one instruction either way. Byte-number indices, which admit values up to 2^64, keep the unsigned conversion.
2026-10-10 14:47:55 -07:00
Malte Ubl bf17c23af9 Return defaulted and missing reads from TypeScript lambdas
TypeScript types `read ?? "text"` as number when the read is typed number,
and `() => xs[i]` as returning number. Declared functions already widened
their returns for both (the primitive result union and the optional
read), but arrow functions and function expressions kept the checker's
return, so the closure narrowed its result and threw "a 'string' value is
not representable" or "undefined is not representable" (the scanbox
`[string, () => unknown][]` probe table).

Unannotated TypeScript lambdas now get the same widened return as
declared functions. A const or global binding of such a lambda keeps the
checker's parameters and takes the widened result, and calls through the
binding count as optional or primitive-union results for the enclosing
function's return.
2026-10-10 14:47:17 -07:00
Malte Ubl 0027fa3226 Keep undefined in narrowed optional fields and locals
After `obj.field = bytes[i]` on a `number | undefined` field, TypeScript
narrows later reads to number, so the read extracted the number arm
unchecked and an out-of-range read printed 0 instead of undefined. Locals
declared `T | undefined` had the same problem inside functions
(`let x: number | undefined; x = xs[i]; x + 1` gave 1, not NaN).

The optional-read analysis now records instance fields declared exactly
`T | undefined` that receive a possibly missing value; their narrowed
reads keep the stored union, as runtime-optional record fields already
do, and the value propagates into parameters and returns like other
optional reads. Locals declared that way join the runtime-optional
storage tracking, so bare reads keep the union until a guard proves
presence. A member read through such a field throws Node's "Cannot read
properties of undefined" TypeError.
2026-10-10 14:47:12 -07:00
Malte Ubl e52688095d Convert missing number arguments with ToNumber
String.fromCharCode, String.fromCodePoint, the global isNaN and isFinite,
DataView offsets and values, and typed-array slice/subarray bounds took a
`number | undefined` value (an out-of-range read the checker types as
number) through a checked narrow, which threw "undefined is not
representable" or refused to compile. Node converts undefined with
ToNumber: code unit 0, a RangeError for fromCodePoint, isNaN true, offset
0; an undefined slice end is the length.

The Math argument conversion becomes a shared helper that also handles
field reads and any other value lowered as a number-or-undefined union.
2026-10-10 14:46:53 -07:00
Malte Ubl 73a99b2393 Let the cycle collector's growth fraction widen to 16
Mature and old passes are scheduled by heap growth. While growth-triggered
passes at a level keep finding nothing, the fraction they wait for widens by
doublings from 1/4, and it used to stop at 1, so a large live heap that keeps
growing was walked in full once per doubling. On tsc-ts those walks are most
of the collector's time: in single-threaded Excalidraw, 12 growth-triggered
full passes cost 2.0 s of 14.7 s, the last two walking 2.6M and 5.2M objects
to free 1.4%.

The cap is now 16. That is a memory trade-off: once passes stop finding
garbage, the cycle-headered heap may grow to 17 times its size after the last
pass at a level before the next (it was 2 times), so a program that
accumulates dead cyclic garbage while passes find nothing can hold up to 16
times more of it. Yield-driven narrowing is unchanged: a pass that finds old
garbage moves the fraction straight back to what its yield asks for.

SCR_CYCLE_GROWTH_CAP lowers the cap for memory-sensitive programs, read like
SCR_CYCLE_THRESHOLD: a power of two from 1 to 16 (other positive values round
down, larger ones read as 16); 1 restores the previous schedule.

test_cycle pins the cap on a heap at the growth floor: a dead cycle through
the old generation must go exactly when the heap has grown the configured cap
past the floor (not one doubling earlier, not later), and a pass that finds
old garbage must narrow the fraction at once. cycle.test.ts runs it at the
default cap and at SCR_CYCLE_GROWTH_CAP=1, 3 and 64. The large-chain widening
check keeps its walk bound but no longer waits out the cap on a 266k-node
structure, and now runs after the tests that assume the base schedule.

Measured with tsc-ts origin/parallel-check e469e33, built with this runtime
vs the parent commit, interleaved in one Linux Vercel Sandbox (8 vCPU; 5
rounds, medians; --checkers 8 repeated with 10 rounds). Diagnostics identical.
"cap 1" is this build with SCR_CYCLE_GROWTH_CAP=1:

  single-threaded   wall     RSS            cap 1
  Excalidraw        -10.1%   1283 -> 1263   +0.5%
  Playwright        -11.0%   1446 -> 1457   +0.3%

  --checkers 8
  Excalidraw        -8.7% / -4.5%   2110 -> 2132 / 2115 -> 2125   +2.6% (10 rounds)
  Playwright        -7.8% / -7.7%   2616 -> 2634 / 2589 -> 2678   -1.0% (10 rounds)

RSS in MB (max of two runs). The runtime benchmark suite's allocation-heavy
workloads (alloc-trees, word-graph, ast-interp, json-records, records-sort,
functional-pipeline; --layouts=4 --runs=16) are neutral in time and peak RSS
(-0.3% to +1.7%).
2026-10-10 14:44:14 -07:00
Malte Ubl 1c90711572 Record the preflight/order baseline for typed-array-present-reads
One new entry (single-module order, no diagnostics); every existing entry
is unchanged.
2026-10-10 14:35:04 -07:00
Malte Ubl b9eb5254f0 Keep the if-condition bound opt-in for forwarded assertions
indexReadInBounds proves only `i < xs.length`, not that `i` is a
non-negative integer. For `if (f(xs[i]!))` the forwarded-assertion path
used to pass a missing element as undefined; accepting `if` conditions for
every caller turned such reads into the dense bounds trap, which throws for
a negative index where Node passes undefined (tsc-ts: isMatchableRegion and
compareSymbolChains changed ABI). The `if`-condition position is now an
opt-in fact, used by the present-read proof, which also proves the index a
non-negative integer counter.
2026-10-10 14:24:15 -07:00
Malte Ubl 8e07b1cffc Pin that an escaping typed array keeps its optional reads
Code holding the array could detach its buffer between the guard and the
read, so the present-read proof must refuse it. With a genuine NaN also
reaching the parameter and a consumer that observes undefined, the
parameter stays `number | undefined`.
2026-10-10 14:14:16 -07:00
Malte Ubl 1c974f015a Keep guarded and in-bounds typed-array reads out of the optional ABI
A typed-array read that may name a missing element lowers to a boxed
`number | undefined`, and the optional-value analysis widens every
parameter it reaches for all callers. Two patterns in tsc-ts's relater
boxed its hottest relation methods this way, failing the build's scalar
ABI check ("Boxed numeric arguments in Checker.isTypeAssignableTo"):

- `const regular = this.regularTypes[id]; const sourceId = regular >= 0
  ? regular : id;` The read is NaN-coded, but its occurrence in the true
  arm was rebuilt as `number | undefined`, so `sourceId` and the
  parameters of isSimpleTypeRelatedTo and structuredTypeRelatedTo were
  boxed. A comparison with a number that is never NaN is false for
  undefined and NaN alike, so an occurrence of a never-reassigned binding
  under such a guard is a plain, non-NaN number. The analysis now treats
  it as one wherever it flows.

- `for (let i = 0; i < sources.length; i++) isTypeAssignableTo(sources[i],
  targets[i])` over two Uint32Arrays allocated with the same length. The
  parameters also receive a JSON field that may be NaN, so NaN-coding
  cannot carry them (`source === target` must tell NaN from undefined).
  The reads are now proven present instead: the index is a non-negative
  integer counter that only its own function writes, a dominating guard
  bounds it by the array's length or by the length of a parallel typed
  array allocated with the same length, and each array is a `const`
  fixed-length typed array referenced only through element accesses and
  `.length`, so no code can detach its buffer. A proven-present read
  lowers to the plain element read and is never optional.

indexReadInBounds also accepts a read inside an `if` condition and takes
an optional same-length fact. Reads the proofs do not cover keep the
established lowering: an inclusive bound, a shorter parallel array, a
decreasing or fractional counter, a counter a closure writes, and an
array that escapes all still read undefined, as the new corpus program
typed-array-present-reads checks against Node.
2026-10-10 14:12:59 -07:00
Malte Ubl 2d90508bc7 Move busy small-object slices to transparent huge pages
Every class slice used to opt out of transparent huge pages, so each
4 KiB the bump pointers crossed cost a page fault. Those faults (fault
entry, memcg charge, LRU insertion and the page clear) were 6-14% of
tsc-ts samples in the kernel. Backing the whole reservation with huge
pages removes most of them, but makes the 2 MiB page around every bump
pointer resident at once. A worker executable has up to 32 class slices
per script thread, so tRPC at 8 checkers grew from 558 to 955 MB.

A slice now starts on small pages. Once it has carved 8 MiB, the rest
of its range switches to huge pages (MADV_HUGEPAGE). Only slices that
already hold 8 MiB pay for a partial last huge page. The reservation is
huge-page aligned, so the switch point and every slice start on a 2 MiB
boundary. The slow path raises a slice's bump limit in two steps (to the
switch point, then to the slice end), so the inline fast paths are
unchanged. The policy is Linux-only; other targets keep one limit per
slice, as before. The allocator checks now build with _GNU_SOURCE on
Linux like the runtime packs, so the madvise advice is compiled in and
checked (alignment, contiguous carving across the switch, VmFlags nh/hg).

Measured with tsc-ts best-v1 (parallel-check + 6247795 e24057a 64d9af6
1c42c55), built with this runtime vs origin/main, on two Linux Vercel
Sandboxes (8 vCPU; 5-7 interleaved runs, medians). Columns: wall, max
RSS, minor faults; "all" is MADV_HUGEPAGE on the whole reservation:

  single-threaded   wall    RSS     faults         all: wall / RSS
  Excalidraw        -6.1%   +0.8%   331k -> 112k   -10.0% / +3.5%
  Playwright        -1.8%   +1.0%   371k -> 111k    -8.4% / +3.2%
  TypeORM           -7.2%   +0.9%   259k ->  75k   -11.8% / +4.1%
  tRPC              -5.0%   +2.0%   101k ->  47k    -9.0% / +11.3%
  tsc-ts self-check -2.9%   +2.6%   158k ->  64k    -3.1% / +8.4%

  --checkers 8
  Excalidraw        -2.9%   +2.6%  1075k -> 508k    -6.3% / +10.6%
  Playwright       -10.0%   +2.6%   739k -> 381k   -10.3% / +12.2%
  TypeORM           -5.9%   +2.2%   372k -> 232k   -10.1% / +27.7%
  tRPC              -2.5%   +2.5%   159k -> 125k    -4.3% / +71.2%
  tsc-ts self-check -4.0%   +3.9%   213k -> 172k   -12.1% / +46.3%

Switching at 2 or 4 MiB was up to about 1.5 points faster single-threaded
but cost up to +11% RSS at 8 checkers. 16 MiB gave back most of the gain.
Prefaulting small pages with MADV_POPULATE_WRITE did not reduce sys time.
Collapsing the small-page prefix with MADV_COLLAPSE at the switch did not
help either. The runtime benchmark suite (--layouts=4 --runs=16) shows a
geomean change of -1.2%: inventory-report -9.9%, validate-errors -5.3%,
log-summary -2.9%, and regex-logs +2.1%. regex-logs peaks at 6.8 MB and
never reaches the switch point; with one binary the policy measures -0.9%
there, so its delta comes from code layout.
2026-10-10 14:03:22 -07:00
Malte Ubl 028622fc75 Read cached null answers without a non-null assertion
The NaN-coded read analysis memoized lookups whose answer can be null
(symbols, eligible functions, call sites, plain-number returns) and read
them back with `map.get(key)!`. Under Node that returns the cached null,
but in the natively compiled compiler `!` is a checked extraction, which
threw "null is not representable" while lowering programs
(self-hosting-native-frontend, the native CLI bootstrap contracts). Read
the cache through an undefined check instead.
2026-10-10 14:03:21 -07:00
Malte Ubl d1791b9165 Inline two-argument Math.min and Math.max
Every two-argument Math.min/Math.max was an out-of-line call to
scr_math_min/scr_math_max (94 sites in tsc-ts, including
compareCodePoints and IdentifierTable.intern). LLVM's minimum/maximum
intrinsics have exactly their semantics: NaN propagates and -0 orders
below +0. On x86-64 they lower to minsd/maxsd with inline NaN and
signed-zero handling, on arm64 to fmin/fmax.
2026-10-10 14:03:21 -07:00
Malte Ubl d4aeeea0f5 Carry typed-array reads as plain numbers where undefined acts like NaN
A typed-array element read answers undefined for an index outside
[0, length), so the default lowering returns a heap-boxed
`number | undefined` and widens every binding, parameter and return the
read reaches. In the tsc-ts scanner that boxed every character read and
turned every character predicate (isLineBreak, isIdentifierPart, ...)
into a function taking a boxed union.

Most consumers cannot tell undefined from NaN: relational, arithmetic
and bitwise operators, truthiness, Math functions, String.fromCharCode,
typed-array indexing, switch over number cases, and equality with a
number that is never undefined. The new analysis (nan-coded-reads.ts)
follows reads through local bindings, parameters of directly called
functions and methods (no overrides, no value uses), and their returns.
A slot is NaN-coded when every value it receives is a NaN-coded read or
slot, or any other number (converted with ToNumber on entry), and either:

- it never holds a genuine NaN (integer elements, integer constants,
  guarded integer arithmetic), so NaN stands for undefined exactly; any
  consumer that takes the value whole (an argument, a stored or returned
  value, a literal element, concatenation) gets the exact
  `number | undefined` rebuilt, and the optional-read analysis treats
  that occurrence as optional, so it lowers as before; or
- every consumer is NaN-insensitive.

Equality of two NaN-coded values that never hold a genuine NaN also
answers true for two NaNs, as undefined === undefined does. Any other
slot keeps the established union lowering; asserted reads (`t[i]!`) are
unchanged.

tsc-ts best-v1 (scriptc main + this commit vs main, Linux sandbox,
Xeon 2.5 GHz, interleaved, medians of 5, diagnostics identical):

| app | parse | total (single-threaded) | wall --checkers 8 |
| --- | --- | --- | --- |
| tRPC | 1.263 -> 0.866 s (-31.4%) | 3.035 -> 2.527 s (-16.7%) | -13.0% |
| Excalidraw | 2.637 -> 1.865 s (-29.3%) | 14.99 -> 14.04 s (-6.3%) | -16.2% |
| Playwright | 3.150 -> 2.202 s (-30.1%) | 12.93 -> 12.14 s (-6.1%) | -12.4% |
| TypeORM | 2.562 -> 2.036 s (-20.5%) | 10.93 -> 10.46 s (-4.3%) | -8.6% |
| self-check | 2.080 -> 1.529 s (-26.5%) | 7.06 -> 6.68 s (-5.4%) | -15.1% |

The tsc-ts IR has 646 instead of 697 scr_union_new_f64 sites (the rest
are cold), 22 instead of 168 typed-read helper calls, and 217 instead of
572 scr_union_get_f64 calls; the scanner's hot functions allocate no
union boxes. Binary size -0.2%.
2026-10-10 14:03:21 -07:00
Malte Ubl 7d08a7441c Truncate inline on x86-64 and test typed-read presence without trunc
Baseline x86-64 has no rounding instruction, so every llvm.trunc became
a call to libm's trunc. The typed-array read helper paid that call on
every read: it decided presence with `i >= 0 && i < length &&
Math.trunc(i) === i` before the inline element load.

- Math.trunc (and the DataView offset truncation) now take an integer
  round trip on x86-64 targets: values of magnitude below 2^52 go through
  fptosi/sitofp with the sign restored by copysign (so -0.5 and -0 give
  -0); larger values, NaN and the infinities are already integral and
  keep their value. Other targets keep llvm.trunc, which is one
  instruction there.
- The ToUint32 slow path is only reached for magnitudes above 2^53 (and
  non-finite values), which are integers, so its truncation was a no-op
  and is gone.
- For integer element kinds, the read helper performs the element read
  that yields NaN for an invalid index (one inline index check) and
  answers undefined exactly when that read is NaN: an integer element is
  never NaN. Float element kinds keep the range and integrality test.
2026-10-10 14:03:20 -07:00
Malte Ubl 5d0b38ef87 Keep null distinct from the TDZ in forward-captured nullable bindings
A closure created above the `let`/`const` it captures holds the binding
in a TDZ box whose NULL payload slot marks "not yet initialized". A
union of one reference arm with `null` and/or `undefined` (`Set<T> |
null`, `string | undefined`, `C | null`) is represented as the bare arm
pointer with NULL standing for the unit arm, so storing that unit value
made the box indistinguishable from the temporal dead zone: every later
read or write threw "Cannot access 'x' before initialization" although
the declaration had run.

The self-hosted compiler trips on exactly this shape in
lowerer.ts (`let dynamicViews: Set<string> | null = null`, read by a
callback created earlier), which made scriptc 0.2.8 fail to build
tsc-ts's relater with SC3004. Node evaluates the source correctly; the
native code was wrong.

TDZ boxes of nullable-union bindings now start at the module's immortal
NULLABLE_ABSENT sentinel and the TDZ test compares against it. No value
of the binding can hold that sentinel, and every RC entry point and the
cycle collector already skip it, so box release, trace and the
initializing store need no other change.

Corpus: 4596-tdz-nullable-forward-capture.
2026-10-10 14:03:19 -07:00
Malte Ubl 810a0b1d56 Merge pull request #826 from vercel-labs/fix/tscts-gaps/04-callback-fields
tsc-ts -7%: keep native method calls when only an unrelated callback field shares the name
2026-10-10 14:03:48 -05:00
Malte Ubl b979bf5434 Merge pull request #825 from vercel-labs/fix/tscts-gaps/03-error-int32
tsc-ts -7%: keep int32 fields when an Error subclass forwards its constructor arguments
2026-10-10 14:03:32 -05:00
Malte Ubl 64206c72e7 Merge pull request #824 from vercel-labs/fix/tscts-gaps/02-union-fields
Read and write union class fields declared with different types
2026-10-10 14:03:07 -05:00
Malte Ubl 443b3dc666 Merge pull request #823 from vercel-labs/fix/tscts-gaps/01-optional-receivers
Narrow optional base-class receivers before shared union field access
2026-10-10 13:39:28 -05:00
Malte Ubl 3eeea34ce5 Keep native method calls when only an unrelated callback field shares the name
The class method census marks a method name as replaceable when a
function-typed property write uses that name, so a scanner storing its
error callback in `this.onError` made every call of any class's
`onError` method go through the checked property bag. That bag is a
dynamic store naming any property, so the int32 slot analysis also gave up
int32 storage for every class field exposed to dynamic code.

A function-typed write now marks the name only when an instance of a class
that has the method can inhabit the receiver's type: an assignability check
against the instance types of the method's owners, with `this` in an
instance member read as its class. Generic or anonymous owners, `this`
outside instance members, type parameters, `any` and `unknown` receivers
keep the conservative answer. Writes through interfaces or subclasses that
can reach a method still replace it.

Test: class-method-mutations.test.ts. Corpus:
4597-callback-field-method-name.
2026-10-10 09:01:04 -07:00
Malte Ubl 8d8d478f80 Keep int32 fields when an Error subclass forwards its constructor arguments
A class extending Error without its own constructor (or one passing
options to super) initializes the builtin prefix through the
error.ctorOptions helper, whose message and options arguments are dynamic.
The int32 slot analysis did not list that helper among the dynamic helpers
that never store into a live class view, so a single such class anywhere in
a program counted as a dynamic store naming every property: every f64
field of every class exposed to dynamic code lost its int32 storage, and
the integer facts of the functions reading those fields went with it.

error.ctorOptions runs scr_error_init_options, the same initializer
error.newOptions (already listed) runs: it stamps the receiver's
runtime-owned error prefix and only reads `options.cause`. In the native
tsc-ts self-check, two such classes cost 7% total time and 10% check time.

Unit test: int32-slots.test.ts. Corpus: 4596-error-subclass-int-fields
pins Error subclasses forwarding message and options beside integer
fields.
2026-10-10 09:01:03 -07:00
Malte Ubl 0d679c1b98 Read and write union class fields declared with different types
A field shared by every class arm of a union could only be read or
written when all arms declared it with one IR type. Syntax-tree code
commonly narrows a node to several classes with `instanceof` where one
class declares the field optional and another required (`initializer:
Expression | undefined` beside `initializer: Expression`), or as a
subclass beside its base, and both forms were refused.

Reads now dispatch on the union tag, read each arm's own slot, and widen
it to the arm field type every other one converts into without a check
(a union wrap or class upcast); the caller narrows the result to the
checker's flow type as before. Writes evaluate the value once as the arm
field type that every other arm's storage accepts and widen it per arm.
Arms whose field types are unrelated keep the existing refusal.

Corpus: 4594-union-class-field-mixed-types.
2026-10-10 09:01:03 -07:00
Malte Ubl b901ec78eb Narrow optional base-class receivers before shared union field access
A parameter typed as a base class becomes runtime-optional storage
(`Base | undefined`) when a caller can pass an array hole. After
`instanceof` narrowed it to several subclasses and a guard such as
`node.label === undefined` ended its active runtime-optional state, the
next `node.label` lowered the receiver as the storage union, which has no
retag to the checker's class union, and the shared field read or write was
refused. Union property reads and union field writes now convert such a
receiver with the checked extraction and class test that typed slots
already use.

Corpus: 4595-union-class-field-optional-receiver.
2026-10-10 09:01:02 -07:00
Chris Tate c7e82741f9 Merge pull request #822 from vercel-labs/ctate/prepare-v0-2-04de95
Prepare v0.2.8
v0.2.8
2026-10-10 10:45:36 -05:00
Chris Tate b40dbe9bb7 Show 0.2.8 in the coverage diagnostics example 2026-10-10 10:18:09 -05:00
Chris Tate c67e08a9cf Prepare v0.2.8
- Synchronize published packages and the compiler surface manifest at v0.2.8, and bump the runtime ABI marker to v9.
- Add release notes for worker graph sharing, catchable deep recursion, parallel code generation, runtime performance, and compatibility fixes.
2026-10-10 10:08:39 -05:00
Chris Tate 1bf547a122 Report a rejected declaration's own diagnostic (#818) 2026-10-10 10:01:46 -05:00
Chris Tate 9e0c6564d4 Format the LLVM emitter bootstrap test 2026-10-10 08:36:39 -05:00
Chris Tate 8e2197fe10 Merge main into ctate/report-rejected-declaration-a704c5 2026-10-10 08:07:08 -05:00
Chris Tate da2517a976 Keep record layouts stable for shorthand and literal-argument fields (#819) 2026-10-10 07:59:01 -05:00
Chris Tate ec5d419080 Give the sanitized LLVM emitter bootstrap room to emit itself (#820) 2026-10-10 04:00:46 -05:00
Chris Tate e8e644c7e0 Give the sanitized LLVM emitter bootstrap room to emit itself
The natively built emitter records an allocation stack for every object
under ASan, so emitting its own module takes several times longer than
the plain build and now runs close to the 300 s per-stage limit on shared
runners. Sanitized runs get a 900 s per-stage limit and a matching test
budget; plain runs keep the existing limits.
2026-10-10 03:58:05 -05:00
Chris Tate 2ff5711a54 Keep record layouts stable for shorthand and literal-argument fields
A shorthand property and an object literal passed as an argument no
longer widen the declared record layout of the binding or parameter
that receives them. A widened layout made every existing record that
flowed into that binding or parameter convert to a copy, so later
writes through the copy were lost. Those literals keep a missing
element in their own fields and convert, with a check, into a declared
record.

The debug-variables fixture iterates an array whose elements may be
undefined, so its loop binding keeps the union layout the debugger
check inspects.
2026-10-10 03:40:36 -05:00
Chris Tate 9302fc9c93 Merge main into ctate/report-rejected-declaration-a704c5 2026-10-10 01:54:50 -05:00
Chris Tate 6cf3d4744b Read holes in for-of loops and missing-element shorthand properties as undefined (#817) 2026-10-10 01:08:12 -05:00
Malte Ubl 1b6d0723e9 Merge pull request #814 from vercel-labs/perf/workers/03-typed-publication
@scriptc/threads: publish typed object graphs to workers by reference (parallel tsc-ts 4.72 s -> 0.97 s)
2026-10-10 01:06:23 -05:00
Malte Ubl 272aef3247 Release @scriptc/threads with the other packages
sync-versions stamps packages/threads with the CLI version (0.2.7 now),
the release workflow checks that version and publishes the package
before scriptc, and RELEASING.md lists it with the packages that always
release together. npm trusted publishing must be configured for
@scriptc/threads before the next release: a package without it fails
before upload.
2026-10-10 01:00:36 -05:00
Malte Ubl 6835e5a734 Document sharing published graphs between workers
The new docs page (/docs/threads) explains @scriptc/threads: a runnable
example that publishes a million-order ledger and sends it to four
workers, verified under Node.js 24 and as a scriptc executable; what
publish accepts and refuses, and that a refused publish leaves the graph
unchanged; the frozen-object TypeErrors of the write guard; a comparison
with Node's structured clone (constant-time sends, preserved classes and
identity, immortal objects) and the three rules that make a program
behave the same under both; the automatic sharing of deeply frozen
untyped data; and the cost model. The limitations page gains a Published
graphs section (immortality, class and identity differences, write-check
evaluation order, private fields) and links the new page from the
worker-threads section.

This is the first page added since the migration, so the route-test
fixture now accepts pages without a legacy URL, and the agent guide's
checklist for new pages names the fixture.
2026-10-10 01:00:36 -05:00