Repository navigation
Conversation
…d copy-out
toIsland decided a refused value's crossing from its TypeScript arms, so a
union with a primitive arm beside an object the checker cannot map
("auto" | ToolChoiceObject, string | Part[]) was refused although its native
representation is already checked-dynamic, where the runtime copies the
primitive arm and exports the object itself. Crossing now follows the
representation: a dyn value crosses as any values do, and a union of copied
values with kinds the island export table holds crosses arm by arm through
its dyn box. exportedByReference accepts copied arms by their flags, matching
its comment, and the remaining refusals name the arm with no export instead
of claiming the value "would cross as a copy".
An island object converted into a program record type was a silent deep copy:
identity and every later island write were lost (K1). fromIsland now refuses
it with SC8010, naming the record and both exact alternatives: declare the
island's type (or unknown/any) to keep the handle, or state the copy with
structuredClone, which is exact. The refusal covers what builds a native
record only: promises are adopted, class instances and closures unbox their
own native value, and a conversion whose value nobody reads is unobservable.
Corpus: 8811 pins the union crossing and the object arm's identity, 8812
pins the explicit structuredClone copy, and the programs that copied out
(6836, 6899, 6900, 8809) now state it. node-island-copy-out pins the refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`module.exports = Object.getOwnPropertyDescriptor` (gopd@1.2.0's gOPD.js) stores one standard library function as the module's whole export. Two paths refused it. The exporting statement lowered, but the requirer's binding resolved through the checker's alias to the stdlib declaration the exported expression names, which has no storage in the program, so every read of the binding was an unresolved-reference error. The binding now reconnects to the module's `export=` symbol, whose declaration is the export statement itself: the statement lowering already assigns that statement's snapshot global, so requirer reads share the value Node's requirer sees. The same reconnection covers a factory call and a class-free identifier export. The second path is a mutable source. `var $gOPD = ...; ...; module.exports = $gOPD` (gopd@1.2.0's index.js) exports the value the binding holds at the statement, while a later write to the binding never reaches the export. A source binding that is reassigned somewhere in its module now gets its own snapshot slot, assigned at the export, instead of the refusal that asked for a `const` declaration. A module that also attaches member exports to the same object keeps that refusal: those writes would have to reach the object the snapshot holds.
…wrappers An inline require() of a module whose whole export is a reassigned binding read the binding's value after the module finished, not the value the module.exports statement stored. wholeExportValue lowered the exported identifier, and that read resolves to the live binding; it now reads the snapshot global the statement assigns. A module.exports = <split require binding> export answers the module it names the same way. A module that re-exports another module with module.exports = require() owns no snapshot of its own, so requirer bindings classified it as a namespace binding and followed the inner source binding past the snapshot. The snapshot lookup now follows the re-export chain to the module that owns the storage, and a wrapper with live member exports is not followed. The corpus program requires the snapshot module inline, through a require() wrapper and through a split-binding wrapper. Lowerer.unjournaled lists the requirer-binding cache as a pure memo, and the coverage test expects only the table-export refusals: the reassigned whole-export binding compiles.
A module that re-exports another module's whole export by naming a require
binding (`module.exports = snap`) exports the value that module's export
statement stored. The binding already reconnects to that storage, so the
statement is alias plumbing rather than an unsupported shape and requirers
read the same slot.
A split require binding (`var m; m = require("./x")`) whose target stores
its whole export in a snapshot global reads that storage too, instead of the
source binding's value after a later write. The corpus program requires the
snapshot module through a wrapper, a split binding and an inline require.
V8 and libc learn which stack a thread is on from its pthread record: macOS binds libnode's calls inside libnode, so the bridge's v8::base::Stack definitions, which answer that on Linux, cannot answer there. The record named Node's stack only while Node started, though every frame of Node runs on it. backtrace() is the reader that acts on it beyond V8's single ask: Node's diagnostic report calls it, and with the main stack recorded it takes the alternate-stack path, whose only bound is one megabyte per frame above the last, and walks whatever an island frame links to. The record now names Node's stack for the length of each job that runs on it, nested jobs included, and is restored when the job returns to the thread's own stack. The context of that stack stops inheriting the creating stack's frame pointer too: getcontext captures it, macOS's makecontext leaves it in place, and the island's outermost frame would link to a frame on another stack. The stack starts empty, so the walk ends at its top. A differential harness child killed by a signal now reports its signal. execFile yields no exit code and no output of its own for such a child -- whatever it wrote to stderr is already inside the message, and its stdout is gone -- so the signal is the one fact the report was dropping, and it is what tells a crash from a program that chose a nonzero exit. It is all the failure of 8830-process-report-island said in the macOS LLVM release lane.
…ix/wave2-live-views
…his` wave-2 decisions 3b = A and 6 = C. 3b: the `this` fence in an object literal's method now runs after the checked-dynamic route, so a literal whose method reads its own `this` reaches the dynamic build whenever its type is a checked-identity view — the marking index already marks a receiver-dependent literal's own and contextual types. The receiver is JavaScript's own: the call's receiver, `undefined` when detached, and the other object when the method is copied. 6 = C: a program record type that receives island values is a checked-identity view, so the value keeps the island's handle instead of being deep-copied. ClassProtocols indexes the reads rooted at an island handle and marks their declared types: unions and intersections by their object parts, island-declared parts skipped (they are handles already), and mapped types by instance so the shared `__type` symbol never spreads the fact to every Pick, Partial and Record in the program. fromIsland's record-copy refusal (SC8010, #3005) then never fires for a marked type: the existing dyn route returns the handle. Island membership is per round, so the index answers it per round, and a program without islands collects nothing. Evidence: corpus 8999 pins both pi rpc-mode.ts:235 shapes, the detached and copied methods, and a data-member receiver; corpus 9000 pins anthropic's side-member read around an island write, theme's program-function flow with `vars ?? {}`, and identity probes. The superseded node-island-copy-out diagnostics fixture is removed, and unsafe-record-lie now pins the check at the read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pnpm test:ts7 --baselines-only requires a preflight/order baseline for every fixture. 8999 and 9000 each lower as one module with no preflight diagnostics, so both baselines are the minimal entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`delete comp.k`, where the assertion-erased operand's type is a closed
record that does not declare `k`, has nothing to delete in the operand's
own layout — but Node's object still holds the property, and a checked
record keeps its source's undeclared own keys in the kept-source view
that Object.keys, `in`, hasOwn, JSON, spread and inspect already read.
The delete now removes the key there (one representation: the same table
every other reflective surface answers from), answers `delete`'s boolean
— true for a key the record never held — and refuses a frozen record the
way strict JavaScript does (`Cannot delete property 'k' of #<Object>`,
sloppy code answering false instead).
What still refuses, by name:
- a field the operand's type declares REQUIRED ("the operand's type
'{...}' declares 'y' required, so it cannot be absent"), the corrected
message for a form the old text called "required record fields" even
when the key was not declared at all;
- a record that cannot keep a source (accessor slots and the like):
"a key 'k' the record's layout does not declare";
- a key of several literals and a non-constant key, unchanged.
Runtime: `scr_record_source_delete` beside the other kept-source
writers; a record view deletes from the record it views, and a narrowed
width copy follows its link to the record whose kept source holds the
key, because Node has one object where these are two (a frozen copy
refuses the removal for the same reason). Compiler: `record.sourceDelete`
in the lib signature/symbol tables with its own emission (the frozen
refusal is a pending check) and the
`erasedDeletePlan`/`recordDeletePlan` wiring that sets
`recordSourcesPossible`. Only a program that previously refused builds
any new IR, so typed code is byte-identical.
Evidence: `tests/corpus/8998-delete-undeclared-record-key.ts`
differential against Node (the coding agent's v1-to-v2 session migration
shape, held/missing/multi-key deletes and their order, the value-position
answer, a frozen target's strict TypeError, and a key a frozen record
never had), and `tests/corpus/8999-delete-linked-record-copy.ts` (the
same delete through an asserted optional view and through a narrowed
width copy, and the frozen copy's strict TypeError); corpus costs
recorded; diagnostics snapshots for the four refusals and the corrected
message; the new programs' ts7 order baselines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Darwin link's debug map ties an object's symbols to the source its compilation units name. A program built as several shard objects gave every one of them the same unit name, so a later object's symbols resolved against the first object's debug information. Each physical output now names its compilation units after itself. The shard splitter points every unit at a copy of the unit's file named after the shard. The helper names each partition object's units after the partition, after loading an unoptimized partition, after a ThinLTO import, and in split code generation; output 0 of a code-generation split keeps the module's own unit names because it is the object a single-output build produces. Functions, variables and source locations keep the original file, so line information and breakpoints are unchanged. Object cache keys follow the bytes they key. Co-authored-by: Vincent Koc <vincentkoc@ieee.org> Closes #2760
`delete (narrow as Narrow & { k?: T }).k` removes `k` from the operand's
kept source. That is Node's delete only while the operand is the one
record the object has. A narrowing conversion builds a copy of a wider
record, and a checked conversion of a boxed record answers a view or a
copy of it; when the wider record's type declares `k`, the property is
that record's field, which the delete cannot reach through the narrower
type. It answered `true` and left `wide.k` in place, where main refuses
the program with SC1090.
The deletes are now checked once every function is lowered
(refuseSourceFieldDeletes, before the record links are pruned to the
observed ones): a delete is refused by name when a record type that
declares the key can reach the operand's type through any chain of
narrowing conversions, or of a boxed record converted back by a checked
conversion into a type whose required properties it has. The test is by
type, so it also refuses a program whose wider records never reach the
delete; it accepts none that would answer wrongly. A key the record
keeps in its own source still deletes.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
scr_record_source_delete lived in scr_json.c, which most programs link: adding it moved that object in 3,615 executables that never delete an undeclared key (the zero-cost gate's runtime-only rows, IR and LLVM identical). It is now scr_record_source_delete.c, selected by the recordSourceDelete runtime feature, which the record.sourceDelete library function turns on, the way scr_dyn_integrity.c is selected. It reaches the table through scr_record_source_private.h and otherwise uses the public property accessors, so scr_json.c is byte for byte what the private-interface change left. Co-Authored-By: Claude Code <noreply@anthropic.com>
…values `await later<Rec>()`, where an island function returns `Promise<T>` and the program instantiates `T` with its own record, compiled and then rejected at run time with "TypeError: expected object at $, got Object". A promise the island returns is adopted, never converted: it is the same promise on both sides, and the value that crosses is the one it settles with, where the program awaits it or a reaction receives it. That value is an island object, and its declared type is the promise's type argument. The marking pass for island-received types (wave-2 6 = C) saw only the promise: `Promise<Rec>` is a library object type, so it fell to the last case of `markReceivedView` and was recorded as a view itself, while `Rec` stayed a native record. The awaited island object then met a record type it can never satisfy. `markReceivedView` now looks through a promise to the type it settles with (`getAwaitedType`, which unwraps `Promise` and `PromiseLike` and distributes over unions) and marks that. With `Rec` a live view, `Promise<Rec>` maps to a promise of a dynamic value, so `fromIsland` takes its existing adoption route (`dyn.promiseResolve`) and the awaited value is the island's own object: identity holds across the boundary and the island's later writes are visible, as in Node. tests/corpus/8887-node-island-promise-record-view awaits the payload around an island write, and receives it through `then`, `Promise.all`, `race`, `any`, `finally`, a `then`/`catch` chain, an async function's return, a `Promise<Rec>` binding and a `Rec | undefined` payload; a rejection keeps its error, a literal the island keeps comes back as the island's copy, and the program's own record comes back as itself. Co-Authored-By: Claude Code <noreply@anthropic.com>
… page Co-Authored-By: Claude Code <noreply@anthropic.com>
The predicates that decide whether a type converts to or from a checked dynamic value recursed without end on two shapes, and the compiler died with a stack overflow (seen compiling the yaml package): - A union reached again through its own function arm, such as `type Step = (() => Step) | number`. canDynCheckTo marked records and arrays as in progress but not unions, so the cycle union -> function result -> union had no mark on it. - A recursive record with a member that one rule declines and a later rule accepts, such as a method taking a generator and returning the record. The in-progress mark of a record shared the `checked:` prefix of the per-query success memo, and a failed alternative clears that memo, so the record lost its own mark and was entered again. In-progress marks now have their own prefix, canDynCheckTo marks unions, and the union branch of canConvertToDyn, which had the same shape, marks the union it is inside. Recursive types answer coinductively, as recursive records already did. Types that ended before answer as before. Co-Authored-By: Claude Code <noreply@anthropic.com>
`import { execArgv } from "node:process"` in a JavaScript source ended in
an internal compiler error (seen compiling execa): `arrIntrinsic length on
non-array string`. A named import of a process data member had no lowering.
TypeScript refused it by name; JavaScript handed back a builtin identity
token, the string `"[builtin process.execArgv]"`, and the `.length` and
element reads dispatched on the checker's `string[]`.
- A named import of a data member of the process module (argv, execArgv,
execPath, platform, arch, pid, versions, allowedNodeEnvironmentFlags)
reads what `process.<member>` reads, in both source languages. `env`
stays refused: its spelled read is a snapshot record and a write through
the import must reach the environment.
- In a JavaScript source, a builtin module member whose type is data (a
primitive, an array or a tuple) no longer gets an identity token. The
token was read as the data: its text printed, its length taken. The read
is refused by name (SC2020, deferred to the statement like every
JavaScript fence). Classes and other objects keep their tokens.
Co-Authored-By: Claude Code <noreply@anthropic.com>
`scriptc coverage` on a 6,000-line project did not finish (19 minutes and 8 GB when stopped). The time was in mapCanonical, which commits a slot whose type has no static lowering through its canonical form. Canonical nodes intern by checker type, so a recursive type is a cycle in the graph, and the walk had no memory: it mapped the same nodes again along every path that led back to them, down to its depth cap of 24. A tagged tree with a leaf that does not compile took 1.5 s with three branch arms, 20 s with five, and the project's tree has seventeen. One walk now keeps the mapping of each object node per depth (the depth is part of the key because the cap cuts a mapping off at a distance that depends on it). The answers are the ones the repeated walk computed. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ts fields
A same-shape clone `{ ...r, k: v }` of a type that a checked conversion also
builds, in a program that observes the type's keys, copied the source's own
keys into a dynamic object, added a placeholder per override and noted the
result on the clone, so that a kept source (undeclared keys, another key
order) survives the copy. It did so for every clone, although only a record
that keeps a source has anything to carry: a tree rewriter over such a type
ran 34 times slower than the same program without the conversion.
The clone now tests its source when it reads it and takes the snapshot only
when the plain clone would not reproduce the source's own keys: the record
keeps a source (`record.sourceHas`), or it lacks a key an override creates,
which Node lists after the source's keys while the shape's table may list
it earlier. Any other source holds exactly its declared keys in its own
order and every override replaces a key in place, which is what the clone
helper copies. The test runs once, before the override values evaluate, as
the snapshot did.
Corpus: 8948-clone-source-test.ts covers clones of built records, of parsed
records in table order, in another order and with undeclared keys, overrides
of required and of optional keys, and an override whose value changes the
source, under Node and as a compiled binary.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Constructing a typed array from a view of another kind, set() from one and TypedArray.from(view) converted every element of an integer source through a double: the store was `(uint16_t)scr_numeric_to_u32(value)`, which decodes the double's exponent and significand, or the out-of-line round-half-to-even clamp, even when `value` was a uint8_t. That loop was 80% of the typed-array-convert workload. An integer source now converts directly. Its element is an integer in [-2^31, 2^32), so ToUint32 of it is the value modulo 2^32, and the store is that modulo the destination's width: C's conversion to the unsigned type of that width, which is also the bit pattern of the signed kinds. A Uint8ClampedArray destination clamps the integer to 0..255; an integer has no fraction to round. Float sources keep their path (ToUint32 by exponent, the rounding clamp), and float destinations convert the integer as before. Corpus: tests/corpus/typed-array-conversion-all-pairs.ts pins all 100 (source kind, destination kind) pairs of the ten numeric kinds through new D(src), d.set(src, 1) and D.from(src), with the edges of every kind as source values. It passes before and after this change, plain and sanitized. Sandbox, 8 vCPU Xeon 2.50 GHz, 131,072 elements x 200 rounds of set(), before -> after (Node 24.22.1): Uint8 to Uint16 61.2 -> 1.9 ms (5.1), Uint16 to Uint8 61.3 -> 1.4 (1.4), Int32 to Int8 83.0 -> 4.5 (6.0), Uint32 to Uint8Clamped 25.6 -> 6.6 (7.6), Int16 to Int32 77.0 -> 3.9 (3.9). typed-array-convert: 279.6 -> 137.5 ms (-50.8%, 95% CI -55.8% .. -43.9%); steady-state against Node 3.33x -> 1.78x. What is left is the Float32 to Uint8Clamped loop (82 ms against Node's 40), which this change does not touch. Co-Authored-By: Claude Code <noreply@anthropic.com>
An uncaught Error, and an unhandled rejection whose reason is an Error,
printed one line: `Uncaught RangeError: negative input: -2`. A program
that reads a stack trace keeps source frames in every function that can
throw, and each Error copies them when it is created, so the frames were
already in the Error and the report dropped them.
The two reports now print them under the heading, one `at` line each, as
Node prints its stack trace:
Uncaught RangeError: negative input: -2
at helper (/path/program.ts)
at level2 (/path/program.ts)
scr_error_report_frames (scr_error.c) writes an Error's captured frames;
scr_exc_print_uncaught and the unhandled-rejection report call it, the
latter for typed and checked-dynamic reasons. A program that reads no
stack captures no frames and prints exactly what it printed before. An
Error thrown by Node island code is still reported by Node.
No emitted code changes: IR and LLVM are identical for every program.
The three runtime units are linked by every executable, so every
executable changes once, by the size of the added calls.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Lowering a call probes its receiver (tryLowerExpression) to pick a form, and a later form lowers the receiver again. Two lowerings per link made a call chain cost 2^links. `scriptc coverage` on concurrently, whose yargs setup is one 25-link chain on a refused import, never finished (stopped after 13 minutes at 100% of one core and flat memory). - A refused probe that left nothing behind (no local or lifted function declared, no closure name taken, no diagnostic kept) is recorded for as long as the expression or statement that asked is being lowered. A later request for the same operand inside a probe, under the same function context and type bindings and with the same overrides, optional chains, narrowed types, staged arguments and scopes around it, is refused from the record with the diagnostics the probe captured. A chain on a root that cannot be lowered is polynomial in its length: 16 links took 32 s and take 0.8 s. - Chains that do lower still cost two or three lowerings per link (an array-method chain reaches a minute at 12 links and the heap limit at 14). An attempt budget per outermost statement (5 million lowerings, or 2,000 per node of the statement when that is more) ends them in a named SC1090 at the statement. Reusing a successful probe is not in this change. A first version did, for probes that left nothing behind, and two corpus programs showed why it is not safe as a general rule: the same node lowers differently once an expression override is installed around it. Co-Authored-By: Claude Code <noreply@anthropic.com>
The island value-crossing matrix (next commit) compares every observation
of a value on both sides of a Node island boundary with Node. These are
the differences that were local to the island lowering and the island
runtime units:
- `table[key] === undefined`, `list[i] === undefined` and `v.k == null`
on an island object or array compared the declared type, so a missing
key answered false. The island's value now decides, before it meets the
declared type.
- `{ ...islandObject }` and Object.assign copied nothing from an island
object. They copy its own enumerable string keys, read in Node.
- `x instanceof Array | Map | Date | RegExp | ...` answered false for an
island value held as `unknown`. The island tests its own global of that
name, as it already did for the Error classes; an identifier typed by
the island's declarations also takes the native test, so an island
error subclass a callback receives is `instanceof Error`.
- A program record inside the island answered undefined for the names
every object inherits (`constructor`, `toString`, `hasOwnProperty`,
`valueOf`, ...), so `String(record)` threw. Those reads are left to V8's
Object.prototype, and they are no longer reported as own properties.
- `Object.prototype.toString.call(v)` answered "[object Object]" for an
island array, Map or Date. Node answers.
- `structuredClone(v)` of an island object held as `unknown` threw
DataCloneError, and dereferenced a null string for an object with a
null prototype. The handle's clone operation is Node's structuredClone.
- An `Iterable<T>` or a union with a generator arm passed into an island
reached the IR validator as SC9001. It is the named SC8010.
The checked conversion's TypeError for an island object where a native
class or record is expected now says the value comes from a Node island
and what holds it.
scr_json.c changes only under SCR_NODE_ISLANDS, the variant island
programs already select, so every other program keeps its object.
Co-Authored-By: Claude Code <noreply@anthropic.com>
A function whose number parameter bounds a numeric loop gets a copy that takes the parameter as an int32, entered after a test at the top of the function. Which loops count as numeric is decided by two lists of IR node kinds. They admitted class field writes (`fieldSet`) and not record field writes (`recordSet`), nor the sequence expression (`seqExpr`) that a compound assignment to a field lowers to. n-body's `advance(bodies, n, dt)` is that shape (`bi.vx -= dx * bj.mass * mag`), so its pair loop kept a double counter, compared it with a double bound and converted it for every index. Both kinds are now admitted. The lists are a cost choice, not a safety condition, and the comment on versionLoopBoundParameters now says why: the entry test reads only the parameters before any statement runs, `p | 0` is the same number as `p` when it passes, and the copy runs the same body. A write through a reference cannot change a versioned parameter, which is an unboxed local of the frame (boxed and TDZ locals and capturing functions are excluded); it can change what the loop reads next, which the copy and the body read alike. Whether the copy's slot becomes an i32 is decided separately by the slot analysis over the copy's body, which sees every assignment to the parameter. Corpus: loop-bound-versions-record-writes.ts runs plain writes, compound assignments, increments, an assignment used as a value, a string field, two references to one record and a bound reassigned from a written field, with integers, fractions, NaN, -0, negatives, infinities and integers past the int32 range. Plain and sanitized. Unit: parameter-version-emission. Zero-cost gate (--corpus=all, base 714482d): 5175 programs identical, 96 fail to build on both sides, 1 differs: bench/n-body (`advance` gains its version; executable +600 B). The self-hosted compiler's LLVM is identical. Both native bootstraps pass (plain 2226 s, sanitized 3953 s, 32 vCPU). n-body, Sandbox 8 vCPU Xeon 2.50 GHz, --layouts=4 --runs=16: 158.4 -> 132.5 ms (-16.4%, 95% CI -31.6% .. -10.7%); at scale 4, 590.8 -> 486.4 ms, where Node takes 699 to 724 ms (steady-state against Node 0.91x -> 0.81x). With the call site written `bodies.length | 0`, which needs no entry test, the same run takes 460 ms. Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Both sides add a gated runtime unit beside scr_dyn_integrity.c; main's scr_dyn_reflect_proto.c keeps its place and scr_record_source_delete.c follows it. Co-Authored-By: Claude Code <noreply@anthropic.com>
The runtime's Proxy constructor takes an untyped object as the handler,
and an untyped object, a function or a plain record as the target. A
TypeScript handler whose traps take their types from ProxyHandler<T>
compiles to a typed record, and a class instance or an array is typed
storage too, so `new Proxy(counts, { get(target, key) { … } })` built
without a diagnostic, got "fully static" from coverage, and ended with
"non-plain targets or handlers on a native Proxy is not supported yet"
when the statement ran.
The lowering now reads how each argument reaches the constructor and
refuses the forms that cannot succeed with SC1090 and a hint that says
how to write the handler so it compiles. Handlers typed any, empty
handlers, and plain-record or untyped targets compile and run as before.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…ipt calls to path, fs and timers (#3261)
Three conflicts with #3218 (--emit-lowered / --from-lowered): the build command's option list takes both sides, and the frontend that main wraps in prepareLoweredProgram calls prepareExecutableBuild, in the Node driver (index.ts) and in the native one (native/prepare.ts). The executable cache key keeps main's tier field beside the decision file's digest. Co-Authored-By: Claude Code <noreply@anthropic.com>
Main's guard (#3260) asks every build and run option either to change the executable cache's keys or to say why it need not. The decision file is in the island key with the digest of its bytes, the option that ignores the project's file is in the same key, and the check builds no executable. The three options of #3218 that the guard also names on main are answered by #3306, not here. Co-Authored-By: Claude Code <noreply@anthropic.com>
A build that replays a recorded island decision asks its first load which recorded islands the program still imports, and drops the others. It asked the island scan, and the scan is not the list of files the lowering compiles: it follows the startup module order and literal import() calls, and it collects package edges only when the first load holds an npm package. An island whose only import is in a module the lowering reaches another way (a module behind a wrapper around import()), or in a program whose packages only import() loads, had no edge. The check dropped it, the lowering refused its use again, and the replay paid the lowering round the record exists to save. The set it ended with was the recorded one, so the file and the diagnostics showed nothing. The question now goes to the load itself (Frontend.islandImports): the imports of the recorded packages in every implementation file the program holds. That is a superset of what the lowering compiles, so the check cannot drop an island the lowering would find again. The island scan and the first decision read what they read before. Two things the replays that now really replay showed: - The file listed the islands by name, and a round emits its SC8001 notes in the order of the set it starts from, so a replay printed the notes of the same build in another order. The file now keeps the order the rounds grew the islands in; the islands the load decides follow, by name. - When the import check ends the first round, the next round decides from the capture of that load. The capture is now given the scan that decision asks for, instead of the one the check happened to request. An island the load decides (a marked package, a module that patches a builtin, a reader of the patched object) is decided again from the first load in every round, which costs no round: a test pins that such islands are listed, never replayed, cost a replay nothing, and are named by the check when the load no longer decides them. Co-Authored-By: Claude Code <noreply@anthropic.com>
The sweep compared a replay's IR, diagnostics and file with its recording build's. A replay that dropped its islands and found them again passed all three and lowered the program twice. The sweep now counts each build's lowerings (SCRIPTC_TIMING's lower phase) and fails a replay that lowers more than once. One kind lowers again by design and is reported apart: a native build that tries a package as an island because a fence in it surely fires, and keeps itself when the attempt fails (SC8023). --executables builds every program that records an island three ways (no decision file, recording, replaying), each with its own cache, and requires the three executables to be the same bytes. Co-Authored-By: Claude Code <noreply@anthropic.com>
… the import check 04d5c37 makes a coverage analysis lower with the recorded user modules as well as the recorded packages. Its admission of the record takes this branch's question to the load (Frontend.islandImports) instead of the island scan: the packages by their imports in every file of the program, the modules by the files the program holds. Co-Authored-By: Claude Code <noreply@anthropic.com>
A replay that lowered twice: the import check dropped islands the program still importsFound on a large program: its replay lowered the program twice. The cause is in this PR, and three commits on top of a merge of Cause. A replay asks its first load which recorded islands the program still imports and drops the others ( On the large program the check dropped four of 17 recorded packages. All four are static imports of one module that the program reaches through a wrapper around Small reproduction, an existing npm-suite case,
Fix (
The sweep ( ProofIn 32 vCPU Vercel Sandboxes (Intel Xeon @ 2.90GHz), on
The merge
After the first CI run
The other failures of that run are One test is red on |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Main's registry of whole-program scans (program-facts.ts) counts every iteration of the program's source files by the declaration that holds it. The frontend's new answer to a replayed island decision iterates the implementation files for the imports of the recorded packages: a third scan in loadFrontend, of the discovery kind (it finds which packages the program imports and decides nothing about how other code compiles). Co-Authored-By: Claude Code <noreply@anthropic.com>
What
#3117 lets a build keep its Node island decision in a file and start the next build from it. This PR makes that something a project has rather than a flag someone passes, and closes the cases where a recorded island could outlive its reason.
Base:
feat/node-island-decision-wp1, which is #2881 with #3117 merged in (the two conflict inexecutable/prepare.ts; that branch is the resolution). The diff here is only this PR. It retargets tomainwhen those two land.The file serves a project. It holds one section per entry file, target platform and set of options that steer the decision (
--node-island,--native,--npm-static,--tier), so a project with several programs, built on macOS and Linux, keeps one file and each build rewrites only its own section. A section lists every island of the program with the rule that put it there:loadfor the islands the first decision makes from the load, andrefusal,preflight,lowering,esm-importer,fence-reportorfence-surefor the ones a round grew. The growth rules stamp the rule on what they add.A replay asks what the record cannot know.
fence-reportisland recorded by a build that compiled is what made it compile and never goes stale this way.What a replay still cannot notice is a still-imported package that would now compile: only lowering it natively can tell, which is the work the file saves. That is what the check is for.
A check.
scriptc build <entry> --node-island-decision-checkdecides from scratch with the frontend only, compares with the section a build would write, prints one line per island that differs and exits 1 if any does. It writes nothing and generates no code.scripts/island-decision-sweep.mjsrecords, replays and checks every corpus program and npm-suite case.A project key.
"scriptc": { "nodeIslands": { "decision": true } }inpackage.jsonnamesscriptc-islands.jsonbeside it (a string names another file). With the key,buildandrunreplay and record without a flag.coveragereplays the file and never writes it.--node-island-decision=<file>overrides the key, and--ignore-node-island-decisionturns it off for one command.What a build prints. Nothing when the replayed decision stands. When it writes the file, one line on stderr and one per island that changed:
How
node-islands/decision-file.ts: the sections, the per-entry checks at read (readIslandDecision), the import check (admitRecorded), the outcome rule (staleByOutcome, two lines inholdsWhile), the write (re-reads the file and replaces its own section) and the comparison.executable/prepare.ts:prepareExecutableBuildruns the rounds from the record, applies the outcome rule at most once, and writes. In the first round of a replay, when the load shows a recorded island is gone, the round returnsretrywith the rest; the next round decides from the captured load, so nothing loads twice. Each round reports whether it reached its lowering and every island it ran.checkIslandDecisionis the same rounds from scratch.node-islands/growth.ts,graph-islands.ts: each rule stampsrule(andfollows) on theIslandPackageit adds.frontend/analysis.ts,node-islands/report.ts: an analysis lowers with the recorded packages, applies the same two checks, and its island report names a replayed package with the class and reason the build recorded (it never lowers that package natively, so it has no refusal of its own to show).project-config.ts,cli/*,index.ts,native/*: the key, the three options,checkIslandDecisionfor both hosts. Options are resolved once wherecompileandanalyzebegin, so every cache key reads the file from one field. The native executable cache no longer stands aside for a decision file: its key is the generated LLVM, which a different decision changes.Proof
Against the base
feat/node-island-decision-wp1(f9242bac8), in 32 vCPU Vercel Sandboxes (Intel Xeon @ 2.90GHz). The sweep, the island-program comparison, the large-program builds and the lanes ran ondf95bba22; the head,e1dd21d7f, adds the lock around the file's write and two tests, and the tests, the gate and the bootstraps were run again on it.Tests.
packages/compiler/test/island-decision-replay.test.tsbuilds real island fixtures throughprepareExecutableBuildfrom scratch and from the record, and usescheckIslandDecisionas the oracle throughout: a replay is one round with the same serialized IR, warnings and island list, or the same failure, for a fence-report island, a lowering refusal and a sure-fence island; a package the program stopped importing leaves the set in one lowering and the result is the build from scratch's; a failing build's fence-report island is decided again when the program compiles (a package that compiles natively ends native, as from scratch) and comes back when the error does; a sure-fence island is decided again when the program stops compiling; a build that stops at a type error leaves the file byte for byte; an island the load decides is listed and not replayed; an analysis lowers once instead of twice with the record and reports the same statements and diagnostics.node-islands/decision-file.test.tscovers the file (sections, other sections left byte for byte, the compiler release, each way an entry leaves, each kind of difference the comparison names, the project key).packages/cli/test/node-island-decision.test.tscovers the command line: the project key records and replays without a flag,--ignore-node-island-decision, a second entry's section, coverage, the check's output and exit status for a match, a missing file, a missing island, an extra island and a program that fails before its lowering, and that the check writes nothing. 278 tests pass across these, the island round suites andtests/harness/node-island.test.ts.The whole corpus and the npm suite: record, replay, check.
node scripts/island-decision-sweep.mjs --jobs=30: 5,422 programs and cases, 5,421 recorded a decision, 85 with an island (87 island packages, 12 island modules), 1 stops before its lowering. 0 differ: every replay writes the IR and prints the diagnostics of its recording build, leaves the file as it is, and the check from scratch reports no difference. 1,043 s.Without a decision file nothing changes. Zero-cost gate,
--gate-corpus=all, no--gate-allow: PASS on this head: 5,207 programs identical (IR, LLVM, release executables), 96 fail on both sides, 0 differ. Compile time within tolerance: benchmarks −0.56 % [95 % CI −1.79 %, +1.17 %], the compiler compiling itself −0.88 % [−3.02 %, +0.88 %], fixtures −0.70 % [−1.40 %, +0.01 %]. (The same gate ondf95bba22was also identical for all 5,207, with the self-compile time +1.64 % [+0.10 %, +4.91 %], which the gate called inconclusive; this run settles it.) The gate links executables, so it cannot compare programs that need a libnode pack: separately, all 85 island programs build to byte-identical IR and diagnostics with the base compiler and with this one.A large multi-package program (14 island packages, 2 island modules, stops at the end of lowering with 54 errors), one build at a time in one sandbox, the same diagnostics byte for byte in every build:
--node-island-decision-check: matches, exit 0The file is byte for byte the same after both replays. The three runs that decide from scratch do the same work and range from 641 to 819 s, so read the gain as 1.8 to 2.6 times. The recorded section lists 7
lowering, 5fence-report, 1esm-importerand 1loadpackage.Lanes with the libnode pack: plain 21,674 passed, 0 failed. Sanitized 18,439 passed, 3 failed, all in the one shard that also runs the sanitized LLVM-emitter bootstrap (2,576 s there): two are test timeouts (
npm-staticEffect generator at 900 s,three.jsnodes at 1,200 s) and one is a compile of an Effect 4 fixture that did not finish. None builds with a decision file or an island. Run again on their own under the sanitizer on this head, all three pass (195 s, 382 s and 697 s).Native bootstraps (
self-hosting-native-driver,self-hosting-llvm-emitter; the driver's round loop and the decision file are compiled by the self-hosted compiler). Ondf95bba22: plain pass (3 tests, 2,397 s), and withSCRIPTC_SAN=1pass (3 tests, 4,155 s). On this head: plain pass (3 tests, 2,845 s), and withSCRIPTC_SAN=1pass (3 tests, 4,197 s).pnpm lint,pnpm format:check: clean.Not in this PR
coveragedoes not replay a recorded island module on main: its analysis never runs a program module in an island and type-checks its first load, which still holds the module (a test pins this). Make coverage report the islands a build runs, and warn when a build ships a reachable refusal #3186 gives the analysis the build's rounds; the replay then seeds its modules too.coveragedoes not record. On main its island set is not the build's (it admits deferred imports a build leaves to its lowering and has no fence report), so a section it wrote would hand the next build islands it should not have. With Make coverage report the islands a build runs, and warn when a build ships a reachable refusal #3186 it is onewriteIslandDecisionafter the analysis's loop. On the large program, coverage takes 915 s without the file and 360 s with the build's file.🤖 Generated with Claude Code