diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 23936e46a..35a328639 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -136,6 +136,8 @@ jobs:
run: pnpm generate:ts7-ast --check
- name: Verify the generated IR field readers
run: pnpm generate:ir-fields --check
+ - name: Verify the generated IR codec
+ run: pnpm generate:ir-codec --check
native_macos:
needs: [validation_scope, lanes]
@@ -464,6 +466,10 @@ jobs:
name: native-linux-x64-gnu-shape-overlay
path: ${{ runner.temp }}
- run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz"
+ # The npm suites' Node island cases load Node from a libnode pack, and
+ # fail rather than skip in this lane when none is configured.
+ - uses: ./.github/actions/libnode
+ if: matrix.suite == 'npm' || matrix.suite == 'npm-static'
- name: Run the suite with the overlay on
run: |
case "${{ matrix.suite }}" in
@@ -686,11 +692,17 @@ jobs:
run: pnpm test:checker-identity --optimization dev
llvm_artifacts_linux_x64:
- needs: [validation_scope, lanes]
+ needs: [validation_scope, lanes, native_linux]
if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }}
name: test (Linux x64 LLVM artifacts, no clang)
runs-on: ubuntu-24.04
- timeout-minutes: 30
+ # The glibc helper and runtime pack come from native_linux, as the macOS
+ # artifact jobs take theirs from native_macos. The musl packages and the
+ # WASI smoke run in llvm_artifacts_linux_x64_musl and
+ # llvm_artifacts_linux_x64_wasi: together with a rebuild of the glibc
+ # packages, which a runtime change forces, the one job took 25 to 32
+ # minutes.
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -700,32 +712,13 @@ jobs:
with:
node-version-file: .node-version
cache: pnpm
- - uses: ./.github/actions/llvm-linux
- run: pnpm install --frozen-lockfile
- uses: ./.github/actions/zig
- - uses: ./.github/actions/native-packages
+ - uses: actions/download-artifact@v4
with:
- target: linux-x64-gnu
- - name: Build and verify Linux x64 musl release packages
- run: |
- pnpm --filter @scriptc/llvm-linux-x64-musl build:native
- CC=zig AR=zig pnpm --filter @scriptc/runtime-linux-x64-musl build:native
- helper=packages/llvm-linux-x64-musl/bin/scriptc-llvm-codegen
- readelf -l "$helper" > "$RUNNER_TEMP/musl-helper-program-headers"
- if grep -q INTERP "$RUNNER_TEMP/musl-helper-program-headers"; then
- echo "musl helper must be a static executable" >&2
- exit 1
- fi
- docker run --rm --volume "$GITHUB_WORKSPACE:/work:ro" alpine:3.22 \
- /work/$helper version --format=json | node -e '
- const value = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
- if (value.default_target !== "x86_64-unknown-linux-musl" || !value.supported_targets?.includes("wasm32-unknown-wasi")) process.exit(1);
- '
- node packages/runtime-linux-x64-musl/scripts/verify.mjs
- - uses: ./.github/actions/native-packages
- with:
- target: wasm32-wasi
- helper: "false"
+ name: native-linux-x64-gnu
+ path: ${{ runner.temp }}
+ - run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz"
- run: pnpm build
- name: Linux helper object format and no-clang output contract
run: |
@@ -751,6 +744,82 @@ jobs:
SCRIPTC_NO_CACHE=1 SCRIPTC_LEGACY_C_PIPELINE=0 node packages/cli/dist/main.js build tests/corpus/001-hello.ts -o /tmp/hello
test "$(/tmp/hello)" = "hello world"
'
+
+ llvm_artifacts_linux_x64_musl:
+ needs: [validation_scope, lanes, native_linux]
+ if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }}
+ name: test (Linux x64 LLVM artifacts, no clang, musl)
+ runs-on: ubuntu-24.04
+ # The static musl helper and runtime pack, restored from the
+ # native-packages cache when their inputs are unchanged: about 9 minutes
+ # when both are built. The runtime build emits its units through the
+ # host's glibc helper (runtime-unit.mjs), which comes from native_linux.
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 11.1.3
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .node-version
+ cache: pnpm
+ - uses: ./.github/actions/llvm-linux
+ - run: pnpm install --frozen-lockfile
+ - uses: ./.github/actions/zig
+ - uses: actions/download-artifact@v4
+ with:
+ name: native-linux-x64-gnu
+ path: ${{ runner.temp }}
+ - run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz"
+ - uses: ./.github/actions/native-packages
+ with:
+ target: linux-x64-musl
+ - name: Verify the Linux x64 musl release helper
+ run: |
+ helper=packages/llvm-linux-x64-musl/bin/scriptc-llvm-codegen
+ readelf -l "$helper" > "$RUNNER_TEMP/musl-helper-program-headers"
+ if grep -q INTERP "$RUNNER_TEMP/musl-helper-program-headers"; then
+ echo "musl helper must be a static executable" >&2
+ exit 1
+ fi
+ docker run --rm --volume "$GITHUB_WORKSPACE:/work:ro" alpine:3.22 \
+ /work/$helper version --format=json | node -e '
+ const value = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
+ if (value.default_target !== "x86_64-unknown-linux-musl" || !value.supported_targets?.includes("wasm32-unknown-wasi")) process.exit(1);
+ '
+
+ llvm_artifacts_linux_x64_wasi:
+ needs: [validation_scope, lanes, native_linux]
+ if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }}
+ name: test (Linux x64 LLVM artifacts, no clang, wasm32)
+ runs-on: ubuntu-24.04
+ # The wasm32-wasi runtime pack (restored from the native-packages cache
+ # when unchanged; its units go through native_linux's glibc helper) and
+ # the WASI smoke tests, which take 6 to 9 minutes.
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 11.1.3
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .node-version
+ cache: pnpm
+ - uses: ./.github/actions/llvm-linux
+ - run: pnpm install --frozen-lockfile
+ - uses: ./.github/actions/zig
+ - uses: actions/download-artifact@v4
+ with:
+ name: native-linux-x64-gnu
+ path: ${{ runner.temp }}
+ - run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz"
+ - uses: ./.github/actions/native-packages
+ with:
+ target: wasm32-wasi
+ helper: "false"
+ - run: pnpm build
- name: WASI helper object/runtime-pack smoke
run: pnpm test packages/cli/test/wasi-runtime-pack.test.ts tests/harness/wasm-library.test.ts
@@ -1120,6 +1189,8 @@ jobs:
ts7_parity,
checker_identity,
llvm_artifacts_linux_x64,
+ llvm_artifacts_linux_x64_musl,
+ llvm_artifacts_linux_x64_wasi,
llvm_artifacts_macos_arm64,
llvm_artifacts_macos_arm64_contracts,
llvm_artifacts_macos_x64,
@@ -1154,6 +1225,8 @@ jobs:
lane ts7_parity "${{ needs.ts7_parity.result }}" "${{ needs.lanes.outputs.linux }}"
lane checker_identity "${{ needs.checker_identity.result }}" "${{ needs.lanes.outputs.linux }}"
lane llvm_artifacts_linux_x64 "${{ needs.llvm_artifacts_linux_x64.result }}" "${{ needs.lanes.outputs.linux }}"
+ lane llvm_artifacts_linux_x64_musl "${{ needs.llvm_artifacts_linux_x64_musl.result }}" "${{ needs.lanes.outputs.linux }}"
+ lane llvm_artifacts_linux_x64_wasi "${{ needs.llvm_artifacts_linux_x64_wasi.result }}" "${{ needs.lanes.outputs.linux }}"
lane llvm_artifacts_macos_arm64 "${{ needs.llvm_artifacts_macos_arm64.result }}" "${{ needs.lanes.outputs.macos }}"
lane llvm_artifacts_macos_arm64_contracts "${{ needs.llvm_artifacts_macos_arm64_contracts.result }}" "${{ needs.lanes.outputs.macos }}"
lane llvm_artifacts_macos_x64 "${{ needs.llvm_artifacts_macos_x64.result }}" "${{ needs.lanes.outputs.macos }}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ae28f585f..bdbe0edde 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -231,8 +231,8 @@ jobs:
llvm_dist: x64
use_zig: true
runs-on: ${{ matrix.runner }}
- # The Intel macOS build took 24 minutes for 0.3.2 and passed 30 for 0.3.3.
- timeout-minutes: 45
+ # The Intel macOS build took 24 minutes for 0.3.2, 40 for 0.4.5, and passed 45 for 0.4.6.
+ timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
diff --git a/.gitignore b/.gitignore
index 04c889158..36b158611 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,6 +35,7 @@ output/
/packages/runtime-*/artifacts/
/packages/runtime-*/runtime-pack.json
/packages/runtime-*/.runtime-pack-*
+/packages/runtime-*/runtime-pack.verified.json*
# libnode packs: built by build:native, never committed
/packages/libnode-*/libnode.so.*
/packages/libnode-*/libnode.*.dylib
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8ab725f0..8116c5d08 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,46 @@ All notable changes to scriptc will be documented in this file.
+## 0.4.6
+
+### Highlights
+
+- **Node islands:** programs can load Node.js in-process on first use and run npm packages that scriptc doesn't compile in Node islands.
+ - Native values, closures and promises pass into islands by reference, and island handles, classes and collections can be used from compiled code.
+ - Modules are shared between a program and its islands, and island modules are evaluated only when the program uses them.
+ - Once Node loads, the runtime's event loop runs on Node's libuv, and programs exit cleanly with Node loaded.
+- **New options:**
+ - `--exceptions=rare` unwinds through recursive functions instead of checking a status after each call.
+ - `--static` builds static Linux executables (opt-in).
+ - `--large-pages` moves executable code onto huge pages; `--cpu-dispatch` adds x86-64-v3 copies of hot functions.
+ - Type patches replace a package's declaration files or supply missing declarations.
+ - The shape overlay types JavaScript from facts the compiler proves (opt-in).
+- **Faster programs:**
+ - Exceptions raise through cached unwind plans on x86-64 Linux, and every native function has an unwind table entry.
+ - Ordinary objects are stored as a layout plus value slots; unions of classes are the instance pointer itself; class instances that stay in their frame use frame storage.
+ - Object groups that die together are allocated in arenas, and the cycle collector does cheaper trial deletion and adapts its nursery trigger.
+ - The speed runtime is optimized with a committed runtime profile, and Math functions are computed as V8 does, with libm linked only when needed.
+ - Loops: short constant-count loops run together, for and while loops are versioned on the ranges their locals enter with, counted loops unroll by the period of a guard, and small leaf functions called from loops are inlined.
+ - Typed arrays: header fields are read once at a speculative loop's entry, products under bitwise operators stay in 32 bits, and shared typed-array elements are read and written with plain accesses at a counted loop's counter.
+ - Strings: UTF-16 indexes of long non-ASCII strings map in constant time, ASCII case converts eight bytes at a time, template literals allocate at their exact length, and strings iterate over their bytes.
+ - Untyped code: class instance fields are read inline at dynamic property sites with two inline-cache entries, native arrays iterate without type-name scans, and numbers pass to directly called untyped functions as JavaScript values.
+ - I/O: fs thread-pool completions and pool-thread writes make fewer syscalls; sockets, clients and agent entries register in constant time; fetch() reuses keep-alive connections; web streams, WritableStream and TransformStream run natively.
+ - Large executables map their text with 2 MiB pages, and freed heap pages are returned to the system after the frontend is disposed.
+- **Faster builds:**
+ - Small release and speed programs compile in balanced partitions, the entry module's top-level code gets its own partition, and unchanged partitions stay cached across edits.
+ - Optimized executables that stay one partition generate code concurrently, and emission's workers stay busy.
+ - The native compiler is laid out by context-sensitive profiles collected in two phases.
+ - Type checking uses a shared tsc-ts server by default on Linux and macOS, and lib diagnostics are reused across options and module sets that cannot change them.
+ - Rebuilds skip the helper version probe and the digests of unchanged files, and the CLI links without huge text pages, so cold builds use less memory.
+- **Node.js parity:**
+ - `Error.captureStackTrace`, Node's navigator, builtin globals read through the global object, and unhandled rejections reported in programs without async work.
+ - node:zlib is built on the zlib Node bundles; `Response.clone` and `Request.clone` work on typed values; fetch bodies have the members of a web ReadableStream; redirects whose chunked body has not ended are followed.
+ - Classes that extend Array compile; strings spread into their code points; frozen checked records stay frozen; sloppy-mode `this` is the global object.
+ - `util.format` runtime format strings, `inspect.custom` and colors, and untyped `promisify`.
+- **TypeScript:** scriptc reads TypeScript 7.1's binary AST format and API server beside 7.0's, and tsc-ts is updated to 0.0.10.
+
+
+
## 0.4.5
### Highlights
@@ -40,8 +80,6 @@ All notable changes to scriptc will be documented in this file.
- `util.inspect` of dynamic classes, functions and `%o` arguments, and `Symbol.toStringTag` fields and getters, match Node.
- **npm packages:** more npm and CommonJS dependencies compile statically, including namespace-style packages with guarded declarations and `var` exports, and untyped Maps, Sets and arrays are typed from their uses.
-
-
## 0.4.4
### Highlights
diff --git a/benchmarks/runtime/workloads/stream-buffered-drain.json b/benchmarks/runtime/workloads/stream-buffered-drain.json
new file mode 100644
index 000000000..e1746d52b
--- /dev/null
+++ b/benchmarks/runtime/workloads/stream-buffered-drain.json
@@ -0,0 +1,4 @@
+{
+ "entry": "workloads/stream-buffered-drain.ts",
+ "args": []
+}
diff --git a/benchmarks/runtime/workloads/stream-buffered-drain.ts b/benchmarks/runtime/workloads/stream-buffered-drain.ts
new file mode 100644
index 000000000..62f89833b
--- /dev/null
+++ b/benchmarks/runtime/workloads/stream-buffered-drain.ts
@@ -0,0 +1,36 @@
+// Readable streams that buffer many chunks before anyone reads them, then
+// drain: 200,000 objects pushed into an object-mode Readable and taken
+// with read() in a 'readable' handler, and 100,000 small Buffers pushed
+// into a byte-mode Readable and piped into a Writable.
+import { Readable, Writable } from "node:stream";
+
+const objects = new Readable({ objectMode: true, highWaterMark: 16, read() {} });
+for (let i = 0; i < 200000; i++) objects.push({ id: i, qty: i % 9 });
+objects.push(null);
+
+let taken = 0;
+let qty = 0;
+objects.on("readable", () => {
+ let row: { id: number; qty: number } | null;
+ while ((row = objects.read() as { id: number; qty: number } | null) !== null) {
+ taken++;
+ qty += row.qty;
+ }
+});
+objects.on("end", () => {
+ console.log(`objects taken=${taken} qty=${qty}`);
+ const bytes = new Readable({ read() {} });
+ for (let i = 0; i < 100000; i++) bytes.push(Buffer.from([i & 0xff, (i >> 8) & 0xff, 7]));
+ bytes.push(null);
+ let length = 0;
+ let sum = 0;
+ const sink = new Writable({
+ write(chunk: Buffer, _enc, cb) {
+ length += chunk.length;
+ for (const b of chunk) sum = (sum + b) % 65521;
+ cb();
+ },
+ });
+ sink.on("finish", () => console.log(`bytes length=${length} sum=${sum}`));
+ bytes.pipe(sink);
+});
diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx
index f439edf52..fc3ae2790 100644
--- a/docs/content/docs/cli.mdx
+++ b/docs/content/docs/cli.mdx
@@ -309,14 +309,14 @@ A profile that already holds context-sensitive counts cannot start a second phas
### Block counts
-A profile matches a function only while the function's control flow hashes as it did when the profile was collected, so a change to the program or to scriptc leaves the changed functions without counts. A block-count file beside the profile, with its name and a `.blocks` extension in place of `.profdata` (`app.blocks` beside `app.profdata`), describes each function by the labels of its blocks instead. `--profile-use` reads it when it exists and annotates the functions whose control flow changed (the mismatched ones; a function the profile has no record of keeps no counts): blocks that keep their place in the function take their recorded counts, and the counts of the other blocks follow from the counts of the blocks and edges around them. The report then adds a line, and the warning counts only the functions that remain without counts:
+A profile matches a function only while the function's control flow hashes as it did when the profile was collected, so a change to the program or to scriptc leaves the changed functions without counts. A block-count file beside the profile, with its name and a `.blocks` extension in place of `.profdata` (`app.blocks` beside `app.profdata`), describes each function by the labels of its blocks instead. `--profile-use` reads it when it exists and annotates the functions whose control flow changed (the mismatched ones): blocks that keep their place in the function take their recorded counts, and the counts of the other blocks follow from the counts of the blocks and edges around them. The file also names the functions that never ran while the profile was collected, which a profile merged with `--sparse` has no record of. Those functions are compiled as cold code (an entry count of 0, optimized for size and placed with the other unlikely code), which shortens the build. A function the file does not name, for example one added since the profile was collected, keeps no counts and is optimized as without a profile. The report then adds a line, and the warning counts only the functions that remain without counts:
```
scriptc: profile app-cs.profdata: 0 functions annotated, 1 mismatched, 0 without a record
scriptc: profile app-cs.profdata: 1 function without profile counts annotated from app-cs.blocks
```
-A `--profile-use` build writes the block counts of the functions its profile annotates when the `SCRIPTC_RECORD_PROFILE_BLOCKS` environment variable names a path; such a build reuses no cached artifacts. For a context-sensitive profile, record them in the second phase, which the first-phase profile annotates:
+A `--profile-use` build writes the block counts of the functions its profile annotates, and the names of the functions it has no record of, when the `SCRIPTC_RECORD_PROFILE_BLOCKS` environment variable names a path; such a build reuses no cached artifacts. For a context-sensitive profile, record them in the second phase, which the first-phase profile annotates:
```console
$ SCRIPTC_RECORD_PROFILE_BLOCKS=app-cs.blocks scriptc build app.ts --optimization=speed --profile-use app.profdata --profile-generate profiles-cs/%p.profraw -o app-instrumented-cs
diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx
index 88b2ca5fc..192c376be 100644
--- a/docs/content/docs/limitations.mdx
+++ b/docs/content/docs/limitations.mdx
@@ -100,6 +100,8 @@ title: "Limitations"
../limitations/builtin-apis/file-streams.mdx
+../limitations/builtin-apis/file-watching.mdx
+
../limitations/builtin-apis/crypto.mdx
../limitations/builtin-apis/assertions.mdx
@@ -172,6 +174,8 @@ title: "Limitations"
../limitations/runtime-differences-from-node-js.mdx
+../limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx
+
### Array bounds and runtime errors
../limitations/array-bounds-and-runtime-errors.mdx
diff --git a/docs/content/limitations/builtin-apis/date.mdx b/docs/content/limitations/builtin-apis/date.mdx
index d783984fa..c15efd94e 100644
--- a/docs/content/limitations/builtin-apis/date.mdx
+++ b/docs/content/limitations/builtin-apis/date.mdx
@@ -1,7 +1,7 @@
-- **Date:** Spread constructor arguments and locale formatters are unsupported.
+- **Date:** Spread constructor arguments, `toLocaleDateString()`, and locale or options arguments to the locale formatters are unsupported.
Dates are objects with identity: `===` and `==` compare identity, `new Date(date)` makes a new Date, setters change the one Date every reference holds, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in unions with other types, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators.
Thrown and rejected Dates keep their identity; `structuredClone` and `postMessage` copy them.
- Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, the local and UTC calendar getters and setters, `setTime`, `getTimezoneOffset`, and single-string `Date.parse` compile.
+ Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, the argument-free `toLocaleString()` and `toLocaleTimeString()` (the default locale's ICU patterns over the host time zone, as Node prints them), the local and UTC calendar getters and setters, `setTime`, `getTimezoneOffset`, and single-string `Date.parse` compile.
Unary `+`, `-` and `Number(date)` read the time value; `String(date)`, template literals and `+` with a string read the `toString()` text; `date[Symbol.toPrimitive](hint)` compiles with a literal hint.
A Date converted to `unknown` is the same Date object.
See [Dates and timezones](#dates-and-timezones) for timezone data.
diff --git a/docs/content/limitations/builtin-apis/file-watching.mdx b/docs/content/limitations/builtin-apis/file-watching.mdx
new file mode 100644
index 000000000..2d2b386f0
--- /dev/null
+++ b/docs/content/limitations/builtin-apis/file-watching.mdx
@@ -0,0 +1,5 @@
+- **File watching:** `fs.watch` returns an `FSWatcher` whose `on`, `once`, `off`, and `close` take `change`, `rename`, `error`, and `close` listeners.
+ A listener may take the event name and the entry name, but the name arrives as a string (Node's default encoding), so a listener annotated with the `Buffer` arm of `@types/node`'s `change` overload has no lowering.
+ Watching a directory on macOS with a listener that reads the entry name is refused with `SC2020` because FSEvents is not implemented yet; file watches and listeners that ignore the name work on every target.
+- **Stat polling:** `fs.watchFile` polls the resolved path every `interval` milliseconds (default 5007) and calls its listener with `(current, previous)` `Stats`; `fs.unwatchFile` removes the last listener registered with that callback, or every listener when called without one.
+ The call must be a statement of its own, because the `StatWatcher` it returns in Node.js is not lowered, and `bigint: true` is refused.
diff --git a/docs/content/limitations/builtin-apis/intl-segmenter.mdx b/docs/content/limitations/builtin-apis/intl-segmenter.mdx
index 2682acaf0..f762b6712 100644
--- a/docs/content/limitations/builtin-apis/intl-segmenter.mdx
+++ b/docs/content/limitations/builtin-apis/intl-segmenter.mdx
@@ -1,3 +1,4 @@
- **Intl.Segmenter:** Unicode grapheme segmentation, iteration, and `containing()` compile, with the default locale or one language tag of the form language[-script][-region] written as a string literal (grapheme clusters do not depend on the locale).
- In JavaScript dependencies, word and sentence segmenters construct, and their `segment()` throws an SC2020 error.
- Locale negotiation over several locales, word and sentence segmentation, `resolvedOptions()`, and detached methods are unsupported.
+ Word granularity compiles too, over ICU's UAX #29 word iterator at that locale; its segments carry `isWordLike` exactly as Node.js's do.
+ In JavaScript dependencies, sentence segmenters construct, and their `segment()` throws an SC2020 error; in TypeScript sources the construction is refused.
+ Locale negotiation over several locales, sentence segmentation, `resolvedOptions()`, and detached methods are unsupported.
diff --git a/docs/content/limitations/builtin-apis/process-memory.mdx b/docs/content/limitations/builtin-apis/process-memory.mdx
index 6a3121e2f..6865fd7ff 100644
--- a/docs/content/limitations/builtin-apis/process-memory.mdx
+++ b/docs/content/limitations/builtin-apis/process-memory.mdx
@@ -1,2 +1,5 @@
- **Process memory:** `process.memoryUsage()` reads `rss` from the operating system and `heapTotal` and `heapUsed` from the runtime's allocator, so the heap figures start near zero and grow as the program allocates.
- `external` and `arrayBuffers` are `0`, because Buffer and typed-array storage is part of the same heap.
+ `arrayBuffers` is the storage of the ArrayBuffers, typed arrays, Buffers, DataViews and SharedArrayBuffers the program holds, and `external` is the same storage without SharedArrayBuffers, as in Node.
+ Node also counts its own startup allocations in both, so a program that holds no such storage reports `0` where Node reports a few megabytes.
+ In a program with worker threads, `external` and the non-shared part of `arrayBuffers` count the calling thread's storage.
+ The storage comes from the same allocator, so it also counts in `heapTotal` and `heapUsed`.
diff --git a/docs/content/limitations/builtin-apis/streams-and-readline.mdx b/docs/content/limitations/builtin-apis/streams-and-readline.mdx
index 52bf534e8..01cd9eb9d 100644
--- a/docs/content/limitations/builtin-apis/streams-and-readline.mdx
+++ b/docs/content/limitations/builtin-apis/streams-and-readline.mdx
@@ -35,7 +35,9 @@
`stream.compose` accepts streams and async generator functions in any position, an async function as the last stage, and an array, a string, or a generator as the first stage; one stage is `Duplex.from` of it.
A web `ReadableStream` is read as an async iterable by `Readable.from` and as the first stage of `compose` or `pipeline`, and through its reader by `Readable.fromWeb` (with `objectMode`, `highWaterMark`, `encoding`, and `signal` in an options object literal) and `Duplex.from`.
In TypeScript, `new TransformStream()` without arguments builds the identity transform: its readable side is a web `ReadableStream`, its writable side supports `getWriter`, `abort`, `close`, and `locked`, and the writer supports `write`, `close`, `abort`, `releaseLock`, `ready`, `closed`, and `desiredSize`, with Node's backpressure (a write completes when the readable side reads its chunk).
- A web stream in a later compose stage and `Readable.toWeb` are unsupported, and in TypeScript, so are a `TransformStream` with a transformer or queuing strategies, the `WritableStream` constructor, `pipeTo`, and `pipeThrough`.
+ In TypeScript, `tee`, `pipeTo`, and `pipeThrough`, the `WritableStream` constructor with an underlying sink, and a `TransformStream` with a transformer or queuing strategies compile: a program that uses one of them builds its `ReadableStream`, `WritableStream`, and `TransformStream` values with the same classes as JavaScript, and a `Response` or `Request` body has those members too.
+ A TypeScript program that uses only `getReader`, `cancel`, `locked`, and the identity `TransformStream` keeps the smaller native streams.
+ A web stream in a later compose stage and `Readable.toWeb` are unsupported.
The `ERR_INVALID_ARG_VALUE` message for a stream that is no longer readable or writable names the stream's class instead of printing the stream.
`readable.compose(stage, { signal })` is `stream.compose(readable, stage)` with the signal added; the options are an object literal whose `signal` is a variable or a property read.
`pipeline` from `node:stream/promises` accepts a trailing `{ signal, end }` object, with or without function stages, and `finished` accepts `readable`, `writable`, `error`, `cleanup`, and `signal` options, with boolean literals for the flags.
@@ -60,7 +62,7 @@
The `readline/promises` `Readline` class writes to the same outputs.
`readline.emitKeypressEvents` emits `'keypress'` events on `process.stdin` or a readable stream, shared with terminal interfaces on that stream as in Node; a listener's first parameter must admit `undefined`, which it receives for escape sequences.
Chained listener registration is unsupported.
- `node:stream/web` exports the same classes as the globals; in TypeScript, only `ReadableStream` and the identity `TransformStream` have lowerings.
+ `node:stream/web` exports the same classes as the globals; in TypeScript, `ReadableStream`, `WritableStream`, and `TransformStream` have lowerings.
In JavaScript, `ReadableStream`, `WritableStream`, and `TransformStream` with their default readers, writers, and controllers, `CountQueuingStrategy`, `ByteLengthQueuingStrategy`, `TextEncoderStream`, and `TextDecoderStream`, as globals and as `node:stream/web` exports, are constructor values with Node's prototypes, brand checks, and errors.
Their underlying sources, sinks, and transformers, queuing strategies with a `size` function, readers and writers, `tee`, `pipeTo` and `pipeThrough` with their options and an abort signal, `ReadableStream.from`, and async iteration settle promises in Node's order, and the streams take own properties.
`TextDecoderStream` decodes UTF-8; its other encodings are unsupported.
diff --git a/docs/content/limitations/builtin-apis/v8.mdx b/docs/content/limitations/builtin-apis/v8.mdx
index 066fb000d..daafa3d6c 100644
--- a/docs/content/limitations/builtin-apis/v8.mdx
+++ b/docs/content/limitations/builtin-apis/v8.mdx
@@ -1,5 +1,5 @@
- **V8 heap statistics:** `node:v8` can be imported.
- `v8.getHeapStatistics()` returns Node's fields in Node's order, computed from the runtime allocator like `process.memoryUsage()`: `total_heap_size` and `total_physical_size` are the bytes the heap holds, `used_heap_size` the bytes allocated, `heap_size_limit` the machine's memory (the heap has no fixed ceiling), and `total_available_size` the limit minus the used bytes.
- Fields that describe V8 itself (`total_heap_size_executable`, `malloced_memory`, `peak_malloced_memory`, the global handle sizes, `external_memory`, and `does_zap_garbage`) are `0`, and `number_of_native_contexts` is `1`.
+ `v8.getHeapStatistics()` returns Node's fields in Node's order, computed from the runtime allocator like `process.memoryUsage()`: `total_heap_size` and `total_physical_size` are the bytes the heap holds, `used_heap_size` the bytes allocated, `external_memory` the same figure as `process.memoryUsage().external`, `heap_size_limit` the machine's memory (the heap has no fixed ceiling), and `total_available_size` the limit minus the used bytes.
+ Fields that describe V8 itself (`total_heap_size_executable`, `malloced_memory`, `peak_malloced_memory`, the global handle sizes, and `does_zap_garbage`) are `0`, and `number_of_native_contexts` is `1`.
The values are approximate and are not V8 heap figures.
The other `node:v8` members observe the V8 engine, such as heap snapshots, profiles, flags, and serialization, and throw `SC2020` where they are used.
diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx
index 96c48375c..f6a14be60 100644
--- a/docs/content/limitations/builtin-function-values.mdx
+++ b/docs/content/limitations/builtin-function-values.mdx
@@ -1,6 +1,8 @@
Some builtins can be stored and passed as functions:
- Lowered `node:path` functions across the bare, POSIX, and win32 modules, including optional `basename` and variadic `join`/`resolve`.
+ Called through an untyped value, they validate their arguments with Node's `ERR_INVALID_ARG_TYPE`.
+ A path module held by untyped code is a plain object with Node's members in Node's order.
- Supported zero-argument `node:os` functions and `querystring.escape`/`unescape`.
- `util.isDeepStrictEqual`, `styleText`, `stripVTControlCharacters`, `toUSVString`, `inherits`, `deprecate`, `callbackify`, `debuglog`, and `debug`.
- `fs.existsSync`, `unlinkSync`, `chmodSync`, `chownSync`, `renameSync`, and `closeSync`, and `fs/promises.unlink`, `chmod`, and `rename`.
diff --git a/docs/content/limitations/console-and-process-streams.mdx b/docs/content/limitations/console-and-process-streams.mdx
index 946cb8061..8173d037e 100644
--- a/docs/content/limitations/console-and-process-streams.mdx
+++ b/docs/content/limitations/console-and-process-streams.mdx
@@ -12,9 +12,11 @@ Stored global console values and detached output methods retain identity and acc
Stored methods do not support format specifiers with substitution arguments.
Other stored console APIs, custom `Console` instances, and console mutation are unsupported.
-JavaScript can store `process.stdin`, `stdout`, and `stderr`, replace output `write` methods, and call saved methods with their stream receiver.
+JavaScript and TypeScript can store `process.stdin`, `stdout`, and `stderr`, replace output `write` methods, and call saved methods with their stream receiver.
+A replaced `write` method does not receive console's error-callback argument.
Input supports data/end/error listeners, pause/resume, buffered reads, and TTY raw mode.
Output writes are synchronous, with successful callbacks on the next-tick queue; queued backpressure is not modeled.
+Because no write is ever queued, `writableLength` is 0, `writableNeedDrain` is false, `writableHighWaterMark` is 65536, and `'drain'` never fires, so drain listeners register silently and `events.once(process.stdout, "drain")` stays pending, as it does in Node when no write returned false.
Stored streams do not support piping, encoding changes, arbitrary properties, or other events.
A TypeScript `NodeJS.WritableStream` parameter that only receives `process.stdout` or `process.stderr` supports `write(string)`.
When other streams flow into the same parameter, the process streams join them as `Writable` instances with the full writable surface.
diff --git a/docs/content/limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx b/docs/content/limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx
new file mode 100644
index 000000000..49b748f65
--- /dev/null
+++ b/docs/content/limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx
@@ -0,0 +1,3 @@
+- **Event order across native code and islands:** Timers, worker output, and other macrotasks from the program and from JavaScript it runs in a Node island share one event loop under Node.js, so their order is fixed.
+ A compiled program runs both on the event loop it hosts for Node.js, but it does not merge them into one order, so callbacks from the two sides can run in a different order than under Node.js, and the order can vary between runs.
+ Only programs that depend on the ordering between native and island callbacks are affected.
diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json
index 387f5b21c..391d7a5f8 100644
--- a/docs/src/generated/node-v24-compatibility.json
+++ b/docs/src/generated/node-v24-compatibility.json
@@ -30842,9 +30842,9 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "detail": "No static compiler lowering is registered for this API yet.",
- "verification": "registry-gap"
+ "status": "partial",
+ "detail": "Implemented by a dedicated static compiler/runtime path.",
+ "verification": "test-backed"
}
},
{
@@ -30911,9 +30911,9 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "detail": "No static compiler lowering is registered for this API yet.",
- "verification": "registry-gap"
+ "status": "partial",
+ "detail": "Implemented by a dedicated static compiler/runtime path.",
+ "verification": "test-backed"
}
},
{
@@ -32659,9 +32659,9 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "detail": "No static compiler lowering is registered for this API yet.",
- "verification": "registry-gap"
+ "status": "partial",
+ "detail": "Implemented by a dedicated static compiler/runtime path.",
+ "verification": "test-backed"
}
},
{
diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json
index 00d6f8db3..5e5cc8c4c 100644
--- a/internal/compatibility/generated/node-v24-internal.json
+++ b/internal/compatibility/generated/node-v24-internal.json
@@ -44292,6 +44292,7 @@
"evidence": "derived:descendants",
"tests": [
"tests/corpus/1403-buffer-fs.ts",
+ "tests/corpus/1564-fs-watch.ts",
"tests/corpus/2595-fs-arg-ladders.cjs",
"tests/corpus/2686-fs-file-handle.ts",
"tests/corpus/2844-node-disposable-resources.ts",
@@ -44328,6 +44329,8 @@
"tests/corpus/6207-fs-constants-object.ts",
"tests/corpus/6208-fs-constants-require.cjs",
"tests/corpus/fs-native-realpath.ts",
+ "tests/corpus/fs-watch-file-names.ts",
+ "tests/corpus/fs-watch-file-polling.ts",
"tests/corpus/node24-fs-links-callbacks.cjs",
"tests/corpus/node24-fs-links-errors.cjs",
"tests/corpus/node24-fs-links-options.cjs",
@@ -47429,8 +47432,13 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:fs"
+ "status": "partial",
+ "evidence": "compiler-dedicated:fs.unwatchFile",
+ "tests": [
+ "tests/corpus/1564-fs-watch.ts",
+ "tests/corpus/fs-watch-file-names.ts",
+ "tests/corpus/fs-watch-file-polling.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -47601,8 +47609,13 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:fs"
+ "status": "partial",
+ "evidence": "compiler-dedicated:fs.watchFile",
+ "tests": [
+ "tests/corpus/1564-fs-watch.ts",
+ "tests/corpus/fs-watch-file-names.ts",
+ "tests/corpus/fs-watch-file-polling.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -49669,8 +49682,13 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:fs"
+ "status": "partial",
+ "evidence": "compiler-dedicated:fs.FSWatcher",
+ "tests": [
+ "tests/corpus/1564-fs-watch.ts",
+ "tests/corpus/fs-watch-file-names.ts",
+ "tests/corpus/fs-watch-file-polling.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -79679,6 +79697,7 @@
"tests/corpus/process-builtin-values.js",
"tests/corpus/process-hrtime.js",
"tests/corpus/process-named-signals.cjs",
+ "tests/corpus/process-stream-drain.ts",
"tests/corpus/process-uncaught-handler-throws.cjs",
"tests/corpus/process-uncaught-loop.cjs",
"tests/corpus/process-uncaught-module.mjs",
@@ -79686,9 +79705,11 @@
"tests/corpus/process-uncaught-rejection.cjs",
"tests/corpus/process-uncaught-sync.cjs",
"tests/corpus/process-versions-storage.ts",
+ "tests/corpus/process-write-dynamic-chunks.ts",
"tests/corpus/stdio-values.mjs",
"tests/corpus/stdio-write-errors.mjs",
"tests/corpus/stdio-write-overrides.mjs",
+ "tests/corpus/stdout-write-patch.ts",
"tests/fixtures/node-types/env-files.ts",
"tests/fixtures/node-types/util-callables.ts",
"tests/harness/event-loop.test.ts",
@@ -82047,6 +82068,9 @@
"tests/corpus/stdio-values.mjs",
"tests/corpus/stdio-write-overrides.mjs",
"tests/corpus/stdio-write-errors.mjs",
+ "tests/corpus/stdout-write-patch.ts",
+ "tests/corpus/process-stream-drain.ts",
+ "tests/corpus/process-write-dynamic-chunks.ts",
"tests/harness/event-loop.test.ts"
]
},
@@ -82089,6 +82113,9 @@
"tests/corpus/stdio-values.mjs",
"tests/corpus/stdio-write-overrides.mjs",
"tests/corpus/stdio-write-errors.mjs",
+ "tests/corpus/stdout-write-patch.ts",
+ "tests/corpus/process-stream-drain.ts",
+ "tests/corpus/process-write-dynamic-chunks.ts",
"tests/harness/event-loop.test.ts"
]
},
@@ -82131,6 +82158,9 @@
"tests/corpus/stdio-values.mjs",
"tests/corpus/stdio-write-overrides.mjs",
"tests/corpus/stdio-write-errors.mjs",
+ "tests/corpus/stdout-write-patch.ts",
+ "tests/corpus/process-stream-drain.ts",
+ "tests/corpus/process-write-dynamic-chunks.ts",
"tests/harness/event-loop.test.ts"
]
},
@@ -86933,7 +86963,8 @@
"tests/corpus/5611-readable-from-class-iterables.ts",
"tests/corpus/5612-readable-from-iterables.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "chapter"
@@ -87059,7 +87090,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -87093,7 +87125,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -87127,7 +87160,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -87184,7 +87218,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -87218,7 +87253,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -87252,7 +87288,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -87648,7 +87685,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -87705,7 +87743,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -87739,7 +87778,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -87796,7 +87836,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "ancestor"
@@ -87830,7 +87871,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "ancestor"
@@ -87864,7 +87906,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "ancestor"
@@ -88260,7 +88303,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "ancestor"
@@ -88363,7 +88407,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -88423,7 +88468,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -88457,7 +88503,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -88491,7 +88538,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -88548,7 +88596,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -89141,7 +89190,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -89175,7 +89225,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -89209,7 +89260,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -89243,7 +89295,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -89277,7 +89330,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -89311,7 +89365,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -89345,7 +89400,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -89379,7 +89435,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -89413,7 +89470,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -90832,7 +90890,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "exact"
@@ -91096,7 +91155,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "ancestor"
@@ -91130,7 +91190,8 @@
"tests/corpus/5425-pipeline-generator-stage.ts",
"tests/corpus/5428-pipeline-generator-stages.cjs",
"tests/corpus/5617-pipeline-function-ends.ts",
- "tests/corpus/5619-pipeline-function-ends.cjs"
+ "tests/corpus/5619-pipeline-function-ends.cjs",
+ "tests/corpus/writable-error-callbacks.ts"
]
},
"anchorSource": "ancestor"
diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json
index 1242ad9a7..3918a8941 100644
--- a/internal/compatibility/static-support.json
+++ b/internal/compatibility/static-support.json
@@ -314,7 +314,7 @@
{ "chapter": "process", "signatures": ["`process.hrtime([time])`", "`process.hrtime.bigint()`"], "status": "partial", "evidence": ["tests/corpus/process-hrtime.js"] },
{ "chapter": "errors", "signatures": ["`stackTraceLimit` Type: {number}"], "status": "partial", "evidence": ["tests/corpus/error-stack-limit.ts"] },
{ "chapter": "errors", "signatures": ["`stack` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/error-stack-frames.ts", "tests/corpus/error-stack-limit.ts"] },
- { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/harness/event-loop.test.ts"] },
+ { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", "tests/corpus/process-stream-drain.ts", "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts"] },
{ "chapter": "process", "signatures": ["Signal events"], "status": "partial", "evidence": ["tests/corpus/1443-signal-handlers.ts", "tests/corpus/process-named-signals.cjs"] },
{ "chapter": "process", "signatures": ["Event: `'uncaughtException'`", "Event: `'uncaughtExceptionMonitor'`"], "status": "partial", "evidence": ["tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-uncaught-loop.cjs", "tests/corpus/process-uncaught-module.mjs", "tests/corpus/process-uncaught-rejection.cjs", "tests/corpus/process-uncaught-monitor.cjs", "tests/corpus/process-uncaught-handler-throws.cjs"] },
{ "chapter": "process", "signatures": ["`process.getBuiltinModule(id)`"], "status": "partial", "evidence": ["tests/corpus/process-builtin-values.js", "packages/compiler/test/process-builtin-values.test.ts"] },
@@ -478,13 +478,14 @@
{ "symbols": ["StringDecoder", "stringDecoder.write", "stringDecoder.end"], "status": "partial", "evidence": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts"] },
{ "symbols": ["setImmediate", "setInterval", "setTimeout", "clearImmediate", "clearInterval", "clearTimeout", "timersPromises.setTimeout", "timersPromises.setImmediate", "timersPromises.setInterval", "immediate.hasRef", "immediate.ref", "immediate.unref", "timeout.hasRef", "timeout.ref", "timeout.unref", "timeout.refresh"], "status": "partial", "evidence": ["tests/corpus/1804-timers-module.ts", "tests/corpus/1805-timer-callback-args.ts", "tests/corpus/2093-timers-promises.ts", "tests/corpus/2852-timers-promises-interval.ts", "tests/corpus/5830-timers-promises-signal.ts"] },
{ "symbols": ["timersPromises.scheduler.wait", "timersPromises.scheduler.yield"], "status": "partial", "evidence": ["tests/corpus/5839-timers-promises-scheduler.ts", "tests/corpus/5889-timers-scheduler-signal.ts"] },
- { "symbols": ["stream.finished", "stream.pipeline", "stream.getDefaultHighWaterMark", "readable.push", "readable.unshift", "readable.read", "readable.pause", "readable.resume", "readable.isPaused", "readable.setEncoding", "readable.pipe", "readable.unpipe", "readable.destroy", "writable.write", "writable.end", "writable.cork", "writable.uncork", "writable.setDefaultEncoding"], "status": "partial", "evidence": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1688-stream-writable-basics.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/5302-stream-promises-options.ts", "tests/corpus/5303-stream-finished-callback-opts.cjs", "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", "tests/corpus/5619-pipeline-function-ends.cjs"] },
+ { "symbols": ["stream.finished", "stream.pipeline", "stream.getDefaultHighWaterMark", "readable.push", "readable.unshift", "readable.read", "readable.pause", "readable.resume", "readable.isPaused", "readable.setEncoding", "readable.pipe", "readable.unpipe", "readable.destroy", "writable.write", "writable.end", "writable.cork", "writable.uncork", "writable.setDefaultEncoding"], "status": "partial", "evidence": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1688-stream-writable-basics.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/5302-stream-promises-options.ts", "tests/corpus/5303-stream-finished-callback-opts.cjs", "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", "tests/corpus/5619-pipeline-function-ends.cjs", "tests/corpus/writable-error-callbacks.ts"] },
{ "symbols": ["net.createServer", "net.connect", "net.createConnection", "net.getDefaultAutoSelectFamilyAttemptTimeout", "net.setDefaultAutoSelectFamilyAttemptTimeout", "server.address", "server.close", "server.listen", "socket.destroy", "socket.end", "socket.pause", "socket.resume", "socket.setEncoding", "socket.setTimeout", "socket.write"], "status": "partial", "evidence": ["tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs"] },
{ "symbols": ["dgram.createSocket", "socket.address", "socket.bind", "socket.close", "socket.connect", "socket.ref", "socket.remoteAddress", "socket.send", "socket.unref"], "status": "partial", "evidence": ["tests/corpus/2597-dgram-send-ladders.cjs"] },
{ "symbols": ["net.SocketAddress", "net.BlockList"], "status": "partial", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] },
{ "symbols": ["SocketAddress.parse", "BlockList.isBlockList", "blockList.addAddress", "blockList.addAddresses", "blockList.addCIDR", "blockList.addCIDRs", "blockList.addRange", "blockList.addSubnet", "blockList.check", "blockList.clear", "blockList.fromJSON", "blockList.removeAddress", "blockList.removeCIDR", "blockList.removeRange", "blockList.removeSubnet", "blockList.toJSON"], "status": "supported", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] },
{ "symbols": ["readline.createInterface", "rl.close"], "status": "partial", "evidence": ["tests/corpus/1475-readline-closed-stdin.ts", "tests/corpus/5878-readline-dynamic-streams.mjs"] },
{ "symbols": ["test", "it", "test.skip", "test.todo", "test.only", "describe", "suite", "before", "after", "beforeEach", "afterEach", "context.test", "context.skip", "context.todo", "context.assert"], "status": "partial", "evidence": ["tests/harness/node-test.test.ts"] },
- { "symbols": ["http.request", "http.get", "http.createServer", "https.request", "https.get", "https.createServer", "request.end", "request.destroy", "request.setTimeout", "request.write", "response.end", "response.getHeader", "response.hasHeader", "response.removeHeader", "response.setHeader", "response.write", "response.writeHead", "headersTimeout", "requestTimeout", "timeout", "keepAliveTimeout", "keepAliveTimeoutBuffer"], "status": "partial", "evidence": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2689-http-server-timeout-properties.ts"] }
+ { "symbols": ["http.request", "http.get", "http.createServer", "https.request", "https.get", "https.createServer", "request.end", "request.destroy", "request.setTimeout", "request.write", "response.end", "response.getHeader", "response.hasHeader", "response.removeHeader", "response.setHeader", "response.write", "response.writeHead", "headersTimeout", "requestTimeout", "timeout", "keepAliveTimeout", "keepAliveTimeoutBuffer"], "status": "partial", "evidence": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2689-http-server-timeout-properties.ts"] },
+ { "symbols": ["fs.FSWatcher", "fs.watchFile", "fs.unwatchFile"], "status": "partial", "evidence": ["tests/corpus/1564-fs-watch.ts", "tests/corpus/fs-watch-file-names.ts", "tests/corpus/fs-watch-file-polling.ts"] }
]
}
diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp
index 17d69be72..52333ca59 100644
--- a/native/llvm-codegen/src/emit.cpp
+++ b/native/llvm-codegen/src/emit.cpp
@@ -252,15 +252,20 @@ struct UnnameStructTypes : PassInfoMixin {
// over partitions: functions that received its counts, functions whose
// control flow changed since it was collected (hash mismatch), and functions
// it has no record of (new since it was collected, or never run in training
-// when the profile was merged with --sparse). A partition reused from the cache without its counts is counted
-// in Unknown. Recovered counts the mismatched functions that --profile-blocks
-// annotated, so it never exceeds Mismatched.
+// when the profile was merged with --sparse). A partition reused from the cache
+// without its counts is counted in Unknown. Recovered counts the mismatched
+// functions that --profile-blocks annotated, so it never exceeds Mismatched.
+// The two weights sum the profile's counters for the annotated and for the
+// mismatched functions, so a changed hot function weighs more than a changed
+// cold one.
struct ProfileMatch {
std::atomic Annotated{0};
std::atomic Mismatched{0};
std::atomic Missing{0};
std::atomic Recovered{0};
std::atomic Unknown{0};
+ std::atomic AnnotatedCounts{0};
+ std::atomic MismatchedCounts{0};
// Set once the annotation before inlining has run. The context-sensitive
// annotation after inlining, which a single-module pipeline also runs,
// reports its mismatched and missing functions again; they are not
@@ -272,27 +277,32 @@ struct ProfileMatch {
Mismatched += Other.Mismatched;
Missing += Other.Missing;
Recovered += Other.Recovered;
+ AnnotatedCounts += Other.AnnotatedCounts;
+ MismatchedCounts += Other.MismatchedCounts;
}
std::string serialize() const {
- return formatv("{0} {1} {2} {3}", Annotated.load(), Mismatched.load(),
- Missing.load(), Recovered.load())
+ return formatv("{0} {1} {2} {3} {4} {5}", Annotated.load(),
+ Mismatched.load(), Missing.load(), Recovered.load(),
+ AnnotatedCounts.load(), MismatchedCounts.load())
.str();
}
bool deserialize(StringRef Text) {
- SmallVector Parts;
+ SmallVector Parts;
Text.split(Parts, ' ');
- uint64_t Values[4];
- if (Parts.size() != 4)
+ uint64_t Values[6];
+ if (Parts.size() != 6)
return false;
- for (size_t I = 0; I < 4; ++I)
+ for (size_t I = 0; I < 6; ++I)
if (Parts[I].getAsInteger(10, Values[I]))
return false;
Annotated += Values[0];
Mismatched += Values[1];
Missing += Values[2];
Recovered += Values[3];
+ AnnotatedCounts += Values[4];
+ MismatchedCounts += Values[5];
return true;
}
};
@@ -374,23 +384,34 @@ bool hasProfileCounts(const Function &F) {
// Runs right after the profile annotation, before inlining and dead code
// elimination change the functions it annotated: counts them and records
-// their block counts, then annotates the functions whose control flow
-// changed from FallbackCounts. A function the profile has no record of keeps
-// no counts, as without block counts. Returns the analyses that remain valid.
+// their block counts, then annotates from FallbackCounts the functions whose
+// control flow changed, and the functions the profile has no record of that
+// FallbackCounts records as never run. A sparse profile leaves out the
+// functions its training never ran; the block counts name them, so they get
+// an entry count of 0 and LLVM optimizes them as cold code, as a profile with
+// zero counts would make it. A function neither names keeps no counts, as
+// without block counts: it was added after the profile was collected.
+// Returns the analyses that remain valid.
PreservedAnalyses afterProfileAnnotation(Module &M, ProfileMatch &Match) {
std::vector Mismatched;
+ std::vector Unrecorded;
for (Function &F : M) {
if (F.isDeclaration())
continue;
if (!hasProfileCounts(F)) {
if (hasHashMismatchTag(F))
Mismatched.push_back(&F);
+ else
+ Unrecorded.push_back(&F);
continue;
}
++Match.Annotated;
if (Recorder)
Recorder->add(F);
}
+ if (Recorder)
+ for (Function *F : Unrecorded)
+ Recorder->addNeverRan(*F);
if (!FallbackCounts)
return PreservedAnalyses::all();
ProfileSummaryInfo Summary(M);
@@ -403,6 +424,13 @@ PreservedAnalyses afterProfileAnnotation(Module &M, ProfileMatch &Match) {
++Match.Recovered;
Preserved = PreservedAnalyses::none();
}
+ for (Function *F : Unrecorded) {
+ auto Record = FallbackCounts->find(F->getName());
+ if (Record == FallbackCounts->end() || Record->second.EntryCount != 0)
+ continue;
+ annotateFromBlocks(*F, Record->second, Summary);
+ Preserved = PreservedAnalyses::none();
+ }
return Preserved;
}
@@ -412,11 +440,77 @@ void reportProfileMatch(const ProfileMatch &Match) {
{"mismatched", Match.Mismatched.load()},
{"missing", Match.Missing.load()},
{"recovered", Match.Recovered.load()},
- {"unknown_partitions", Match.Unknown.load()}};
+ {"unknown_partitions", Match.Unknown.load()},
+ {"annotated_counts", Match.AnnotatedCounts.load()},
+ {"mismatched_counts", Match.MismatchedCounts.load()}};
json::Object Response{{"ok", true}, {"profile", std::move(Profile)}};
outs() << formatv("{0}\n", json::Value(std::move(Response)));
}
+// The sum of each function's counters in the indexed profile at `Path`, by
+// profile name, read once per process. An unreadable profile has no counts
+// (the annotation reports it).
+const StringMap &profileCounts(const std::string &Path) {
+ static std::once_flag Once;
+ static StringMap Counts;
+ std::call_once(Once, [&Path] {
+ Expected> Reader =
+ IndexedInstrProfReader::create(Path, *vfs::getRealFileSystem());
+ if (!Reader) {
+ consumeError(Reader.takeError());
+ return;
+ }
+ for (const NamedInstrProfRecord &Record : **Reader) {
+ uint64_t Sum = 0;
+ for (uint64_t Count : Record.Counts)
+ Sum += Count;
+ Counts[Record.Name] += Sum;
+ }
+ consumeError((*Reader)->getError());
+ });
+ return Counts;
+}
+
+// Weighs one module's profile match by the profile's counters: collects the
+// annotated and the mismatched functions while the module is optimized,
+// then adds their counts to the match.
+class MatchWeigher {
+public:
+ explicit MatchWeigher(const Module &M) {
+ for (const Function &F : M)
+ if (!F.isDeclaration())
+ ProfileNames[F.getName()] = getIRPGOFuncName(F);
+ }
+
+ void weigh(const std::string &ProfileUse, ProfileMatch &Match) const {
+ const StringMap &Counts = profileCounts(ProfileUse);
+ auto CountsOf = [&Counts](StringRef Name) -> uint64_t {
+ auto It = Counts.find(Name);
+ return It == Counts.end() ? 0 : It->second;
+ };
+ // A context-sensitive profile annotates the module twice; each function
+ // counts once.
+ StringSet<> Seen;
+ for (const std::string &Name : Annotated)
+ if (Seen.insert(Name).second)
+ Match.AnnotatedCounts += CountsOf(Name);
+ // The mismatch warnings name functions by symbol.
+ for (const std::string &Name : Mismatched) {
+ auto It = ProfileNames.find(Name);
+ StringRef ProfileName =
+ It == ProfileNames.end() ? StringRef(Name) : StringRef(It->second);
+ if (Seen.insert(ProfileName).second)
+ Match.MismatchedCounts += CountsOf(ProfileName);
+ }
+ }
+
+ std::vector Annotated;
+ std::vector Mismatched;
+
+private:
+ StringMap ProfileNames;
+};
+
// Reads the profile's header once, before any partition needs it: it must
// hold IR-level counts. Context-sensitive counts lay out the code by them;
// a profile that has them cannot take a second set.
@@ -515,13 +609,15 @@ struct Pipeline {
});
}
- // Adds to Names the profile names of the functions the annotation gave
- // counts to, as soon as that pass has run.
+ // Adds to Names the profile names of the functions the annotation before
+ // inlining gave counts to, as soon as that pass has run.
void collectAnnotated(std::vector &Names) {
Callbacks.registerAfterPassCallback(
- [&Names](StringRef PassID, Any IR, const PreservedAnalyses &) {
- if (PassID != "PGOInstrumentationUse")
+ [&Names, Done = false](StringRef PassID, Any IR,
+ const PreservedAnalyses &) mutable {
+ if (Done || PassID != "PGOInstrumentationUse")
return;
+ Done = true;
if (const auto *M = llvm::any_cast(&IR))
for (const Function &F : **M) {
std::optional Entry = F.getEntryCount();
@@ -714,13 +810,21 @@ emitModule(Module &M, TargetMachine &Machine, OptimizationLevel Level,
StringRef OutputPath, std::vector> &Temporaries,
ProfileMatch &Match) {
bool Annotating = Profile && Profile->Action == PGOOptions::IRUse;
+ std::string ProfileUse = Annotating ? Profile->ProfileFile : "";
+ MatchWeigher Weigher(M);
if (Annotating)
M.getContext().setDiagnosticHandler(
- std::make_unique(Match));
+ std::make_unique(Match, &Weigher.Mismatched));
Pipeline P(Machine, Level, std::move(Profile));
- if (Annotating)
+ if (Annotating) {
+ // Registered first, so it lists the functions the profile itself
+ // annotated, before block counts recover any.
+ P.collectAnnotated(Weigher.Annotated);
P.afterProfileAnnotation(Match);
+ }
P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM);
+ if (Annotating)
+ Weigher.weigh(ProfileUse, Match);
if (std::optional Failure =
verify(M, "post_optimization_verification_failed"))
return Failure;
@@ -911,14 +1015,16 @@ compileOptimizedPartitions(const EmitOptions &Options,
return Failures.set({"target_machine_failed", LookupError});
LLVMContext Context;
ProfileMatch PartitionMatch;
+ std::vector MismatchedNames;
if (Annotating)
- Context.setDiagnosticHandler(
- std::make_unique(PartitionMatch));
+ Context.setDiagnosticHandler(std::make_unique(
+ PartitionMatch, &MismatchedNames));
Expected> Loaded =
loadPartition(Options, Plan.Modules[I], I, Context, *Machine);
if (!Loaded)
return Failures.set({"partition_failed", toString(Loaded.takeError())});
Module &Mod = **Loaded;
+ MatchWeigher Weigher(Mod);
if (!Options.ImportBitcode.empty()) {
// See emit(): the attribute is inert without a sanitizer pass and
// would block inlining of the imported runtime bodies.
@@ -937,8 +1043,12 @@ compileOptimizedPartitions(const EmitOptions &Options,
// Profile instrumentation or annotation happens here, before the thin
// link; the post-link optimization reads the annotated weights.
Pipeline P(*Machine, Level, profileOptions(Options));
- if (Annotating)
+ if (Annotating) {
+ // Registered first, so it lists the functions the profile itself
+ // annotated, before block counts recover any.
+ P.collectAnnotated(Weigher.Annotated);
P.afterProfileAnnotation(PartitionMatch);
+ }
ModulePassManager MPM = P.PB.buildThinLTOPreLinkDefaultPipeline(Level);
MPM.addPass(UnnameStructTypes());
// The module hash is set at the thin link instead (see Names).
@@ -948,6 +1058,8 @@ compileOptimizedPartitions(const EmitOptions &Options,
MPM.run(Mod, P.MAM);
}
if (Annotating) {
+ Weigher.Mismatched = std::move(MismatchedNames);
+ Weigher.weigh(Options.ProfileUse, PartitionMatch);
Match.add(PartitionMatch);
// Stored before the summary, so a reused summary finds its counts.
Cache.store(Key, "match", PartitionMatch.serialize());
@@ -1445,15 +1557,22 @@ std::optional
emitSplitCodegen(const EmitOptions &Options, Module &M, TargetMachine &Machine,
OptimizationLevel Level, ProfileMatch &Match) {
bool Annotating = !Options.ProfileUse.empty();
+ MatchWeigher Weigher(M);
if (Annotating)
M.getContext().setDiagnosticHandler(
- std::make_unique(Match));
+ std::make_unique(Match, &Weigher.Mismatched));
{
Pipeline P(Machine, Level, profileOptions(Options));
- if (Annotating)
+ if (Annotating) {
+ // Registered first, so it lists the functions the profile itself
+ // annotated, before block counts recover any.
+ P.collectAnnotated(Weigher.Annotated);
P.afterProfileAnnotation(Match);
+ }
P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM);
}
+ if (Annotating)
+ Weigher.weigh(Options.ProfileUse, Match);
if (std::optional Failure =
verify(M, "post_optimization_verification_failed"))
return Failure;
diff --git a/native/llvm-codegen/src/partition.cpp b/native/llvm-codegen/src/partition.cpp
index d56839426..9ab1ce657 100644
--- a/native/llvm-codegen/src/partition.cpp
+++ b/native/llvm-codegen/src/partition.cpp
@@ -176,71 +176,108 @@ void balanceRoots(std::vector &Symbols, ArrayRef Roots,
ArrayRef Live, unsigned Count) {
if (Count >= MinGroupedPartitions)
return;
+ // A root's size leaves out the digits of metadata references, which an
+ // edit elsewhere in the module can renumber.
+ auto TextBytes = [](StringRef Text) {
+ uint64_t Bytes = 0;
+ for (size_t I = 0; I < Text.size(); ++I) {
+ ++Bytes;
+ if (Text[I] == '!')
+ while (I + 1 < Text.size() && isDigit(Text[I + 1]))
+ ++I;
+ }
+ return Bytes;
+ };
DenseMap RootBytes;
- uint64_t Total = 0;
for (unsigned I = 0; I < Symbols.size(); ++I)
if (Symbols[I].Kind == SymbolKind::Function && Live[I])
- RootBytes[Roots[I]] += Symbols[I].Header.size() + Symbols[I].Body.size();
- // Each root counts in whole 4 KiB units, so that an edit that grows a root
- // by less than that leaves every comparison, and every other root's
- // partition, unchanged.
- constexpr uint64_t Unit = 4096;
- for (auto &Entry : RootBytes) {
- Entry.second = (Entry.second + Unit - 1) / Unit * Unit;
- Total += Entry.second;
- }
+ RootBytes[Roots[I]] +=
+ TextBytes(Symbols[I].Header) + TextBytes(Symbols[I].Body);
std::vector Order;
for (auto &Entry : RootBytes)
Order.push_back(Entry.first);
llvm::sort(Order, [&](unsigned A, unsigned B) {
return Symbols[A].Name < Symbols[B].Name;
});
+ DenseSet EntryRoots;
+ for (unsigned I = 0; I < Symbols.size(); ++I)
+ if (Symbols[I].Entry && Live[I])
+ EntryRoots.insert(Roots[I]);
+ // A root of at least one unit counts in whole units, so that an edit that
+ // grows it by less than that leaves every comparison, and every other
+ // root's partition, unchanged.
+ constexpr uint64_t Unit = 4096;
+ uint64_t Total = 0;
+ for (unsigned Root : Order)
+ if (RootBytes[Root] >= Unit) {
+ RootBytes[Root] = (RootBytes[Root] + Unit - 1) / Unit * Unit;
+ Total += RootBytes[Root];
+ }
uint64_t Cap = Total * 5 / 4 / Count;
std::vector Load(Count, 0);
// Partitions that take no further root.
std::vector Closed(Count, 0);
DenseMap Placed;
+ auto FirstOpen = [&](unsigned Home) {
+ for (unsigned K = 0; K < Count; ++K)
+ if (!Closed[(Home + K) % Count])
+ return (Home + K) % Count;
+ return Home;
+ };
// A root without room anywhere stays in the first open partition from its
// own.
auto Place = [&](unsigned Root) {
uint64_t Bytes = RootBytes[Root];
- unsigned Home = stableHash(Symbols[Root].Name) % Count, Chosen = Home;
- bool Found = false;
- for (unsigned K = 0; K < Count && !Found; ++K) {
+ unsigned Home = stableHash(Symbols[Root].Name) % Count;
+ unsigned Chosen = FirstOpen(Home);
+ for (unsigned K = 0; K < Count; ++K) {
unsigned P = (Home + K) % Count;
if (!Closed[P] && Load[P] + Bytes <= Cap) {
Chosen = P;
- Found = true;
- }
- }
- for (unsigned K = 0; K < Count && !Found; ++K) {
- unsigned P = (Home + K) % Count;
- if (!Closed[P]) {
- Chosen = P;
- Found = true;
+ break;
}
}
Load[Chosen] += Bytes;
Placed[Root] = Chosen;
};
- DenseSet EntryRoots;
- for (unsigned I = 0; I < Symbols.size(); ++I)
- if (Symbols[I].Entry && Live[I])
- EntryRoots.insert(Roots[I]);
- // Entry roots first, each closing its partition, then roots of more than
- // half the cap, in name order, so that small roots fill around them; the
- // others follow in name order.
+ // Entry roots first, each closing its partition; then roots of more than
+ // half the cap, in name order, so that the others fill around them; then
+ // the other roots of at least one unit, in name order.
for (unsigned Root : Order)
if (EntryRoots.contains(Root)) {
Place(Root);
Closed[Placed[Root]] = 1;
}
for (unsigned Root : Order)
- if (!EntryRoots.contains(Root) && RootBytes[Root] * 2 > Cap)
+ if (!EntryRoots.contains(Root) && RootBytes[Root] >= Unit &&
+ RootBytes[Root] * 2 > Cap)
Place(Root);
for (unsigned Root : Order)
- if (!EntryRoots.contains(Root) && RootBytes[Root] * 2 <= Cap)
+ if (!EntryRoots.contains(Root) && RootBytes[Root] >= Unit &&
+ RootBytes[Root] * 2 <= Cap)
Place(Root);
+ // Roots under one unit (runtime helpers, mostly) count in no load of the
+ // larger roots, so a helper that an edit adds or removes moves no larger
+ // root. Each goes to the first open partition from its name's whose load,
+ // the helpers placed there included, is below the cap, or else to its
+ // name's: the helpers fill the partitions with room, and once those are
+ // full they spread by name instead of gathering in one partition (which
+ // could then hold most of the module and have it optimized whole).
+ std::vector SmallLoad(Count, 0);
+ for (unsigned Root : Order)
+ if (!EntryRoots.contains(Root) && RootBytes[Root] < Unit) {
+ unsigned Home = stableHash(Symbols[Root].Name) % Count;
+ unsigned Chosen = FirstOpen(Home);
+ for (unsigned K = 0; K < Count; ++K) {
+ unsigned P = (Home + K) % Count;
+ if (!Closed[P] && Load[P] + SmallLoad[P] < Cap) {
+ Chosen = P;
+ break;
+ }
+ }
+ SmallLoad[Chosen] += RootBytes[Root];
+ Placed[Root] = Chosen;
+ }
for (unsigned I = 0; I < Symbols.size(); ++I)
if (Symbols[I].Kind == SymbolKind::Function && Live[I])
Symbols[I].Partition = Placed[Roots[I]];
diff --git a/native/llvm-codegen/src/profile-blocks.cpp b/native/llvm-codegen/src/profile-blocks.cpp
index 4c94577a3..6504f585b 100644
--- a/native/llvm-codegen/src/profile-blocks.cpp
+++ b/native/llvm-codegen/src/profile-blocks.cpp
@@ -424,6 +424,16 @@ void annotateFromBlocks(Function &F, const FunctionBlocks &Record,
markFrequency(F, EntryCount, MaxCount, Summary);
}
+void BlockRecorder::addNeverRan(const Function &F) {
+ std::string Text;
+ raw_string_ostream OS(Text);
+ OS << "f ";
+ writeEscaped(OS, F.getName());
+ OS << " 0 0\n";
+ std::lock_guard Guard(Lock);
+ Records.emplace(F.getName().str(), std::move(Text));
+}
+
void BlockRecorder::add(const Function &F) {
std::optional Entry = F.getEntryCount();
if (!Entry || Entry->isSynthetic())
diff --git a/native/llvm-codegen/src/profile-blocks.h b/native/llvm-codegen/src/profile-blocks.h
index 1db71b53e..9368bcbe2 100644
--- a/native/llvm-codegen/src/profile-blocks.h
+++ b/native/llvm-codegen/src/profile-blocks.h
@@ -71,6 +71,8 @@ class BlockRecorder {
public:
// Records F's block counts, which the profile annotation just gave it.
void add(const llvm::Function &F);
+ // Records F, which the profile has no record of, as never run.
+ void addNeverRan(const llvm::Function &F);
// Writes the file through a private sibling; returns an error message on
// failure.
std::optional write(llvm::StringRef Path);
diff --git a/package.json b/package.json
index d049a4ecb..5540010f2 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"test:ts7": "node scripts/test-ts7.mjs",
"test:checker-identity": "node scripts/checker-identity.mjs",
"generate:ts7-ast": "node scripts/generate-ts7-ast-schema.mjs",
+ "generate:ir-codec": "node scripts/generate-ir-codec.mjs",
"generate:ir-fields": "node scripts/generate-ir-fields.mjs",
"test:fetch-conformance": "vitest run tests/harness/fetch-conformance.test.ts",
"test:test262": "tsx tests/test262/run.ts",
diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json
index 1d4921f48..ad846da82 100644
--- a/packages/cli-darwin-arm64/package.json
+++ b/packages/cli-darwin-arm64/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-darwin-arm64",
- "version": "0.4.5",
+ "version": "0.4.6",
"description": "Native scriptc compiler for darwin-arm64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json
index dca236b32..7ecacacd1 100644
--- a/packages/cli-darwin-x64/package.json
+++ b/packages/cli-darwin-x64/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-darwin-x64",
- "version": "0.4.5",
+ "version": "0.4.6",
"description": "Native scriptc compiler for darwin-x64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-arm64-gnu/package.json b/packages/cli-linux-arm64-gnu/package.json
index 1459caa1d..b74fffebe 100644
--- a/packages/cli-linux-arm64-gnu/package.json
+++ b/packages/cli-linux-arm64-gnu/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-arm64-gnu",
- "version": "0.4.5",
+ "version": "0.4.6",
"description": "Native scriptc compiler for linux-arm64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-arm64-musl/package.json b/packages/cli-linux-arm64-musl/package.json
index 3703c0059..a5a49e9ba 100644
--- a/packages/cli-linux-arm64-musl/package.json
+++ b/packages/cli-linux-arm64-musl/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-arm64-musl",
- "version": "0.4.5",
+ "version": "0.4.6",
"description": "Native scriptc compiler for linux-arm64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-x64-gnu/package.json b/packages/cli-linux-x64-gnu/package.json
index 6d70361f9..4cc72ac73 100644
--- a/packages/cli-linux-x64-gnu/package.json
+++ b/packages/cli-linux-x64-gnu/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-x64-gnu",
- "version": "0.4.5",
+ "version": "0.4.6",
"description": "Native scriptc compiler for linux-x64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-x64-musl/package.json b/packages/cli-linux-x64-musl/package.json
index 0c12fc60d..14db344e4 100644
--- a/packages/cli-linux-x64-musl/package.json
+++ b/packages/cli-linux-x64-musl/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-x64-musl",
- "version": "0.4.5",
+ "version": "0.4.6",
"description": "Native scriptc compiler for linux-x64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-win32-x64-msvc/package.json b/packages/cli-win32-x64-msvc/package.json
index 8872d2f15..827b76ad1 100644
--- a/packages/cli-win32-x64-msvc/package.json
+++ b/packages/cli-win32-x64-msvc/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-win32-x64-msvc",
- "version": "0.4.5",
+ "version": "0.4.6",
"description": "Native scriptc compiler for win32-x64-msvc",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 59f9db90a..0ab88f222 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "scriptc",
- "version": "0.4.5",
+ "version": "0.4.6",
"description": "Compile ordinary TypeScript and JavaScript to small, fast native executables and WebAssembly modules that run without Node.js",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli/test/native-install.test.ts b/packages/cli/test/native-install.test.ts
index 7a6d3ef16..369bc392b 100644
--- a/packages/cli/test/native-install.test.ts
+++ b/packages/cli/test/native-install.test.ts
@@ -4,6 +4,7 @@ import {
mkdtempSync,
mkdirSync,
readFileSync,
+ realpathSync,
renameSync,
rmSync,
statSync,
@@ -22,8 +23,10 @@ afterEach(() => {
});
function fixture() {
- const root = mkdtempSync(
- join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-install-"),
+ // installNativeCli resolves the installation directory, and on macOS /tmp
+ // is a link to /private/tmp, so the fixture's paths start resolved.
+ const root = realpathSync(
+ mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-install-")),
);
roots.push(root);
const directory = join(root, "installation");
diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts
index 044926914..4aca82386 100644
--- a/packages/compiler/ambient/node-fallback/fs.d.ts
+++ b/packages/compiler/ambient/node-fallback/fs.d.ts
@@ -604,11 +604,29 @@ declare module "node:fs" {
): void;
/* fs.watch — the file-watching slice (scr_watch.c, kqueue EVFILT_VNODE):
* "rename"/"change" events on ONE path, watcher.close() to stop. The
- * listener takes zero parameters or the eventType string (the filename
- * parameter has no lowering); an open watcher keeps the loop alive. A
- * path that cannot be opened THROWS Node's fs error synchronously. */
+ * listener takes (), (eventType) or (eventType, filename); an open
+ * watcher keeps the loop alive. A path that cannot be opened THROWS
+ * Node's fs error synchronously. */
+ export type WatchEventType = "rename" | "change";
+ /* (eventType, filename): filename is the changed entry's name (null when
+ * the platform does not provide one). */
+ export type WatchListener = (event: WatchEventType, filename: T | null) => void;
export interface FSWatcher {
close(): void;
+ on(event: "change", listener: (eventType: string, filename: string | Buffer) => void): this;
+ on(event: "error", listener: (error: Error) => void): this;
+ on(event: "close", listener: () => void): this;
+ once(event: "change", listener: (eventType: string, filename: string | Buffer) => void): this;
+ once(event: "error", listener: (error: Error) => void): this;
+ once(event: "close", listener: () => void): this;
+ addListener(
+ event: "change",
+ listener: (eventType: string, filename: string | Buffer) => void,
+ ): this;
+ addListener(event: "error", listener: (error: Error) => void): this;
+ addListener(event: "close", listener: () => void): this;
+ off(event: "change" | "error" | "close", listener: (...args: any[]) => void): this;
+ removeListener(event: "change" | "error" | "close", listener: (...args: any[]) => void): this;
}
/* The options-record stance (see http.RequestOptions): persistent: true,
* recursive: false, and encoding: "utf8" state the lowered behavior and
@@ -620,12 +638,34 @@ declare module "node:fs" {
encoding?: string;
[option: string]: unknown;
}
- export function watch(path: string, listener?: (eventType: string) => void): FSWatcher;
+ export function watch(path: string, listener?: WatchListener): FSWatcher;
export function watch(
path: string,
options: WatchOptions,
- listener?: (eventType: string) => void,
+ listener?: WatchListener,
): FSWatcher;
+ /* fs.watchFile — stat polling (scr_watch_file.c, libuv's uv_fs_poll
+ * shape): the listener runs with (current, previous) Stats when a poll
+ * sees a difference, the first poll only records the baseline, and a
+ * path that cannot be stat'd polls as all-zero Stats. One poller per
+ * resolved path is shared by every listener on it. interval defaults to
+ * 5007 ms, persistent defaults to true; bigint: true (BigIntStats) has
+ * no lowering. The call is a statement: Node's StatWatcher has no
+ * lowering. */
+ export interface StatWatcher {}
+ export function watchFile(
+ filename: string,
+ options: { interval?: number; persistent?: boolean; bigint?: false },
+ listener: (current: Stats, previous: Stats) => void,
+ ): StatWatcher;
+ export function watchFile(
+ filename: string,
+ listener: (current: Stats, previous: Stats) => void,
+ ): StatWatcher;
+ export function unwatchFile(
+ filename: string,
+ listener?: (current: Stats, previous: Stats) => void,
+ ): void;
/* fs.promises IS the fs/promises module (Node's rule) — the namespace-
* import form `fs.promises.readFile(...)` lowers through the same
* fs/promises table as `import { readFile } from "node:fs/promises"`. */
diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts
index edb391f7d..bccbac7be 100644
--- a/packages/compiler/ambient/scriptc-node-fallback.d.ts
+++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts
@@ -246,12 +246,13 @@ declare var process: {
/* "v" plus versions.node: the compatibility target's release. */
readonly version: string;
/* The build-configuration snapshot Node exposes from its gyp config.
- * A scriptc binary has no V8 and no gyp, so `variables` answers the
- * honest capability record (v8_enable_i18n_support: 0 — no ICU; asan
- * mirrors the sanitizer build flag) and `target_defaults` is absent —
- * probe code takes its documented fallbacks. Reads of undeclared
- * variables go through the index signature (string | number | boolean
- * | undefined) and answer undefined. */
+ * A scriptc binary has no V8 and no gyp, so `variables` answers what is
+ * true of a compiled binary: v8_enable_i18n_support is 1 like Node's
+ * (the read links ICU, which the binary then carries), while the gyp
+ * variables it cannot answer (icu_path, asan, …) read undefined through
+ * the index signature (string | number | boolean | undefined), and
+ * `target_defaults` is absent — probe code takes its documented
+ * fallbacks. */
readonly config: {
readonly variables: { readonly [name: string]: string | number | boolean | undefined };
readonly target_defaults?: { readonly default_configuration: string };
@@ -1115,9 +1116,9 @@ declare var ReadableStream: {
from(iterable: string): ReadableStream;
readonly prototype: ReadableStream;
};
-/* The writable side of a TransformStream and its writer. The
- * WritableStream constructor has no native lowering; writable streams come
- * from `new TransformStream()`. */
+/* The writable side of a TransformStream and its writer. A program that
+ * constructs a WritableStream, or a TransformStream with a transformer,
+ * builds its streams with the web stream classes (scr_webstreams.c). */
interface WritableStreamDefaultWriter {
readonly closed: Promise;
readonly desiredSize: number | null;
@@ -1133,14 +1134,25 @@ interface WritableStream {
close(): Promise;
getWriter(): WritableStreamDefaultWriter;
}
-/* The identity transform: chunks written to `writable` are read from
- * `readable`, and a write completes when its chunk is read. */
+/* Without a transformer, the identity transform: chunks written to
+ * `writable` are read from `readable`, and a write completes when its chunk
+ * is read. */
interface TransformStream {
readonly readable: ReadableStream;
readonly writable: WritableStream;
}
+interface Transformer {
+ start?(controller: TransformStreamDefaultController): unknown;
+ transform?(chunk: I, controller: TransformStreamDefaultController): unknown;
+ flush?(controller: TransformStreamDefaultController): unknown;
+ cancel?(reason?: unknown): unknown;
+}
declare var TransformStream: {
- new (): TransformStream;
+ new (
+ transformer?: Transformer,
+ writableStrategy?: QueuingStrategy,
+ readableStrategy?: QueuingStrategy,
+ ): TransformStream;
readonly prototype: TransformStream;
};
/* The other web stream interfaces. JavaScript sources construct and read
@@ -1158,8 +1170,14 @@ interface WritableStreamDefaultController {
readonly signal: AbortSignal;
error(reason?: unknown): void;
}
+interface UnderlyingSink {
+ start?(controller: WritableStreamDefaultController): unknown;
+ write?(chunk: W, controller: WritableStreamDefaultController): unknown;
+ close?(): unknown;
+ abort?(reason?: unknown): unknown;
+}
declare var WritableStream: {
- new (sink?: object, strategy?: QueuingStrategy): WritableStream;
+ new (sink?: UnderlyingSink, strategy?: QueuingStrategy): WritableStream;
readonly prototype: WritableStream;
};
declare var WritableStreamDefaultWriter: {
@@ -1747,6 +1765,18 @@ declare module "tty" {
/* The bytes queued behind the last write: the compiled binary's fd
* 1/2 writes are synchronous, so it is always 0. */
readonly writableLength: number;
+ /* Node's default for the process streams (65536 for a pipe, a file and a
+ * TTY): a constant, because nothing is ever queued. */
+ readonly writableHighWaterMark: number;
+ /* Always false: a synchronous write never returns false, so no 'drain'
+ * is ever pending. */
+ readonly writableNeedDrain: boolean;
+ /* 'drain' and the other events by name. Node returns the stream. */
+ on(event: string, listener: (...args: any[]) => void): this;
+ once(event: string, listener: (...args: any[]) => void): this;
+ addListener(event: string, listener: (...args: any[]) => void): this;
+ off(event: string, listener: (...args: any[]) => void): this;
+ removeListener(event: string, listener: (...args: any[]) => void): this;
getWindowSize(): [number, number];
}
export function isatty(fd: number): boolean;
diff --git a/packages/compiler/package.json b/packages/compiler/package.json
index 3237def0a..a6b52a42f 100644
--- a/packages/compiler/package.json
+++ b/packages/compiler/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/compiler",
- "version": "0.4.5",
+ "version": "0.4.6",
"description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts
index 15cab8778..f5e9382b4 100644
--- a/packages/compiler/src/backend/executable-features.ts
+++ b/packages/compiler/src/backend/executable-features.ts
@@ -20,9 +20,12 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ
...(features.wasm ? { wasm: true } : {}),
...(features.warningReplace ? { warningReplace: true } : {}),
...(features.stdioExtended ? { stdioExtended: true } : {}),
+ ...(features.streamWriteErrors ? { streamWriteErrors: true } : {}),
+ ...(features.stdioWriteSlot ? { stdioWriteSlot: true } : {}),
...(features.nodeIsland ? { nodeIsland: true } : {}),
...(features.moduleNamespace ? { moduleNamespace: true } : {}),
...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}),
+ ...(features.primitiveWrapper ? { primitiveWrapper: true } : {}),
zlib: features.zlib,
...(features.brotli ? { brotli: true } : {}),
assert: features.assert,
@@ -67,6 +70,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ
dgramDyn: features.dgramDyn,
textCodecDyn: features.textCodecDyn,
watch: features.fsWatch,
+ watchFile: features.fsWatchFile,
foreignFfi: hasForeignFfiCallback(mod.ffiImports ?? []),
nodeTest: features.nodeTest,
tls: features.tls,
@@ -79,5 +83,6 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ
x509: features.x509,
webcrypto: features.webcrypto,
perfLoop: features.perfLoop,
+ ...(features.icu ? { intl: true } : {}),
};
}
diff --git a/packages/compiler/src/backend/library-pack.ts b/packages/compiler/src/backend/library-pack.ts
index 14ffdff28..7b74dc808 100644
--- a/packages/compiler/src/backend/library-pack.ts
+++ b/packages/compiler/src/backend/library-pack.ts
@@ -56,6 +56,7 @@ export async function compilePackedLibrary(
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/backend/linker.ts b/packages/compiler/src/backend/linker.ts
index ab76597ed..8d4976403 100644
--- a/packages/compiler/src/backend/linker.ts
+++ b/packages/compiler/src/backend/linker.ts
@@ -7,7 +7,7 @@
import { execFile } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, realpathSync, statSync } from "node:fs";
-import { mkdtemp, readFile, rename, rm, stat } from "node:fs/promises";
+import { copyFile, mkdtemp, readFile, rename, rm, stat } from "node:fs/promises";
import { delimiter } from "node:path";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { promisify } from "node:util";
@@ -21,7 +21,11 @@ import {
toolchainEnvironmentFingerprint,
} from "./toolchain-environment.js";
import { type NativeArtifactDependency } from "./native/contracts.js";
-import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js";
+import {
+ RuntimePackError,
+ stageRuntimePackArtifacts,
+ type RuntimePackSelection,
+} from "./runtime-pack.js";
import type { NativeLinkPlan } from "./link-plan.js";
import { inputsWithoutLld, lldLinkerArgs } from "./lld.js";
import { hugeTextLinkerArgs, rewriteHugeTextExecutable } from "./huge-text.js";
@@ -225,6 +229,38 @@ async function objectLinkerDependencyPaths(
.sort();
}
+/** Install the selected runtime pack's RUN-time data files (ICU's
+ * icudt78l.dat) beside the executable that links them. The compiler's link
+ * step is the only moment the executable's directory is known, and a data
+ * file next to the binary is what the runtime looks for first.
+ *
+ * A file already present at the pinned size is left alone: the pack ships
+ * one data set per file name, and re-copying 31.6 MiB on every build would
+ * be pure cost. The copy is atomic (private temporary, then rename) so two
+ * concurrent builds of the same program never observe a partial file. */
+export async function installRuntimeDataFiles(
+ selection: RuntimePackSelection,
+ outputPath: string,
+): Promise {
+ if (selection.dataFiles.length === 0) return;
+ const outputDir = dirname(resolve(outputPath));
+ for (const source of selection.dataFiles) {
+ const destination = join(outputDir, basename(source));
+ const sourceSize = (await stat(source)).size;
+ const existing = await stat(destination).catch(() => null);
+ if (existing?.isFile() && existing.size === sourceSize) continue;
+ const temporary = join(
+ outputDir,
+ `.${basename(destination)}.scriptc-${process.pid}-${randomUUID()}`,
+ );
+ await copyFile(source, temporary);
+ await rename(temporary, destination).catch(async () => {
+ await rm(destination, { force: true });
+ await rename(temporary, destination);
+ });
+ }
+}
+
export async function linkNativeExecutable(
plan: NativeLinkPlan,
options: {
@@ -314,6 +350,7 @@ export async function linkNativeExecutable(
await rm(plan.outputPath, { force: true });
await rename(privateOut, plan.outputPath);
});
+ await installRuntimeDataFiles(plan.runtimePack, plan.outputPath);
if (plan.target.platform === "darwin" && !plan.darwinDebugSymbols) {
await rm(`${plan.outputPath}.dSYM`, { recursive: true, force: true });
}
diff --git a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts
index 8bdab0408..a54f3294c 100644
--- a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts
+++ b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts
@@ -1,7 +1,10 @@
import { expect, test } from "vitest";
import {
BOOL,
+ BYTES_U8,
+ DYN,
F64,
+ STRING,
VOID,
funcOf,
type IrExpr,
@@ -260,6 +263,59 @@ test("receiver elision admits only synchronous receiver-free work", () => {
expect(callbackIgnoresReceiver(fn)).toBe(false);
});
+test("receiver elision admits library work that runs no user code", () => {
+ const str: IrExpr = { kind: "strLit", value: "a", type: STRING, loc };
+ const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
+ const dyn = ref("d", DYN);
+ const ignores = (expr: IrExpr): boolean => {
+ const fn = fixture().functions[0]!;
+ fn.body.unshift({ kind: "exprStmt", expr, loc });
+ return callbackIgnoresReceiver(fn);
+ };
+ const lib = (fn: string, args: IrExpr[]): IrExpr =>
+ ({ kind: "libCall", fn, args, type: F64, loc }) as IrExpr;
+ const intrinsic = (name: string, args: IrExpr[]): IrExpr =>
+ ({ kind: "intrinsic", name, args, type: VOID, loc }) as IrExpr;
+ // String and number members over primitives; a callback operand may run.
+ expect(ignores(lib("string.lastIndexOfFrom", [str, str, num(0)]))).toBe(true);
+ expect(ignores(lib("num.sameValue", [num(0), num(1)]))).toBe(true);
+ expect(ignores(lib("string.lastIndexOf", [str, closure("predicate")]))).toBe(false);
+ expect(ignores(lib("perf.now", []))).toBe(true);
+ // `new Error(message, options)` with a primitive message and no options.
+ const message: IrExpr = { kind: "dynFrom", value: str, type: DYN, loc };
+ expect(ignores(lib("error.newOptions", [message, message]))).toBe(true);
+ expect(ignores(lib("error.newOptions", [message, dyn]))).toBe(false);
+ // The module binding check only throws; console output may inspect.
+ const initialized: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
+ expect(ignores(intrinsic("module.tdzCheck", [initialized, str]))).toBe(true);
+ expect(ignores(intrinsic("console.log", [dyn]))).toBe(false);
+ // A typed array built from nothing or a typed source; a dynamic source
+ // may be an iterable.
+ const bytes = (source: IrExpr | null): IrExpr => ({
+ kind: "bytesNew",
+ source,
+ type: BYTES_U8,
+ loc,
+ });
+ expect(ignores(bytes(null))).toBe(true);
+ expect(ignores(bytes(dyn))).toBe(false);
+});
+
+test("receiver elision admits array for-of loops", () => {
+ const fn = fixture().functions[0]!;
+ const numbers = { kind: "array", elem: F64 } as const;
+ const one: IrExpr = { kind: "numLit", value: 1, type: F64, loc };
+ fn.locals.push({ id: "x", name: "x", type: F64, mutable: false });
+ fn.body.unshift({
+ kind: "forOf",
+ localId: "x",
+ iterable: { kind: "arrayLit", elems: [one], type: numbers, loc },
+ body: [],
+ loc,
+ });
+ expect(callbackIgnoresReceiver(fn)).toBe(true);
+});
+
function callsOf(name: string, callee: string, extra: IrStmt[] = []): IrFunction {
return {
name,
diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts
index 6f261d4bb..b442c7921 100644
--- a/packages/compiler/src/backend/llvm/emit-module.ts
+++ b/packages/compiler/src/backend/llvm/emit-module.ts
@@ -478,6 +478,10 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
const usesEvents = runtimeFeatures.processEvents;
const usesChildProcess = runtimeFeatures.childProcess;
const usesFsWatch = runtimeFeatures.fsWatch;
+ // fs.watchFile programs register the poller unit's exit cleanup — the
+ // same one-line gating (native/executable.ts links scr_watch_file.c on
+ // the same predicate).
+ const usesFsWatchFile = runtimeFeatures.fsWatchFile;
// Programs that call into Node record the libnode pack and its Node
// version before %main (scr_node_island.c).
const usesNodeIsland = runtimeFeatures.nodeIsland;
@@ -541,6 +545,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
if (workerStdioBox || workerStdinBox)
emitter.declare(`declare void @scr_worker_stdio_install(ptr, ptr, ptr)`);
if (usesFsWatch) emitter.declare(`declare void @scr_watch_install()`);
+ if (usesFsWatchFile) emitter.declare(`declare void @scr_watch_file_install()`);
if (usesNodeIsland) emitter.declare(`declare void @scr_node_island_install(ptr, ptr)`);
// Interned now: the C-string constants flush before main assembles.
const nodeIslandConfig = usesNodeIsland
@@ -1226,6 +1231,9 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
// fs.watch programs fill the loop's watch hooks the same way —
// scr_watch.c links only when this line is emitted.
...(usesFsWatch ? [` call void @scr_watch_install()`] : []),
+ // fs.watchFile programs register the poller unit's atexit cleanup the
+ // same way — scr_watch_file.c links only when this line is emitted.
+ ...(usesFsWatchFile ? [` call void @scr_watch_file_install()`] : []),
...(nodeIslandConfig
? [
` call void @scr_node_island_install(ptr ${nodeIslandConfig[0]}, ptr ${nodeIslandConfig[1]})`,
@@ -1377,25 +1385,147 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
return out;
}
-/** The out-of-line hashed probe of a number-key map (scr_map_find_f64):
- * `keyLines` define the normalized key word %kb from the parameter, and
- * `runtimeKey` is the double passed to the runtime lookup. A map with at
- * most SCR_MAP_LINEAR_LIMIT entries keeps no bucket table: the absent-key
- * filter is tested before its entries are scanned. A bucket table is
- * probed without the filter: the filter keeps two of its 64 bits per key,
- * so past a few dozen keys it rejects few absent keys, and the probe's
- * first bucket read answers those. Compact tables tag each 24-bit entry
- * index (+1) with the hash's top byte; wider tables take the runtime
- * lookup. */
+/** The `.loop` lookup helpers only read memory and always return, as
+ * declared: LLVM then merges two lookups of one key (a has() followed by
+ * get()) before it inlines them. */
+const INLINED_LOOKUP_ATTRS = "alwaysinline memory(read) nounwind willreturn";
+
+/** The number-key entry lookups (sc_map_entry_f64 and sc_map_entry_f64_int)
+ * and their hashed probes. With `loop` they are the `.loop` flavor, which
+ * lookups inside a loop call (emitNumberMapEntry): each of its functions is
+ * inlined, so a lookup that finds its key in a bucket table makes no call,
+ * and the loop keeps no argument moves or spills around one. */
+function numberMapEntryHelpers(sz: string, loop: boolean): string[] {
+ const flavor = loop ? ".loop" : "";
+ const attrs = loop ? `${INLINED_LOOKUP_ATTRS} ${FN_ATTRS}` : FN_ATTRS;
+ let mapTmp = 0;
+ const mapIndex = exactIndexLines("%k", "%nd", sz, () => `%ix${mapTmp++}`);
+ return [
+ `define internal ptr @sc_map_entry_f64${flavor}(ptr %m, double %k) ${attrs} {`,
+ `entry:`,
+ ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`,
+ ...(sz === "i64"
+ ? [` %nd32 = load i32, ptr %ndp`, ` %nd = zext i32 %nd32 to i64`]
+ : [` %nd = load i32, ptr %ndp`]),
+ // A non-integral key inside the table is as absent as one outside it.
+ ...mapIndex.lines.map((line) => ` ${line}`),
+ ` br i1 ${mapIndex.ok}, label %slot, label %slow`,
+ `slot:`,
+ sz === "i64"
+ ? ` %i = add i64 ${mapIndex.wide}, 0`
+ : ` %i = trunc i64 ${mapIndex.wide} to ${sz}`,
+ ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`,
+ ` %dense = load ptr, ptr %dp`,
+ ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`,
+ ` %s = load i32, ptr %sp`,
+ ` %empty = icmp eq i32 %s, 0`,
+ ` br i1 %empty, label %miss, label %hit`,
+ `hit:`,
+ ` %e32 = sub i32 %s, 1`,
+ sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`,
+ ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`,
+ ` %entries = load ptr, ptr %ep`,
+ ` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`,
+ ` ret ptr %found`,
+ `miss:`,
+ ` ret ptr null`,
+ `slow:`,
+ ` %hashed = call ptr @sc_map_entry_f64_hashed${flavor}(ptr %m, double %k)`,
+ ` ret ptr %hashed`,
+ `}`,
+ ``,
+ // The same lookup for a key the emitter knows is an integer (`%ki`,
+ // the double `%k`'s exact value): the direct index tests the integer
+ // itself, so the double is converted only by the hashed probe. A
+ // negative key is not below the index's size as an unsigned value.
+ `define internal ptr @sc_map_entry_f64_int${flavor}(ptr %m, i64 %ki, double %k) ${attrs} {`,
+ `entry:`,
+ ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`,
+ ` %nd32 = load i32, ptr %ndp`,
+ ` %nd = zext i32 %nd32 to i64`,
+ ` %inside = icmp ult i64 %ki, %nd`,
+ ` br i1 %inside, label %slot, label %slow`,
+ `slot:`,
+ sz === "i64" ? ` %i = add i64 %ki, 0` : ` %i = trunc i64 %ki to ${sz}`,
+ ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`,
+ ` %dense = load ptr, ptr %dp`,
+ ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`,
+ ` %s = load i32, ptr %sp`,
+ ` %empty = icmp eq i32 %s, 0`,
+ ` br i1 %empty, label %miss, label %hit`,
+ `hit:`,
+ ` %e32 = sub i32 %s, 1`,
+ sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`,
+ ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`,
+ ` %entries = load ptr, ptr %ep`,
+ ` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`,
+ ` ret ptr %found`,
+ `miss:`,
+ ` ret ptr null`,
+ `slow:`,
+ ` %hashed = call ptr @sc_map_entry_f64_hashed_int${flavor}(ptr %m, i64 %ki, double %k)`,
+ ` ret ptr %hashed`,
+ `}`,
+ ``,
+ // Hashing (scr_map_find_f64): the SameValueZero key word and its
+ // scr_key_mix hash. sc_map_entry_f64_int's probe takes the key's double
+ // with its integer view: its bits are the key word, never NaN, and +0
+ // for the integer 0. The caller converts the integer: cvtsi2sd writes
+ // only the low half of its register, so in a function entry, where
+ // LLVM cannot clear the register first, it waits for the register's
+ // last value, in a loop of lookups often a value the previous
+ // iteration loaded.
+ ...hashedNumberProbe(
+ `sc_map_entry_f64_hashed${flavor}`,
+ loop,
+ "double %k",
+ sz,
+ [
+ ` %nan = fcmp uno double %k, %k`,
+ ` %zero = fcmp oeq double %k, 0.0`,
+ ` %kz = select i1 %zero, double 0.0, double %k`,
+ ` %kzb = bitcast double %kz to i64`,
+ ` %kb = select i1 %nan, i64 9221120237041090560, i64 %kzb`,
+ ],
+ "%k",
+ ),
+ ...hashedNumberProbe(
+ `sc_map_entry_f64_hashed_int${flavor}`,
+ loop,
+ "i64 %ki, double %k",
+ sz,
+ [
+ ` %kzero = icmp eq i64 %ki, 0`,
+ ` %kbits = bitcast double %k to i64`,
+ ` %kb = select i1 %kzero, i64 0, i64 %kbits`,
+ ],
+ "%k",
+ ),
+ ];
+}
+
+/** The hashed probe of a number-key map (scr_map_find_f64), out of line,
+ * or inlined with `loop`: `keyLines` define the normalized key word %kb
+ * from the parameter, and `runtimeKey` is the double passed to the runtime
+ * lookup. A map with at most SCR_MAP_LINEAR_LIMIT entries keeps no bucket
+ * table, and neither does a map with the direct index, whose lookups reach
+ * the probe only for a key outside the index, which is absent. In a small
+ * map the absent-key filter is tested before its entries are scanned.
+ * A bucket table is probed without the filter: the filter keeps two of its
+ * 64 bits per key, so past a few dozen keys it rejects few absent keys, and
+ * the probe's first bucket read answers those. Compact tables tag each
+ * 24-bit entry index (+1) with the hash's top byte; wider tables take the
+ * runtime lookup. */
function hashedNumberProbe(
name: string,
+ loop: boolean,
param: string,
sz: string,
keyLines: string[],
runtimeKey: string,
): string[] {
return [
- `define internal ptr @${name}(ptr %m, ${param}) noinline ${FN_ATTRS} {`,
+ `define internal ptr @${name}(ptr %m, ${param}) ${loop ? INLINED_LOOKUP_ATTRS : "noinline"} ${FN_ATTRS} {`,
`entry:`,
...keyLines,
` %x1s = lshr i64 %kb, 30`,
@@ -1415,7 +1545,14 @@ function hashedNumberProbe(
` %eps = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`,
` %ents = load ptr, ptr %eps`,
` %linear = icmp eq ${sz} %nb, 0`,
- ` br i1 %linear, label %filter, label %buckets`,
+ ` br i1 %linear, label %unbucketed, label %buckets`,
+ // A map without a bucket table either indexes its keys directly, and
+ // then holds none outside the index, or scans its entries.
+ `unbucketed:`,
+ ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`,
+ ` %nd = load i32, ptr %ndp`,
+ ` %indexed = icmp ne i32 %nd, 0`,
+ ` br i1 %indexed, label %miss, label %filter`,
`filter:`,
` %f1s = lshr i64 %h, 44`,
` %f1a = and i64 %f1s, 63`,
@@ -1582,117 +1719,22 @@ export function helperDefs(emitter: LlEmitter): string[] {
}
// Number-key Map/Set lookups probe the runtime's direct index inline:
// while ndense is nonzero every live key is an integer below it, so an
- // in-range integral key reads its slot (entry index + 1, 0 = absent) and
- // any other key is absent. Maps without the index (ndense 0) hash the
- // key in sc_map_entry_f64_hashed, which scans the entries of a small map
- // or probes a compact bucket table as scr_map_find_f64 does, and calls
- // the runtime lookup for a wide table. It stays out of line so that the
- // direct-index path stays small at every call site. Both only read
- // memory, so LLVM merges lookups of one key when nothing is stored in
- // between (a has() followed by get(), for example).
- if (emitter.decls.has(NUMBER_MAP_ENTRY_DECL)) {
- const sz = emitter.sizeType;
- let mapTmp = 0;
- const mapIndex = exactIndexLines("%k", "%nd", sz, () => `%ix${mapTmp++}`);
+ // in-range integral key reads its slot (entry index + 1, 0 = absent).
+ // Any other key goes to sc_map_entry_f64_hashed, which probes a compact
+ // bucket table as scr_map_find_f64 does, calls the runtime lookup for a
+ // wide table, and in a map without a bucket table answers absent when the
+ // map has the index (a map with buckets keeps no index, scr_map.h) and
+ // scans the entries otherwise. A lookup in a hashed map so tests the
+ // index once. The probe stays out of line so that the direct-index path
+ // stays small at every call site, except in the `.loop` flavor
+ // (numberMapEntryHelpers). Both only read memory, so LLVM merges lookups
+ // of one key when nothing is stored in between (a has() followed by
+ // get(), for example).
+ if (emitter.decls.has(NUMBER_MAP_ENTRY_DECL))
defs.push(
- `define internal ptr @sc_map_entry_f64(ptr %m, double %k) ${FN_ATTRS} {`,
- `entry:`,
- ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`,
- ...(sz === "i64"
- ? [` %nd32 = load i32, ptr %ndp`, ` %nd = zext i32 %nd32 to i64`]
- : [` %nd = load i32, ptr %ndp`]),
- // A non-integral key inside the table is as absent as one outside it.
- ...mapIndex.lines.map((line) => ` ${line}`),
- ` br i1 ${mapIndex.ok}, label %slot, label %outside`,
- `slot:`,
- sz === "i64"
- ? ` %i = add i64 ${mapIndex.wide}, 0`
- : ` %i = trunc i64 ${mapIndex.wide} to ${sz}`,
- ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`,
- ` %dense = load ptr, ptr %dp`,
- ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`,
- ` %s = load i32, ptr %sp`,
- ` %empty = icmp eq i32 %s, 0`,
- ` br i1 %empty, label %miss, label %hit`,
- `hit:`,
- ` %e32 = sub i32 %s, 1`,
- sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`,
- ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`,
- ` %entries = load ptr, ptr %ep`,
- ` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`,
- ` ret ptr %found`,
- `miss:`,
- ` ret ptr null`,
- `outside:`,
- ` %dense.any = icmp ne ${sz} %nd, 0`,
- ` br i1 %dense.any, label %miss, label %slow`,
- `slow:`,
- ` %hashed = call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)`,
- ` ret ptr %hashed`,
- `}`,
- ``,
- // The same lookup for a key the emitter knows is an integer (`%ki`,
- // the double `%k`'s exact value): the direct index tests the integer
- // itself, so the double is converted only by the hashed probe. A
- // negative key is not below the index's size as an unsigned value.
- `define internal ptr @sc_map_entry_f64_int(ptr %m, i64 %ki, double %k) ${FN_ATTRS} {`,
- `entry:`,
- ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`,
- ` %nd32 = load i32, ptr %ndp`,
- ` %nd = zext i32 %nd32 to i64`,
- ` %inside = icmp ult i64 %ki, %nd`,
- ` br i1 %inside, label %slot, label %outside`,
- `slot:`,
- sz === "i64" ? ` %i = add i64 %ki, 0` : ` %i = trunc i64 %ki to ${sz}`,
- ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`,
- ` %dense = load ptr, ptr %dp`,
- ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`,
- ` %s = load i32, ptr %sp`,
- ` %empty = icmp eq i32 %s, 0`,
- ` br i1 %empty, label %miss, label %hit`,
- `hit:`,
- ` %e32 = sub i32 %s, 1`,
- sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`,
- ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`,
- ` %entries = load ptr, ptr %ep`,
- ` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`,
- ` ret ptr %found`,
- `miss:`,
- ` ret ptr null`,
- `outside:`,
- ` %dense.any = icmp ne i64 %nd, 0`,
- ` br i1 %dense.any, label %miss, label %slow`,
- `slow:`,
- ` %hashed = call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki)`,
- ` ret ptr %hashed`,
- `}`,
- ``,
- // Hashing (scr_map_find_f64): the SameValueZero key word and its
- // scr_key_mix hash. sc_map_entry_f64_int's probe converts the integer
- // view to the key word, which is never NaN or -0, and so skips the
- // normalization.
- ...hashedNumberProbe(
- "sc_map_entry_f64_hashed",
- "double %k",
- sz,
- [
- ` %nan = fcmp uno double %k, %k`,
- ` %zero = fcmp oeq double %k, 0.0`,
- ` %kz = select i1 %zero, double 0.0, double %k`,
- ` %kzb = bitcast double %kz to i64`,
- ` %kb = select i1 %nan, i64 9221120237041090560, i64 %kzb`,
- ],
- "%k",
- ),
- ...hashedNumberProbe(
- "sc_map_entry_f64_hashed_int",
- "i64 %ki",
- sz,
- [` %kf = sitofp i64 %ki to double`, ` %kb = bitcast double %kf to i64`],
- "%kf",
- ),
+ ...numberMapEntryHelpers(emitter.sizeType, false),
+ ...numberMapEntryHelpers(emitter.sizeType, true),
);
- }
// Map/Set iteration steps read the entries array inline: an index is
// live when it is below the entry count and its entry's cached hash is
// nonzero (a tombstone's is zero), as scr_map_iter_live decides, or for
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts
index e6a72807c..7263554bf 100644
--- a/packages/compiler/src/backend/llvm/emitter.ts
+++ b/packages/compiler/src/backend/llvm/emitter.ts
@@ -286,6 +286,8 @@ import {
cryptoIntThunkFor,
type BytesThunkValue,
fsRenameThunkFor,
+ watchNameThunkFor,
+ watchErrorThunkFor,
streamCbThunkFor,
zlibBytesThunkFor,
} from "./expr-stream-callbacks.js";
@@ -8161,6 +8163,14 @@ export class LlEmitter {
return fsRenameThunkFor(this, cbT);
}
+ watchNameThunkFor(cbT: IrType & { kind: "func" }): string {
+ return watchNameThunkFor(this, cbT);
+ }
+
+ watchErrorThunkFor(cbT: IrType & { kind: "func" }): string {
+ return watchErrorThunkFor(this, cbT);
+ }
+
cryptoBytesThunkFor(
cbT: IrType & { kind: "func" },
arrayBuffer = false,
diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts
index 9ad8e5f05..e62fae52a 100644
--- a/packages/compiler/src/backend/llvm/expr-calls.ts
+++ b/packages/compiler/src/backend/llvm/expr-calls.ts
@@ -1003,6 +1003,16 @@ export function emitCallExpr(
} else if (needsReceiver) B.line("call void @scr_dyn_this_pop()");
return result;
};
+ // The direct entry of a known target brackets the call when the target
+ // may observe the receiver. A signature adapter that only forwards the
+ // receiver to its original carries the original's RECEIVER_FREE bit
+ // (set where the adapter closure is built), so its entry tests the bit,
+ // like the entry through the closure's own pointer.
+ const invokeKnown = (target: string): string => {
+ const readers = host.receiverReaders;
+ if (readers.ignores(known!)) return invoke(target, false);
+ return invoke(target, true, args, readers.forwardsOnly(known!));
+ };
const lending =
lent && !dynamic
? known!.captures !== undefined
@@ -1025,7 +1035,7 @@ export function emitCallExpr(
(e.callee.kind === "closure" ||
(e.callee.kind === "varRef" && host.knownCallbacks.has(e.callee.localId)))
) {
- t = invoke(lending ?? direct, !host.receiverReaders.ignores(known!));
+ t = invokeKnown(lending ?? direct);
if (quiet) pending = false;
} else {
const fnp = B.tmp(),
@@ -1072,7 +1082,7 @@ export function emitCallExpr(
B.line(`${matches} = icmp eq ptr ${fn}, ${direct}`);
B.condBr(matches, fast, fallback);
B.startBlock(fast);
- const specialized = invoke(lending ?? direct, !host.receiverReaders.ignores(known!));
+ const specialized = invokeKnown(lending ?? direct);
// `fast` may have split for exception checks; join from the end.
const fastEnd = B.currentLabel();
B.br(done);
diff --git a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts
index 3d9d8da76..78dbc3639 100644
--- a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts
+++ b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts
@@ -1,7 +1,7 @@
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
-import { type IrType, isRefCounted, typeKey } from "../../ir/ir.js";
-import { FN_ATTRS, releaseSym, retainSym, traceArg } from "./shapes.js";
+import { type IrType, isRefCounted, STRING, typeKey } from "../../ir/ir.js";
+import { FN_ATTRS, releaseSym, retainSym, traceArg, vAdapters } from "./shapes.js";
import type { LlvmEmitterContext } from "./expr-context.js";
import { unionPeekLines, unionTagLines } from "./union-repr.js";
import { registerReplay } from "./emit-journal.js";
@@ -279,8 +279,6 @@ export function streamDoneFnFor(
host.declare(
`declare void @${entry}(ptr, ptr${twoSlot ? ", ptr, ptr" : valueData ? ", ptr" : ""})`,
);
- host.declare(`declare ptr @scr_box_get_ref(ptr)`);
- host.declare(`declare void @scr_stream_release_v(ptr)`);
host.declare(`declare ptr @scr_error_retain_v(ptr)`);
host.declare(`declare void @scr_union_release(ptr)`);
const params = [
@@ -295,7 +293,10 @@ export function streamDoneFnFor(
...(twoSlot ? [` %bslot = alloca ptr`, ` %sslot = alloca ptr`] : []),
` %capsp = getelementptr inbounds %ScrClosure, ptr %self, i64 1`,
` %bx = load ptr, ptr %capsp`,
- ` %s = call ptr @scr_box_get_ref(ptr %bx) ; the stream, +1`,
+ // the stream, borrowed: the box holds it, and the closure (alive for
+ // the call) holds the box
+ ` %sp = getelementptr inbounds %ScrBox, ptr %bx, i64 0, i32 5`,
+ ` %s = load ptr, ptr %sp`,
` store ptr null, ptr %eslot`,
...(twoSlot ? [` store ptr null, ptr %bslot`, ` store ptr null, ptr %sslot`] : []),
];
@@ -347,7 +348,6 @@ export function streamDoneFnFor(
} else {
d.push(` call void @${entry}(ptr %s, ptr %ev) ; moves err; borrows s`);
}
- d.push(` call void @scr_stream_release_v(ptr %s)`);
if (errT !== undefined) d.push(` call void ${releaseSym(host.shapeHost, errT)}(ptr %e)`);
if (dataT !== undefined && isRefCounted(dataT))
d.push(` call void ${releaseSym(host.shapeHost, dataT)}(ptr %d)`);
@@ -868,18 +868,46 @@ export function streamCbThunkFor(
host.declare(`declare void @scr_dyn_release(ptr)`);
host.declare(`declare zeroext i1 @scr_exc_pending()`);
if (hasThis) host.declare(`declare void @scr_stream_release_v(ptr)`);
+ // An object-mode write without an encoding argument passes
+ // undefined: a pointer union whose unit arm is undefined holds it as
+ // the null pointer, so that case skips the conversion.
+ const pointer = p.kind === "union" ? host.nullableUnions.get(p.unionId) : null;
+ const undefinedIsNull =
+ pointer !== null &&
+ p.kind === "union" &&
+ host.unionsById.get(p.unionId)?.arms[pointer.unitTag]?.kind === "undefinedT";
+ if (undefinedIsNull) {
+ host.declare(`declare zeroext i1 @scr_stream_chunk_encoding_undefined(ptr)`);
+ d.push(
+ ` br label %etchk${i}`,
+ `etchk${i}:`,
+ ` %eu${i} = call zeroext i1 @scr_stream_chunk_encoding_undefined(ptr %s)`,
+ ` br i1 %eu${i}, label %etok${i}, label %etconv${i}`,
+ `etconv${i}:`,
+ );
+ }
d.push(
` %ev${i} = call ptr @scr_stream_chunk_encoding_value(ptr %s)`,
` %et${i} = call ${ty === "i1" ? "zeroext i1" : ty} @${helper}(ptr %ev${i}, ptr null)`,
` call void @scr_dyn_release(ptr %ev${i})`,
` %etbad${i} = call zeroext i1 @scr_exc_pending()`,
- ` br i1 %etbad${i}, label %etfail${i}, label %etok${i}`,
+ ` br i1 %etbad${i}, label %etfail${i}, label %${undefinedIsNull ? "etconvok" : "etok"}${i}`,
`etfail${i}:`,
...(hasThis ? [` call void @scr_stream_release_v(ptr %sr)`] : []),
` ret void`,
- `etok${i}:`,
);
- passed.push(`${ty} %et${i}`);
+ if (undefinedIsNull) {
+ d.push(
+ `etconvok${i}:`,
+ ` br label %etok${i}`,
+ `etok${i}:`,
+ ` %etv${i} = phi ptr [ null, %etchk${i} ], [ %et${i}, %etconvok${i} ]`,
+ );
+ passed.push(`${ty} %etv${i}`);
+ } else {
+ d.push(`etok${i}:`);
+ passed.push(`${ty} %et${i}`);
+ }
} else if (isErrPos) {
// destroy's error argument: `Error | null` — wrap type-directedly.
// The finished/pipeline callback ("e") may declare `Error | null |
@@ -966,6 +994,161 @@ registerReplay("streamDoneFnFor", (host, args) =>
registerReplay("fsRenameThunkFor", (host, args) =>
fsRenameThunkFor(host, args[0] as IrType & { kind: "func" }),
);
+/** The fs.watch listener adapter for an (eventType, filename) listener, or
+ * for an (eventType) listener whose parameter is dynamic: the runtime hands
+ * the borrowed C strings, the thunk builds what the listener declared. The
+ * listener owns its +1 arguments (the universal convention
+ * scr_watch_thunk_event follows), so a string the thunk boxes into a dynamic
+ * value is released once the box holds it. */
+export function watchNameThunkFor(
+ host: LlvmEmitterContext,
+ cbT: IrType & { kind: "func" },
+): string {
+ const key = `watchname:${typeKey(cbT)}`;
+ let sym = host.resolveThunks.get(key);
+ if (sym) return sym;
+ sym = host.contentSymbol("sc_watchname_", key);
+ host.resolveThunks.set(key, sym);
+ host.journal?.allocated(sym, "watchNameThunkFor", [cbT]);
+ const S = host.sizeType;
+ host.declare(`declare ${S} @strlen(ptr)`);
+ host.declare(`declare ptr @scr_str_new(ptr, ${S})`);
+ host.declare(`declare void @scr_str_release(ptr)`);
+ const d: string[] = [
+ `define internal void @${sym}(ptr %cb, ptr %event, ptr %name) ${FN_ATTRS} { ; fs.watch listener ${typeKey(cbT)}`,
+ `entry:`,
+ ` %evlen = call ${S} @strlen(ptr %event)`,
+ ` %evstr = call ptr @scr_str_new(ptr %event, ${S} %evlen)`,
+ ];
+ const named = cbT.params.length === 2;
+ if (named) {
+ d.push(
+ ` %slot = alloca ptr`,
+ ` %has = icmp ne ptr %name, null`,
+ ` br i1 %has, label %yes, label %no`,
+ `yes:`,
+ );
+ const nameParam = cbT.params[1]!;
+ if (nameParam.kind === "dyn") {
+ host.declare(`declare ptr @scr_dyn_new_null()`);
+ host.declare(`declare ptr @scr_dyn_new_str(ptr)`);
+ d.push(
+ ` %nlen = call ${S} @strlen(ptr %name)`,
+ ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
+ ` %ndyn = call ptr @scr_dyn_new_str(ptr %nstr)`,
+ ` call void @scr_str_release(ptr %nstr)`,
+ ` store ptr %ndyn, ptr %slot`,
+ ` br label %go`,
+ `no:`,
+ ` %dn = call ptr @scr_dyn_new_null()`,
+ ` store ptr %dn, ptr %slot`,
+ );
+ } else {
+ if (nameParam.kind !== "union")
+ throw new InternalCompilerError("llvm emitter bug: fs.watch filename param is not a union");
+ const def = host.unionsById.get(nameParam.unionId);
+ const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1;
+ const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1;
+ if (strTag < 0 || nullTag < 0)
+ throw new InternalCompilerError("llvm emitter bug: fs.watch filename union lacks its arms");
+ if (host.nullableUnions.has(nameParam.unionId)) {
+ // `string | null` erases: the owned payload IS the union's value.
+ d.push(
+ ` %nlen = call ${S} @strlen(ptr %name)`,
+ ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
+ ` store ptr %nstr, ptr %slot`,
+ ` br label %go`,
+ `no:`,
+ ` store ptr null, ptr %slot`,
+ );
+ } else {
+ const rc = vAdapters(host.shapeHost, STRING);
+ host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`);
+ d.push(
+ ` %nlen = call ${S} @strlen(ptr %name)`,
+ ` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
+ ` %nu = call ptr @scr_union_new_ref(i32 ${strTag}, ptr %nstr, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, STRING)})`,
+ ` store ptr %nu, ptr %slot`,
+ ` br label %go`,
+ `no:`,
+ ` store ptr ${host.unitInstanceRef(nameParam.unionId, nullTag)}, ptr %slot`,
+ );
+ }
+ }
+ d.push(` br label %go`, `go:`);
+ }
+ let ev: string;
+ if (cbT.params[0]?.kind === "dyn") {
+ // The box retains the string; the thunk's own reference goes.
+ host.declare(`declare ptr @scr_dyn_new_str(ptr)`);
+ d.push(
+ ` %evd = call ptr @scr_dyn_new_str(ptr %evstr)`,
+ ` call void @scr_str_release(ptr %evstr)`,
+ );
+ ev = "%evd";
+ } else {
+ ev = "%evstr";
+ }
+ d.push(
+ ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`,
+ ` %fn = load ptr, ptr %fnp`,
+ );
+ if (named)
+ d.push(` %arg = load ptr, ptr %slot`, ` call void %fn(ptr %cb, ptr ${ev}, ptr %arg)`);
+ else d.push(` call void %fn(ptr %cb, ptr ${ev})`);
+ d.push(` ret void`, `}`, ``);
+ host.resolveThunkDefs.push(...d);
+ host.journal?.built();
+ return sym;
+}
+
+/** The FSWatcher 'error' listener adapter: the runtime hands a borrowed
+ * ScrError, the listener receives the declared dyn value or an Error
+ * object it owns. A zero-parameter listener uses the runtime's
+ * scr_watch_err_thunk0 and never reaches here. */
+export function watchErrorThunkFor(
+ host: LlvmEmitterContext,
+ cbT: IrType & { kind: "func" },
+): string {
+ const key = `watcherr:${typeKey(cbT)}`;
+ let sym = host.resolveThunks.get(key);
+ if (sym) return sym;
+ sym = host.contentSymbol("sc_watcherr_", key);
+ host.resolveThunks.set(key, sym);
+ host.journal?.allocated(sym, "watchErrorThunkFor", [cbT]);
+ const param = cbT.params[0]!;
+ const d: string[] = [
+ `define internal void @${sym}(ptr %cb, ptr %err) ${FN_ATTRS} { ; FSWatcher error listener ${typeKey(cbT)}`,
+ `entry:`,
+ ];
+ if (param.kind === "dyn") {
+ host.declare(`declare ptr @scr_dyn_from_error(ptr)`);
+ d.push(` %de = call ptr @scr_dyn_from_error(ptr %err)`);
+ } else if (param.kind === "object" && param.className === "%Error") {
+ host.declare(`declare ptr @scr_error_retain(ptr)`);
+ d.push(` %de = call ptr @scr_error_retain(ptr %err)`);
+ } else {
+ throw new InternalCompilerError("llvm emitter bug: FSWatcher error param is not an Error");
+ }
+ d.push(
+ ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`,
+ ` %fn = load ptr, ptr %fnp`,
+ ` call void %fn(ptr %cb, ptr %de)`,
+ ` ret void`,
+ `}`,
+ ``,
+ );
+ host.resolveThunkDefs.push(...d);
+ host.journal?.built();
+ return sym;
+}
+
+registerReplay("watchNameThunkFor", (host, args) =>
+ watchNameThunkFor(host, args[0] as IrType & { kind: "func" }),
+);
+registerReplay("watchErrorThunkFor", (host, args) =>
+ watchErrorThunkFor(host, args[0] as IrType & { kind: "func" }),
+);
registerReplay("cryptoIntThunkFor", (host, args) =>
cryptoIntThunkFor(host, args[0] as IrType & { kind: "func" }),
);
diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts
index 8378fabea..fc6120803 100644
--- a/packages/compiler/src/backend/llvm/lib-dispatch.ts
+++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts
@@ -214,6 +214,9 @@ export function emitIoLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue
export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
+ // The generic emitter registrations queue the maxListeners leak warning
+ // for the next tick like emitter.on (lib-process.ts).
+ if (e.fn === "emitter.onFlex" || e.fn === "emitter.onAny") host.usesTimers = true;
if (host.coroAwait && e.fn === "async.hop") {
host.emitCoroAwait(null);
return { name: "", type: e.type };
@@ -1185,6 +1188,10 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
case "str":
case "regexp":
case "intl":
+ // ICU-backed Intl (scr_intl.c): ordinary libCalls whose C symbols the
+ // runtime provides — the same generic emission the engine-free intl
+ // forms use.
+ case "icu":
case "sym":
case "perf":
case "number":
@@ -1272,6 +1279,9 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
return host.emitGenericLibCall(e);
case "nodeIsland":
return host.emitGenericLibCall(e);
+ // Primitive wrapper objects (scr_js_wrapper.c).
+ case "wrapper":
+ return host.emitGenericLibCall(e);
case "webstream":
// A stream's start, pull and cancel steps run as promise jobs, so
// a program with the classes runs its event loop.
diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts
index ea275738e..2c44bb8ea 100644
--- a/packages/compiler/src/backend/llvm/lib-filesystem.ts
+++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts
@@ -1,7 +1,7 @@
/* Focused LLVM library-call emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
-import { DYN, F64, STRING, type IrExpr } from "../../ir/ir.js";
+import { DYN, F64, STRING, type IrExpr, type IrType } from "../../ir/ir.js";
import { mangleRecordNew } from "../mangle.js";
import { arrNewCall, traceArg, vAdapters } from "./shapes.js";
import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js";
@@ -173,6 +173,22 @@ function emitObjEntryPairs(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
return out;
}
+/** The adapter of an fs.watch 'change' listener, by its declared shape: the
+ * runtime's for no parameters and for a string eventType, and one this
+ * module emits for a dynamic eventType or an (eventType, filename) listener.
+ * Only the runtime's adapters need a declaration. */
+function watchChangeAdapter(host: LlvmEmitterContext, cbT: IrType & { kind: "func" }): string {
+ const runtime =
+ cbT.params.length === 0
+ ? "scr_watch_thunk0"
+ : cbT.params.length === 1 && cbT.params[0]!.kind !== "dyn"
+ ? "scr_watch_thunk_event"
+ : null;
+ if (runtime === null) return host.watchNameThunkFor(cbT);
+ host.declare(`declare void @${runtime}(ptr, ptr, ptr)`);
+ return runtime;
+}
+
export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
const alwaysThrowSym = FS_ALWAYS_THROW_SYMS[e.fn];
@@ -448,17 +464,19 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
// loop (usesTimers).
host.usesTimers = true;
const path = host.emitExpr(e.args[0]!);
- host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr)`);
+ host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`);
const t = B.tmp();
- B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr null, ptr null)`);
+ B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr null, ptr null, i1 false)`);
const out = host.own({ name: t, type: e.type });
host.emitPendingCheck();
return out;
}
if (e.fn === "fs.watchCb") {
// The callback MOVES into the watcher's registry; the adapter is
- // runtime-provided per listener shape (zero-param, or the eventType
- // string). May throw (ENOENT) — the standard pending check.
+ // runtime-provided per listener shape (zero-param, the eventType
+ // string, or the compiler's (eventType, filename) adapter). May
+ // throw (ENOENT, or the macOS directory-names refusal) — the
+ // standard pending check.
host.usesTimers = true;
const cbT = e.args[1]!.type;
if (cbT.kind !== "func")
@@ -466,15 +484,64 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
const path = host.emitExpr(e.args[0]!);
const cb = host.emitExpr(e.args[1]!);
host.toHeap(cb);
- const adapter = cbT.params.length === 0 ? "scr_watch_thunk0" : "scr_watch_thunk_event";
- host.declare(`declare void @${adapter}(ptr, ptr)`);
- host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr)`);
+ const adapter = watchChangeAdapter(host, cbT);
+ host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`);
const t = B.tmp();
- B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr ${cb.name}, ptr @${adapter})`);
+ B.line(
+ `${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr ${cb.name}, ptr @${adapter}, i1 ${cbT.params.length === 2 ? "true" : "false"})`,
+ );
const out = host.own({ name: t, type: e.type });
host.emitPendingCheck();
return out;
}
+ if (e.fn === "watcher.on") {
+ // (recv, event, flags, listener) — flags: 1 once, 2 prepend. The
+ // listener MOVES in; the adapter follows the event (0 change, 1
+ // error, 2 close — CLOSE takes no adapter, a plain zero-arg call).
+ // Registration can throw (the macOS directory-names refusal).
+ const flagsArg = e.args[2]!;
+ if (flagsArg.kind !== "numLit")
+ throw new InternalCompilerError("llvm emitter bug: watcher.on flags not a literal");
+ const flags = flagsArg.value;
+ const codeArg = e.args[1]!;
+ if (codeArg.kind !== "numLit")
+ throw new InternalCompilerError("llvm emitter bug: watcher.on event not a literal");
+ const code = codeArg.value;
+ const cbT = e.args[3]!.type;
+ if (cbT.kind !== "func")
+ throw new InternalCompilerError("llvm emitter bug: watcher.on listener not a func");
+ const w = host.emitExpr(e.args[0]!);
+ const event = host.emitExpr(e.args[1]!);
+ const cb = host.emitExpr(e.args[3]!);
+ host.toHeap(cb);
+ let adapter = "null";
+ let wantsName = false;
+ if (code === 0) {
+ adapter = watchChangeAdapter(host, cbT);
+ wantsName = cbT.params.length === 2;
+ } else if (code === 1) {
+ adapter = cbT.params.length === 0 ? "scr_watch_err_thunk0" : host.watchErrorThunkFor(cbT);
+ if (cbT.params.length === 0) host.declare(`declare void @scr_watch_err_thunk0(ptr, ptr)`);
+ }
+ host.declare(
+ `declare void @scr_watcher_on(ptr, double, ptr, ptr, i1 zeroext, i1 zeroext, i1 zeroext)`,
+ );
+ B.line(
+ `call void @scr_watcher_on(ptr ${w.name}, double ${event.name}, ptr ${cb.name}, ptr ${adapter === "null" ? "null" : `@${adapter}`}, i1 ${flags & 1 ? "true" : "false"}, i1 ${flags & 2 ? "true" : "false"}, i1 ${wantsName ? "true" : "false"})`,
+ );
+ host.emitPendingCheck();
+ return { name: "", type: e.type };
+ }
+ if (e.fn === "watcher.off") {
+ // Removal is by pointer identity: the listener stays borrowed
+ // (never heap-moved), exactly like process.offExit's.
+ const w = host.emitExpr(e.args[0]!);
+ const event = host.emitExpr(e.args[1]!);
+ const cb = host.emitExpr(e.args[2]!);
+ host.declare(`declare void @scr_watcher_off(ptr, double, ptr)`);
+ B.line(`call void @scr_watcher_off(ptr ${w.name}, double ${event.name}, ptr ${cb.name})`);
+ return { name: "", type: e.type };
+ }
if (e.fn === "watcher.close") {
// Idempotent; never throws.
const w = host.emitExpr(e.args[0]!);
@@ -482,6 +549,45 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
B.line(`call void @scr_watcher_close(ptr ${w.name})`);
return { name: "", type: e.type };
}
+ if (e.fn === "fs.watchFile") {
+ // (path, interval, persistent, listener) — the listener MOVES into
+ // the poller's registry and the adapter is runtime-provided per
+ // listener shape (zero, one or two Stats parameters). The poller
+ // re-arms a timer every interval, so this is a timer program. May
+ // throw (a null byte in the path, or an interval that is not a
+ // uint32) — the standard pending check.
+ host.usesTimers = true;
+ const cbT = e.args[3]!.type;
+ if (cbT.kind !== "func")
+ throw new InternalCompilerError("llvm emitter bug: fs.watchFile listener not a func");
+ const path = host.emitExpr(e.args[0]!);
+ const interval = host.emitExpr(e.args[1]!);
+ const persistent = host.emitExpr(e.args[2]!);
+ const cb = host.emitExpr(e.args[3]!);
+ host.toHeap(cb);
+ const adapter = `scr_watch_file_thunk${cbT.params.length}`;
+ host.declare(`declare void @${adapter}(ptr, ptr, ptr)`);
+ host.declare(`declare void @scr_fs_watch_file(ptr, double, i1 zeroext, ptr, ptr)`);
+ B.line(
+ `call void @scr_fs_watch_file(ptr ${path.name}, double ${interval.name}, i1 ${persistent.name}, ptr ${cb.name}, ptr @${adapter})`,
+ );
+ host.emitPendingCheck();
+ return { name: "", type: e.type };
+ }
+ if (e.fn === "fs.unwatchFile") {
+ // Removal is by closure identity: the listener stays borrowed
+ // (never heap-moved), exactly like watcher.off's; an absent one is
+ // the NULL that removes every listener on the path. A null byte in
+ // the path throws.
+ const path = host.emitExpr(e.args[0]!);
+ const cb = e.args[1] === undefined ? null : host.emitExpr(e.args[1]!);
+ host.declare(`declare void @scr_fs_unwatch_file(ptr, ptr)`);
+ B.line(
+ `call void @scr_fs_unwatch_file(ptr ${path.name}, ptr ${cb === null ? "null" : cb.name})`,
+ );
+ host.emitPendingCheck();
+ return { name: "", type: e.type };
+ }
if (e.fn === "fs.globTypesSync" || e.fn === "fsp.globTypes") {
// Dirent rows from one glob snapshot (scr_glob.c): mode 1 is the
// globSync form, 3 the fs/promises form with resolved parent paths.
diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts
index 01efd3354..d8b94fac0 100644
--- a/packages/compiler/src/backend/llvm/lib-process.ts
+++ b/packages/compiler/src/backend/llvm/lib-process.ts
@@ -867,6 +867,10 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
const cbT = e.args[2]!.type;
if (cbT.kind !== "func")
throw new InternalCompilerError("llvm emitter bug: emitter.on listener not a func");
+ // A registration past the emitter's maxListeners queues the leak
+ // warning for the next tick (scr_events_emitter.c), which the loop
+ // delivers before the process exits.
+ host.usesTimers = true;
const args = e.args.map((a) => host.emitExpr(a));
const { fn: adapterFn, shim } = host.emitterFixedAdapter(cbT);
// The wrapper's capture box owns its OWN +1 of the listener; the
@@ -897,6 +901,7 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
const adT = e.args[3]!.type;
if (adT.kind !== "func")
throw new InternalCompilerError("llvm emitter bug: emitter.onDyn adapter not a func");
+ host.usesTimers = true; // the leak warning's tick (emitter.on)
const args = e.args.map((a) => host.emitExpr(a));
const { fn: adapterFn, shim } = host.emitterFixedAdapter(adT);
host.toHeap(args[3]!); // the frame's +1 moves into the wrapper's box
@@ -1046,6 +1051,7 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
// emitter.on/onDyn, but the DATA adapter (the three-slot payload ABI
// — scr_stream_emit_data) behind the arity-3 fixed shim.
const isDyn = e.fn === "emitter.onDataDyn";
+ host.usesTimers = true; // the leak warning's tick (emitter.on)
const cbT = e.args[isDyn ? 3 : 2]!.type;
if (cbT.kind !== "func")
throw new InternalCompilerError(`llvm emitter bug: ${e.fn} listener not a func`);
diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts
index 73fd0b799..caa8696b7 100644
--- a/packages/compiler/src/backend/llvm/lib-shared.ts
+++ b/packages/compiler/src/backend/llvm/lib-shared.ts
@@ -67,6 +67,11 @@ export const LIB_FN_SYMS: Record = {
"http.reqH2StreamOrThrow": "scr_http_req_h2_stream_or_throw",
"intl.segmenterNew": "scr_intl_segmenter_new",
"intl.segmenterRefusing": "scr_intl_segmenter_refusing",
+ "icu.segmenterWord": "scr_intl_segmenter_word_new",
+ "icu.numFormatDefault": "scr_intl_num_format_default",
+ "icu.i18nSupport": "scr_intl_i18n_support",
+ "icu.dateLocaleString": "scr_intl_date_locale_string",
+ "icu.dateLocaleTimeString": "scr_intl_date_locale_time_string",
"weakMap.is": "scr_weak_map_is",
"weakSet.is": "scr_weak_set_is",
"weakMap.new": "scr_weak_map_new",
@@ -261,6 +266,8 @@ export const LIB_FN_SYMS: Record = {
"os.userName": "scr_os_user_name",
"os.userShell": "scr_os_user_shell",
"os.userHomedir": "scr_os_user_homedir",
+ "os.userUid": "scr_os_user_uid",
+ "os.userGid": "scr_os_user_gid",
"os.tmpdir": "scr_os_tmpdir",
"process.argv": "scr_process_argv",
"process.execArgv": "scr_process_exec_argv",
@@ -304,9 +311,19 @@ export const LIB_FN_SYMS: Record = {
"navigator.languages": "scr_navigator_languages",
"navigator.platform": "scr_navigator_platform",
"process.builtinModule": "scr_process_builtin_module",
+ "process.builtinObject": "scr_process_builtin_object",
"process.builtinUnsupported": "scr_process_builtin_unsupported",
"module.namespaceValue": "scr_module_namespace_value",
"module.namespaceDyn": "scr_module_namespace_dyn_value",
+ "wrapper.new": "scr_js_wrapper_new",
+ "wrapper.toObject": "scr_js_wrapper_to_object",
+ "wrapper.instanceOf": "scr_js_wrapper_instance_of",
+ "wrapper.objectTag": "scr_js_wrapper_object_tag",
+ "wrapper.typeIs": "scr_js_wrapper_type_is",
+ "wrapper.guard": "scr_js_wrapper_guard",
+ "wrapper.guardList": "scr_js_wrapper_guard_list",
+ "wrapper.isStringMethod": "scr_js_wrapper_is_string_method",
+ "wrapper.thisString": "scr_js_wrapper_this_string",
"fs.callbackValue": "scr_fs_callback_value",
"fs.callbackCall": "scr_fs_callback_call",
"process.hrtimeValue": "scr_process_hrtime_value",
@@ -379,9 +396,14 @@ export const LIB_FN_SYMS: Record = {
"process.envPairs": "scr_env_pairs",
"process.stdoutWrite": "scr_process_stdout_write",
"process.stdio": "scr_process_stdio",
+ "process.stdioSlot": "scr_process_stdio",
+ "process.stdioWriteDyn": "scr_process_stdio_write_dyn",
"process.stderrWrite": "scr_process_stderr_write",
"process.isTTY": "scr_process_is_tty",
"process.isTTYValue": "scr_process_is_tty_value",
+ // A promise that never settles (events.once of an event a synchronous
+ // stream never emits); a fresh pending promise is its owned result.
+ "events.pendingForever": "scr_promise_new",
"date.now": "scr_date_now",
"date.newNow": "scr_date_now",
"date.newMs": "scr_date_new_ms",
@@ -441,6 +463,12 @@ export const LIB_FN_SYMS: Record = {
"fsp.globError": "scr_fsp_glob_error",
"fs.realpathSync": "scr_fs_realpath",
"fs.realpathValue": "scr_fs_realpath_value",
+ "dyn.validateString": "scr_value_string",
+ "dyn.validateStrings": "scr_value_strings",
+ "path.resolveValue": "scr_path_resolve_value",
+ "path.parseValue": "scr_path_parse_value",
+ "path.formatValue": "scr_path_format_value",
+ "path.toNamespacedValue": "scr_path_to_namespaced_value",
"fs.realpathNativeSync": "scr_fs_realpath_promise",
"fs.unlinkSync": "scr_fs_unlink",
"fs.chmodSync": "scr_fs_chmod",
@@ -1896,6 +1924,11 @@ export const USES_TIMERS_LIB_FNS = new Set([
"worker.receiveMessageOnPort",
"worker.broadcastChannel",
"process.stdio",
+ // The typed write slot and the run-time-typed chunk write keep the loop
+ // live for the write callbacks they queue: both reach the same stream
+ // object as process.stdio and call back from a nextTick.
+ "process.stdioSlot",
+ "process.stdioWriteDyn",
"fetch.start",
"fetch.input",
"fetch.function",
@@ -1981,6 +2014,7 @@ export const USES_TIMERS_LIB_FNS = new Set([
"writable.writeU",
"writable.writeDyn",
"writable.writeEnc",
+ "writable.writeEcb",
"writable.end",
"writable.uncork",
"stream.destroy",
diff --git a/packages/compiler/src/backend/llvm/lib-stream.ts b/packages/compiler/src/backend/llvm/lib-stream.ts
index 53f455964..89a97d002 100644
--- a/packages/compiler/src/backend/llvm/lib-stream.ts
+++ b/packages/compiler/src/backend/llvm/lib-stream.ts
@@ -455,6 +455,44 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
host.emitPendingCheck();
return e.type.kind === "void" ? { name: "", type: e.type } : { name: t, type: e.type };
}
+ if (e.fn === "writable.writeEcb") {
+ // (recv, mode, chunk, encoding, cb): mode 0 bytes, 1 string, 2 dyn. The
+ // callback MOVES into an error-first wrapper (scr_stream_ecb_wrap); the
+ // adapter turns the runtime's ScrError (NULL = success) into the
+ // callback's Error | null argument, as fs.rename callbacks do.
+ const modeArg = e.args[1]!;
+ if (modeArg.kind !== "numLit")
+ throw new InternalCompilerError("llvm emitter bug: writable.writeEcb mode not a literal");
+ const cbT = e.args[4]!.type;
+ if (cbT.kind !== "func")
+ throw new InternalCompilerError("llvm emitter bug: writable.writeEcb callback not a func");
+ const args = e.args.map((a) => host.emitExpr(a));
+ host.toHeap(args[4]!);
+ const adapter = host.fsRenameThunkFor(cbT);
+ host.declare(`declare ptr @scr_stream_ecb_wrap(ptr, ptr)`);
+ const wrapped = B.tmp();
+ B.line(`${wrapped} = call ptr @scr_stream_ecb_wrap(ptr ${args[4]!.name}, ptr @${adapter})`);
+ const entry =
+ modeArg.value === 0
+ ? "scr_stream_write"
+ : modeArg.value === 1
+ ? "scr_stream_write_str"
+ : null;
+ const t = B.tmp();
+ if (entry !== null) {
+ host.declare(`declare zeroext i1 @${entry}(ptr, ptr, ptr)`);
+ B.line(
+ `${t} = call zeroext i1 @${entry}(ptr ${args[0]!.name}, ptr ${args[2]!.name}, ptr ${wrapped})`,
+ );
+ } else {
+ host.declare(`declare zeroext i1 @scr_stream_write_enc(ptr, ptr, ptr, ptr)`);
+ B.line(
+ `${t} = call zeroext i1 @scr_stream_write_enc(ptr ${args[0]!.name}, ptr ${args[2]!.name}, ptr ${args[3]!.name}, ptr ${wrapped})`,
+ );
+ }
+ host.emitPendingCheck();
+ return e.type.kind === "void" ? { name: "", type: e.type } : { name: t, type: e.type };
+ }
if (e.fn === "writable.end") {
// (recv, flags[, chunk][, encoding][, cb]) — flags: 1 bytes chunk, 2
// string chunk, 8 dyn chunk (write first, Node's end(chunk)
diff --git a/packages/compiler/src/backend/llvm/loop-state.ts b/packages/compiler/src/backend/llvm/loop-state.ts
index ce9189a45..be33ae517 100644
--- a/packages/compiler/src/backend/llvm/loop-state.ts
+++ b/packages/compiler/src/backend/llvm/loop-state.ts
@@ -3,11 +3,19 @@
* local arrays they rely on. */
import { findUnaliasedLocalArrays } from "../../ir/array-length-stability.js";
import type { IrFunction, IrStmt } from "../../ir/ir.js";
+import { everyStmtList } from "../../ir/traverse.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import type { DenseArrayLoop } from "./dense-array-loops.js";
import type { LlEmitter } from "./emitter.js";
import { defineModuleState } from "./emitter-state.js";
+/** The most IR nodes a function may have for its in-loop number-key
+ * lookups to inline the hashed probe (emitNumberMapEntry). Each inlined
+ * probe adds code to its function, and LLVM's passes over a large function
+ * take longer for it; a small function's loop of lookups, such as a k-mer
+ * count, gains the most from it. */
+const MAX_INLINED_LOOKUP_NODES = 200;
+
/** Sets the loop state for `fn` at the start of its emission; `numericFn`
* is `fn` with its initializer bindings (emitFunction). */
export function beginFunctionLoops(
@@ -32,6 +40,15 @@ export function beginFunctionLoops(
loops.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes")
? findInitializedByteLoopBindings(fn)
: new Map();
+ loops.inlinedLookups = fewNodes(fn.body, MAX_INLINED_LOOKUP_NODES);
+}
+
+/** Whether `body` has at most `max` statements and expressions. */
+function fewNodes(body: IrStmt[], max: number): boolean {
+ let count = 0;
+ const visit = (): boolean => ++count <= max;
+ everyStmtList(body, { expr: visit, stmt: visit });
+ return count <= max;
}
/** The state LOOPS holds on each emitter. */
@@ -58,6 +75,9 @@ export interface LoopState {
/** Counted loops whose integer-form safety test an enclosing version
* guard already passed. */
readonly provenCountedGuards: Set;
+ /** Number-key lookups in this function's loops inline the hashed probe:
+ * the function has at most MAX_INLINED_LOOKUP_NODES nodes. */
+ inlinedLookups: boolean;
}
export const LOOPS = defineModuleState(() => ({
@@ -73,4 +93,5 @@ export const LOOPS = defineModuleState(() => ({
bytesLoopEntries: null,
bytesLoopFunction: null,
provenCountedGuards: new Set(),
+ inlinedLookups: false,
}));
diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts
index f7bd87af1..05af8f77b 100644
--- a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts
+++ b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts
@@ -117,8 +117,9 @@ test("string keys and reference values are retained inline, boolean slots compar
expect(text).toContain("call ptr @scr_map_iter_val_ref(");
});
-/** work(map, k): map.has(k) on a number-keyed map. */
-function lookupFixture(key: IrExpr = ref("k", F64)): IrModule {
+/** work(map, k): map.has(k) on a number-keyed map, or with `loop`, the
+ * condition of `while (map.has(k)) {}`, after `filler` statements `k;`. */
+function lookupFixture(key: IrExpr = ref("k", F64), loop = false, filler = 0): IrModule {
const map: IrType = { kind: "map", key: F64, value: F64 };
const params = [
{ localId: "map", name: "map", type: map },
@@ -138,7 +139,17 @@ function lookupFixture(key: IrExpr = ref("k", F64)): IrModule {
returnType: BOOL,
loc,
locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: true })),
- body: [{ kind: "return", value: has, loc }],
+ body: loop
+ ? [
+ ...Array.from({ length: filler }, (): IrStmt => ({
+ kind: "exprStmt",
+ expr: ref("k", F64),
+ loc,
+ })),
+ { kind: "while", cond: has, body: [], loc },
+ { kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc },
+ ]
+ : [{ kind: "return", value: has, loc }],
};
return {
irVersion: 17,
@@ -159,6 +170,10 @@ test("hashed number-key lookups probe inline and call the runtime only for wide
module,
)![0];
expect(helper).toContain("call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)");
+ // A key outside the direct index goes to the probe, whose small-map
+ // branch tells a map with the index (field 16) from one without it.
+ expect(helper).toMatch(/br i1 %ix\d+, label %slot, label %slow\n/);
+ expect(helper).not.toContain("dense.any");
const hashed =
/^define internal ptr @sc_map_entry_f64_hashed\(ptr %m, double %k\) noinline [^]*?^}/m.exec(
module,
@@ -171,7 +186,10 @@ test("hashed number-key lookups probe inline and call the runtime only for wide
// Filter (field 10) before the small-map scan of the entry count (field
// 8) only, and compact buckets (fields 13 and 14) tagged with the hash's
// top byte.
- expect(hashed).toContain("br i1 %linear, label %filter, label %buckets");
+ expect(hashed).toContain("br i1 %linear, label %unbucketed, label %buckets");
+ expect(hashed).toMatch(
+ /^unbucketed:\n[^]*i32 0, i32 16\n[^]*br i1 %indexed, label %miss, label %filter\n/m,
+ );
expect(hashed).toContain("getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 10");
expect(hashed).toContain(`load i32, ptr %nep`);
expect(hashed).toContain(`load i32, ptr %nbp`);
@@ -194,22 +212,27 @@ test("a key with an integer view tests the direct index on the integer", () => {
integral.module,
)![0];
// An unsigned compare also rejects negative keys; the hashed probe takes
- // the integer.
+ // the integer and its double.
expect(helper).toContain("icmp ult i64 %ki, %nd");
expect(helper).toContain(`getelementptr inbounds i32, ptr %dense, ${sz} %i`);
- expect(helper).toContain("call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki)");
+ expect(helper).toContain("call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki, double %k)");
+ expect(helper).toContain("br i1 %inside, label %slot, label %slow");
expect(helper).not.toContain("fptosi");
- // Its key word is the integer's double, which needs no NaN or -0
- // normalization; the runtime lookup gets that double.
+ // Its key word is the bits of the integer's double, which needs no NaN
+ // normalization and is +0 for the integer 0; the runtime lookup gets
+ // that double. The probe converts nothing itself (cvtsi2sd would wait
+ // for its register's last value).
const hashed =
- /^define internal ptr @sc_map_entry_f64_hashed_int\(ptr %m, i64 %ki\) noinline [^]*?^}/m.exec(
+ /^define internal ptr @sc_map_entry_f64_hashed_int\(ptr %m, i64 %ki, double %k\) noinline [^]*?^}/m.exec(
integral.module,
)![0];
- expect(hashed).toContain("sitofp i64 %ki to double");
+ expect(hashed).toContain("bitcast double %k to i64");
+ expect(hashed).toContain("select i1 %kzero, i64 0, i64 %kbits");
+ expect(hashed).not.toContain("sitofp");
expect(hashed).not.toContain("fcmp");
expect(hashed).toContain("mul i64 %x1, -4658895280553007687");
expect(hashed).toContain(
- "call ptr @scr_map_entry_f64(ptr %m, double %kf) memory(read) nounwind willreturn",
+ "call ptr @scr_map_entry_f64(ptr %m, double %k) memory(read) nounwind willreturn",
);
// A fractional key and a key without a proof keep the double lookup.
for (const key of [{ kind: "numLit", value: 0.5, type: F64, loc } as IrExpr, ref("k", F64)]) {
@@ -220,6 +243,36 @@ test("a key with an integer view tests the direct index on the integer", () => {
}
});
+test("a lookup inside a loop calls the flavor that inlines the hashed probe", () => {
+ for (const key of [ref("k", F64), { kind: "numLit", value: 12, type: F64, loc } as IrExpr]) {
+ const { work, module } = emitted(lookupFixture(key, true), 64);
+ const name = key.kind === "numLit" ? "sc_map_entry_f64_int" : "sc_map_entry_f64";
+ expect(work).toContain(`call ptr @${name}.loop(`);
+ expect(work).not.toContain(`call ptr @${name}(`);
+ // They only read memory and always return, so a has() and get() of one
+ // key merge before they are inlined.
+ const helper = new RegExp(
+ `^define internal ptr @${name}\\.loop\\([^)]*\\) alwaysinline memory\\(read\\) nounwind willreturn [^]*?^}`,
+ "m",
+ ).exec(module)![0];
+ const hashed =
+ key.kind === "numLit" ? "sc_map_entry_f64_hashed_int" : "sc_map_entry_f64_hashed";
+ expect(helper).toContain(`call ptr @${hashed}.loop(`);
+ expect(module).toMatch(
+ new RegExp(
+ `^define internal ptr @${hashed}\\.loop\\([^)]*\\) alwaysinline memory\\(read\\) nounwind willreturn `,
+ "m",
+ ),
+ );
+ // A lookup outside a loop keeps the out-of-line probe, and so does a
+ // lookup in a loop of a function of more than 200 nodes.
+ expect(emitted(lookupFixture(key), 64).work).toContain(`call ptr @${name}(`);
+ const large = emitted(lookupFixture(key, true, 100), 64).work;
+ expect(large).toContain(`call ptr @${name}(`);
+ expect(large).not.toContain(".loop(");
+ }
+});
+
test("a key view converted in a branch is not reused by a later lookup of the same constant", () => {
// work(map, k, taken): if (taken) return map.has((k = 2 ** 40)); return map.has(2 ** 40).
const wide = 2 ** 40;
diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.ts b/packages/compiler/src/backend/llvm/map-number-lookup.ts
index 17a1563c3..da2e5981a 100644
--- a/packages/compiler/src/backend/llvm/map-number-lookup.ts
+++ b/packages/compiler/src/backend/llvm/map-number-lookup.ts
@@ -32,6 +32,7 @@
import type { IrExpr, IrType } from "../../ir/ir.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { exactInteger, widenInteger } from "./integer-values.js";
+import { LOOPS } from "./loop-state.js";
import { mapKeyAccess } from "./shapes.js";
export const NUMBER_MAP_ENTRY_DECL = "declare ptr @scr_map_entry_f64(ptr, double)";
@@ -55,19 +56,22 @@ export function noteNumberMapKey(host: LlvmEmitterContext, key: LlValue, expr: I
/** The entry for `key`, or null when absent. Valid until the next mutation.
* A key with an integer view (noteNumberMapKey) tests the direct index on
- * that integer instead of converting the double back. */
+ * that integer instead of converting the double back. A lookup inside a
+ * loop of a small function (LOOPS.inlinedLookups) calls the helpers' `.loop`
+ * flavor, which inlines the hashed probe. */
export function emitNumberMapEntry(host: LlvmEmitterContext, map: string, key: string): string {
host.declare(NUMBER_MAP_ENTRY_DECL);
const entry = host.B.tmp();
+ const flavor = host.loopDepth > 0 && LOOPS.of(host).inlinedLookups ? ".loop" : "";
const integer = host.numberMapKeyIntegers.get(key);
if (integer) {
const wide = widenInteger(host, integer);
host.B.line(
- `${entry} = call ptr @sc_map_entry_f64_int(ptr ${map}, i64 ${wide}, double ${key})`,
+ `${entry} = call ptr @sc_map_entry_f64_int${flavor}(ptr ${map}, i64 ${wide}, double ${key})`,
);
return entry;
}
- host.B.line(`${entry} = call ptr @sc_map_entry_f64(ptr ${map}, double ${key})`);
+ host.B.line(`${entry} = call ptr @sc_map_entry_f64${flavor}(ptr ${map}, double ${key})`);
return entry;
}
diff --git a/packages/compiler/src/backend/llvm/receiver-brackets.test.ts b/packages/compiler/src/backend/llvm/receiver-brackets.test.ts
new file mode 100644
index 000000000..65ceb8f85
--- /dev/null
+++ b/packages/compiler/src/backend/llvm/receiver-brackets.test.ts
@@ -0,0 +1,68 @@
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { expect, test } from "vitest";
+import { compile, deserializeModule } from "../../index.js";
+import { emitLlvmModule } from "./emitter.js";
+
+async function llvmFor(source: string): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "scriptc-receiver-brackets-"));
+ try {
+ const entry = join(dir, "main.ts"),
+ output = join(dir, "main.ir.json");
+ await writeFile(entry, source);
+ const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" });
+ if (!result.ok)
+ throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
+ return emitLlvmModule(deserializeModule(await readFile(output, "utf8")));
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+function body(ir: string, name: string): string {
+ const match = new RegExp(`^define [^\\n]*@${name}\\(.*?^}`, "ms").exec(ir);
+ if (!match) throw new Error(`no function ${name}`);
+ return match[0];
+}
+
+test("a known signature adapter's direct entry brackets the receiver only when its original reads it", async () => {
+ const ir = await llvmFor(`
+class Node {
+ kids: Node[] = [];
+ constructor(readonly kind: number) {}
+}
+function walk(root: Node, cb: (node: Node, depth: number) => void | "skip" | "stop"): void {
+ const stack: Node[] = [root];
+ while (stack.length > 0) {
+ const node = stack.pop()!;
+ const verdict = cb(node, stack.length);
+ if (verdict === "stop") return;
+ if (verdict === "skip") continue;
+ for (const kid of node.kids) stack.push(kid);
+ }
+}
+class Counter {
+ count = 0;
+ run(root: Node): number {
+ walk(root, (node) => {
+ if (node.kind === 3) this.count++;
+ });
+ return this.count;
+ }
+}
+const root = new Node(0);
+root.kids.push(new Node(3));
+console.log(new Counter().run(root));
+`);
+ const walk = body(ir, "sc_bf_walk");
+ const blocks = walk.split(/^(?=[\w.]+:$)/m);
+ const direct = blocks.find((block) => block.startsWith("callback.direct"));
+ // The adapter forwards the receiver to the arrow function, which never
+ // reads it: the direct entry tests the closure's RECEIVER_FREE bit, and
+ // every receiver push is behind such a test.
+ expect(direct).toMatch(/ = and i32 %\S+, 16\n/);
+ const pushes = blocks.filter((block) => block.includes("@scr_dyn_this_push_dyn"));
+ expect(pushes.length).toBeGreaterThan(0);
+ for (const block of pushes) expect(block).toMatch(/^receiver\.push\d+:/);
+});
diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts
index abae9032e..e02ae5cf8 100644
--- a/packages/compiler/src/backend/llvm/reference-writes.ts
+++ b/packages/compiler/src/backend/llvm/reference-writes.ts
@@ -103,6 +103,7 @@ const INERT_LIB_CALLS = new Set([
"json.parse",
"process.stderrWrite",
"process.stdoutWrite",
+ "process.stdioWriteDyn",
"process.envGet",
"process.envPairs",
"regex.groupEquals",
diff --git a/packages/compiler/src/backend/llvm/speculative-integers.ts b/packages/compiler/src/backend/llvm/speculative-integers.ts
index 1a2b220b0..f29ea1f47 100644
--- a/packages/compiler/src/backend/llvm/speculative-integers.ts
+++ b/packages/compiler/src/backend/llvm/speculative-integers.ts
@@ -234,6 +234,17 @@ function powerOfTwo(e: IrExpr): boolean {
/** Typed-array element kinds whose elements are integers. */
const INTEGER_ELEMENTS = new Set(["u8", "u8c", "i8", "u16", "i16", "u32", "i32"]);
+/** The values of each integer element kind. */
+const ELEMENT_RANGES: Record = {
+ u8: { min: 0, max: 255 },
+ u8c: { min: 0, max: 255 },
+ i8: { min: -128, max: 127 },
+ u16: { min: 0, max: 65535 },
+ i16: { min: -32768, max: 32767 },
+ u32: { min: 0, max: 2 ** 32 - 1 },
+ i32: { min: -(2 ** 31), max: 2 ** 31 - 1 },
+};
+
/** A typed-array element read of an integer kind with no effect: an
* out-of-range read gives NaN, which fails its check. Float elements would
* only bail. */
@@ -904,6 +915,17 @@ export function emitSafeIntegerCheck(host: LlvmEmitterContext, value: string): [
return [integer, ok];
}
+/** The condition that a bounded value's exponent is above `limit`. A
+ * value test it replaces can fail only past that limit; testing the
+ * exponent alone, which is usually the same in every iteration of a loop,
+ * keeps the value out of the test. Past the limit the write bails to the
+ * double copy, also for a value the replaced test would have passed. */
+function exponentAbove(host: LlvmEmitterContext, bound: MaskBound, limit: number): string {
+ const above = host.B.tmp();
+ host.B.line(`${above} = icmp sgt i64 ${bound.exponent}, ${limit}`);
+ return above;
+}
+
/** A condition that is true when an i64 lies outside the safe range. */
function unsafeInteger(host: LlvmEmitterContext, value: string): string {
const B = host.B;
@@ -932,6 +954,29 @@ function outsideRange(host: LlvmEmitterContext, value: string, low: number, high
return outside;
}
+/** The range of an operand once its speculative evaluation continued: a
+ * literal's value, the range analysis' proof (well inside the safe
+ * integers), or for an integer element read, which bails on the NaN of an
+ * out-of-range index, the element kind's values. */
+function operandRange(
+ host: LlvmEmitterContext,
+ e: IrExpr,
+): { min: number; max: number } | undefined {
+ const range = staticRange(host, e);
+ if (range || !isSpeculativeByteRead(e) || e.kind !== "bytesIntrinsic") return range;
+ return e.receiver.type.kind === "bytes" ? ELEMENT_RANGES[e.receiver.type.elem] : undefined;
+}
+
+/** An i64 the speculative evaluation has bounded to [0, 2^(exponent +
+ * shift)): the floor-mod of a value by 2^exponent, shifted left by `shift`.
+ * `exponent` is an i64 already checked to be in [0, 52]; in a loop it is
+ * usually the same value on every iteration, so a test on it alone is
+ * loop-invariant. */
+interface MaskBound {
+ exponent: string;
+ shift: number;
+}
+
/** A literal's value or the range analysis' proof for an expression whose
* range lies well inside the safe integers. */
function staticRange(
@@ -962,6 +1007,7 @@ export function emitSpeculativeInteger(
bail: (condition: string) => void,
reads: ReadonlyMap = new Map(),
integral = false,
+ bounds = new Map(),
): string {
const B = host.B;
// A value the range analysis already proves takes the ordinary integer
@@ -1000,12 +1046,13 @@ export function emitSpeculativeInteger(
// bits, also for a negative value: in [0, 2^n), and never -0.
const mod = floorModPowerOfTwo(e);
if (mod) {
- const value = emitSpeculativeInteger(host, mod.value, bail, reads, integral);
+ const value = emitSpeculativeInteger(host, mod.value, bail, reads, integral, bounds);
const n = exponentOf(host, mod.power, bail, reads, integral);
const mask = B.tmp(),
low = B.tmp();
B.line(`${mask} = sub i64 ${shiftOne(host, n)}, 1`);
B.line(`${low} = and i64 ${value}, ${mask}`);
+ bounds.set(low, { exponent: n, shift: 0 });
return low;
}
// A product with 2^n shifts: it is safe iff the other factor's
@@ -1013,8 +1060,19 @@ export function emitSpeculativeInteger(
// integer and a positive power is +0.
if (e.op === "*" && (powerOfTwo(e.right) || powerOfTwo(e.left))) {
const [factor, power] = powerOfTwo(e.right) ? [e.left, e.right] : [e.right, e.left];
- const value = emitSpeculativeInteger(host, factor, bail, reads, integral);
+ const value = emitSpeculativeInteger(host, factor, bail, reads, integral, bounds);
const n = exponentOf(host, power, bail, reads, integral);
+ // A factor below 2^(m + s) times 2^k is below 2^(m + s + k), so
+ // safe when m + s + k <= 53.
+ const bound = bounds.get(value);
+ const k = power.kind === "numLit" ? exactPowerOfTwo(power.value) : null;
+ if (bound && k !== null) {
+ bail(exponentAbove(host, bound, 53 - bound.shift - k));
+ const product = B.tmp();
+ B.line(`${product} = shl nuw nsw i64 ${value}, ${n}`);
+ bounds.set(product, { exponent: bound.exponent, shift: bound.shift + k });
+ return product;
+ }
const limit = B.tmp(),
magnitude = B.tmp(),
outside = B.tmp(),
@@ -1027,16 +1085,24 @@ export function emitSpeculativeInteger(
B.line(`${product} = shl nsw i64 ${value}, ${n}`);
return product;
}
- const a = emitSpeculativeInteger(host, e.left, bail, reads, integral);
- const b = emitSpeculativeInteger(host, e.right, bail, reads, integral);
+ const a = emitSpeculativeInteger(host, e.left, bail, reads, integral, bounds);
+ const b = emitSpeculativeInteger(host, e.right, bail, reads, integral, bounds);
const result = B.tmp();
if (e.op === "+" || e.op === "-") {
// With one operand's range known, the other is checked against the
// narrowed bounds before the operation: the check then depends on
// no new value, and LLVM keeps the operation itself simple.
- const right = staticRange(host, e.right);
- const left = e.op === "+" ? staticRange(host, e.left) : undefined;
- if (right) {
+ const right = operandRange(host, e.right);
+ const left = e.op === "+" ? operandRange(host, e.left) : undefined;
+ // A bounded operand, in [0, 2^(m + s)), plus one in [lo, hi] with
+ // |lo|, hi <= 2^52 is safe when m + s <= 52.
+ const bound = (value: string): MaskBound | undefined =>
+ e.op === "+" ? bounds.get(value) : undefined;
+ const aBound = right ? bound(a) : undefined;
+ const bBound = !right && left ? bound(b) : undefined;
+ if (aBound) bail(exponentAbove(host, aBound, 52 - aBound.shift));
+ else if (bBound) bail(exponentAbove(host, bBound, 52 - bBound.shift));
+ else if (right) {
// a + b with b in [lo, hi] is safe iff a in [-SAFE - lo, SAFE - hi];
// a - b iff a in [-SAFE + hi, SAFE + lo].
const low = e.op === "+" ? -SAFE - right.min : -SAFE + right.max;
diff --git a/packages/compiler/src/backend/native-codegen.ts b/packages/compiler/src/backend/native-codegen.ts
index c683ffaee..c7c18a8a7 100644
--- a/packages/compiler/src/backend/native-codegen.ts
+++ b/packages/compiler/src/backend/native-codegen.ts
@@ -563,6 +563,9 @@ function helperProfileMatch(match: NativeProfileMatch): Record {
missing: match.missing,
recovered: match.recovered,
unknown_partitions: match.unknownPartitions,
+ ...(match.annotatedCounts === undefined || match.mismatchedCounts === undefined
+ ? {}
+ : { annotated_counts: match.annotatedCounts, mismatched_counts: match.mismatchedCounts }),
};
}
diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts
index ded050bce..28d03088a 100644
--- a/packages/compiler/src/backend/native-link-info-core.ts
+++ b/packages/compiler/src/backend/native-link-info-core.ts
@@ -34,6 +34,12 @@ export interface NativeLinkFeatures {
/** The program uses process.stdin.setEncoding, string 'data' listeners
* or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */
stdioExtended?: boolean;
+ /** A write completion callback takes the error argument: links the
+ * SCR_STREAM_WRITE_ERRORS build of the stream unit. */
+ streamWriteErrors?: boolean;
+ /** The program reaches the process output streams' write slot: links the
+ * SCR_STDIO_WRITE_SLOT build of the events unit. */
+ stdioWriteSlot?: boolean;
/** Calls into Node.js (@scriptc/node): scr_node_island.c and the
* SCR_NODE_ISLANDS variant of the event loop unit. */
nodeIsland?: boolean;
@@ -43,6 +49,9 @@ export interface NativeLinkFeatures {
/** A module namespace boxed for typed code links
* scr_module_namespace_dyn.c; absent (false) otherwise. */
moduleNamespaceDyn?: boolean;
+ /** Primitive wrapper objects link scr_js_wrapper.c; absent (false)
+ * otherwise. */
+ primitiveWrapper?: boolean;
zlib: boolean;
/** node:zlib's Brotli forms: scr_brotli.c and the vendored brotli archive. */
brotli?: boolean;
@@ -120,6 +129,8 @@ export interface NativeLinkFeatures {
* scr_text_codec_dyn.c. */
textCodecDyn: boolean;
watch: boolean;
+ /** fs.watchFile/fs.unwatchFile: scr_watch_file.c. */
+ watchFile: boolean;
foreignFfi: boolean;
nodeTest: boolean;
tls: boolean;
@@ -144,6 +155,10 @@ export interface NativeLinkFeatures {
x509: boolean;
/** perf_hooks event-loop metrics: scr_perf_loop.c. */
perfLoop: boolean;
+ /** Intl over ICU (scr_intl.c plus the ICU archive and its data
+ * file): the `icu.*` libCalls of the first slice. Absent (false) for
+ * programs without them, so their link features stay unchanged. */
+ intl?: boolean;
tlsCa: boolean;
/** crypto.subtle importKey/sign/verify/digest and CryptoKey values:
* scr_webcrypto.c. */
diff --git a/packages/compiler/src/backend/native-link-info.ts b/packages/compiler/src/backend/native-link-info.ts
index a4e951055..247918e7b 100644
--- a/packages/compiler/src/backend/native-link-info.ts
+++ b/packages/compiler/src/backend/native-link-info.ts
@@ -34,6 +34,7 @@ export async function createNativeLinkInfo(options: {
features: pack.features,
runtime: pack.selectedRuntimeArtifacts,
archives: pack.selectedArchiveArtifacts,
+ data: pack.selectedDataArtifacts,
systemLibraries: pack.systemLibraries,
},
});
diff --git a/packages/compiler/src/backend/native-profile.test.ts b/packages/compiler/src/backend/native-profile.test.ts
index 7d2119d4b..77207f834 100644
--- a/packages/compiler/src/backend/native-profile.test.ts
+++ b/packages/compiler/src/backend/native-profile.test.ts
@@ -145,6 +145,20 @@ test("the helper's profile match is read from its output", () => {
'{"ok":true,"profile":{"annotated":1,"mismatched":0,"missing":0,"recovered":-1,"unknown_partitions":0}}\n',
),
).toBeNull();
+ // With the profile's counters on the annotated and the mismatched functions.
+ expect(
+ parseNativeProfileMatch(
+ '{"ok":true,"profile":{"annotated":120,"mismatched":7,"missing":40,"recovered":0,"unknown_partitions":0,"annotated_counts":9000,"mismatched_counts":1000}}\n',
+ ),
+ ).toEqual({
+ annotated: 120,
+ mismatched: 7,
+ missing: 40,
+ recovered: 0,
+ unknownPartitions: 0,
+ annotatedCounts: 9000,
+ mismatchedCounts: 1000,
+ });
// A helper that predates the report prints nothing.
expect(parseNativeProfileMatch("")).toBeNull();
expect(parseNativeProfileMatch('{"ok":true}\n')).toBeNull();
diff --git a/packages/compiler/src/backend/native-profile.ts b/packages/compiler/src/backend/native-profile.ts
index fa6fc134f..b581c73ed 100644
--- a/packages/compiler/src/backend/native-profile.ts
+++ b/packages/compiler/src/backend/native-profile.ts
@@ -111,13 +111,16 @@ export function nativeProfileHelperArgs(profile: NativeProfileOptions | undefine
* profile has no record of (new, or never run while it was collected),
* mismatched functions that the block-count file annotated (never more
* than the mismatched ones), and partitions reused from the cache without
- * their counts. */
+ * their counts. Helpers that weigh the match also report the profile's
+ * counters on the annotated and on the mismatched functions. */
export interface NativeProfileMatch {
annotated: number;
mismatched: number;
missing: number;
recovered: number;
unknownPartitions: number;
+ annotatedCounts?: number;
+ mismatchedCounts?: number;
}
/** The profile match in a successful helper emit's output, or null when the
@@ -150,7 +153,18 @@ export function parseNativeProfileMatch(stdout: string): NativeProfileMatch | nu
unknownPartitions === null
)
continue;
- return { annotated, mismatched, missing, recovered, unknownPartitions };
+ const annotatedCounts = count("annotated_counts");
+ const mismatchedCounts = count("mismatched_counts");
+ return {
+ annotated,
+ mismatched,
+ missing,
+ recovered,
+ unknownPartitions,
+ ...(annotatedCounts === null || mismatchedCounts === null
+ ? {}
+ : { annotatedCounts, mismatchedCounts }),
+ };
}
return null;
}
diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts
index 5026fd5f2..d3ed79793 100644
--- a/packages/compiler/src/backend/native/contracts.ts
+++ b/packages/compiler/src/backend/native/contracts.ts
@@ -83,6 +83,17 @@ export interface CcOptions {
/** The program uses node:zlib's Brotli forms (with zlib): compiles
* scr_brotli.c and links the vendored brotli archive. */
brotli?: boolean;
+ /** The program uses an Intl form that only ICU can answer (the `icu.*`
+ * libCalls: default-locale number/date formats, word segmentation):
+ * compiles scr_intl.c and links the ICU archive, and the
+ * executable gets icudt78l.dat next to it. Every engine-free form keeps
+ * linking none of it. */
+ intl?: boolean;
+ /** With `intl`: the installed runtime pack's ICU archive and the include
+ * root of the ICU headers it ships. The C-source path then links that
+ * archive and compiles scr_intl.c against those headers, instead of
+ * building ICU from its fetched release with Zig's C++ driver. */
+ icuPack?: { archive: string; include: string };
/** The program uses node:assert (index.ts detects assert.* libCalls on
* the IR): compiles scr_assert.c — the zlib gating precedent, so
* assert-free binaries keep their exact size class. scr_regex.c calls
@@ -161,6 +172,12 @@ export interface CcOptions {
warningReplace?: boolean;
/** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */
stdioExtended?: boolean;
+ /** SCR_STREAM_WRITE_ERRORS: the stream unit publishes the error each
+ * write completion callback receives (error-taking write callbacks). */
+ streamWriteErrors?: boolean;
+ /** SCR_STDIO_WRITE_SLOT: the events unit carries the typed write slot
+ * (the write method as a value, its replacement, run-time-typed chunks). */
+ stdioWriteSlot?: boolean;
/** Calls into Node.js (@scriptc/node): compiles scr_node_island.c and
* the event loop with SCR_NODE_ISLANDS (its helper slot). */
nodeIsland?: boolean;
@@ -170,6 +187,8 @@ export interface CcOptions {
/** A module namespace boxed for typed code: compiles
* scr_module_namespace_dyn.c. */
moduleNamespaceDyn?: boolean;
+ /** Primitive wrapper objects: compiles scr_js_wrapper.c. */
+ primitiveWrapper?: boolean;
/** The program uses the process-events surface (signal/exit listeners,
* stdin events, for-await over stdin — moduleUsesProcessEvents on the
* IR): compiles scr_events.c into the binary. Event-free binaries keep
@@ -258,6 +277,11 @@ export interface CcOptions {
* scr_watch.c into the binary — the net gating precedent, so watch-free
* binaries keep their exact link line. */
watch?: boolean;
+ /** The program uses fs.watchFile or fs.unwatchFile (moduleUsesFsWatchFile
+ * on the IR): compiles scr_watch_file.c into the binary — its own gated
+ * unit under the same precedent, so polling-free binaries keep their
+ * exact link line. */
+ watchFile?: boolean;
/** The executable manifest has a format-5 foreign callback descriptor:
* compiles the MPSC queue/self-pipe unit. Other FFI and non-FFI binaries
* keep their existing runtime size class. */
diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts
index a650955d6..2f6387875 100644
--- a/packages/compiler/src/backend/native/executable.ts
+++ b/packages/compiler/src/backend/native/executable.ts
@@ -84,6 +84,9 @@ import {
vendorBuildCacheRoot,
tlsArchivePath,
brotliArchivePath,
+ ensureIcuSources,
+ icuArchivePath,
+ ensureIcuArchive,
lreObjectPaths,
zlibObjectPaths,
stageVendorInputs,
@@ -124,6 +127,10 @@ interface RuntimeUnitContext {
zlibObjects: readonly string[];
tlsArchive: string | null;
brotliArchive: string | null;
+ icuArchive: string | null;
+ /** The include roots of the ICU headers scr_intl.c compiles against: the
+ * runtime pack's, or the fetched release's common/ and i18n/. */
+ icuIncludeDirs: readonly string[];
}
/** An optional runtime input: runtime sources compiled when `when` holds,
@@ -174,9 +181,10 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
// SCR_MUSL flag and this target-selected TU travel together.
unit((u) => isMuslTarget(u.driver), "scr_musl.c"),
// scr_glob.c (path.matchesGlob) compiles its segments through the
- // regex unit and scr_idna.c (node:url's IDNA) normalizes through it,
- // so both ride the same switch; section GC drops them from binaries
- // that never use them.
+ // regex unit, scr_idna.c (node:url's IDNA) normalizes through it and
+ // scr_path_win32_relative.c lowercases through its case mapping, so all
+ // three ride the same switch; section GC drops them from binaries that
+ // never use them.
unitArgs((u) =>
u.regex
? [
@@ -185,6 +193,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
u.rt(join(u.rtDir, "scr_regex.c")),
u.rt(join(u.rtDir, "scr_glob.c")),
u.rt(join(u.rtDir, "scr_idna.c")),
+ u.rt(join(u.rtDir, "scr_path_win32_relative.c")),
...u.lreObjects,
]
: [],
@@ -212,6 +221,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.moduleNamespace === true, "scr_module_namespace.c"),
// A module namespace as the frozen null-prototype object typed code sees.
unit((u) => u.opts.moduleNamespaceDyn === true, "scr_module_namespace_dyn.c"),
+ // Primitive wrapper objects (new String/Number/Boolean, Object(primitive)).
+ unit((u) => u.opts.primitiveWrapper === true, "scr_js_wrapper.c"),
unit(
(u) =>
u.opts.dynAsync ||
@@ -253,6 +264,20 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
]
: [],
),
+ // Intl over ICU (scr_intl.c): ICU's C API only, at the default locale,
+ // reading its data file from beside the executable. The ICU headers come
+ // from the installed runtime pack beside its archive, or, when ICU is
+ // built from its fetched release, from that source tree (common/unicode
+ // for the core API, i18n/unicode for udat.h and unumberformatter.h).
+ unitArgs((u) =>
+ u.icuIncludeDirs.length === 0 || u.icuArchive === null
+ ? []
+ : [
+ ...u.icuIncludeDirs.flatMap((dir) => ["-I", dir]),
+ u.rt(join(u.rtDir, "scr_intl.c")),
+ u.icuArchive,
+ ],
+ ),
unit((u) => u.opts.events, "scr_events.c", "scr_readline.c"),
// readline's terminal mode: rl.terminal sets the regex unit it
// measures with.
@@ -344,6 +369,9 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.dgram && u.opts.dgramDyn, "scr_dgram_dyn.c"),
unit((u) => u.opts.textCodecDyn, "scr_text_codec_dyn.c"),
unit((u) => u.opts.watch, "scr_watch.c"),
+ // fs.watchFile's stat poller: its own gated unit (no loop hook — the
+ // re-armed timers are the liveness), linked only by programs that poll.
+ unit((u) => u.opts.watchFile, "scr_watch_file.c"),
unit((u) => u.opts.foreignFfi, "scr_ffi_queue.c"),
unit((u) => u.opts.nodeTest, "scr_test.c"),
unit((u) => u.opts.webcrypto, "scr_webcrypto.c"),
@@ -389,6 +417,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
"scr_node_island_handle.c",
"scr_node_island_export.c",
"scr_node_island_boot.c",
+ "scr_node_island_gc.c",
),
// Readable.fromWeb reads a web ReadableStream through its reader.
unit((u) => u.nativeFetch && u.opts.stream, "scr_stream_from_web.c"),
@@ -417,12 +446,19 @@ const RUNTIME_DEFINES: readonly {
{ define: "SCR_JS_TIER", when: (opts) => opts.dynTier === true },
{ define: "SCR_WARNING_REPLACE", when: (opts) => opts.warningReplace },
{ define: "SCR_STDIO_EXTENDED", when: (opts) => opts.stdioExtended },
+ // scr_stream.c's write-callback error publication, which the compiler's
+ // error-first write adapter reads.
+ { define: "SCR_STREAM_WRITE_ERRORS", when: (opts) => opts.streamWriteErrors },
+ // scr_events.c's typed process output write slot.
+ { define: "SCR_STDIO_WRITE_SLOT", when: (opts) => opts.stdioWriteSlot },
// scr_async.c's native promise reactions, which the web streams unit uses,
// scr_json.c's and scr_dyn_builtin_class.c's instanceof over its classes,
// and scr_fetch.c's bodies of its streams.
{ define: "SCR_NATIVE_REACTIONS", when: (opts) => opts.webStreams },
{ define: "SCR_HANDLE_CLASSES", when: (opts) => opts.webStreams },
{ define: "SCR_YIN", when: (opts) => opts.youngNursery },
+ // scr_lib.c's path stat and zero Stats for the fs.watchFile poll.
+ { define: "SCR_FS_WATCH_FILE", when: (opts) => opts.watchFile },
{ define: "SCR_NODE_ISLANDS", when: (opts) => opts.nodeIsland },
];
@@ -455,6 +491,7 @@ export async function compileCInternal(
const pki = opts.pki ?? false;
const mbedtls = tls || cipher || ecdh || dh || pki;
const brotli = (opts.zlib ?? false) && opts.brotli === true;
+ const icu = opts.intl ?? false;
const driver = resolveCc();
const darwinDebugSymbols = needsDarwinDebugSymbols(
targetPlatform(driver),
@@ -697,6 +734,24 @@ export async function compileCInternal(
let brotliArchive = brotli
? brotliArchivePath(sanitize, driver, vendorBuildIdentity, vendorCacheRoot)
: null;
+ // The installed runtime pack's ICU archive is the one this path would
+ // build (uninstrumented either way), so a pack that ships it and its
+ // headers spares the Zig C++ build and the source download.
+ const icuPack = icu ? (opts.icuPack ?? null) : null;
+ let icuArchive = icu
+ ? (icuPack?.archive ?? icuArchivePath(driver, vendorBuildIdentity, vendorCacheRoot))
+ : null;
+ // scr_intl.c's ICU headers. They are known before the runtime-object flags
+ // are: those flags compile every runtime unit, so without them a cached
+ // scr_intl.o would take whatever ICU headers the host has.
+ const icuSource =
+ icu && icuPack === null ? join(await ensureIcuSources(vendorCacheRoot), "source") : null;
+ const icuIncludeDirs: readonly string[] =
+ icuPack !== null
+ ? [icuPack.include]
+ : icuSource !== null
+ ? [join(icuSource, "common"), join(icuSource, "i18n")]
+ : [];
// ours would collide; see scr_regex.c) — the standalone objects are for
// static builds only.
@@ -751,6 +806,18 @@ export async function compileCInternal(
: await stageVendorInputs(materialize, join(stageRoot, "brotli"));
brotliArchive = paths[0]!;
}
+ if (icu && icuPack === null) {
+ const cachedArchive = icuArchivePath(driver, vendorBuildIdentity, materializeCacheRoot);
+ protectCachedArtifact(cacheWarmPaths, cachedArchive);
+ const materialize = async (): Promise => [
+ await ensureIcuArchive(driver, vendorBuildIdentity, materializeCacheRoot),
+ ];
+ const paths =
+ stageRoot === undefined
+ ? await materialize()
+ : await stageVendorInputs(materialize, join(stageRoot, "icu"));
+ icuArchive = paths[0]!;
+ }
if (regex) {
for (const object of lreObjectPaths(
sanitize,
@@ -838,6 +905,8 @@ export async function compileCInternal(
zlibObjects,
tlsArchive,
brotliArchive,
+ icuArchive,
+ icuIncludeDirs,
}),
// A .ll program TU (the LLVM backend) deliberately carries no target
// triple (byte-stable output; clang supplies the host/SCRIPTC_TARGET
@@ -886,6 +955,7 @@ export async function compileCInternal(
...(regex ? ["-I", vendorEngineDir()] : []),
...(zlibObjects.length > 0 ? ["-I", vendorZlibDir()] : []),
...(tlsArchive !== null ? ["-I", join(vendorTlsDir(), "include")] : []),
+ ...icuIncludeDirs.flatMap((dir) => ["-I", dir]),
];
// Keep sanitizer runtime objects optimized in both postures. Large dev
// programs still compile at O0, avoiding an expensive LLVM optimization
diff --git a/packages/compiler/src/backend/native/library.ts b/packages/compiler/src/backend/native/library.ts
index 3cf4afa53..3585ab6a6 100644
--- a/packages/compiler/src/backend/native/library.ts
+++ b/packages/compiler/src/backend/native/library.ts
@@ -104,7 +104,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise
// Zig's musl sysroot does not provide arc4random_buf. Keep the fallback
// inside the archive so library embedders need no extra system library.
...(isMuslTarget(driver) ? ["scr_musl.c"] : []),
- ...(regex ? ["scr_regex.c", "scr_glob.c"] : []),
+ ...(regex ? ["scr_regex.c", "scr_glob.c", "scr_path_win32_relative.c"] : []),
...(opts.assert || regex || opts.symbol ? ["scr_assert.c"] : []),
...(opts.inspect ? ["scr_inspect.c", "scr_console_native.c"] : []),
...(opts.symbol ? ["scr_symbol.c"] : []),
diff --git a/packages/compiler/src/backend/native/runtime-inputs.ts b/packages/compiler/src/backend/native/runtime-inputs.ts
index 60f1a8960..4fe8100da 100644
--- a/packages/compiler/src/backend/native/runtime-inputs.ts
+++ b/packages/compiler/src/backend/native/runtime-inputs.ts
@@ -3,7 +3,13 @@ import { createRequire } from "node:module";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { basename, dirname, extname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
-import { BROTLI_VERSION, MBEDTLS_VERSION, QJS_COMMIT, ZLIB_VERSION } from "../vendor-inputs.js";
+import {
+ BROTLI_VERSION,
+ ICU_VERSION,
+ MBEDTLS_VERSION,
+ QJS_COMMIT,
+ ZLIB_VERSION,
+} from "../vendor-inputs.js";
import { stableTestMemo } from "./session.js";
const implementationPath = fileURLToPath(import.meta.url);
@@ -139,6 +145,7 @@ async function runtimeFingerprintFresh(rtDir: string): Promise {
.update(MBEDTLS_VERSION)
.update(ZLIB_VERSION)
.update(BROTLI_VERSION)
+ .update(ICU_VERSION)
.update("\0backend-recipe\0");
for (const path of NATIVE_RECIPE_IMPLEMENTATION_PATHS) {
h.update(basename(path))
@@ -159,19 +166,29 @@ async function runtimeFingerprintFresh(rtDir: string): Promise {
return h.digest("hex");
}
+interface RuntimeFingerprintGroup {
+ label: string;
+ dir: string;
+}
+
async function runtimeFingerprintInputGroups(
rtDir: string,
): Promise<{ label: string; dir: string; names: string[] }[]> {
return Promise.all(
- [
- { label: "runtime", dir: rtDir },
- { label: "ryu", dir: join(rtDir, "..", "vendor", "ryu") },
- { label: "quickjs-ng", dir: join(rtDir, "..", "vendor", "quickjs-ng") },
- { label: "mbedtls", dir: join(rtDir, "..", "vendor", "mbedtls") },
- { label: "zlib", dir: join(rtDir, "..", "vendor", "zlib") },
- { label: "brotli", dir: join(rtDir, "..", "vendor", "brotli", "c") },
- { label: "curl", dir: join(rtDir, "..", "vendor", "curl") },
- ].map(async (group) => {
+ (
+ [
+ { label: "runtime", dir: rtDir },
+ { label: "ryu", dir: join(rtDir, "..", "vendor", "ryu") },
+ { label: "quickjs-ng", dir: join(rtDir, "..", "vendor", "quickjs-ng") },
+ { label: "mbedtls", dir: join(rtDir, "..", "vendor", "mbedtls") },
+ { label: "zlib", dir: join(rtDir, "..", "vendor", "zlib") },
+ { label: "brotli", dir: join(rtDir, "..", "vendor", "brotli", "c") },
+ // ICU is fetched, not vendored: its pinned version and sha256 key the
+ // archive's cache path (vendor-archives.ts icuArchivePath), so it is
+ // not part of the runtime tree's fingerprint.
+ { label: "curl", dir: join(rtDir, "..", "vendor", "curl") },
+ ] satisfies RuntimeFingerprintGroup[]
+ ).map(async (group) => {
const names = (await nativeSourceFiles(group.dir, true))
.map((path) => relative(group.dir, path))
.sort();
diff --git a/packages/compiler/src/backend/native/vendor.ts b/packages/compiler/src/backend/native/vendor.ts
index b6084d5c4..f7b7dacee 100644
--- a/packages/compiler/src/backend/native/vendor.ts
+++ b/packages/compiler/src/backend/native/vendor.ts
@@ -9,6 +9,9 @@ export const {
vendorBrotliDir,
brotliArchivePath,
ensureBrotliArchive,
+ ensureIcuSources,
+ icuArchivePath,
+ ensureIcuArchive,
vendorBuildCacheRoot,
vendorCacheTargetFlavor,
vendorCacheBuildIdentity,
diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts
index 2ce4f21fe..3bafda8c9 100644
--- a/packages/compiler/src/backend/runtime-pack-core.ts
+++ b/packages/compiler/src/backend/runtime-pack-core.ts
@@ -34,7 +34,7 @@ interface RuntimePackUnit {
}
interface RuntimePackArchive extends RuntimePackArtifact {
- id: "quickjs" | "libregexp" | "zlib" | "brotli" | "mbedtls";
+ id: "quickjs" | "libregexp" | "zlib" | "brotli" | "mbedtls" | "icu";
predicate: RuntimePredicate;
}
@@ -74,6 +74,15 @@ export interface RuntimePackManifest {
};
flavors: Partial>;
archives: RuntimePackArchive[];
+ /** Files the linked executable needs at RUN time rather than at link time,
+ * installed beside it on every build. ICU's data file (icudt78l.dat, 31.6
+ * MiB) is the only one; Intl programs copy it, every other program links
+ * neither ICU nor its data. */
+ data?: RuntimePackArtifact[];
+ /** C headers under one include root (`/unicode/*.h`): the ICU C API
+ * headers scr_intl.c includes, which a sanitized build compiles that unit
+ * against (native/sanitizer.ts). Only a pack that ships ICU lists them. */
+ headers?: RuntimePackArtifact[];
system_libraries: { name: string; predicate: RuntimePredicate }[];
licenses: { path: string; license: string }[];
}
@@ -101,10 +110,17 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures {
computedRequire: boolean;
warningReplace: boolean;
stdioExtended: boolean;
+ /** The program's write completion callbacks take the error argument:
+ * the SCR_STREAM_WRITE_ERRORS build of the stream unit. */
+ streamWriteErrors: boolean;
+ /** The program reaches the process output streams' write slot: the
+ * SCR_STDIO_WRITE_SLOT build of the events unit. */
+ stdioWriteSlot: boolean;
nodeIsland: boolean;
brotli: boolean;
moduleNamespace: boolean;
moduleNamespaceDyn: boolean;
+ primitiveWrapper: boolean;
nativeFetch: boolean;
abortSignalOnly: boolean;
netEffective: boolean;
@@ -245,6 +261,10 @@ export function parseRuntimePackManifest(value: unknown): RuntimePackManifest {
validArtifact(raw) && typeof archive?.id === "string" && validPredicate(archive.predicate)
);
}) ||
+ (manifest.data !== undefined &&
+ (!Array.isArray(manifest.data) || !manifest.data.every(validArtifact))) ||
+ (manifest.headers !== undefined &&
+ (!Array.isArray(manifest.headers) || !manifest.headers.every(validArtifact))) ||
!Array.isArray(manifest.system_libraries) ||
!manifest.system_libraries.every((raw) => {
const library = object(raw);
@@ -312,10 +332,13 @@ export function effectiveRuntimeFeatures(
computedRequire: features.computedRequire === true,
warningReplace: features.warningReplace === true,
stdioExtended: features.stdioExtended === true,
+ streamWriteErrors: features.streamWriteErrors === true,
+ stdioWriteSlot: features.stdioWriteSlot === true,
nodeIsland: features.nodeIsland === true,
brotli: features.brotli === true,
moduleNamespace: features.moduleNamespace === true,
moduleNamespaceDyn: features.moduleNamespaceDyn === true,
+ primitiveWrapper: features.primitiveWrapper === true,
nativeFetch,
// The fetch unit has AbortController and AbortSignal; scr_abort.c is
// the same implementation without the fetch transport.
@@ -395,6 +418,8 @@ export interface RuntimePackArtifacts {
/** Selected unit variants; each may carry its bitcode artifact. */
runtime: (RuntimePackArtifact & { bitcode?: RuntimePackArtifact })[];
archives: RuntimePackArtifact[];
+ /** Files to install beside the executable (ICU's data file for Intl). */
+ data: RuntimePackArtifact[];
systemLibraries: string[];
}
@@ -447,12 +472,26 @@ export function selectRuntimePackArtifacts(
`runtime pack has no ${key} flavor; reinstall the matching runtime package`,
"invalid",
);
+ // Intl needs the ICU objects only the host-flavored packs carry (ICU is
+ // C++, so a pack builds it with Zig's C++ driver for its own glibc target);
+ // say so instead of linking a program whose scr_intl.c references would go
+ // undefined.
+ if (
+ features.intl === true &&
+ !selectedFlavor.runtime_units.some((unit) => unit.source === "scr_intl.c")
+ )
+ throw new RuntimePackError(
+ `the ${manifest.target.name} runtime pack has no ICU objects, which Intl needs; reinstall a runtime package that supports Intl`,
+ "unsupported",
+ );
return {
features,
runtime: selectedRuntimeUnits(selectedFlavor, features).map(({ variant }) => variant),
archives: manifest.archives.filter((archive) =>
evaluateRuntimePredicate(archive.predicate, features),
),
+ // ICU's data file is 31.6 MiB: only a program that links ICU installs it.
+ data: features.intl === true ? (manifest.data ?? []) : [],
systemLibraries: manifest.system_libraries
.filter((entry) => evaluateRuntimePredicate(entry.predicate, features))
.map((entry) => entry.name),
diff --git a/packages/compiler/src/backend/runtime-pack-native.test.ts b/packages/compiler/src/backend/runtime-pack-native.test.ts
index 7a8a2e6c4..18975e1f9 100644
--- a/packages/compiler/src/backend/runtime-pack-native.test.ts
+++ b/packages/compiler/src/backend/runtime-pack-native.test.ts
@@ -1,8 +1,12 @@
import { createHash } from "node:crypto";
import {
+ chmodSync,
+ existsSync,
mkdirSync,
+ readdirSync,
readFileSync,
renameSync,
+ rmSync,
statSync,
symlinkSync,
unlinkSync,
@@ -148,10 +152,12 @@ async function packFixture() {
store,
);
let stages = 0;
- const stage = (selection: NativeRuntimeSelection) => {
- const directory = join(root, `stage-${stages++}`);
+ const stage = (selection: NativeRuntimeSelection, with_: RuntimePackStore = store) => {
+ const directory = join(tmpdir(), `scriptc-runtime-pack-stage-${process.pid}-${stages++}`);
+ rmSync(directory, { recursive: true, force: true });
mkdirSync(directory);
- return stageNativeRuntimeSelection(selection, directory, store);
+ dirs.push(directory);
+ return stageNativeRuntimeSelection(selection, directory, with_);
};
return { root, object, manifestText, entries, select, stage };
}
@@ -241,3 +247,180 @@ test.skipIf(process.platform === "win32")(
expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object");
},
);
+
+/** A store that keeps nothing: a build with an empty cache. */
+function emptyStore(): RuntimePackStore {
+ const entries = new Map();
+ return {
+ read: (key) => entries.get(key) ?? null,
+ write: (key, text) => void entries.set(key, text),
+ };
+}
+
+const packRecord = (root: string) => join(root, "runtime-pack.verified.json");
+const hashes = () => vi.mocked(createHash).mock.calls.length;
+
+test("a build with an empty cache trusts the record beside the pack", async () => {
+ const f = await packFixture();
+ const selection = f.select();
+ let start = hashes();
+ f.stage(selection);
+ const firstStage = hashes() - start;
+ const record = JSON.parse(readFileSync(packRecord(f.root), "utf8"));
+ expect(record.schema).toBe(1);
+ expect(record.artifacts.map((artifact: { path: string }) => artifact.path)).toEqual([
+ "artifacts/base.o",
+ ]);
+ start = hashes();
+ const staged = f.stage(selection, emptyStore());
+ // Only the store key is hashed.
+ expect(hashes() - start).toBe(firstStage - 1);
+ expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object");
+});
+
+test.each(["replaced", "rewritten in place", "resized"])(
+ "the record beside the pack does not trust an artifact that was %s",
+ async (change) => {
+ const f = await packFixture();
+ const selection = f.select();
+ f.stage(selection);
+ const before = statSync(f.object);
+ const changed = change === "replaced" ? f.object + ".new" : f.object;
+ writeFileSync(changed, "x".repeat(before.size + (change === "resized" ? 1 : 0)));
+ utimesSync(changed, before.atime, before.mtime);
+ if (changed !== f.object) renameSync(changed, f.object);
+ expect(() => f.stage(selection, emptyStore())).toThrow(
+ "runtime pack artifact hash mismatch: artifacts/base.o",
+ );
+ },
+);
+
+test("an artifact whose mtime changed is hashed again and recorded again", async () => {
+ const f = await packFixture();
+ const selection = f.select();
+ f.stage(selection);
+ const before = statSync(f.object);
+ utimesSync(f.object, before.atime, new Date(before.mtimeMs + 5000));
+ const start = hashes();
+ f.stage(selection, emptyStore());
+ // The store key and the artifact.
+ expect(hashes() - start).toBe(2);
+ const [entry] = JSON.parse(readFileSync(packRecord(f.root), "utf8")).artifacts;
+ expect(entry.mtime).toBe(statSync(f.object).mtimeMs);
+ const again = hashes();
+ f.stage(selection, emptyStore());
+ expect(hashes() - again).toBe(1);
+});
+
+test.each([
+ ["truncated", () => "{"],
+ ["null", () => "null"],
+ ["another schema", (text: string) => text.replace('"schema":1', '"schema":2')],
+ [
+ "an entry with a string ctime",
+ (text: string) => text.replace(/"ctime":([\d.]+)/, '"ctime":"$1"'),
+ ],
+ ["an entry without an inode", (text: string) => text.replace(/"ino":[\d.]+,/, "")],
+ [
+ "another digest",
+ (text: string) => text.replace(/"sha256":"[0-9a-f]+"/, `"sha256":"${"0".repeat(64)}"`),
+ ],
+])("a record beside the pack that is %s is not trusted", async (_name, rewrite) => {
+ const f = await packFixture();
+ const selection = f.select();
+ f.stage(selection);
+ const text = readFileSync(packRecord(f.root), "utf8");
+ const rewritten = rewrite(text);
+ expect(rewritten).not.toBe(text);
+ writeFileSync(packRecord(f.root), rewritten);
+ const start = hashes();
+ const staged = f.stage(selection, emptyStore());
+ expect(hashes() - start).toBe(2);
+ expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object");
+ // The record is written again, whole.
+ const [entry] = JSON.parse(readFileSync(packRecord(f.root), "utf8")).artifacts;
+ expect(entry.ino).toBe(statSync(f.object).ino);
+});
+
+test("an unreadable record beside the pack is not trusted, and staging still succeeds", async () => {
+ const f = await packFixture();
+ const selection = f.select();
+ // A directory where the record would be: it can be neither read nor replaced.
+ mkdirSync(packRecord(f.root));
+ const start = hashes();
+ f.stage(selection, emptyStore());
+ f.stage(selection, emptyStore());
+ expect(hashes() - start).toBe(4);
+ expect(readdirSync(f.root).filter((name) => name.endsWith(".tmp"))).toEqual([]);
+});
+
+test.skipIf(process.platform === "win32")(
+ "a pack directory that cannot be written keeps the record in the cache only",
+ async (context) => {
+ const f = await packFixture();
+ const selection = f.select();
+ chmodSync(f.root, 0o555);
+ try {
+ // A process that may write any directory cannot run this case.
+ try {
+ writeFileSync(join(f.root, "probe"), "");
+ rmSync(join(f.root, "probe"));
+ context.skip();
+ } catch {
+ /* The directory is read-only for this process. */
+ }
+ const store = emptyStore();
+ let start = hashes();
+ f.stage(selection, store);
+ expect(hashes() - start).toBe(2);
+ expect(existsSync(packRecord(f.root))).toBe(false);
+ expect(readdirSync(f.root).filter((name) => name.endsWith(".tmp"))).toEqual([]);
+ // The cache's record still answers the next build with that cache.
+ start = hashes();
+ f.stage(selection, store);
+ expect(hashes() - start).toBe(1);
+ } finally {
+ chmodSync(f.root, 0o755);
+ }
+ },
+);
+
+test("writing the record beside the pack keeps the entries another build wrote", async () => {
+ const f = await packFixture();
+ const selection = f.select();
+ const other = {
+ path: "artifacts/other.o",
+ sha256: "1".repeat(64),
+ dev: 1,
+ ino: 2,
+ size: 3,
+ mtime: 4,
+ ctime: 5,
+ };
+ writeFileSync(packRecord(f.root), JSON.stringify({ schema: 1, artifacts: [other] }));
+ f.stage(selection, emptyStore());
+ const record = JSON.parse(readFileSync(packRecord(f.root), "utf8"));
+ expect(record.artifacts.map((artifact: { path: string }) => artifact.path).sort()).toEqual([
+ "artifacts/base.o",
+ "artifacts/other.o",
+ ]);
+ expect(
+ record.artifacts.find((artifact: { path: string }) => artifact.path === other.path),
+ ).toEqual(other);
+ expect(readdirSync(f.root).filter((name) => name.endsWith(".tmp"))).toEqual([]);
+});
+
+test("a cache that already trusts the artifacts writes the record beside the pack", async () => {
+ const f = await packFixture();
+ const selection = f.select();
+ f.stage(selection);
+ rmSync(packRecord(f.root));
+ const start = hashes();
+ f.stage(selection);
+ // The cache's record answers; only the store key is hashed.
+ expect(hashes() - start).toBe(1);
+ expect(existsSync(packRecord(f.root))).toBe(true);
+ const empty = hashes();
+ f.stage(selection, emptyStore());
+ expect(hashes() - empty).toBe(1);
+});
diff --git a/packages/compiler/src/backend/runtime-pack-native.ts b/packages/compiler/src/backend/runtime-pack-native.ts
index 0fff34455..850258ebb 100644
--- a/packages/compiler/src/backend/runtime-pack-native.ts
+++ b/packages/compiler/src/backend/runtime-pack-native.ts
@@ -2,22 +2,27 @@
* selected artifact is verified and copied from the same read into private
* storage. With a store, a pack's selections and the identities of the
* artifacts it verified are kept between builds: an artifact whose file
- * identity is unchanged since it was hashed is not hashed again. */
+ * identity is unchanged since it was hashed is not hashed again. The
+ * identities are also kept in a record beside the pack when its directory
+ * can be written, which builds with an empty store read too. */
import type { NativeOptimization } from "./optimization.js";
-import { createHash } from "node:crypto";
+import { createHash, randomBytes, randomUUID } from "node:crypto";
import {
closeSync,
constants,
+ copyFileSync,
existsSync,
fstatSync,
mkdirSync,
openSync,
readFileSync,
+ renameSync,
+ rmSync,
statSync,
writeSync,
type Stats,
} from "node:fs";
-import { dirname, join } from "node:path";
+import { basename, dirname, join, resolve } from "node:path";
import type { NativeLinkFeatures } from "./native-link-info.js";
import type { NativeTargetSpec } from "./targets.js";
import {
@@ -94,7 +99,9 @@ function storeKey(text: string): string {
/** A stored selection: what selecting from the pack's files, with these
* identities, returned. */
interface StoredSelection {
- schema: 1;
+ /** 2 adds the selection's data artifacts: a stored selection from before
+ * them would make an Intl build stage none of them, so it is discarded. */
+ schema: 2;
licenses: RuntimePackManifest["licenses"];
selected: RuntimePackArtifacts;
}
@@ -172,7 +179,7 @@ export function selectNativeRuntimePack(
sameIdentity(fileIdentity(packagePath), identities[0]!) &&
sameIdentity(fileIdentity(manifestPath), identities[1]!)
) {
- const entry: StoredSelection = { schema: 1, licenses: manifest.licenses, selected };
+ const entry: StoredSelection = { schema: 2, licenses: manifest.licenses, selected };
store.write(key, JSON.stringify(entry));
}
} catch {
@@ -201,13 +208,14 @@ function readStoredSelection(text: string | null): StoredSelection | null {
if (value === null || typeof value !== "object") return null;
const entry = value as Partial;
if (
- entry.schema !== 1 ||
+ entry.schema !== 2 ||
!Array.isArray(entry.licenses) ||
entry.selected === undefined ||
entry.selected === null ||
typeof entry.selected !== "object" ||
!Array.isArray(entry.selected.runtime) ||
!Array.isArray(entry.selected.archives) ||
+ !Array.isArray(entry.selected.data) ||
!Array.isArray(entry.selected.systemLibraries)
)
return null;
@@ -239,21 +247,53 @@ function readVerified(
try {
const text = store?.read(key) ?? null;
if (text === null) return verified;
- const stored = JSON.parse(text) as StoredVerification;
- if (stored.schema === 1)
- for (const artifact of stored.artifacts) verified.set(artifact.path, artifact);
+ for (const artifact of storedArtifacts(text)) verified.set(artifact.path, artifact);
} catch {
/* A malformed entry verifies everything again. */
}
return verified;
}
+/** The well-formed entries of a stored verification; none when the text is
+ * not a stored verification. */
+function storedArtifacts(text: string): VerifiedArtifact[] {
+ const stored = JSON.parse(text) as Partial | null;
+ if (stored === null || stored.schema !== 1 || !Array.isArray(stored.artifacts)) return [];
+ const number = (value: unknown): boolean => typeof value === "number" && Number.isFinite(value);
+ return stored.artifacts.filter(
+ (artifact): artifact is VerifiedArtifact =>
+ artifact !== null &&
+ typeof artifact === "object" &&
+ typeof artifact.path === "string" &&
+ typeof artifact.sha256 === "string" &&
+ number(artifact.dev) &&
+ number(artifact.ino) &&
+ number(artifact.size) &&
+ number(artifact.mtime) &&
+ number(artifact.ctime),
+ );
+}
+
+/** `known` records `artifact`'s digest and size for a file with `identity`. */
+function verifiedAs(
+ known: VerifiedArtifact | undefined,
+ artifact: { sha256: string; size: number },
+ identity: FileIdentity,
+): boolean {
+ return (
+ known !== undefined &&
+ known.sha256 === artifact.sha256 &&
+ known.size === artifact.size &&
+ sameIdentity(known, identity)
+ );
+}
+
function stageArtifact(
root: string,
stage: string,
artifact: RuntimePackArtifact,
- verified: Map,
- newlyVerified: VerifiedArtifact[],
+ records: readonly Map[],
+ verified: VerifiedArtifact[],
): string {
const source = join(root, artifact.path);
const destination = join(stage, artifact.path);
@@ -276,26 +316,17 @@ function stageArtifact(
}
// The bytes are those last hashed when the file kept the identity it had
// then for the whole read.
- const known = verified.get(artifact.path);
const unchanged = sameIdentity(before, after);
const trusted =
- known !== undefined &&
- known.sha256 === artifact.sha256 &&
- known.size === artifact.size &&
- unchanged &&
- known.dev === before.dev &&
- known.ino === before.ino &&
- known.size === before.size &&
- known.mtime === before.mtime &&
- known.ctime === before.ctime;
+ unchanged && records.some((record) => verifiedAs(record.get(artifact.path), artifact, before));
if (
bytes.length !== artifact.size ||
(!trusted && createHash("sha256").update(bytes).digest("hex") !== artifact.sha256)
) {
throw new RuntimePackError(`runtime pack artifact hash mismatch: ${artifact.path}`, "invalid");
}
- if (!trusted && unchanged)
- newlyVerified.push({
+ if (unchanged)
+ verified.push({
path: artifact.path,
sha256: artifact.sha256,
dev: before.dev,
@@ -353,10 +384,12 @@ export function stageNativeRuntimeSelection(
const packagePath = join(root, "package.json");
const manifestPath = join(root, "runtime-pack.json");
const key = storeKey(JSON.stringify({ schema: 1, verified: root }));
- const verified = readVerified(store, key);
- const newlyVerified: VerifiedArtifact[] = [];
+ const cacheRecord = readVerified(store, key);
+ const packRecordPath = join(root, PACK_VERIFIED_FILE);
+ const packRecord = readPackVerified(packRecordPath);
+ const verified: VerifiedArtifact[] = [];
const stage = (artifact: RuntimePackArtifact): string =>
- stageArtifact(root, stageRoot, artifact, verified, newlyVerified);
+ stageArtifact(root, stageRoot, artifact, [cacheRecord, packRecord], verified);
const runtimeObjects = selected.runtime.map(stage);
const archives = selected.archives.map(stage);
for (const license of licenses) {
@@ -372,14 +405,112 @@ export function stageNativeRuntimeSelection(
) {
throw new RuntimePackError("runtime pack changed while staging verified artifacts", "invalid");
}
- if (store !== undefined && newlyVerified.length > 0) {
- for (const artifact of newlyVerified) verified.set(artifact.path, artifact);
- const entry: StoredVerification = { schema: 1, artifacts: [...verified.values()] };
+ // Each record learns the artifacts verified here that it does not list
+ // with their current identity.
+ const missing = (record: Map): VerifiedArtifact[] =>
+ verified.filter((artifact) => !verifiedAs(record.get(artifact.path), artifact, artifact));
+ const cacheMissing = missing(cacheRecord);
+ if (store !== undefined && cacheMissing.length > 0) {
+ for (const artifact of cacheMissing) cacheRecord.set(artifact.path, artifact);
+ const entry: StoredVerification = { schema: 1, artifacts: [...cacheRecord.values()] };
try {
store.write(key, JSON.stringify(entry));
} catch {
/* A failed write only means hashing again next time. */
}
}
+ const packMissing = missing(packRecord);
+ if (packMissing.length > 0) writePackVerified(packRecordPath, packMissing);
return { runtimeObjects, archives, systemLibraries: selected.systemLibraries };
}
+
+/** Install the selected RUN-time data artifacts (ICU's icudt78l.dat) beside an
+ * executable this host linked. The in-process linker does the same from its
+ * own selection (backend/linker.ts); this host writes executables
+ * synchronously and has no link plan, so it installs them here.
+ *
+ * A file already present at the pinned size is left alone (31.6 MiB of pure
+ * cost otherwise), and the copy is atomic — a private temporary, then a
+ * rename — so two concurrent builds of one program never read a partial
+ * file. */
+export function installNativeRuntimeData(
+ selection: NativeRuntimeSelection,
+ outputPath: string,
+): void {
+ const data = selection.selected.data ?? [];
+ if (data.length === 0) return;
+ const outputDir = dirname(resolve(outputPath));
+ for (const artifact of data) {
+ const destination = join(outputDir, basename(artifact.path));
+ const existing = statSync(destination, { throwIfNoEntry: false });
+ if (existing?.isFile() === true && existing.size === artifact.size) continue;
+ const temporary = join(
+ outputDir,
+ `.${basename(destination)}.scriptc-${process.pid}-${randomUUID()}`,
+ );
+ copyFileSync(join(selection.root, artifact.path), temporary);
+ try {
+ renameSync(temporary, destination);
+ } catch {
+ rmSync(destination, { force: true });
+ renameSync(temporary, destination);
+ }
+ }
+}
+
+/** The verification record kept beside the runtime pack, so a build with an
+ * empty cache does not hash the pack's artifacts again. Like the cache's
+ * record, it trusts an artifact only while its file keeps the identity
+ * (device, inode, size, mtime, ctime) the record lists. */
+export const PACK_VERIFIED_FILE = "runtime-pack.verified.json";
+
+/** The pack record's well-formed entries; none when it is missing,
+ * unreadable or malformed. */
+function readPackVerified(path: string): Map {
+ const verified = new Map();
+ try {
+ for (const artifact of storedArtifacts(readFileSync(path, "utf8")))
+ verified.set(artifact.path, artifact);
+ } catch {
+ /* The artifacts are hashed again. */
+ }
+ return verified;
+}
+
+/** Adds `artifacts` to the pack record: the record as it is now, with these
+ * entries replacing the ones for the same paths, written to a new file and
+ * renamed over the record, so a reader sees a whole record. Of two builds
+ * writing at once, the last rename wins; the entries the other added are
+ * hashed again by a later build. When the pack directory cannot be written,
+ * the cache's record is the only one. */
+function writePackVerified(path: string, artifacts: readonly VerifiedArtifact[]): void {
+ const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
+ try {
+ const record = readPackVerified(path);
+ for (const artifact of artifacts) record.set(artifact.path, artifact);
+ const entry: StoredVerification = { schema: 1, artifacts: [...record.values()] };
+ const bytes = Buffer.from(JSON.stringify(entry));
+ const fd = openSync(
+ temporary,
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
+ 0o644,
+ );
+ try {
+ let offset = 0;
+ while (offset < bytes.length) {
+ const written = writeSync(fd, bytes, offset, bytes.length - offset);
+ if (written === 0) throw new Error("the pack record was not written");
+ offset += written;
+ }
+ } finally {
+ closeSync(fd);
+ }
+ renameSync(temporary, path);
+ } catch {
+ try {
+ rmSync(temporary, { force: true });
+ } catch {
+ /* Cleanup is best effort. */
+ }
+ }
+}
diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts
index fcbe3f08e..510e046b6 100644
--- a/packages/compiler/src/backend/runtime-pack-targets.test.ts
+++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts
@@ -120,6 +120,7 @@ const features = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts
index 457f96749..ee45ef84f 100644
--- a/packages/compiler/src/backend/runtime-pack.test.ts
+++ b/packages/compiler/src/backend/runtime-pack.test.ts
@@ -62,6 +62,7 @@ const BASE: NativeLinkFeatures = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/backend/runtime-pack.ts b/packages/compiler/src/backend/runtime-pack.ts
index 2bfe71756..c83a3ceb4 100644
--- a/packages/compiler/src/backend/runtime-pack.ts
+++ b/packages/compiler/src/backend/runtime-pack.ts
@@ -40,12 +40,19 @@ export interface RuntimePackSelection {
features: RuntimeFeatureSet;
runtimeObjects: string[];
archives: string[];
+ /** Files the executable loads at run time, installed beside it. */
+ dataFiles: string[];
+ /** The ICU headers an Intl selection ships (manifest `headers`), all under
+ * `/unicode/`, verified like the other artifacts. */
+ headerFiles: string[];
systemLibraries: string[];
dependencyPaths: string[];
/** Exact installed inputs observed while the selected artifacts were verified. */
sourceDependencies: NativeArtifactDependency[];
selectedRuntimeArtifacts: RuntimePackArtifact[];
selectedArchiveArtifacts: RuntimePackArtifact[];
+ selectedDataArtifacts: RuntimePackArtifact[];
+ selectedHeaderArtifacts: RuntimePackArtifact[];
}
async function verifyArtifact(root: string, artifact: RuntimePackArtifact): Promise {
@@ -209,18 +216,27 @@ export async function loadRuntimePack(options: {
const features = selected.features;
const selectedVariants = selected.runtime;
const selectedArchives = selected.archives;
- const selectedArtifactPaths = [...selectedVariants, ...selectedArchives].map((artifact) =>
- join(root, artifact.path),
- );
+ const selectedData = selected.data;
+ // The C-source path compiles scr_intl.c against these headers and links
+ // the pack's ICU archive (index.ts runtimePackIcuInputs).
+ const selectedHeaders = features.intl === true ? (manifest.headers ?? []) : [];
+ const selectedArtifactPaths = [
+ ...selectedVariants,
+ ...selectedArchives,
+ ...selectedData,
+ ...selectedHeaders,
+ ].map((artifact) => join(root, artifact.path));
let artifactDependencies: NativeArtifactDependency[];
try {
artifactDependencies = await snapshotDependencies(selectedArtifactPaths);
} catch {
throw new RuntimePackError("runtime pack artifact set changed during selection", "invalid");
}
- const [runtimeObjects, archives] = await Promise.all([
+ const [runtimeObjects, archives, dataFiles, headerFiles] = await Promise.all([
Promise.all(selectedVariants.map((artifact) => verifyArtifact(root, artifact))),
Promise.all(selectedArchives.map((artifact) => verifyArtifact(root, artifact))),
+ Promise.all(selectedData.map((artifact) => verifyArtifact(root, artifact))),
+ Promise.all(selectedHeaders.map((artifact) => verifyArtifact(root, artifact))),
]);
await Promise.all(manifest.licenses.map((license) => readFile(join(root, license.path)))).catch(
() => {
@@ -242,10 +258,21 @@ export async function loadRuntimePack(options: {
features,
runtimeObjects,
archives,
+ dataFiles,
+ headerFiles,
systemLibraries: selected.systemLibraries,
- dependencyPaths: [packagePath, manifestPath, ...runtimeObjects, ...archives],
+ dependencyPaths: [
+ packagePath,
+ manifestPath,
+ ...runtimeObjects,
+ ...archives,
+ ...dataFiles,
+ ...headerFiles,
+ ],
sourceDependencies,
selectedRuntimeArtifacts: selectedVariants,
selectedArchiveArtifacts: selectedArchives,
+ selectedDataArtifacts: selectedData,
+ selectedHeaderArtifacts: selectedHeaders,
};
}
diff --git a/packages/compiler/src/backend/vendor-archives.ts b/packages/compiler/src/backend/vendor-archives.ts
index 363d14b08..a68294ae5 100644
--- a/packages/compiler/src/backend/vendor-archives.ts
+++ b/packages/compiler/src/backend/vendor-archives.ts
@@ -1,6 +1,6 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
-import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
+import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { availableParallelism, tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import { promisify } from "node:util";
@@ -11,9 +11,32 @@ import {
validCachedFile,
} from "./build-cache.js";
import type { CcDriver } from "./native/driver.js";
+import { NativeCodegenError } from "./native-codegen-core.js";
const execFileAsync = promisify(execFile);
+/** One C++ translation unit of the ICU archive: the source, the archive member
+ * name it compiles to, and the flags of its group. */
+interface IcuUnit {
+ path: string;
+ member: string;
+ flags: string[];
+ define?: string;
+}
+
+/** Zig's library directory, which carries the libc++/libc++abi sources the
+ * ICU archive merges: `ZIG_LIB_DIR` when the caller pins one, else the pinned
+ * toolchain's own answer. */
+async function zigLibDir(): Promise {
+ const pinned = process.env["ZIG_LIB_DIR"];
+ if (pinned !== undefined && pinned !== "") return pinned;
+ const { stdout } = await execFileAsync("zig", ["env"]);
+ const match = /\.lib_dir\s*=\s*"([^"]+)"/.exec(stdout);
+ if (match === null)
+ throw new Error("Intl needs Zig 0.16.0's C++ toolchain: `zig env` named no lib_dir");
+ return match[1]!;
+}
+
/** The section flags the libregexp/libunicode objects build with (the
* runtime pack's executable_section_elimination flags). */
const LRE_SECTION_FLAGS = ["-ffunction-sections", "-fdata-sections"];
@@ -24,13 +47,33 @@ function vendorObjectName(source: string): string {
return source.replaceAll("/", "-").replace(/\.c$/, ".o");
}
+/** The sha256 of a buffer, as lowercase hex. */
+function sha256(bytes: Uint8Array): string {
+ return createHash("sha256").update(bytes).digest("hex");
+}
+
+/** Whether `path` is a non-empty file. */
+async function presentFile(path: string): Promise {
+ try {
+ return (await stat(path)).size > 0;
+ } catch {
+ return false;
+ }
+}
+
import {
QJS_COMMIT,
MBEDTLS_VERSION,
ZLIB_VERSION,
BROTLI_VERSION,
+ ICU_VERSION,
+ ICU_SOURCE_URL,
+ ICU_SOURCE_SHA256,
+ ICU_SOURCE_SIZE,
+ ICU_CXX_TARGET,
LRE_SOURCES,
ZLIB_SOURCES,
+ icuSourceList,
zlibInputs,
} from "./vendor-inputs.js";
export {
@@ -38,6 +81,7 @@ export {
MBEDTLS_VERSION,
ZLIB_VERSION,
BROTLI_VERSION,
+ ICU_VERSION,
LRE_SOURCES,
ZLIB_SOURCES,
} from "./vendor-inputs.js";
@@ -532,9 +576,271 @@ export function createVendorArchives(context: VendorArchiveContext) {
return archive;
}
+ /** The fetched ICU release root (`icu/`, holding `source/` and LICENSE),
+ * downloaded once per cache root and reused after that. ICU is not
+ * vendored: the release's own source tarball is pinned by version and
+ * sha256 in vendor-inputs.ts, verified on every use, and extracted by the
+ * system `tar` as the libnode build does. An existing extracted tree is
+ * enough, so an offline build works from a pre-populated cache. */
+ const icuSourcesByCacheRoot = new Map>();
+ function ensureIcuSources(cacheRoot: string = vendorBuildCacheRoot()): Promise {
+ const pending = icuSourcesByCacheRoot.get(cacheRoot);
+ if (pending !== undefined) return pending;
+ const pendingFetch = (async (): Promise => {
+ const tree = join(cacheRoot, `icu4c-${ICU_VERSION}-source`);
+ if (await presentFile(join(tree, "source", "common", "sources.txt"))) return tree;
+ const tarball = join(cacheRoot, `icu4c-${ICU_VERSION}-sources.tgz`);
+ let archive: Buffer;
+ try {
+ archive = await readFile(tarball);
+ if (archive.length !== ICU_SOURCE_SIZE || sha256(archive) !== ICU_SOURCE_SHA256)
+ throw new Error("cached ICU source tarball does not match its pin");
+ } catch {
+ let response: Response;
+ try {
+ response = await fetch(ICU_SOURCE_URL);
+ } catch (cause) {
+ throw new Error(
+ `could not download ICU's sources (${ICU_SOURCE_URL}); ` +
+ `populate ${tarball} or allow network access`,
+ { cause },
+ );
+ }
+ if (!response.ok)
+ throw new Error(
+ `could not download ICU's sources (${ICU_SOURCE_URL}): ` +
+ `${response.status} ${response.statusText}`,
+ );
+ archive = Buffer.from(await response.arrayBuffer());
+ if (archive.length !== ICU_SOURCE_SIZE || sha256(archive) !== ICU_SOURCE_SHA256)
+ throw new Error(
+ `ICU source tarball from ${ICU_SOURCE_URL} is ${archive.length} bytes with sha256 ` +
+ `${sha256(archive)}, expected ${ICU_SOURCE_SIZE} bytes with sha256 ` +
+ `${ICU_SOURCE_SHA256}`,
+ );
+ await mkdir(cacheRoot, { recursive: true });
+ await writeFile(tarball, archive);
+ }
+ await mkdir(cacheRoot, { recursive: true });
+ const stage = await mkdtemp(join(cacheRoot, ".icu-sources-"));
+ try {
+ await execFileAsync("tar", ["-xzf", tarball, "-C", stage]);
+ if (!(await presentFile(join(stage, "icu", "source", "common", "sources.txt"))))
+ throw new Error(`ICU source tarball ${ICU_SOURCE_URL} does not hold icu/source`);
+ await rm(tree, { recursive: true, force: true });
+ await rename(join(stage, "icu"), tree);
+ } finally {
+ await rm(stage, { recursive: true, force: true });
+ }
+ return tree;
+ })();
+ icuSourcesByCacheRoot.set(cacheRoot, pendingFetch);
+ pendingFetch.catch(() => icuSourcesByCacheRoot.delete(cacheRoot));
+ return pendingFetch;
+ }
+
+ /** The archive is the same for sanitized and plain builds: it is never
+ * instrumented (see ensureIcuArchive), so neither the path nor the build
+ * carries a sanitizer flavor. */
+ function icuArchivePath(
+ driver: CcDriver,
+ buildIdentity: string,
+ cacheRoot: string = vendorBuildCacheRoot(),
+ ): string {
+ const flavor = `plain-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`;
+ return join(cacheRoot, `icu-${ICU_VERSION}-${flavor}`, "libicu.a");
+ }
+
+ /** The ICU archive for one flavor, compiled lazily on the first
+ * Intl-using build (~470 C++ TUs, parallelized) and cached under
+ * /vendor/icu----/
+ * libicu.a with the mbedTLS archive's atomic-rename publish.
+ *
+ * Three things make this archive different from every other vendored one:
+ *
+ * - It is C++, so the pack's C driver cannot compile it: the TUs (and the
+ * standard library they use) build with Zig's C++ driver for the glibc
+ * target the runtime packs pin. That target is the point. In C++ mode,
+ * glibc 2.38+ headers redirect strtol/strtoul to the C23 __isoc23_*
+ * names, and a host libstdc++ binds those names too, so a host-compiled
+ * C++ archive would raise the glibc floor above the packs'. Zig's
+ * bundled libc++ and libc++abi compile from the toolchain's own sources
+ * for the target and bind the target's names only.
+ * - ICU requires RTTI (`typeid` in unifiedcache.h and ustrenum.cpp,
+ * `dynamic_cast` in uniset_props.cpp), so the design note's
+ * `-fno-rtti` is not available; `-fno-exceptions` is, and ICU, libc++,
+ * and libc++abi all build so. libc++abi's Itanium personality routine
+ * (cxa_personality.cpp) is the one unit that needs exceptions, so it is
+ * left out: nothing can throw into it.
+ * - Both the standard library and the C++ runtime it needs are merged INTO
+ * the archive, so the final link stays C-only: a plain `clang`/`zig` C
+ * link with the runtime pack's `-lm` resolves every undefined symbol.
+ * Members are prefixed by source (`cxx-libcxx-`, `cxx-libcxxabi-`) so
+ * their names stay unique.
+ *
+ * ICU builds with symbol renaming on (version suffixes `_78`), which is
+ * what lets the zero-cost gate assert that only Intl programs link ICU
+ * symbols.
+ *
+ * Sanitized builds link this same uninstrumented archive. AddressSanitizer
+ * cannot instrument it: Zig's bundled libc++ compiles with
+ * `-fsanitize=address` only against a `sanitizer/asan_interface.h` its
+ * tree does not ship, and compiling ~600 C++ TUs again for the sanitizer
+ * lane would add minutes to the first Intl program of every sanitized
+ * build. The runtime's own ICU code (scr_intl.c) still compiles with the
+ * sanitizer, and AddressSanitizer's interceptors cover ICU's allocations,
+ * so a sanitized Intl program reports heap errors from ICU data as it
+ * does everywhere else — the ICU code itself is simply not instrumented. */
+ async function ensureIcuArchive(
+ driver: CcDriver,
+ buildIdentity: string,
+ cacheRoot: string = vendorBuildCacheRoot(),
+ ): Promise {
+ // The archive is host-targeted ELF for the packs' glibc floor; a cross
+ // target would need a Zig C++ link recipe this slice has not validated.
+ // Both refusals are compile diagnostics (SC3002), not internal errors:
+ // an Intl program on any other host or target is a documented "not
+ // here yet", the same answer the missing runtime pack gives.
+ if (driver.target !== null)
+ throw new NativeCodegenError(
+ "SC3002",
+ `Intl needs ICU, which scriptc builds for the host's linux-x64 gnu target only: ` +
+ `the ${driver.target} target is not supported yet`,
+ );
+ if (process.platform !== "linux" || process.arch !== "x64")
+ throw new NativeCodegenError(
+ "SC3002",
+ "Intl needs ICU, which this compiler builds for linux-x64 only: " +
+ `the ${process.platform}-${process.arch} host is not supported yet`,
+ );
+ const flavor = `plain-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`;
+ const archive = icuArchivePath(driver, buildIdentity, cacheRoot);
+ if (await validVendorArtifact(archive)) return archive;
+
+ await mkdir(cacheRoot, { recursive: true });
+ const buildDir = await mkdtemp(join(tmpdir(), `scriptc-vendor-icu-${flavor}-`));
+ // The tarball extracts to `icu/source/...` with ICU's LICENSE beside it.
+ const vendor = join(await ensureIcuSources(cacheRoot), "source");
+ const libDir = await zigLibDir();
+ const cxx = ["zig", "c++", "-target", ICU_CXX_TARGET];
+ const optimize = ["-Os"];
+ const commonFlags = [
+ "-std=c++17",
+ ...optimize,
+ "-fno-exceptions",
+ "-ffunction-sections",
+ "-fdata-sections",
+ ];
+ const icuFlags = [
+ ...commonFlags,
+ "-DU_STATIC_IMPLEMENTATION",
+ "-DU_ATTRIBUTE_DEPRECATED=",
+ "-DNDEBUG",
+ "-Wno-deprecated-declarations",
+ "-I",
+ join(vendor, "common"),
+ "-I",
+ join(vendor, "i18n"),
+ ];
+ // libc++ 20 sources need C++20 (`constinit`, ranges) and libc++'s
+ // charconv reads a header from `libcxx/libc`.
+ const cxxLibraryFlags = [
+ "-std=c++23",
+ ...optimize,
+ "-fno-exceptions",
+ "-ffunction-sections",
+ "-fdata-sections",
+ "-I",
+ join(libDir, "libcxx", "include"),
+ "-I",
+ join(libDir, "libcxx", "src"),
+ "-I",
+ join(libDir, "libcxx", "libc"),
+ "-I",
+ join(libDir, "libcxxabi", "include"),
+ "-D_LIBCPP_BUILDING_LIBRARY",
+ "-D_LIBCXXABI_BUILDING_LIBRARY",
+ "-DLIBCXX_BUILDING_LIBCXXABI",
+ "-Wno-unknown-warning-option",
+ ];
+ const cxxLibraryUnits = async (
+ subdirectory: string,
+ memberPrefix: string,
+ skip: ReadonlySet,
+ ): Promise => {
+ const sources = (await readdir(join(libDir, subdirectory, "src")))
+ .filter((name) => name.endsWith(".cpp") && !skip.has(name))
+ .sort();
+ return sources.map((source) => ({
+ path: join(libDir, subdirectory, "src", source),
+ member: `${memberPrefix}${source}`,
+ flags: cxxLibraryFlags,
+ }));
+ };
+ try {
+ const units: IcuUnit[] = [
+ ...icuSourceList(await readFile(join(vendor, "common", "sources.txt"), "utf8")).map(
+ (source) => ({
+ path: join(vendor, "common", source),
+ member: `common-${source}`,
+ flags: icuFlags,
+ define: "U_COMMON_IMPLEMENTATION",
+ }),
+ ),
+ {
+ path: join(vendor, "stubdata", "stubdata.cpp"),
+ member: "stubdata-stubdata.cpp",
+ flags: icuFlags,
+ define: "U_COMMON_IMPLEMENTATION",
+ },
+ ...icuSourceList(await readFile(join(vendor, "i18n", "sources.txt"), "utf8")).map(
+ (source) => ({
+ path: join(vendor, "i18n", source),
+ member: `i18n-${source}`,
+ flags: icuFlags,
+ define: "U_I18N_IMPLEMENTATION",
+ }),
+ ),
+ ...(await cxxLibraryUnits("libcxx", "cxx-libcxx-", new Set())),
+ ...(await cxxLibraryUnits("libcxxabi", "cxx-libcxxabi-", new Set(["cxa_personality.cpp"]))),
+ ];
+ const width = availableParallelism();
+ for (let i = 0; i < units.length; i += width) {
+ await Promise.all(
+ units.slice(i, i + width).map(async (unit) => {
+ const output = join(buildDir, unit.member.replace(/\.cpp$/, ".o"));
+ await execFileAsync(cxx[0]!, [
+ ...cxx.slice(1),
+ ...unit.flags,
+ ...(unit.define === undefined ? [] : [`-D${unit.define}`]),
+ "-g0",
+ "-c",
+ unit.path,
+ "-o",
+ output,
+ ]);
+ return output;
+ }),
+ );
+ }
+ await execFileAsync("ar", [
+ "rcs",
+ join(buildDir, "libicu.a"),
+ ...units.map((unit) => join(buildDir, unit.member.replace(/\.cpp$/, ".o"))),
+ ]);
+ await publishVendorArtifact(join(buildDir, "libicu.a"), archive);
+ } finally {
+ await rm(buildDir, { recursive: true, force: true });
+ }
+ return archive;
+ }
+
return {
vendorEngineDir,
vendorTlsDir,
+ ensureIcuSources,
+ icuArchivePath,
+ ensureIcuArchive,
vendorBrotliDir,
brotliArchivePath,
ensureBrotliArchive,
diff --git a/packages/compiler/src/backend/vendor-inputs.test.ts b/packages/compiler/src/backend/vendor-inputs.test.ts
index 53e9b8c22..b0025baf8 100644
--- a/packages/compiler/src/backend/vendor-inputs.test.ts
+++ b/packages/compiler/src/backend/vendor-inputs.test.ts
@@ -2,7 +2,21 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expect, test } from "vitest";
-import { ZLIB_SOURCES, zlibInputs } from "./vendor-inputs.js";
+import {
+ ZLIB_SOURCES,
+ ICU_VERSION,
+ ICU_SOURCE_URL,
+ ICU_SOURCE_SHA256,
+ ICU_SOURCE_SIZE,
+ ICU_DATA_URL,
+ ICU_DATA_FILE,
+ ICU_DATA_SHA256,
+ ICU_DATA_SIZE,
+ ICU_DATA_ZIP_SHA256,
+ ICU_DATA_ZIP_SIZE,
+ icuSourceList,
+ zlibInputs,
+} from "./vendor-inputs.js";
const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url));
const zlibDir = join(repoRoot, "packages/runtime/vendor/zlib");
@@ -78,3 +92,39 @@ test("zlib inputs follow Node's deps/zlib/zlib.gyp", () => {
expect(wasm.sources).not.toContain("contrib/optimizations/inflate.c");
expect(wasm.defines).toEqual(["HAVE_HIDDEN", "CPU_NO_SIMD"]);
});
+
+test("the pinned ICU is the release the oracle Node bundles", () => {
+ // Node's own Intl answers from the ICU it was built with, so the fetched
+ // release must be that version exactly: the differential corpus compares
+ // scriptc's ICU output against Node's, byte for byte.
+ expect(process.versions.icu).toBe(ICU_VERSION);
+});
+
+test("the compiler's ICU pins match the runtime packs'", async () => {
+ const pack = (await import(
+ pathToFileURL(join(repoRoot, "packages/runtime-pack-common/scripts/icu-inputs.mjs")).href
+ )) as Record;
+ expect(pack["ICU_VERSION"]).toBe(ICU_VERSION);
+ expect(pack["ICU_SOURCE_URL"]).toBe(ICU_SOURCE_URL);
+ expect(pack["ICU_SOURCE_SHA256"]).toBe(ICU_SOURCE_SHA256);
+ expect(pack["ICU_SOURCE_SIZE"]).toBe(ICU_SOURCE_SIZE);
+ expect(pack["ICU_DATA_URL"]).toBe(ICU_DATA_URL);
+ expect(pack["ICU_DATA_FILE"]).toBe(ICU_DATA_FILE);
+ expect(pack["ICU_DATA_SHA256"]).toBe(ICU_DATA_SHA256);
+ expect(pack["ICU_DATA_SIZE"]).toBe(ICU_DATA_SIZE);
+ expect(pack["ICU_DATA_ZIP_SHA256"]).toBe(ICU_DATA_ZIP_SHA256);
+ expect(pack["ICU_DATA_ZIP_SIZE"]).toBe(ICU_DATA_ZIP_SIZE);
+ // Both pins name the official release assets for the pinned version.
+ expect(ICU_SOURCE_URL).toBe(
+ `https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION}/icu4c-${ICU_VERSION}-sources.tgz`,
+ );
+ expect(ICU_DATA_URL).toBe(
+ `https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION}/icu4c-${ICU_VERSION}-data-bin-l.zip`,
+ );
+ expect(ICU_SOURCE_SHA256).toMatch(/^[0-9a-f]{64}$/);
+ expect(ICU_DATA_SHA256).toMatch(/^[0-9a-f]{64}$/);
+});
+
+test("ICU source lists parse to the units a build compiles", () => {
+ expect(icuSourceList("# a comment\n\n b.cpp \nc.cpp\n")).toEqual(["b.cpp", "c.cpp"]);
+});
diff --git a/packages/compiler/src/backend/vendor-inputs.ts b/packages/compiler/src/backend/vendor-inputs.ts
index 270815b00..4e87272d8 100644
--- a/packages/compiler/src/backend/vendor-inputs.ts
+++ b/packages/compiler/src/backend/vendor-inputs.ts
@@ -6,6 +6,48 @@ export const MBEDTLS_VERSION = "3.6.7";
export const ZLIB_VERSION = "1.3.2.1-motley-285e94b";
/** The brotli release Node v24.22.0 bundles (packages/runtime/vendor/brotli). */
export const BROTLI_VERSION = "1.2.0";
+/** ICU4C release 78.3 — the exact version Node v24.22.0 bundles
+ * (`process.versions.icu` there), from the `release-78.3` tag. ICU is not
+ * vendored: the build fetches the release's own source tarball and data zip
+ * from unicode-org/icu's GitHub release, pinning each by version and sha256,
+ * and caches the download. Its data file icudt78l.dat (33,107,232 B) ships
+ * beside the executable; see packages/runtime-pack-common/scripts/
+ * icu-data.mjs. */
+export const ICU_VERSION = "78.3";
+const ICU_RELEASE_URL = `https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION}`;
+/** The release's source tarball (icu, not icu4j), the only build input that
+ * is not vendored. */
+export const ICU_SOURCE_URL = `${ICU_RELEASE_URL}/icu4c-${ICU_VERSION}-sources.tgz`;
+export const ICU_SOURCE_SHA256 = "3a2e7a47604ba702f345878308e6fefeca612ee895cf4a5f222e7955fabfe0c0";
+export const ICU_SOURCE_SIZE = 27_977_255;
+/** The release's prebuilt little-endian data zip. 969 source files are
+ * reviewable; 33 MiB of compiled CLDR tables are not, so the data is fetched
+ * too, pinned the same way. */
+export const ICU_DATA_URL = `${ICU_RELEASE_URL}/icu4c-${ICU_VERSION}-data-bin-l.zip`;
+export const ICU_DATA_ZIP_SHA256 =
+ "982619632b78887f1895b063e96e8c3cc7f99283337c8abbd05aa71635de613c";
+export const ICU_DATA_ZIP_SIZE = 12_836_659;
+export const ICU_DATA_FILE = "icudt78l.dat";
+export const ICU_DATA_SHA256 = "d5cf2a40dccbe471781ec7af85693bff542ff12f0b670c9630c4e72d60714b8b";
+export const ICU_DATA_SIZE = 33_107_232;
+/** The glibc target the ICU archive compiles for: the floor the linux-x64-gnu
+ * runtime pack builds against (packages/runtime-pack-common/scripts/
+ * glibc-toolchain.mjs GLIBC_RUNTIME_FLOOR), spelled for Zig's C++ driver.
+ * ICU is the one C++ archive, and C++ compiled against a newer host glibc
+ * binds the C23 strtol names (__isoc23_*) that floor forbids. */
+export const ICU_CXX_TARGET = "x86_64-linux-gnu.2.34";
+/** The translation units one of ICU's own source lists names, in its order.
+ * The fetched tarball carries the lists (`source/common/sources.txt`,
+ * `source/i18n/sources.txt`) verbatim, so the units a build compiles are
+ * exactly the units ICU's own autoconf build compiles for this release.
+ * Entries are bare file names. The pack build reads the same files
+ * (packages/runtime-pack-common/scripts/icu-data.mjs). */
+export function icuSourceList(text: string): string[] {
+ return text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
+}
/** Exact translation-unit membership shared by cache builds and external
* source-pack recipes. */
export const LRE_SOURCES = ["libregexp.c", "libunicode.c"] as const;
diff --git a/packages/compiler/src/backend/warm-cache.ts b/packages/compiler/src/backend/warm-cache.ts
index c42ceb79e..25254934f 100644
--- a/packages/compiler/src/backend/warm-cache.ts
+++ b/packages/compiler/src/backend/warm-cache.ts
@@ -63,6 +63,7 @@ export async function warmNativeCaches(
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/compat/fetch-profile.ts b/packages/compiler/src/compat/fetch-profile.ts
index 07f5f09c8..e723d2df6 100644
--- a/packages/compiler/src/compat/fetch-profile.ts
+++ b/packages/compiler/src/compat/fetch-profile.ts
@@ -155,6 +155,12 @@ const typedInterfaceUnsupported = "typed source has no compiler bridge for this
const metadataExclusion =
"WebIDL brand metadata is observable reflection, not an executable compatibility operation";
+/** ReadableStream methods a typed program reaches through the web stream
+ * classes (scr_webstreams.c): on a stream it constructs with them, and on
+ * the native stream through its web view (scr_fetch.c, sf_stream_web_view).
+ * A program that calls one links the classes. */
+export const READABLE_STREAM_WEB_VIEW_CALLS = ["pipeThrough", "pipeTo", "tee"] as const;
+
function buildFetchCompatProfile() {
return {
schemaVersion: 1,
@@ -233,7 +239,7 @@ function buildFetchCompatProfile() {
],
responseCalls: ["json", "text", "bytes", "clone"],
readableStreamReads: ["locked"],
- readableStreamCalls: ["cancel", "getReader"],
+ readableStreamCalls: ["cancel", "getReader", ...READABLE_STREAM_WEB_VIEW_CALLS],
},
operations: [
{
@@ -447,6 +453,13 @@ function buildFetchCompatProfile() {
facets: ["webidl-conversion", "identity", "state-machine", "error-shape"],
evidence: [generated("stream-traces"), fixture("static-stream")],
},
+ ...READABLE_STREAM_WEB_VIEW_CALLS.map((member): FetchCompatOperation => ({
+ id: `stdlib.readable-stream.${member}`,
+ name: `ReadableStream.${member}`,
+ kind: "method",
+ facets: ["identity", "promise-settlement", "state-machine", "error-shape"],
+ evidence: [fixture("static-web-streams")],
+ })),
{
id: "stdlib.readable-stream-default-reader.closed",
name: "ReadableStreamDefaultReader.closed",
@@ -757,25 +770,15 @@ function buildFetchCompatProfile() {
"getReader",
"prototype",
),
- ...["pipeThrough", "values"].map((member) =>
- unsupportedEntry(
- `stdlib.readable-stream.${member}`,
- "ReadableStream",
- member,
- "prototype",
- member === "values"
- ? "direct for-await consumption compiles statically; storing or driving the first-class iterator handle is unsupported"
- : "the wider Web Streams graph is outside the native readable-stream slice",
- ),
+ ...READABLE_STREAM_WEB_VIEW_CALLS.map((member) =>
+ staticEntry(`stdlib.readable-stream.${member}`, "ReadableStream", member, "prototype"),
),
- ...["pipeTo", "tee"].map((member) =>
- unsupportedEntry(
- `stdlib.readable-stream.${member}`,
- "ReadableStream",
- member,
- "prototype",
- "this operation is outside the native readable-stream implementation",
- ),
+ unsupportedEntry(
+ "stdlib.readable-stream.values",
+ "ReadableStream",
+ "values",
+ "prototype",
+ "direct for-await consumption compiles statically; storing or driving the first-class iterator handle is unsupported",
),
unsupportedEntry(
"stdlib.readable-stream.symbol.asyncIterator",
@@ -954,7 +957,8 @@ export interface StaticFetchMembers {
readableStreamReads: ReadonlySet;
readableStreamCalls: ReadonlySet;
/** ReadableStream prototype members the native stream does not
- * implement, symbol-keyed ones as `[Symbol.name]`. */
+ * implement itself, symbol-keyed ones as `[Symbol.name]`: the unsupported
+ * ones, and the calls it answers through its web view. */
readableStreamUnsupported: ReadonlySet;
}
@@ -979,8 +983,8 @@ export function staticFetchMembers(): StaticFetchMembers {
),
readableStreamReads: new Set(profile.members.readableStreamReads),
readableStreamCalls: new Set(profile.members.readableStreamCalls),
- readableStreamUnsupported: new Set(
- profile.inventory.entries
+ readableStreamUnsupported: new Set([
+ ...profile.inventory.entries
.filter(
(entry) =>
entry.owner === "ReadableStream" &&
@@ -988,7 +992,8 @@ export function staticFetchMembers(): StaticFetchMembers {
entry.status === "unsupported",
)
.map((entry) => entry.member),
- ),
+ ...READABLE_STREAM_WEB_VIEW_CALLS,
+ ]),
};
return staticFetchMemberSets;
}
diff --git a/packages/compiler/src/coverage/surface-manifest.ts b/packages/compiler/src/coverage/surface-manifest.ts
index b87a8065d..d0cff1eb2 100644
--- a/packages/compiler/src/coverage/surface-manifest.ts
+++ b/packages/compiler/src/coverage/surface-manifest.ts
@@ -98,7 +98,7 @@ function coverageNotes(): string[] {
"Entries with status 'unsupported' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.",
`The engine-free fetch projection targets Node ${fetchTarget.node} with bundled Undici ${fetchTarget.undici}. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.`,
"The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.",
- "Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.",
+ "Process-level diagnostic codes are not surface entries: SC0001-SC0007 are preflight gates (SC0005-SC0007 are the ECMAScript early errors), SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.",
"Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.",
"No scheduling metadata is published; entry ids are the stable diff keys across releases.",
];
diff --git a/packages/compiler/src/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts
index f3d7681d5..f4ad1409e 100644
--- a/packages/compiler/src/diagnostics/codes.ts
+++ b/packages/compiler/src/diagnostics/codes.ts
@@ -19,6 +19,9 @@ export const DIAGNOSTIC_CODES = {
SC0002: "project configuration incompatible with scriptc",
SC0003: "malformed imported JSON module",
SC0004: "TypeScript checker panic",
+ SC0005: "duplicate lexical declaration in a block or switch statement list",
+ SC0006: "lexical declaration colliding with a var in the same block",
+ SC0007: "declaration in a single-statement position",
SC1010: "package imports outside the supported set",
SC1012: "default exports/imports (residual edges)",
SC1013: "namespace imports (residual edges)",
@@ -40,6 +43,7 @@ export const DIAGNOSTIC_CODES = {
SC1071: "generators",
SC1080: "'this' outside a class method",
SC1090: "this syntax (generic construct fence)",
+ SC1091: "write to an undeclared name in a JavaScript source",
SC1100: "operations on 'unknown' values",
SC1101: "converting typed values to 'unknown'",
SC1110: "comptime evaluation failure",
diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts
index 0dfd8bd02..a5f4fdbe2 100644
--- a/packages/compiler/src/diagnostics/diagnostic.ts
+++ b/packages/compiler/src/diagnostics/diagnostic.ts
@@ -7,8 +7,12 @@
* Code bands:
* SC0xxx preflight gates: TypeScript errors passed through (SC0001),
* project-config incompatibilities (SC0002), malformed imported
- * JSON modules (SC0003), and upstream tsgo checker panics
- * surfaced as source-anchored diagnostics (SC0004)
+ * JSON modules (SC0003), upstream tsgo checker panics surfaced
+ * as source-anchored diagnostics (SC0004), and the ECMAScript
+ * early errors the checker does not model: a duplicated or
+ * `var`-colliding lexical declaration in a block or switch
+ * statement list (SC0005/SC0006) and a declaration where the
+ * grammar admits only a Statement (SC0007)
* SC1xxx supported-TypeScript-not-yet: valid TS outside the current subset
* SC2xxx scriptc type rules (types we cannot compile yet)
* SC3xxx backend/target coverage: the program is valid, but the selected
@@ -366,6 +370,24 @@ export function sharedModuleDuplicatedDiag(
};
}
+/** SC8020 — the program replaces a process output stream's write method
+ * while a Node island exists: the island's `process.stdout` is its own
+ * object, so the replacement and the island's writes cannot see each other.
+ * H2 shares the native stream object with the islands and removes this. */
+export function stdioPatchAcrossIslandsDiag(
+ stream: "stdout" | "stderr" | null,
+ loc: SrcLoc,
+): ScrDiagnostic {
+ const what =
+ stream === null ? "process.stdout.write or process.stderr.write" : `process.${stream}.write`;
+ return {
+ code: "SC8020",
+ message: `${what} is replaced while a Node island exists: the island's process output stream would not see this patch, and island writes would bypass it`,
+ loc,
+ hint: "the island's process.stdout would not see this patch",
+ };
+}
+
/** SC8022 — island code extends a class of a module the program compiles.
* A native instance cannot take an island subclass's overrides, so the
* module is not owned by the program: a package runs in Node for both
@@ -687,6 +709,11 @@ export const UNSUPPORTED: Record = {
// remains is `this` outside any method (top level, plain functions).
SC1080: { feature: "'this' outside a class method", milestone: "later" },
SC1090: { feature: "this syntax", milestone: "later" }, // generic fallback; message names the construct
+ SC1091: {
+ feature: "writing an undeclared name in a JavaScript source",
+ milestone: "later",
+ hint: "a write to an undeclared name has no native lowering: sloppy mode makes it a property on Node's global object, which a native binary has none of, and strict mode makes it throw; declare the name, or keep the module in a Node island (sloppy-mode programs route there)",
+ },
SC1100: {
feature: "operations on 'unknown' values",
milestone: "later",
diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts
index 1f5625fb8..9c81ccc19 100644
--- a/packages/compiler/src/executable/executable-cache.test.ts
+++ b/packages/compiler/src/executable/executable-cache.test.ts
@@ -60,6 +60,7 @@ const native: EarlyExecutableNativeFeatures = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts
index a6dcc69d8..604af05b9 100644
--- a/packages/compiler/src/executable/executable-cache.ts
+++ b/packages/compiler/src/executable/executable-cache.ts
@@ -65,9 +65,14 @@ export interface EarlyExecutableNativeFeatures {
wasm?: boolean;
warningReplace?: boolean;
stdioExtended?: boolean;
+ /** Error-taking write completion callbacks: SCR_STREAM_WRITE_ERRORS. */
+ streamWriteErrors?: boolean;
+ /** The process output write slot: SCR_STDIO_WRITE_SLOT. */
+ stdioWriteSlot?: boolean;
nodeIsland?: boolean;
moduleNamespace?: boolean;
moduleNamespaceDyn?: boolean;
+ primitiveWrapper?: boolean;
zlib: boolean;
brotli?: boolean;
assert: boolean;
@@ -111,6 +116,7 @@ export interface EarlyExecutableNativeFeatures {
dgramDyn: boolean;
textCodecDyn: boolean;
watch: boolean;
+ watchFile: boolean;
foreignFfi: boolean;
nodeTest: boolean;
tls: boolean;
@@ -123,6 +129,8 @@ export interface EarlyExecutableNativeFeatures {
perfLoop: boolean;
tlsCa: boolean;
webcrypto: boolean;
+ /** Intl over ICU (scr_intl.c, the ICU archive and its data file). */
+ intl?: boolean;
}
interface EarlyExecutableCacheStamp {
@@ -299,6 +307,7 @@ const BOOLEAN_NATIVE_KEYS = [
"dgramDyn",
"textCodecDyn",
"watch",
+ "watchFile",
"foreignFfi",
"nodeTest",
"tls",
diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts
index 2629a65d5..a85e17f37 100644
--- a/packages/compiler/src/executable/prepare.ts
+++ b/packages/compiler/src/executable/prepare.ts
@@ -8,6 +8,7 @@ import {
npmStaticRefusalDiag,
iceDiag,
isCheckerPanic,
+ stdioPatchAcrossIslandsDiag,
type ScrDiagnostic,
} from "../diagnostics/diagnostic.js";
import type { IrModule, SrcLoc } from "../ir/ir.js";
@@ -20,6 +21,7 @@ import {
} from "../backend/target-diagnostics.js";
import type { LowerResult, LowerStats } from "../frontend/lowering/lowerer.js";
import type { FrontendFactory } from "../frontend/pipeline.js";
+import type { UnitStore } from "../frontend/lowering/unit-reuse.js";
import type { CompilationTiming } from "../timing.js";
import { readProjectConfig } from "../project-config.js";
import { nodeIslandPolicy } from "../node-islands/policy.js";
@@ -42,7 +44,9 @@ export interface PreparedExecutableModule {
warnings: ScrDiagnostic[];
}
-/** Release the parser/checker before native object generation begins. */
+/** Release the parser/checker before native object generation begins.
+ * `unitStore` keeps the lowered units of this build for the next one
+ * (unit-reuse.ts). */
export function prepareExecutableModule(
entryPath: string,
opts: CompileRequestOptions,
@@ -52,6 +56,7 @@ export function prepareExecutableModule(
timing: CompilationTiming = () => {},
lowerIslands: ReadonlyMap = new Map(),
retryFences = true,
+ unitStore: UnitStore | null = null,
): PreparedExecutableModule | CompileFailure {
// The island decision reads the detector's scan unless islands are off.
const islandScan =
@@ -115,6 +120,7 @@ export function prepareExecutableModule(
timing,
grown,
retryFences,
+ unitStore,
);
}
return fail(fe.preflight);
@@ -127,6 +133,7 @@ export function prepareExecutableModule(
targetPlatform: buildPlatform,
...(ffi !== null ? { ffiImports: ffi.functions } : {}),
...(opts.tier !== undefined ? { tierMode: opts.tier } : {}),
+ ...(unitStore !== null ? { unitStore } : {}),
},
(module) => validateModule(module).length === 0,
);
@@ -165,6 +172,7 @@ export function prepareExecutableModule(
timing,
grown,
retryFences,
+ unitStore,
);
}
// A failed program whose compiled packages need the runtime or a
@@ -195,6 +203,14 @@ export function prepareExecutableModule(
return fail(lowered.diagnostics);
}
+ // A program that replaces a process output stream's write method has
+ // nowhere to route an island's writes: the island's process.stdout is
+ // its own object. Until islands share the native stream object (H2),
+ // refuse the combination instead of silently missing the patch.
+ if (islandDecision !== null && lowered.stdioPatches !== undefined) {
+ return fail(lowered.stdioPatches.map((p) => stdioPatchAcrossIslandsDiag(p.stream, p.loc)));
+ }
+
const validation = validateModule(lowered.module);
timing("ir-validate");
if (validation.length > 0) {
@@ -267,6 +283,7 @@ export function prepareExecutableModule(
timing,
islands,
false,
+ unitStore,
);
if (islanded.ok) return islanded;
firstError ??= islanded.diagnostics[0] ?? null;
diff --git a/packages/compiler/src/frontend/early-errors.test.ts b/packages/compiler/src/frontend/early-errors.test.ts
new file mode 100644
index 000000000..718294a48
--- /dev/null
+++ b/packages/compiler/src/frontend/early-errors.test.ts
@@ -0,0 +1,43 @@
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { expect, test } from "vitest";
+import { checkPreflight, loadProgram } from "./program-node.js";
+
+/** The preflight diagnostics of a CommonJS script (sloppy unless it says
+ * "use strict"). */
+function diagnostics(source: string) {
+ const directory = mkdtempSync(join(tmpdir(), "scriptc-early-errors-"));
+ writeFileSync(join(directory, "main.cjs"), source);
+ const load = loadProgram(join(directory, "main.cjs"));
+ try {
+ return checkPreflight(load).map((diagnostic) => diagnostic.code);
+ } finally {
+ load.dispose();
+ rmSync(directory, { recursive: true, force: true });
+ }
+}
+
+test.for([
+ // A function body's top-level function declarations are var-scoped, so
+ // duplicates are legal in strict code too.
+ 'function outer() { "use strict"; function d() {} function d() {} }\nouter();',
+ "class C { static { function s() {} function s() {} } }\nnew C();",
+ // Object literal methods and accessors are sloppy unless they say otherwise.
+ "var o = { m() { if (true) function f() {} } };\no.m();",
+ "var o = { get g() { { function h() {} function h() {} } return 1; } };\no.g;",
+ // A function's own directive makes its body strict, not the code declaring it.
+ 'if (true) function k() { "use strict"; }',
+ '{ function z() { "use strict"; } function z() {} }',
+])("Node accepts: %s", (source) => {
+ expect(diagnostics(source)).toEqual([]);
+});
+
+test.for([
+ ["function outer() { let d; function d() {} }\nouter();", "SC0006"],
+ ['(() => { "use strict"; if (true) function f() {} })();', "SC0007"],
+ ["class C { m() { if (true) function f() {} } }\nnew C().m();", "SC0007"],
+ ['var o = { m() { "use strict"; { function h() {} function h() {} } } };\no.m();', "SC0005"],
+])("Node refuses: %s", ([source, code]) => {
+ expect(diagnostics(source!)).toContain(code);
+});
diff --git a/packages/compiler/src/frontend/early-errors.ts b/packages/compiler/src/frontend/early-errors.ts
new file mode 100644
index 000000000..e37112115
--- /dev/null
+++ b/packages/compiler/src/frontend/early-errors.ts
@@ -0,0 +1,384 @@
+/* ECMAScript early errors the TypeScript checker does not model.
+ *
+ * Static semantics reject a program before it runs, and until this pass
+ * existed scriptc borrowed that rejection from whatever tsc happened to
+ * report (`Let x` declarations at an odd position, a redeclared block name
+ * TypeScript refuses). Test262's negative-parse suites show where the
+ * borrowing stops: 1,198 programs the spec makes SyntaxErrors compile — the
+ * constructs below are the ones the corpus covers, in descending variant
+ * count (plan §7.7 D-4 option (c)).
+ *
+ * One code per construct, so C1's checker relaxation stays safe: relaxing a
+ * tsc code that doubles as an early error removes the ONLY rejection some
+ * parse-negative test had, which is why these codes live beside
+ * JS_RELAXED_TSC_CODES in tsc-codes.ts and never depend on it.
+ *
+ * Scope: JavaScript sources only. A TypeScript source is type-checked, and
+ * its checker diagnostics (TS2451 and friends) are the gate; re-reporting
+ * the same collision under a second code would only duplicate them.
+ *
+ * The pass runs at preflight, before any lowering, and reports through
+ * SC0xxx — the preflight band — so a rejected program fails the same way a
+ * tsc error does. */
+
+import * as ts from "./ts7/adapter.js";
+import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
+import type { SrcLoc } from "../ir/ir.js";
+import { isJsSourceFileName } from "./tsc-codes.js";
+
+/** The early-error codes. SC0005 and SC0006 are the Block/CaseBlock static
+ * semantics (a statement list may not declare one lexical name twice, and a
+ * lexical name may not be `var`-declared in the same list); SC0007 is a
+ * declaration where the grammar admits only a Statement. */
+export const EARLY_ERROR_CODES = ["SC0005", "SC0006", "SC0007"] as const;
+
+const NO_DUPLICATE_LEXICAL = "SC0005";
+const LEXICAL_VAR_COLLISION = "SC0006";
+const NOT_A_STATEMENT = "SC0007";
+
+/** Whether the code around a node is strict — the one input this pass needs
+ * from outside: Node's own module classification (program.ts's
+ * isNodeEsmFile7: fixed extensions, package type, then syntax markers). The
+ * checker's external-module bit cannot answer it, because
+ * moduleDetection=force gives every file module scope, CommonJS included. */
+export type IsStrictCode = (sf: ts.SourceFile, node: ts.Node) => boolean;
+
+export function earlyErrorDiagnostics7(
+ files: readonly ts.SourceFile[],
+ isModuleCode: (sf: ts.SourceFile) => boolean,
+): ScrDiagnostic[] {
+ const diags: ScrDiagnostic[] = [];
+ const isStrict: IsStrictCode = (sf, node) => isStrictCode(sf, node, isModuleCode);
+ for (const sf of files) {
+ if (!isJsSourceFileName(sf.fileName)) continue;
+ // Iterative walk: a deeply nested program must not overflow the stack
+ // before the lowering's own nesting fence runs.
+ const stack: ts.Node[] = [sf];
+ while (stack.length > 0) {
+ const node = stack.pop()!;
+ checkStatementListScope(sf, node, diags, isStrict);
+ checkStatementSlot(node, diags, isStrict);
+ ts.forEachChild(node, (child) => {
+ stack.push(child);
+ return undefined;
+ });
+ }
+ }
+ return diags;
+}
+
+function locOf(node: ts.Node): SrcLoc {
+ const sf = ts.getSourceFileOfNode(node);
+ return { file: sf.fileName, start: node.getStart(sf), end: node.end };
+}
+
+function report(diags: ScrDiagnostic[], code: EarlyErrorCode, node: ts.Node, message: string) {
+ diags.push({ code, message, loc: locOf(node) });
+}
+
+type EarlyErrorCode = (typeof EARLY_ERROR_CODES)[number];
+
+/* ── SC0005 / SC0006: Block and CaseBlock static semantics ───────────────
+ *
+ * Block : { StatementList }
+ * CaseBlock : { CaseClauses_opt DefaultClause CaseClauses_opt }
+ *
+ * * It is a Syntax Error if the LexicallyDeclaredNames of StatementList
+ * contains any duplicate entries.
+ * * It is a Syntax Error if any element of the LexicallyDeclaredNames of
+ * StatementList also occurs in the VarDeclaredNames of StatementList.
+ *
+ * A case block is ONE lexical scope across its clauses, and VarDeclaredNames
+ * reach into nested blocks (a `var` anywhere below the block belongs to it)
+ * but never into a function or class body. The duplicate rule's one carve-out
+ * is Annex B's B.3.3.1: two plain function declarations of the same name in
+ * the same block are legal in non-strict code.
+ *
+ * A function body and a class static block have the same two rules over
+ * their top-level declarations (FunctionStatementList and
+ * ClassStaticBlockStatementList), but there a function declaration is
+ * var-scoped: it is one of the VarDeclaredNames, so two of them are legal in
+ * strict code too, while `let f; function f() {}` still collides. */
+
+function checkStatementListScope(
+ sf: ts.SourceFile,
+ node: ts.Node,
+ diags: ScrDiagnostic[],
+ isStrict: IsStrictCode,
+): void {
+ let statements: readonly ts.Statement[] | null = null;
+ if (ts.isBlock(node)) statements = node.statements.nodes;
+ else if (ts.isCaseBlock(node)) {
+ statements = node.clauses.nodes.flatMap((clause) => clause.statements.nodes);
+ }
+ if (statements === null) return;
+ if (statements.length < 2) return;
+ const functionBody = isFunctionBody(node);
+
+ const lexical = new Map();
+ for (const stmt of statements) {
+ for (const [name, decl] of lexicalDeclarationsOf(stmt, functionBody)) {
+ const list = lexical.get(name);
+ if (list === undefined) lexical.set(name, [decl]);
+ else list.push(decl);
+ }
+ }
+ if (lexical.size === 0) return;
+
+ const varNames = new Set();
+ for (const stmt of statements) {
+ collectVarDeclaredNames(stmt, varNames);
+ if (functionBody && ts.isFunctionDeclaration(stmt) && stmt.name !== undefined)
+ varNames.add(stmt.name.text);
+ }
+
+ for (const [name, decls] of lexical) {
+ if (varNames.has(name)) {
+ report(
+ diags,
+ LEXICAL_VAR_COLLISION,
+ decls[0]!,
+ functionBody
+ ? `the lexical declaration '${name}' collides with a 'var' or function declaration of the same name in this function body`
+ : `the lexical declaration '${name}' collides with a 'var' declaration of the same name in this block`,
+ );
+ continue;
+ }
+ if (decls.length < 2) continue;
+ // Annex B B.3.3.1: duplicate plain function declarations are the web's
+ // legacy, and only non-strict code keeps them.
+ if (decls.every((d) => ts.isFunctionDeclaration(d) && !isEnhancedFunction(d))) {
+ if (!isStrict(sf, decls[0]!)) continue;
+ }
+ report(
+ diags,
+ NO_DUPLICATE_LEXICAL,
+ decls[1]!,
+ `the declaration '${name}' is already declared in this block's statement list`,
+ );
+ }
+}
+
+/** Whether a block is the body of a function or a class static block, whose
+ * top-level function declarations are var-scoped. */
+function isFunctionBody(node: ts.Node): boolean {
+ const parent = node.parent;
+ if (parent === undefined || !ts.isBlock(node)) return false;
+ if (ts.isClassStaticBlockDeclaration(parent)) return true;
+ return ts.isFunctionLike(parent) && (parent as { body?: ts.Node }).body === node;
+}
+
+/** The names a DIRECT statement of a statement list declares lexically
+ * (let/const/using, class, and every function declaration form outside a
+ * function body), paired with the node to point the diagnostic at. */
+function lexicalDeclarationsOf(
+ stmt: ts.Statement,
+ functionBody: boolean,
+): Array<[string, ts.Node]> {
+ const names: Array<[string, ts.Node]> = [];
+ if (ts.isVariableStatement(stmt)) {
+ if ((stmt.declarationList.flags & ts.NodeFlags.BlockScoped) === 0) return names;
+ for (const decl of stmt.declarationList.declarations.nodes)
+ collectBindingNames(decl.name, names);
+ return names;
+ }
+ if (ts.isClassDeclaration(stmt) || (ts.isFunctionDeclaration(stmt) && !functionBody)) {
+ const name = stmt.name;
+ if (name !== undefined && ts.isIdentifier(name)) names.push([name.text, stmt]);
+ }
+ return names;
+}
+
+function collectBindingNames(name: ts.BindingName, into: Array<[string, ts.Node]>): void {
+ if (ts.isIdentifier(name)) {
+ into.push([name.text, name]);
+ return;
+ }
+ for (const element of name.elements.nodes) {
+ if (ts.isOmittedExpression(element)) continue;
+ if (ts.isBindingElement(element) && element.name !== undefined)
+ collectBindingNames(element.name, into);
+ }
+}
+
+/** VarDeclaredNames of a statement: every `var`-declared name below it,
+ * stopping at function and class boundaries (their declarations are not
+ * var-scoped here). */
+function collectVarDeclaredNames(root: ts.Node, into: Set): void {
+ const stack: ts.Node[] = [root];
+ while (stack.length > 0) {
+ const node = stack.pop()!;
+ if (ts.isFunctionLike(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node))
+ continue;
+ if (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.BlockScoped) === 0) {
+ const names: Array<[string, ts.Node]> = [];
+ for (const decl of node.declarations.nodes) collectBindingNames(decl.name, names);
+ for (const [name] of names) into.add(name);
+ }
+ ts.forEachChild(node, (child) => {
+ stack.push(child);
+ return undefined;
+ });
+ }
+}
+
+/** `async function` and `function*` are Annex B's allowance never — only the
+ * plain form is. */
+function isEnhancedFunction(decl: ts.FunctionDeclaration): boolean {
+ if (decl.asteriskToken !== undefined) return true;
+ return (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Async) !== 0;
+}
+
+/* ── SC0007: a declaration where the grammar admits only a Statement ─────
+ *
+ * Every `Statement` slot below admits Statement productions only, and only
+ * Annex B widens three of them to a plain FunctionDeclaration in non-strict
+ * code: the body of an `if`/`else` and a labelled statement. Async functions,
+ * generators, classes and lexical declarations are never admitted, and a
+ * LABELLED function is never admitted as an if/else body (`IsLabelledFunction`
+ * is an explicit early error there, so `if (x) l: function f(){}` fails even
+ * in sloppy code). */
+type StatementSlot = "if" | "loop" | "with" | "label";
+
+function checkStatementSlot(node: ts.Node, diags: ScrDiagnostic[], isStrict: IsStrictCode): void {
+ if (ts.isIfStatement(node)) {
+ checkStatementPosition(node.thenStatement, "if", diags, isStrict);
+ if (node.elseStatement !== undefined)
+ checkStatementPosition(node.elseStatement, "if", diags, isStrict);
+ return;
+ }
+ if (
+ ts.isWhileStatement(node) ||
+ ts.isDoStatement(node) ||
+ ts.isForStatement(node) ||
+ ts.isForInStatement(node) ||
+ ts.isForOfStatement(node)
+ ) {
+ checkStatementPosition(node.statement, "loop", diags, isStrict);
+ return;
+ }
+ if (ts.isWithStatement(node)) {
+ checkStatementPosition(node.statement, "with", diags, isStrict);
+ return;
+ }
+ if (ts.isLabeledStatement(node)) checkStatementPosition(node.statement, "label", diags, isStrict);
+}
+
+function checkStatementPosition(
+ stmt: ts.Statement,
+ slot: StatementSlot,
+ diags: ScrDiagnostic[],
+ isStrict: IsStrictCode,
+): void {
+ const sf = ts.getSourceFileOfNode(stmt);
+ // A labelled function is an early error as an if-clause, loop, or 'with'
+ // body in BOTH modes (Node agrees); only a plain labelled STATEMENT is
+ // Annex B's, and that is the `slot === "label"` case, whose terminal
+ // function the function branches below handle.
+ const labelTerminal =
+ slot === "label" || !ts.isLabeledStatement(stmt) ? null : labelledFunctionOf(stmt);
+ if (labelTerminal !== null) {
+ report(
+ diags,
+ NOT_A_STATEMENT,
+ labelTerminal,
+ "a labelled function declaration cannot be the body of a loop, a 'with' statement, or an 'if' clause",
+ );
+ return;
+ }
+ if (ts.isVariableStatement(stmt)) {
+ if ((stmt.declarationList.flags & ts.NodeFlags.BlockScoped) !== 0)
+ report(
+ diags,
+ NOT_A_STATEMENT,
+ stmt,
+ "a lexical declaration cannot appear in a single-statement position",
+ );
+ return;
+ }
+ if (ts.isClassDeclaration(stmt)) {
+ report(
+ diags,
+ NOT_A_STATEMENT,
+ stmt,
+ "a class declaration cannot appear in a single-statement position",
+ );
+ return;
+ }
+ if (!ts.isFunctionDeclaration(stmt)) return;
+ if (isEnhancedFunction(stmt)) {
+ report(
+ diags,
+ NOT_A_STATEMENT,
+ stmt,
+ "an async function or generator declaration can only appear at the top level or inside a block",
+ );
+ return;
+ }
+ // A plain function declaration: Annex B admits it as an if/else body and as
+ // a labelled statement, in non-strict code only, and never as a loop's or a
+ // 'with' statement's body.
+ if (slot === "loop" || slot === "with") {
+ report(
+ diags,
+ NOT_A_STATEMENT,
+ stmt,
+ "a function declaration can only appear at the top level, inside a block, or as the body of an if statement",
+ );
+ return;
+ }
+ if (isStrict(sf, stmt))
+ report(
+ diags,
+ NOT_A_STATEMENT,
+ stmt,
+ "strict mode code may not declare a function in a single-statement position",
+ );
+}
+
+/** The function declaration at the end of a `label:` chain, when `stmt` is a
+ * labelled statement around one. */
+function labelledFunctionOf(stmt: ts.Statement): ts.FunctionDeclaration | null {
+ let cur: ts.Statement = stmt;
+ while (ts.isLabeledStatement(cur)) cur = cur.statement;
+ return ts.isFunctionDeclaration(cur) ? cur : null;
+}
+
+/* ── strictness of the code around a node ───────────────────────────────
+ *
+ * A directive prologue's "use strict" applies to the function (or script) it
+ * opens, an arrow function's block body included, and every part of a class is strict by
+ * construction. Object literal methods and accessors are ordinary functions:
+ * strict only by their own prologue or their surroundings. Module sources are
+ * strict whether they say so or not, and Node's classification — not the
+ * checker's forced external-module scope — decides which sources are modules.
+ *
+ * The walk starts at the node's parent: a function declaration's own "use
+ * strict" makes its body strict, not the code that declares it. */
+function isStrictCode(
+ sf: ts.SourceFile,
+ node: ts.Node,
+ isModuleCode: (sf: ts.SourceFile) => boolean,
+): boolean {
+ if (isModuleCode(sf)) return true;
+ for (let n: ts.Node | undefined = node.parent; n !== undefined; n = n.parent) {
+ if (ts.isSourceFile(n)) return hasUseStrictPrologue(n.statements.nodes);
+ if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) return true;
+ if (ts.isFunctionLike(n)) {
+ const body = n.body;
+ if (body !== undefined && ts.isBlock(body) && hasUseStrictPrologue(body.statements.nodes))
+ return true;
+ }
+ }
+ return false;
+}
+
+function hasUseStrictPrologue(statements: readonly ts.Statement[]): boolean {
+ for (const stmt of statements) {
+ if (!ts.isExpressionStatement(stmt)) return false;
+ const expr = stmt.expression;
+ if (!ts.isStringLiteral(expr)) return false;
+ if (expr.text === "use strict") return true;
+ }
+ return false;
+}
diff --git a/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts b/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts
index 96d285f03..49eab16b5 100644
--- a/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts
+++ b/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts
@@ -94,3 +94,23 @@ test.for([
invalid,
);
});
+
+test("JavaScript async generators may delegate over a runtime async-iterator shape", () => {
+ expect(
+ diagnostics(
+ `export async function* delegated() { yield* { [Symbol.asyncIterator]: 42 }; }
+export async function* looped() { for await (const value of { [Symbol.asyncIterator]() { return "text"; } }) console.dir(value); }`,
+ 'import { delegated, looped } from "./library.js"; delegated(); looped();',
+ ),
+ ).toEqual([]);
+});
+
+test("TypeScript keeps the async-iterator demand", () => {
+ expect(
+ diagnostics(
+ "export async function* delegated() { yield* { [Symbol.asyncIterator]: 42 }; }",
+ 'import { delegated } from "./library.js"; delegated();',
+ "ts",
+ ).some((diagnostic) => diagnostic.message.includes("Symbol.asyncIterator")),
+ ).toBe(true);
+});
diff --git a/packages/compiler/src/frontend/lowering/binding-analysis.ts b/packages/compiler/src/frontend/lowering/binding-analysis.ts
index c88c06c90..09a3bd3c5 100644
--- a/packages/compiler/src/frontend/lowering/binding-analysis.ts
+++ b/packages/compiler/src/frontend/lowering/binding-analysis.ts
@@ -6,7 +6,8 @@ import { isGenericCallableMemberType } from "../type-mapper.js";
/** True when nothing in `sym`'s DECLARING FILE ever writes it after the
* initializer: assignments (plain and compound, destructuring targets
- * included), ++/--, and for-of/for-in expression targets all count.
+ * included), ++/--, for-of/for-in expression targets, a `var` in a
+ * for-of/for-in head, and redeclarations that initialize all count.
* Sound file-locally for module-scope bindings because ESM import
* bindings are read-only — no other file can write one. Cached per
* symbol (the scan walks the whole file once). */
@@ -31,6 +32,7 @@ export function fileWritesBinding(
file: ts.SourceFile,
resolve: (ident: ts.Identifier) => ts.Symbol | null | undefined,
): boolean {
+ if (declarationsWriteBinding(sym)) return true;
let written = false;
// Text pre-check keeps the file walk cheap: only same-named
// identifiers pay a symbol resolution.
@@ -78,6 +80,77 @@ export function fileWritesBinding(
return written;
}
+/** True when `sym`'s own declarations write it after it holds a value:
+ * a `var` in a for-in or for-of head is written on every iteration, a
+ * `var` with an initializer inside a loop body is written each time the
+ * body runs, and a binding declared more than once (`var x = 1; ... var x
+ * = 2;`, a parameter or function declaration that a `var` redeclares) is
+ * written by each initializing declaration that runs. Only value
+ * declarations count, and of functions only implementations: overload
+ * signatures have no body. A redeclaration without an initializer is
+ * counted too, which only over-approximates. */
+function declarationsWriteBinding(sym: ts.Symbol): boolean {
+ let values = 0;
+ let initializing = 0;
+ for (const d of sym.declarations) {
+ let root: ts.Node = d;
+ while (
+ root.parent !== undefined &&
+ (ts.isBindingElement(root) ||
+ ts.isObjectBindingPattern(root) ||
+ ts.isArrayBindingPattern(root))
+ )
+ root = root.parent;
+ if (ts.isVariableDeclaration(root)) {
+ const list = root.parent;
+ if (
+ ts.isVariableDeclarationList(list) &&
+ (list.flags & ts.NodeFlags.BlockScoped) === 0 &&
+ (ts.isForInStatement(list.parent) || ts.isForOfStatement(list.parent))
+ )
+ return true;
+ values++;
+ if (root.initializer !== undefined) {
+ if (insideLoopBody(root)) return true;
+ initializing++;
+ }
+ } else if (
+ ts.isParameter(root) ||
+ (ts.isFunctionDeclaration(d) && d.body !== undefined) ||
+ ts.isClassDeclaration(d)
+ ) {
+ values++;
+ initializing++;
+ }
+ }
+ return values > 1 && initializing > 0;
+}
+
+/** Whether a declaration sits in the body of a loop of its own function,
+ * where it runs once per iteration. */
+function insideLoopBody(node: ts.Node): boolean {
+ let child = node;
+ for (let parent = node.parent; parent !== undefined; parent = parent.parent) {
+ if (
+ ts.isFunctionLike(parent) ||
+ ts.isClassStaticBlockDeclaration(parent) ||
+ ts.isSourceFile(parent)
+ )
+ return false;
+ if (
+ (ts.isForStatement(parent) ||
+ ts.isForInStatement(parent) ||
+ ts.isForOfStatement(parent) ||
+ ts.isWhileStatement(parent) ||
+ ts.isDoStatement(parent)) &&
+ parent.statement === child
+ )
+ return true;
+ child = parent;
+ }
+ return false;
+}
+
/** Strips the value-preserving wrappers off an expression: parens,
* non-null assertions, `as`/`satisfies`/angle-bracket casts. What
* remains is the expression that actually evaluates. */
diff --git a/packages/compiler/src/frontend/lowering/builtin-calls.ts b/packages/compiler/src/frontend/lowering/builtin-calls.ts
index d2e4b53d5..33c4502bc 100644
--- a/packages/compiler/src/frontend/lowering/builtin-calls.ts
+++ b/packages/compiler/src/frontend/lowering/builtin-calls.ts
@@ -1,4 +1,5 @@
import { lowerReadlineCursorCall } from "./builtins/readline.js";
+import { lowerFsWatchFileCall } from "./builtins/filesystem.js";
import { lowerMonitorEventLoopDelayCall } from "./builtins/performance.js";
import {
lowerEnvironmentData,
@@ -134,6 +135,13 @@ export function lowerBuiltinCall(
if (streamServed) return streamServed;
const fsTs = lowerer.lowerFsToUnixTimestampCall(expr, bi, loc);
if (fsTs) return fsTs;
+ // fs.watchFile/fs.unwatchFile have no table entry (their checked-dynamic
+ // form is the fs.watchFileChk ladder below), so the typed polling forms
+ // dispatch here; anything else falls through to the ladder and the fence.
+ if (bi.module === "fs" && (bi.member === "watchFile" || bi.member === "unwatchFile")) {
+ const polling = lowerFsWatchFileCall(lowerer, expr, loc, bi.member === "unwatchFile");
+ if (polling) return polling;
+ }
const fsLadder = lowerer.lowerFsLadderCall(expr, bi, loc);
if (fsLadder) return fsLadder;
const cryptoServed = lowerer.lowerCryptoModuleCall(expr, bi, loc);
diff --git a/packages/compiler/src/frontend/lowering/builtins/dates.ts b/packages/compiler/src/frontend/lowering/builtins/dates.ts
index 075047606..d6deb771f 100644
--- a/packages/compiler/src/frontend/lowering/builtins/dates.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/dates.ts
@@ -72,7 +72,7 @@ const DATE_TEXT_FNS: ReadonlyMap = new Map([
]);
export const DATE_METHOD_HINT =
- "getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering";
+ "getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), toLocaleString(), toLocaleTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale and options arguments, toLocaleDateString() and the Intl.DateTimeFormat surface have no lowering";
/** Date statics and methods on Date objects (a TimeClip'd millisecond
* time value with identity): getters, text forms and setters.
@@ -208,6 +208,19 @@ export function dateValueMethod(name: string, value: IrExpr, loc: SrcLoc): IrExp
if (textFn !== undefined) {
return { kind: "libCall", fn: textFn, args: [value], type: STRING, loc };
}
+ // The default locale's forms. The spec makes them DateTimeFormat(locale,
+ // options).format(this); with neither argument that is ICU's default
+ // locale over the host time zone, which scr_intl.c formats over the
+ // ICU release exactly as V8 does.
+ if (name === "toLocaleString" || name === "toLocaleTimeString") {
+ return {
+ kind: "libCall",
+ fn: name === "toLocaleString" ? "icu.dateLocaleString" : "icu.dateLocaleTimeString",
+ args: [value],
+ type: STRING,
+ loc,
+ };
+ }
return null;
}
diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts
index 7a2b262da..5506c45b1 100644
--- a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts
@@ -8,6 +8,7 @@ import {
builtinModuleFnOf,
FS_READDIR_DOCUMENTED_OPTIONS,
FS_WATCH_DOCUMENTED_OPTIONS,
+ FS_WATCH_FILE_DOCUMENTED_OPTIONS,
fenceOrDropOptionKey,
} from "../surfaces.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
@@ -1127,13 +1128,37 @@ export function lowerStatsMethodCall(
);
}
+/** The watch-listener shapes: () , (eventType: string), and
+ * (eventType: string, filename: string | null) — the filename parameter
+ * must admit null (Node passes null when the platform names nothing), or
+ * be the checked dynamic. Returns the parameter count, or null when the
+ * shape has no lowering. */
+function watchListenerArity(lowerer: Lowerer, cb: IrExpr): 0 | 1 | 2 | null {
+ if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 2)
+ return null;
+ const [event, name] = cb.type.params;
+ if (event !== undefined && event.kind !== "string" && event.kind !== "dyn") return null;
+ if (name !== undefined && name.kind !== "dyn") {
+ const def = name.kind === "union" ? lowerer.unions.get(name.unionId) : undefined;
+ if (
+ !def ||
+ !def.arms.some((a) => a.kind === "nullT") ||
+ !def.arms.some((a) => a.kind === "string") ||
+ !def.arms.every((a) => a.kind === "nullT" || a.kind === "undefinedT" || a.kind === "string")
+ )
+ return null;
+ }
+ return cb.type.params.length as 0 | 1 | 2;
+}
+
/** `fs.watch(path[, options][, listener])` → the fs.watch libCall
* (scr_watch.c — kqueue EVFILT_VNODE on the opened path; an unopenable
* path THROWS Node's fs error synchronously, the polling-fallback catch
* shape). The listener fires with "rename"/"change" and takes zero
- * parameters or the eventType string — the filename parameter has no
- * lowering (kqueue watches the inode, not the directory entry; you
- * watched one path). The options record follows the options-record
+ * parameters, the eventType string, or (eventType, filename: string |
+ * null) — the filename is the entry name on a directory watch and the
+ * watched file's basename otherwise (Linux inotify names both; a macOS
+ * directory watch has no name source until F3). The options record follows the options-record
* stance: persistent: true, recursive: false, and encoding: "utf8"
* state the lowered behavior and are accepted; persistent: false
* (a watcher that does NOT hold the loop), recursive: true (kqueue
@@ -1204,7 +1229,7 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc:
lowerer.noLowering(
"fs.watch with a non-utf8 encoding",
prop,
- "the encoding applies to the filename argument, which has no lowering — utf8 (the default) is accepted",
+ "the filename argument is a string — utf8 (the default) is the accepted encoding",
);
}
if (key === "signal") {
@@ -1236,19 +1261,11 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc:
: null;
if (listenerArg !== null) {
const cb = lowerer.lowerExpr(listenerArg);
- if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) {
+ if (watchListenerArity(lowerer, cb) === null) {
lowerer.noLowering(
"fs.watch with this listener shape",
listenerArg,
- "the listener takes () or (eventType: string) — the filename parameter has no lowering (you watched one path)",
- );
- }
- const param = cb.type.params[0];
- if (param !== undefined && param.kind !== "string") {
- lowerer.unsupported(
- "SC1090",
- listenerArg,
- `watch listeners whose parameter is not the eventType string (got '${lowerer.fmt(param)}')`,
+ "the listener takes (), (eventType: string), or (eventType: string, filename: string | null)",
);
}
args.push(cb);
@@ -1262,10 +1279,134 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc:
};
}
-/** Method calls on FSWatcher receivers: close() — idempotent, statement
- * position (Node returns void there too). Everything else @types/node
- * declares (ref/unref, the EventEmitter surface) fences member-
- * qualified. Null for non-watcher receivers. */
+/** `fs.watchFile(path[, options], listener)` and
+ * `fs.unwatchFile(path[, listener])` → the polling libCalls
+ * (scr_watch_file.c: libuv's uv_fs_poll shape — one poller per RESOLVED
+ * path, shared by every listener on it, re-armed every `interval` ms,
+ * 5007 by default, persistent by default). The listener takes
+ * (current: Stats, previous: Stats) — zero, one or both — and runs when a
+ * poll sees a difference; the first poll only records the baseline, and a
+ * file that cannot be stat'd polls as all-zero Stats. The options record
+ * follows the options-record stance: interval, persistent: true/false and
+ * bigint: false are accepted; bigint: true fences (BigIntStats); other
+ * documented keys fence by name; undocumented keys drop like Node.
+ * Statement position only — Node returns a StatWatcher, which has no
+ * lowering. Returns null for any untyped shape so the checked-dynamic
+ * ladder (fs.watchFileChk) keeps its current IR. */
+export function lowerFsWatchFileCall(
+ lowerer: Lowerer,
+ expr: ts.CallExpression,
+ loc: SrcLoc,
+ unwatch: boolean,
+): IrExpr | null {
+ const nodes = expr.arguments.nodes;
+ if (nodes.length < 1 || nodes.length > 3 || nodes.some(ts.isSpreadElement)) return null;
+ if (lowerer.mapTypeOf(lowerer.typeOf(nodes[0]!))?.kind !== "string") return null;
+ const path = lowerer.lowerExprExpecting(nodes[0]!, STRING);
+ if (unwatch) {
+ if (nodes.length > 2) return null;
+ const args: IrExpr[] = [path];
+ if (nodes.length === 2) {
+ if (lowerer.mapTypeOf(lowerer.typeOf(nodes[1]!))?.kind !== "func") return null;
+ args.push(lowerer.lowerExpr(nodes[1]!));
+ }
+ return { kind: "libCall", fn: "fs.unwatchFile", args, type: VOID, loc };
+ }
+ const hasOptions = nodes.length >= 2 && ts.isObjectLiteralExpression(nodes[1]!);
+ if (nodes.length === 3 && !hasOptions) return null;
+ if (nodes.length === 2 && hasOptions) return null; /* no listener */
+ const listenerArg = nodes[nodes.length - 1]!;
+ if (lowerer.mapTypeOf(lowerer.typeOf(listenerArg))?.kind !== "func") return null;
+ const parent = expr.parent;
+ if (!ts.isExpressionStatement(parent) && !(ts.isArrowFunction(parent) && parent.body === expr)) {
+ lowerer.unsupported(
+ "SC1090",
+ expr,
+ "using the returned StatWatcher (register the fs.watchFile call as its own statement)",
+ );
+ }
+ let interval: IrExpr | null = null;
+ let persistent: IrExpr | null = null;
+ if (hasOptions) {
+ for (const prop of (nodes[1] as ts.ObjectLiteralExpression).properties.nodes) {
+ if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) {
+ lowerer.noLowering(
+ "fs.watchFile options with computed keys or spreads",
+ prop,
+ "each option must be a plain `name: value` entry with a literal key",
+ );
+ }
+ if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
+ lowerer.noLowering(
+ "fs.watchFile options with computed keys",
+ prop,
+ "each option must be a plain `name: value` entry with a literal key",
+ );
+ }
+ const key = prop.name.text;
+ const value = ts.isPropertyAssignment(prop)
+ ? prop.initializer
+ : ts.isShorthandPropertyAssignment(prop)
+ ? prop.name
+ : null;
+ if (key === "interval" && value !== null) {
+ interval = lowerer.lowerExprExpecting(value, F64);
+ continue;
+ }
+ if (key === "persistent" && value !== null) {
+ persistent = lowerer.lowerExprExpecting(value, BOOL);
+ continue;
+ }
+ if (key === "bigint") {
+ // The fallback's option record declares `bigint: false`, so the
+ // false literal (or `false` as a value) states the lowered Stats
+ // shape; BigIntStats has no lowering.
+ if (value !== null && value.kind === ts.SyntaxKind.FalseKeyword) continue;
+ lowerer.noLowering(
+ "fs.watchFile with bigint: true",
+ prop,
+ "the listener receives Stats (double integers); BigIntStats has no lowering",
+ );
+ }
+ fenceOrDropOptionKey(
+ lowerer,
+ prop,
+ key,
+ "fs.watchFile",
+ FS_WATCH_FILE_DOCUMENTED_OPTIONS,
+ "interval, persistent: true/false, and bigint: false are the accepted options",
+ );
+ // An undocumented key, dropped like Node drops it.
+ }
+ }
+ const cb = lowerer.lowerExpr(listenerArg);
+ if (
+ cb.type.kind !== "func" ||
+ cb.type.ret.kind !== "void" ||
+ cb.type.params.length > 2 ||
+ !cb.type.params.every((p) => p.kind === "stats")
+ ) {
+ lowerer.noLowering(
+ "fs.watchFile with this listener shape",
+ listenerArg,
+ "the listener takes (current: Stats, previous: Stats)",
+ );
+ }
+ return {
+ kind: "libCall",
+ fn: "fs.watchFile",
+ args: [path, interval ?? numLit(5007, loc), persistent ?? boolLit(true, loc), cb],
+ type: VOID,
+ loc,
+ };
+}
+
+/** Method calls on FSWatcher receivers: close() (idempotent, statement
+ * position — Node returns void there too) and the EventEmitter surface for
+ * 'change', 'error' and 'close' (on/once/addListener/prependListener/
+ * prependOnceListener/off/removeListener, registered as their own
+ * statement). Everything else @types/node declares (ref/unref, other
+ * events) fences member-qualified. Null for non-watcher receivers. */
export function lowerWatcherMethodCall(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -1280,10 +1421,82 @@ export function lowerWatcherMethodCall(
const receiver = lowerer.lowerExpr(access.expression);
return { kind: "libCall", fn: "watcher.close", args: [receiver], type: VOID, loc };
}
+ // The EventEmitter surface for the three events the watcher emits:
+ // "change" (the listener shapes fs.watch takes), "error", and "close".
+ const ON = ["on", "once", "addListener", "prependListener", "prependOnceListener"];
+ if (
+ (ON.includes(name) || name === "off" || name === "removeListener") &&
+ call.arguments.nodes.length === 2
+ ) {
+ const evT = lowerer.typeOf(call.arguments.nodes[0]!);
+ const event = evT.isStringLiteralType() ? evT.value : null;
+ const code = event === "change" ? 0 : event === "error" ? 1 : event === "close" ? 2 : -1;
+ if (code < 0) {
+ lowerer.noLowering(
+ `FSWatcher.${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`,
+ call.arguments.nodes[0]!,
+ '"change", "error", and "close" are the supported FSWatcher events (as literals)',
+ );
+ }
+ const parent = call.parent;
+ if (
+ !ts.isExpressionStatement(parent) &&
+ !(ts.isArrowFunction(parent) && parent.body === call)
+ ) {
+ lowerer.unsupported(
+ "SC1090",
+ call,
+ `using the result of FSWatcher.${name} (register it as its own statement)`,
+ );
+ }
+ const receiver = lowerer.lowerExpr(access.expression);
+ const cb = lowerer.lowerExpr(call.arguments.nodes[1]!);
+ if (name === "off" || name === "removeListener") {
+ return {
+ kind: "libCall",
+ fn: "watcher.off",
+ args: [receiver, numLit(code, loc), cb],
+ type: VOID,
+ loc,
+ };
+ }
+ const ok =
+ code === 0
+ ? watchListenerArity(lowerer, cb) !== null
+ : code === 1
+ ? cb.type.kind === "func" &&
+ cb.type.ret.kind === "void" &&
+ cb.type.params.length <= 1 &&
+ (cb.type.params[0] === undefined ||
+ cb.type.params[0].kind === "dyn" ||
+ (cb.type.params[0].kind === "object" && cb.type.params[0].className === "%Error"))
+ : cb.type.kind === "func" && cb.type.ret.kind === "void" && cb.type.params.length === 0;
+ if (!ok) {
+ lowerer.noLowering(
+ `FSWatcher.${name}("${event}", ...) with this listener shape`,
+ call.arguments.nodes[1]!,
+ code === 0
+ ? "the listener takes (), (eventType: string), or (eventType: string, filename: string | null)"
+ : code === 1
+ ? "the listener takes () or (error: Error)"
+ : "the listener takes ()",
+ );
+ }
+ const flags =
+ (name === "once" || name === "prependOnceListener" ? 1 : 0) |
+ (name.startsWith("prepend") ? 2 : 0);
+ return {
+ kind: "libCall",
+ fn: "watcher.on",
+ args: [receiver, numLit(code, loc), numLit(flags, loc), cb],
+ type: VOID,
+ loc,
+ };
+ }
lowerer.noLowering(
`FSWatcher.${name}`,
call,
- "close() is the supported FSWatcher member",
+ 'close(), and on/once/addListener/prependListener/prependOnceListener/off/removeListener for "change", "error", and "close" are the supported FSWatcher members',
lowerer.checker.getSymbolAtLocation(access.name),
);
}
diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts
index cd9db34ab..1dbedd395 100644
--- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts
@@ -32,6 +32,7 @@ import { lowerUtilTypeCall } from "./util-types.js";
import { InternalCompilerError } from "../../../errors.js";
import { dynUndefinedExpr, varRef } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
+import { jsCheckedBuiltinCall } from "../lower-builtin-values.js";
import { type Lowerer } from "../lowerer.js";
import { type BuiltinModuleFn } from "../surfaces.js";
import { type ParamShape } from "../call-signatures.js";
@@ -72,6 +73,10 @@ export function lowerBuiltinModuleCall(
);
if (fallible) return fallible;
checkFsUrlPaths(lowerer, expr, bi);
+ {
+ const checked = jsCheckedBuiltinCall(lowerer, expr, fn, `${bi.module}.${bi.member}`, loc);
+ if (checked) return checked;
+ }
if (bi.module === "util/types") {
return lowerUtilTypeCall(lowerer, expr, bi, loc, name);
}
diff --git a/packages/compiler/src/frontend/lowering/builtins/operating-system.ts b/packages/compiler/src/frontend/lowering/builtins/operating-system.ts
index ee189a1f3..3f3616f23 100644
--- a/packages/compiler/src/frontend/lowering/builtins/operating-system.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/operating-system.ts
@@ -17,8 +17,9 @@ import {
} from "../../../ir/ir.js";
/** `os.userInfo()` — the passwd-entry snapshot, Node's uv_os_get_passwd
- * behind the call site's own mapped record shape: username = pw_name,
- * uid/gid = getuid/getgid, shell = pw_shell (as the `string | null`
+ * for the effective uid behind the call site's own mapped record shape:
+ * username = pw_name, uid/gid = pw_uid/pw_gid (the user's primary group,
+ * not the process gid; -1 on Windows), shell = pw_shell (as the `string | null`
* union @types/node declares — POSIX always answers the string arm;
* null is Node's Windows answer), homedir = pw_dir (the PASSWD home,
* NOT os.homedir's $HOME-first cascade — Node's own split). The record
@@ -56,12 +57,12 @@ export function lowerOsUserInfoCall(
} else if (f.name === "uid" && f.type.kind === "f64") {
fields.push({
name: f.name,
- value: { kind: "libCall", fn: "process.getuid", args: [], type: F64, loc },
+ value: { kind: "libCall", fn: "os.userUid", args: [], type: F64, loc },
});
} else if (f.name === "gid" && f.type.kind === "f64") {
fields.push({
name: f.name,
- value: { kind: "libCall", fn: "process.getgid", args: [], type: F64, loc },
+ value: { kind: "libCall", fn: "os.userGid", args: [], type: F64, loc },
});
} else if (f.name === "homedir" && f.type.kind === "string") {
fields.push({
diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts
index 8654770ba..f250bf0f3 100644
--- a/packages/compiler/src/frontend/lowering/builtins/process.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/process.ts
@@ -34,6 +34,7 @@ import { builtinFunctionValue } from "../lower-exprs.js";
import { canonicalBuiltinModule } from "../../builtin-modules.js";
import { lowerProcessIpcSend, ipcMessageListener, ipcDisconnectListener } from "./ipc.js";
import { lowerDiagnosticsSubscriber, lowerTracingArguments } from "./async-context.js";
+import { isCheckerPanic } from "../../../diagnostics/diagnostic.js";
/** Whether the process has an IPC channel, Node's test for defining
* process.send and process.disconnect: a channel this fork child opened
@@ -173,6 +174,70 @@ export function programReadsExitCode(lowerer: Lowerer): boolean {
return found;
}
+/** A write-method replacement on a process output stream, its source site.
+ * `stream` is null when the receiver is a first-class alias, whose type does
+ * not say which of the two output streams it holds. */
+export interface StdioPatchSite {
+ stream: "stdout" | "stderr" | null;
+ loc: SrcLoc;
+}
+
+/** Whether any program file replaces a process output stream's write
+ * method: `process.stdout.write = …` / `process.stderr.write = …`, or the
+ * same assignment through a first-class alias of the stream (a value whose
+ * type maps to the process stream). Such a program routes its typed
+ * stdout/stderr writes through the stream object, so the replacement sees
+ * them with their arguments, as in Node. Cached per program; the first
+ * site found is recorded for the executable planner's SC8020 check. */
+export function programPatchesStdio(lowerer: Lowerer): boolean {
+ if (lowerer.patchesStdio !== null) return lowerer.patchesStdio;
+ let found = false;
+ // undefined: not an output stream; null: a first-class alias of one.
+ const streamOf = (recv: ts.Expression): "stdout" | "stderr" | null | undefined => {
+ if (ts.isPropertyAccessExpression(recv)) {
+ const direct = lowerer.stdlibGlobalMember(recv, "process");
+ if (direct === "stdout" || direct === "stderr") return direct;
+ }
+ // An arbitrary receiver's mapping is a query, never a commit: a checker
+ // panic (the type has no readable form) is not a patch site. The type
+ // cannot say WHICH output stream an alias holds.
+ try {
+ return lowerer.mapTypeOf(lowerer.typeOf(recv))?.kind === "procStream" ? null : undefined;
+ } catch (e) {
+ if (!isCheckerPanic(e)) throw e;
+ return undefined;
+ }
+ };
+ const walk = (node: ts.Node): void => {
+ if (found) return;
+ if (
+ ts.isBinaryExpression(node) &&
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+ ts.isPropertyAccessExpression(node.left) &&
+ node.left.name.text === "write"
+ ) {
+ const recv = node.left.expression;
+ const direct = ts.isPropertyAccessExpression(recv)
+ ? lowerer.stdlibGlobalMember(recv, "process")
+ : null;
+ const stream = direct === "stdout" || direct === "stderr" ? direct : streamOf(recv);
+ if (stream !== undefined) {
+ found = true;
+ lowerer.stdioPatchSites.push({ stream, loc: locOf(node) });
+ return;
+ }
+ }
+ ts.forEachChild(node, walk);
+ };
+ for (const sf of lowerer.program.getImplementationSourceFiles()) {
+ if (sf.isDeclarationFile) continue;
+ walk(sf);
+ if (found) break;
+ }
+ lowerer.patchesStdio = found;
+ return found;
+}
+
/** The pinned Node release the runtime reports as process.versions.node
* (packages/runtime/src/scr_lib.c SCR_NODE_COMPAT_VERSION, kept equal to
* internal/compatibility/node-v24.json by test/node-pin.test.ts). */
@@ -341,6 +406,26 @@ export function lowerProcessStreamProperty(
loc: locOf(expr),
};
}
+ // The write method as a VALUE in TypeScript (`const original =
+ // process.stdout.write`, `process.stdout.write.bind(process.stdout)`):
+ // the stream object's current write slot, a checked-dynamic function
+ // (the native method until a program replaces it).
+ if (member === "write" && (direct === "stdout" || direct === "stderr")) {
+ const loc = locOf(expr);
+ return {
+ kind: "dynKeyGet",
+ value: {
+ kind: "libCall",
+ fn: "process.stdioSlot",
+ args: [numLit(direct === "stdout" ? 1 : 2, loc)],
+ type: DYN,
+ loc,
+ },
+ key: strLit("write", locOf(expr.name)),
+ type: DYN,
+ loc,
+ };
+ }
// `if (process.stdin.setRawMode)`: Node's stdin has the method only
// when it is a TTY. The runtime's stream object answers the same
// presence (the method or undefined); calls keep their direct lowering.
@@ -357,7 +442,9 @@ export function lowerProcessStreamProperty(
member !== "isTTY" &&
member !== "columns" &&
member !== "rows" &&
- member !== "writableLength"
+ member !== "writableLength" &&
+ member !== "writableHighWaterMark" &&
+ member !== "writableNeedDrain"
)
return null;
let recv: ts.Expression = expr.expression;
@@ -391,19 +478,31 @@ export function lowerProcessStreamProperty(
// nothing is ever queued and Node's number is always 0. The other
// members stay keyed reads — the runtime stream answers their live
// values (isatty columns/rows, stdin readableLength).
- if (member === "writableLength") {
+ if (
+ member === "writableLength" ||
+ member === "writableHighWaterMark" ||
+ member === "writableNeedDrain"
+ ) {
// The receiver still EVALUATES: a call or property read that hands
// back the stream runs its own side effects — and throws where Node
// throws — before the constant answers, exactly as the read of the
// member would. A pure receiver (the alias above) folds away.
- const answer: IrExpr = { kind: "numLit", value: 0, type: F64, loc: locOf(expr) };
+ const answer: IrExpr =
+ member === "writableNeedDrain"
+ ? { kind: "boolLit", value: false, type: BOOL, loc: locOf(expr) }
+ : {
+ kind: "numLit",
+ value: member === "writableLength" ? 0 : 65536,
+ type: F64,
+ loc: locOf(expr),
+ };
const effect = lowerer.lowerExpr(expr.expression);
if (isSafeToDiscard(effect)) return answer;
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: effect, loc: locOf(expr.expression) }],
result: answer,
- type: F64,
+ type: answer.type,
loc: locOf(expr),
};
}
@@ -435,6 +534,16 @@ export function lowerProcessStreamProperty(
// true for the same reason). Backpressure probes read 0 and stay
// correct; the write itself never defers.
if (member === "writableLength") return { kind: "numLit", value: 0, type: F64, loc };
+ // Node's default high-water mark for the process streams, and the
+ // drain flag a synchronous write never sets.
+ if (member === "writableHighWaterMark") {
+ if (stream === "stdin") return null;
+ return { kind: "numLit", value: 65536, type: F64, loc };
+ }
+ if (member === "writableNeedDrain") {
+ if (stream === "stdin") return null;
+ return { kind: "boolLit", value: false, type: BOOL, loc };
+ }
if (member === "isTTY") {
const type = lowerer.withUndefinedArm(BOOL);
return {
@@ -569,9 +678,10 @@ export function lowerProcessProperty(
// The capability-probe members that honestly DON'T EXIST in a
// compiled binary — each reads undefined (their declared types carry
// the undefined arm), so feature probes take their documented
- // fallbacks: no gyp build config (process.config.variables.* — no ICU,
- // no QUIC — and process.config.target_defaults), no feature flags
- // (process.features.* — no inspector, not a debug build).
+ // fallbacks: no gyp build config (process.config.variables.* — no QUIC
+ // — and process.config.target_defaults; v8_enable_i18n_support is the
+ // exception below, answered from the binary's linked ICU), no feature
+ // flags (process.features.* — no inspector, not a debug build).
if (!expr.questionDotToken && ts.isPropertyAccessExpression(expr.expression)) {
const container = lowerer.stdlibGlobalMember(expr.expression, "process");
if (container === "features") {
@@ -594,6 +704,13 @@ export function lowerProcessProperty(
expr.expression.name.text === "variables" &&
lowerer.stdlibGlobalMember(expr.expression.expression, "process") === "config"
) {
+ // The one variable a compiled binary can answer truthfully:
+ // v8_enable_i18n_support. Node bundles ICU and answers the number 1;
+ // this read selects the `icu` runtime feature (scr_intl.c), so a
+ // binary that reports ICU is a binary that carries it.
+ if (expr.name.text === "v8_enable_i18n_support") {
+ return { kind: "libCall", fn: "icu.i18nSupport", args: [], type: F64, loc: locOf(expr) };
+ }
return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc: locOf(expr) };
}
}
@@ -920,6 +1037,58 @@ export function lowerProcessMethodCall(
}
return { kind: "libCall", fn: "process.stdinSetRawMode", args: [mode], type: VOID, loc };
}
+ // process.stdout/stderr.on("drain", fn) and its spellings: Node emits
+ // 'drain' only after a write() returned false, and these writes are
+ // synchronous (they never return false), so the listener can never run.
+ // Registering it is therefore observably a no-op: the receiver and the
+ // listener are evaluated for their effects and nothing is retained.
+ // listenerCount/listeners/eventNames on the process streams stay
+ // refused, so the missing registration cannot be observed.
+ if (
+ [
+ "on",
+ "once",
+ "off",
+ "addListener",
+ "removeListener",
+ "prependListener",
+ "prependOnceListener",
+ ].includes(access.name.text) &&
+ ts.isPropertyAccessExpression(access.expression) &&
+ call.arguments.nodes.length === 2 &&
+ !call.arguments.nodes.some(ts.isSpreadElement)
+ ) {
+ const stream = lowerer.stdlibGlobalMember(access.expression, "process");
+ const evT = lowerer.typeOf(call.arguments.nodes[0]!);
+ if (
+ (stream === "stdout" || stream === "stderr") &&
+ evT.isStringLiteralType() &&
+ evT.value === "drain"
+ ) {
+ const loc = locOf(call);
+ const parent = call.parent;
+ const discarded =
+ ts.isExpressionStatement(parent) || (ts.isArrowFunction(parent) && parent.body === call);
+ if (!discarded) {
+ lowerer.unsupported(
+ "SC1090",
+ call,
+ `using the result of process.${stream}.${access.name.text}("drain", ...) (register it as its own statement)`,
+ );
+ }
+ const stmts: IrStmt[] = [];
+ for (const arg of call.arguments.nodes) {
+ const value = lowerer.lowerExprExpecting(arg, DYN);
+ if (!isSafeToDiscard(value)) stmts.push({ kind: "exprStmt", expr: value, loc: value.loc });
+ }
+ // The call's value is discarded (statement position or a void arrow
+ // body), so the lowering is the evaluated arguments alone; a bare
+ // unitLit has no standalone IR value, so the no-op result is the
+ // dynamic undefined a discarded call leaves.
+ const result: IrExpr = dynUndefinedExpr(loc);
+ return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: DYN, loc };
+ }
+ }
// process.stdout.on("resize", cb) (and once/off/removeListener/
// addListener, stderr too): the terminal resize event, registered on the
// runtime's process stream object (a SIGWINCH listener that emits when a
@@ -1078,6 +1247,33 @@ export function lowerProcessMethodCall(
if (stream === "stdout" || stream === "stderr") {
const loc = locOf(call);
const args = call.arguments.nodes;
+ // In a program that replaces a process stream's write, a write call is
+ // what Node makes it: a call of the stream's CURRENT write property,
+ // with the arguments as passed (the replacement sees byte chunks,
+ // encodings and callbacks). The native method behind the slot is the
+ // same synchronous write, so an unpatched stream behaves as before.
+ if (programPatchesStdio(lowerer) && !args.some(ts.isSpreadElement)) {
+ const handle: IrExpr = {
+ kind: "libCall",
+ fn: "process.stdioSlot",
+ args: [numLit(stream === "stdout" ? 1 : 2, loc)],
+ type: DYN,
+ loc,
+ };
+ const invoke: IrExpr = {
+ kind: "dynInvoke",
+ recv: handle,
+ method: "write",
+ calleeName: ts.getTextOfNode(access),
+ args: args.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
+ type: DYN,
+ loc,
+ };
+ // The declared boolean: a replacement returning something else
+ // throws at a use that needs the boolean (types do not lie); a
+ // discarded result is not converted.
+ return resultIsDiscarded(call) ? invoke : lowerer.coerceToExpected(invoke, BOOL);
+ }
if (args.length < 1 || args.length > 3 || args.some(ts.isSpreadElement)) {
lowerer.noLowering(
`process.${stream}.write with ${args.length} arguments`,
@@ -1174,6 +1370,24 @@ export function lowerProcessMethodCall(
if (data.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(call))) {
data = { kind: "dynCheck", value: data, type: STRING, loc };
}
+ // A chunk known only at run time (`any`, or a string | Uint8Array
+ // union): the runtime dispatches on the value — a string or a
+ // Uint8Array/Buffer writes, null throws ERR_STREAM_NULL_VALUES, any
+ // other value throws ERR_INVALID_ARG_TYPE, as Node's Writable.write
+ // does before writing anything. Only the one-argument form.
+ if (
+ args.length === 1 &&
+ (data.type.kind === "dyn" || data.type.kind === "union") &&
+ (data.type.kind === "dyn" || lowerer.dynConvertible(data.type))
+ ) {
+ return {
+ kind: "libCall",
+ fn: "process.stdioWriteDyn",
+ args: [numLit(stream === "stdout" ? 1 : 2, loc), lowerer.coerceToExpected(data, DYN)],
+ type: BOOL,
+ loc,
+ };
+ }
const isBytes = data.type.kind === "bytes" && data.type.elem === "u8";
if (!isBytes && data.type.kind !== "string") {
lowerer.noLowering(
diff --git a/packages/compiler/src/frontend/lowering/builtins/v8.ts b/packages/compiler/src/frontend/lowering/builtins/v8.ts
index 989a61a3f..f95a6e6c2 100644
--- a/packages/compiler/src/frontend/lowering/builtins/v8.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/v8.ts
@@ -30,11 +30,11 @@ const HEAP_STATISTICS_FIELDS = [
];
/** `v8.getHeapStatistics()`: one memoryUsage sample (the runtime heap's
- * total and used bytes), with the machine's memory as the heap limit (the
- * runtime heap has no fixed ceiling) and the available size the limit
- * minus used bytes. V8-only figures (executable code space, malloced
- * memory, global handles, external memory, zapping) read 0, and one
- * native context exists. The record is the call site's interned HeapInfo
+ * total and used bytes, and external memory, memoryUsage's `external`),
+ * with the machine's memory as the heap limit (the runtime heap has no
+ * fixed ceiling) and the available size the limit minus used bytes.
+ * V8-only figures (executable code space, malloced memory, global handles,
+ * zapping) read 0, and one native context exists. The record is the call site's interned HeapInfo
* shape, built by a shared helper per shape. */
export function lowerV8HeapStatisticsCall(
lowerer: Lowerer,
@@ -73,6 +73,8 @@ export function lowerV8HeapStatisticsCall(
return sample(1);
case "used_heap_size":
return sample(2);
+ case "external_memory":
+ return sample(3);
case "heap_size_limit":
return limit();
case "total_available_size":
diff --git a/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts b/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts
index 765d3beae..809d5bb03 100644
--- a/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts
@@ -1,4 +1,7 @@
-import { staticFetchMembers } from "../../../compat/fetch-profile.js";
+import {
+ READABLE_STREAM_WEB_VIEW_CALLS,
+ staticFetchMembers,
+} from "../../../compat/fetch-profile.js";
import { numLit } from "../../../ir/build.js";
import { everyStmt } from "../../../ir/traverse.js";
import {
@@ -16,6 +19,7 @@ import { constituentTypes } from "../../ts7/checker.js";
import { ENTRY_NAME, type Lowerer } from "../lowerer.js";
import { stdlibGlobalNameOf } from "../surfaces.js";
import { streamWebImportOf } from "./module-bindings.js";
+import { lowerDynObjectLiteral } from "../expressions/object-literals.js";
/* The web stream classes in JavaScript sources: the globals and the
* node:stream/web exports, which are the same constructors. Each reference
@@ -92,6 +96,60 @@ export function programNamesWebStreamClass(lowerer: Lowerer): boolean {
return found;
}
+/** The classes a TypeScript source constructs through the web stream
+ * classes once it uses an operation the native streams lack. */
+const TYPED_CLASSES: ReadonlySet = new Set([
+ "ReadableStream",
+ "WritableStream",
+ "TransformStream",
+]);
+const WEB_VIEW_CALLS: ReadonlySet = new Set(READABLE_STREAM_WEB_VIEW_CALLS);
+
+/** Whether a TypeScript source of the program uses a web stream operation
+ * the native streams lack: a ReadableStream tee(), pipeTo() or
+ * pipeThrough() call, a WritableStream constructor, or a TransformStream
+ * constructor with arguments (a transformer or queuing strategies). Such a
+ * program builds its ReadableStream, WritableStream and TransformStream
+ * values with the web stream classes, as JavaScript does; a program that
+ * uses only getReader(), cancel() and the identity transform keeps the
+ * native streams. */
+export function programUsesTypedWebStreams(lowerer: Lowerer): boolean {
+ if (lowerer.typedWebStreams !== undefined) return lowerer.typedWebStreams;
+ let found = false;
+ for (const file of lowerer.program.getSourceFiles()) {
+ if (file.isDeclarationFile || isJsSourceFile(file)) continue;
+ ts.walkPreorder(file, (node) => {
+ const member = staticMemberName(node);
+ if (member !== null && WEB_VIEW_CALLS.has(member) && isReadableStreamMember(lowerer, node))
+ found = true;
+ else if (ts.isNewExpression(node)) {
+ const name = stdlibGlobalNameOf(lowerer, unparenthesized(node.expression));
+ if (
+ name === "WritableStream" ||
+ (name === "TransformStream" && (node.arguments?.nodes.length ?? 0) > 0)
+ )
+ found = true;
+ }
+ return found ? "stop" : undefined;
+ });
+ if (found) break;
+ }
+ lowerer.typedWebStreams = found;
+ return found;
+}
+
+/** The class a TypeScript `new` expression constructs through the web
+ * stream classes (programUsesTypedWebStreams), as its index in
+ * WEB_STREAM_CLASSES; null otherwise. */
+function typedWebStreamClassKindOf(lowerer: Lowerer, node: ts.Expression): number | null {
+ const expr = unparenthesized(node);
+ if (!ts.isIdentifier(expr) || isJsSourceFile(ts.getSourceFileOfNode(expr))) return null;
+ const name = streamWebImportOf(lowerer, expr) ?? stdlibGlobalNameOf(lowerer, expr);
+ if (name === null || !TYPED_CLASSES.has(name) || !programUsesTypedWebStreams(lowerer))
+ return null;
+ return CLASS_NAMES.indexOf(name);
+}
+
export function webStreamClassValue(kind: number, loc: SrcLoc): IrExpr {
return { kind: "libCall", fn: "webstream.class", args: [numLit(kind, loc)], type: DYN, loc };
}
@@ -102,13 +160,24 @@ export function lowerWebStreamClassRead(lowerer: Lowerer, node: ts.Expression):
return kind === null ? null : webStreamClassValue(kind, locOf(node));
}
-/** `new X(...args)` on one of the classes in JavaScript. */
+/** `new X(...args)` on one of the classes in JavaScript, and on
+ * ReadableStream, WritableStream and TransformStream in a TypeScript
+ * program that uses the web stream classes (programUsesTypedWebStreams).
+ * A TypeScript source's object literal argument (a source, sink,
+ * transformer or strategy) becomes a plain object with its methods. */
export function lowerWebStreamNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null {
- const kind = webStreamClassKindOf(lowerer, expr.expression);
+ const typed = webStreamClassKindOf(lowerer, expr.expression) === null;
+ const kind = typed
+ ? typedWebStreamClassKindOf(lowerer, expr.expression)
+ : webStreamClassKindOf(lowerer, expr.expression);
if (kind === null) return null;
const nodes = expr.arguments?.nodes ?? [];
if (nodes.some(ts.isSpreadElement)) return null;
const loc = locOf(expr);
+ const argument = (arg: ts.Expression): IrExpr =>
+ typed && ts.isObjectLiteralExpression(arg)
+ ? lowerDynObjectLiteral(lowerer, arg)
+ : lowerer.lowerExprExpecting(arg, DYN);
return {
kind: "libCall",
fn: "dyn.construct",
@@ -116,7 +185,7 @@ export function lowerWebStreamNew(lowerer: Lowerer, expr: ts.NewExpression): IrE
webStreamClassValue(kind, loc),
{
kind: "dynArrLit",
- elems: nodes.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
+ elems: nodes.map(argument),
type: DYN,
loc,
},
@@ -244,10 +313,29 @@ function isReadableStreamType(lowerer: Lowerer, type: ts.Type): boolean {
);
}
-/** Whether a member access resolves to a member of the stdlib
- * ReadableStream interface. */
-function isReadableStreamMember(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean {
- const member = lowerer.checker.getSymbolAtLocation(access.name);
+type MemberAccess = ts.PropertyAccessExpression | ts.ElementAccessExpression;
+
+/** The member a property access names, or an element access with a string
+ * literal key (`body.tee` and `body["tee"]`); null for any other node. */
+function staticMemberName(node: ts.Node): string | null {
+ if (ts.isPropertyAccessExpression(node)) return node.name.text;
+ if (ts.isElementAccessExpression(node)) {
+ const key = node.argumentExpression;
+ if (ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)) return key.text;
+ }
+ return null;
+}
+
+/** Whether a member access (staticMemberName) resolves to a member of the
+ * stdlib ReadableStream interface. */
+function isReadableStreamMember(lowerer: Lowerer, access: ts.Node): boolean {
+ const key = ts.isPropertyAccessExpression(access)
+ ? access.name
+ : ts.isElementAccessExpression(access)
+ ? access.argumentExpression
+ : null;
+ if (key === null) return false;
+ const member = lowerer.checker.getSymbolAtLocation(key);
if (member === undefined || !lowerer.isStdlibSymbol(member)) return false;
let owner: ts.Node | undefined = member.declarations[0]?.parent;
while (owner !== undefined && !ts.isInterfaceDeclaration(owner)) owner = owner.parent;
@@ -285,6 +373,7 @@ function programLinksFetch(functions: IrFunction[]): boolean {
* count in a program that links the fetch unit, since the value can be a
* fetch body. */
function programUsesWebStreamOnlyMembers(lowerer: Lowerer, functions: IrFunction[]): boolean {
+ if (programUsesTypedWebStreams(lowerer)) return true;
const members = staticFetchMembers().readableStreamUnsupported;
const iterates = members.has("[Symbol.asyncIterator]");
let untyped = false;
@@ -293,9 +382,10 @@ function programUsesWebStreamOnlyMembers(lowerer: Lowerer, functions: IrFunction
let found = false;
ts.walkPreorder(file, (node) => {
let receiver: ts.Type | null = null;
- if (ts.isPropertyAccessExpression(node) && members.has(node.name.text)) {
+ const member = staticMemberName(node);
+ if (member !== null && members.has(member)) {
if (isReadableStreamMember(lowerer, node)) found = true;
- else receiver = lowerer.typeOf(node.expression);
+ else receiver = lowerer.typeOf((node as MemberAccess).expression);
} else if (iterates && ts.isForOfStatement(node) && node.awaitModifier !== undefined) {
receiver = lowerer.typeOf(node.expression);
if (isReadableStreamType(lowerer, receiver)) found = true;
@@ -310,7 +400,9 @@ function programUsesWebStreamOnlyMembers(lowerer: Lowerer, functions: IrFunction
}
/** Links the web stream classes into a program whose JavaScript uses a
- * ReadableStream member the fetch unit's own stream lacks: a fetch body's
+ * ReadableStream member the fetch unit's own stream lacks, or whose
+ * TypeScript uses the web stream classes (programUsesTypedWebStreams): a
+ * fetch body's or a native stream's
* handle stands for a web ReadableStream once such a member is used
* (scr_fetch.c, sf_stream_web_view). %main installs the classes first. A
* program that names one of the classes links them already. */
diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts
index 45329d077..5170d72d2 100644
--- a/packages/compiler/src/frontend/lowering/call-signatures.ts
+++ b/packages/compiler/src/frontend/lowering/call-signatures.ts
@@ -630,6 +630,41 @@ export function checkDefaultParamBodyType(
}
}
+/** Whether a lambda's OWN signature maps to its completed ABI position for
+ * position — the contract its reads carry (mapType spells `x?: T` as the
+ * `T | undefined` slot). Two positions can escape the mapped-TYPE
+ * comparison in requireExactArityValue: a parameter the checker spells
+ * `any` holds the checked-dynamic value that mapType leaves unmapped when
+ * its spelling is context-free, and a *contextually* typed lambda's own
+ * signature need not be the slot's (a comparator with a surplus optional
+ * parameter under a two-parameter slot). */
+function lambdaSpellsCompletedAbi(
+ lowerer: Lowerer,
+ lambda: ts.ArrowFunction | ts.FunctionExpression,
+ funcType: IrType,
+): boolean {
+ if (funcType.kind !== "func" || funcType.rest === true) return false;
+ const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(lambda));
+ if (sigs.length !== 1) return false;
+ const sig = sigs[0]!;
+ const params = sig.getParameters();
+ if (params.length !== funcType.params.length) return false;
+ for (let i = 0; i < params.length; i++) {
+ const pTs = lowerer.checker.getTypeOfSymbol(params[i]!);
+ const slot = funcType.params[i]!;
+ if ((pTs.flags & ts.TypeFlags.Any) !== 0) {
+ if (slot.kind !== "dyn") return false;
+ continue;
+ }
+ const mappedParam = lowerer.mapTypeOf(pTs);
+ if (!mappedParam || !typeEquals(mappedParam, slot)) return false;
+ }
+ const retTs = lowerer.checker.getReturnTypeOfSignature(sig);
+ if ((retTs.flags & ts.TypeFlags.Any) !== 0) return funcType.ret.kind === "dyn";
+ const mappedRet = lowerer.mapTypeOf(retTs);
+ return mappedRet !== null && typeEquals(mappedRet, funcType.ret);
+}
+
/** DECISION (docs/ir.md): function VALUES carry one completed native ABI.
* Optional/default parameters appear as literal `T | undefined` slots;
* typed rest parameters appear as one trailing typed-array slot plus the
@@ -675,6 +710,20 @@ export function requireExactArityValue(
? lowerer.mapTypeOf(lowerer.typeOf(contextual))
: null;
if (mapped && typeEquals(mapped, funcType)) return;
+ // A lambda whose OWN signature already spells the completed ABI is a
+ // value: every read of it types it that way, and its slot converts it
+ // (the surplus-parameter and undefined-arm adapters). The mapped-type
+ // comparison above misses two such lambdas — one whose `any` parameter
+ // mapType leaves unmapped (`(a: any, b: any, loose?: boolean) => number`
+ // beside a two-parameter slot), and one whose signature is wider than
+ // the contextual slot it is written under.
+ if (
+ contextual !== null &&
+ (ts.isArrowFunction(contextual) || ts.isFunctionExpression(contextual)) &&
+ lambdaSpellsCompletedAbi(lowerer, contextual, funcType)
+ ) {
+ return;
+ }
// An 'any' slot (`C.prototype.m = function (a, b) {…}` through an
// untyped receiver), or a slot whose type maps to a checked-dynamic
// value (a member of a literal lowered with a generic function's type
@@ -1062,6 +1111,9 @@ export function lambdaSignature(
type: DYN,
mode: "required",
}));
+ // `arguments` holds the call's own tuple: a console line passes one
+ // or two arguments whatever the replacement declares.
+ appendArgumentsParameter(lowerer, node, shapes, "arguments");
return { shapes, funcType: funcTypeFromParamShapes(shapes, DYN) };
}
}
diff --git a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts
index d8893aa72..2e45536d5 100644
--- a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts
+++ b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts
@@ -38,6 +38,7 @@ import { isCjsRequireGlobal } from "./lower-node-module.js";
import { DYN, type IrExpr } from "../../ir/ir.js";
import { nodeThrowExpr } from "../../ir/build.js";
import { probeNodeRequireRefusal } from "../npm.js";
+import { lowerIslandRequireCall } from "./lower-node-island.js";
export interface InlineRequire {
call: ts.CallExpression;
@@ -537,6 +538,9 @@ export function lowerUncompiledRequireCall(
);
}
if (canonicalBuiltinModule(spec) !== null) return null;
+ // A package that runs in a Node island loads at the call (scope B).
+ const island = lowerIslandRequireCall(lowerer, call);
+ if (island !== null) return island;
if (!spec.startsWith(".") && !spec.startsWith("/") && !spec.startsWith("#")) {
const refusal = probeNodeRequireRefusal(sf.fileName, spec);
if (refusal !== null) {
diff --git a/packages/compiler/src/frontend/lowering/class-runtime-statics.ts b/packages/compiler/src/frontend/lowering/class-runtime-statics.ts
index 707c916ec..2041fb71c 100644
--- a/packages/compiler/src/frontend/lowering/class-runtime-statics.ts
+++ b/packages/compiler/src/frontend/lowering/class-runtime-statics.ts
@@ -100,6 +100,21 @@ export function initializeRuntimeStatics(
}
if (!member.name) continue;
const memberLoc = locOf(member);
+ // A private static field: its native global, assigned in order.
+ if (ts.isPrivateIdentifier(member.name)) {
+ const name = member.name.text;
+ const field = info.staticFields.find((candidate) => candidate.name === name);
+ if (!ts.isPropertyDeclaration(member) || !field) {
+ lowerer.unsupported("SC1090", member, "private static fields without an initializer");
+ }
+ statements.push({
+ kind: "assign",
+ localId: field.globalId,
+ value: lowerer.lowerExprExpecting(member.initializer!, field.type),
+ loc: memberLoc,
+ });
+ continue;
+ }
const key: IrExpr = ts.isComputedPropertyName(member.name)
? computedKeys.get(member)!
: lowerer.coerceToExpected(
diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts
index 44bca35e1..322cc09fa 100644
--- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts
+++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts
@@ -114,10 +114,28 @@ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): bool
return false;
}
+/** Whether an omitted parameter position completes with the native
+ * undefined value: a checked-dynamic slot takes undefined as-is, and an
+ * undefined-armed union (`x?: T`, `x: T = e`) is the completed ABI
+ * spelling. */
+export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean {
+ if (type.kind === "dyn" || typeEquals(type, UNDEFINED_T)) return true;
+ return type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0;
+}
+
/** Whether all parameters and the result adapt without a trap-only path.
* Rest signatures are excluded, except a typed-rest source in a slot with
* fixed parameters (the rest packing adapter); extra destination
- * parameters are ignored. */
+ * parameters are ignored. A source with MORE parameters than the slot is
+ * clean when every extra trailing one is omittable — the adapter passes
+ * that absent value explicitly (funcCoerceAdapter's omittedParams), the
+ * same way a call through the slot would. A parameter the slot coercion
+ * converts through a STRUCTURAL width counts (cleanParamPair), which
+ * recurses through a nested function type: a callback parameter of the
+ * value taking a narrower record where the slot's takes its `unknown` view
+ * converts the argument the same way a top-level call does. The result side
+ * likewise accepts what funcCoerceAdapter converts (cleanResult), including
+ * a promise whose payload widens. */
export function cleanFuncAdaptable(
lowerer: Lowerer,
src: IrType & { kind: "func" },
@@ -125,13 +143,35 @@ export function cleanFuncAdaptable(
): boolean {
if (restPackingAdapterFits(lowerer, src, dst)) return true;
if (src.rest === true || dst.rest === true) return false;
- if (src.params.length > dst.params.length) return false;
- for (let i = 0; i < src.params.length; i++) {
- if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false;
+ // Recursive function types (`type Handler = (h: Handler) => void`) reach
+ // the same pair again one parameter down: assume it converts, the same
+ // coinductive answer recordWidthPlan gives its own back-edges.
+ const key = `funcClean:${typeKey(src)}:${typeKey(dst)}`;
+ if (lowerer.coercions.planning.has(key)) return true;
+ lowerer.coercions.planning.add(key);
+ try {
+ for (let i = dst.params.length; i < src.params.length; i++) {
+ if (!omittableTrailingParam(lowerer, src.params[i]!)) return false;
+ }
+ for (let i = 0; i < src.params.length && i < dst.params.length; i++) {
+ if (!cleanParamPair(lowerer, dst.params[i]!, src.params[i]!)) return false;
+ }
+ return cleanResult(lowerer, src.ret, dst.ret);
+ } finally {
+ lowerer.coercions.planning.delete(key);
}
- if (dst.ret.kind === "void") return true;
- if (lowerer.coercibleValue(src.ret, dst.ret)) return true;
- return src.ret.kind === "void" && dst.ret.kind === "dyn";
+}
+
+/** Whether the slot's coercion converts the value's RESULT without a
+ * trap-only path: the dispositions funcCoerceAdapter's result side accepts
+ * (a void slot drops the value; coercibleValue; a structural width, which
+ * includes the promise payload widening and a nested function adaptation;
+ * the void-into-unknown drop). */
+function cleanResult(lowerer: Lowerer, actual: IrType, expected: IrType): boolean {
+ if (expected.kind === "void") return true;
+ if (lowerer.coercibleValue(actual, expected)) return true;
+ if (actual.kind === "void" && expected.kind === "dyn") return true;
+ return lowerer.widthLiftPlan(actual, expected) !== null;
}
/** Whether the slot's argument type `actual` converts into the function's
diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts
index ed8c6ddce..d4183d5e5 100644
--- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts
+++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts
@@ -56,6 +56,7 @@ import { typeKey } from "../../type-mapper.js";
import {
lowerArrayCallback,
requireProducedArrayElement,
+ unitOnlyArrayElement,
callbackArrayElement,
truthyFilterCallback,
} from "./callback-arguments.js";
@@ -69,6 +70,7 @@ import {
import { dynamicReceiverThrows } from "./dynamic-receivers.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
import { checkedFilterNarrowing } from "./filter-predicates.js";
+import { omittableTrailingParam } from "../coercions/function-adapters.js";
/** `a.map(fn)` / `a.filter(fn)` / `a.forEach(fn)` desugar to a direct
* call of a synthetic module function — one per method + element/result
@@ -108,7 +110,14 @@ export function lowerArrayHofCall(
}
const callback = booleanFilter
? { fnArg: truthyFilterCallback(lowerer, valueT, loc), arity: 1, unread: false }
- : lowerArrayCallback(lowerer, argNode, [valueT], arrayOf(elem));
+ : lowerArrayCallback(
+ lowerer,
+ argNode,
+ [valueT],
+ arrayOf(elem),
+ false,
+ method === "map" ? (unitOnlyArrayElement(lowerer, call) ?? undefined) : undefined,
+ );
const { fnArg, arity } = callback;
// Only an array that can hold elements outside its element type needs to
// leave an element its callback ignores unread.
@@ -659,7 +668,14 @@ export function lowerArrayUnionHofCall(
? valueArms[0]!
: { kind: "union", unionId: lowerer.unions.intern(valueArms) };
const argNode = call.arguments.nodes[0]!;
- const { fnArg, arity } = lowerArrayCallback(lowerer, argNode, [valueT], receiverT);
+ const { fnArg, arity } = lowerArrayCallback(
+ lowerer,
+ argNode,
+ [valueT],
+ receiverT,
+ false,
+ method === "map" ? (unitOnlyArrayElement(lowerer, call) ?? undefined) : undefined,
+ );
const fnRet = fnArg.type.ret;
if (method === "map" && fnRet.kind === "void") {
// `void[]` has no element (see the plain-receiver map path).
@@ -1806,10 +1822,10 @@ export function lowerArrayFlatMapCall(
/** How each arm of a flatMap callback's union result contributes to the
* output: an array arm appends its elements, an array arm the checker
* types `never[]` (an empty literal such as `s ? s : []`) appends nothing,
- * and any other arm appends itself. */
+ * a tuple arm appends its fields, and any other arm appends itself. */
interface FlatMapUnionPlan {
outElem: IrType;
- arms: ("spread" | "empty" | "value")[];
+ arms: ("spread" | "empty" | "tuple" | "value")[];
}
/** The plan for a callback returning `T | U[]`: every non-array arm must
@@ -1827,15 +1843,18 @@ function flatMapUnionPlan(
const outElem = out.elem;
const arms = lowerer.unions.get(fnRet.unionId)?.arms;
if (!arms) return null;
- // The `never[]` constituents of the callback's checked result.
+ // The constituents of the callback's checked result that can hold no
+ // element: a `never[]` arm, and the empty tuple a bare `[]` arm infers
+ // when the enclosing call has a contextual array type (`return x ? [a]
+ // : []` in a `f(): T[]`).
const emptyArms: IrType[] = [];
const signature = lowerer.checker.getCallSignatures(lowerer.typeOf(argNode))[0];
if (signature) {
const ret = lowerer.checker.getReturnTypeOfSignature(signature);
for (const part of ret.isUnionType() ? ts.constituentTypes(ret) : [ret]) {
- if (!lowerer.checker.isArrayType(part)) continue;
- const [element] = lowerer.checker.getTypeArguments(part as ts.TypeReference);
- if (element === undefined || (element.flags & ts.TypeFlags.Never) === 0) continue;
+ if (!lowerer.checker.isArrayType(part) && !lowerer.checker.isTupleType(part)) continue;
+ const element = lowerer.checker.getTypeArguments(part as ts.TypeReference)[0];
+ if (element !== undefined && (element.flags & ts.TypeFlags.Never) === 0) continue;
const mapped = lowerer.mapTypeOf(part);
if (mapped) emptyArms.push(mapped);
}
@@ -1847,12 +1866,19 @@ function flatMapUnionPlan(
if (outElem.kind !== "union") return false;
return lowerer.armTag(outElem.unionId, t) >= 0;
};
+ const tupleConverts = (arm: IrType & { kind: "record" }): boolean =>
+ (lowerer.shapes.get(arm.shapeId)?.fields ?? []).every((f) => converts(f.type));
const plan: FlatMapUnionPlan["arms"] = [];
for (const arm of arms) {
if (arm.kind === "array" && emptyArms.some((e) => typeEquals(e, arm))) plan.push("empty");
// An array arm copies its elements as they are stored, a present
// undefined included, so its element type is the result's.
else if (arm.kind === "array" && typeEquals(arm.elem, outElem)) plan.push("spread");
+ // A contextual array type makes a multi-statement body's `[pair]`
+ // infer as a one-element TUPLE (`return x ? [pair] : []`), whose
+ // fields contribute in order.
+ else if (arm.kind === "record" && lowerer.shapes.get(arm.shapeId)?.tuple && tupleConverts(arm))
+ plan.push("tuple");
else if (arm.kind !== "array" && converts(arm)) plan.push("value");
else return null;
}
@@ -1908,6 +1934,32 @@ function flatMapUnionHelper(
loc,
};
if (plan.arms[tag] === "empty") return [];
+ if (plan.arms[tag] === "tuple") {
+ const tuple = arm as IrType & { kind: "record" };
+ // The fields are stored by name ("0", "1", "10", "2", …): the
+ // elements append in index order.
+ const elements = [...(lowerer.shapes.get(tuple.shapeId)?.fields ?? [])].sort(
+ (a, b) => Number(a.name) - Number(b.name),
+ );
+ return elements.map((field) => {
+ const read: IrExpr = {
+ kind: "recordGet",
+ obj: narrowed,
+ shapeId: tuple.shapeId,
+ field: field.name,
+ type: field.type,
+ loc,
+ };
+ return arrayValueStore(
+ lowerer,
+ outRef(),
+ outLength(),
+ lowerer.coerceToExpected(read, plan.outElem),
+ plan.outElem,
+ loc,
+ );
+ });
+ }
if (plan.arms[tag] === "value") {
// A unit arm's value is the unit itself, which has no narrowed
// representation.
@@ -2642,11 +2694,15 @@ export function lowerArraySortCall(
let fnArg =
undefinedArg ?? lowerOptionalElementComparator(lowerer, argNode, unwrappedArg, elem, arrT);
// Reusable JS comparators often accept checked values. Adapt the array's
- // element ABI just as other array callbacks do before sorting.
+ // element ABI just as other array callbacks do before sorting. A
+ // comparator declaring MORE parameters than sort passes (`(a, b, loose?)`,
+ // the imported semver shape) receives the surplus ones as undefined — the
+ // completion an ordinary two-argument call performs — so it adapts to the
+ // (a, b) slot like any other wider signature.
if (
fnArg.type.kind === "func" &&
- fnArg.type.params.length <= 2 &&
- fnArg.type.params.every((param) => lowerer.coercibleValue(elem, param))
+ fnArg.type.params.slice(2).every((param) => omittableTrailingParam(lowerer, param)) &&
+ fnArg.type.params.slice(0, 2).every((param) => lowerer.coercibleValue(elem, param))
) {
// Arithmetic on inferred JS values retains a checked result because
// it can produce BigInt. The sort callback still requires a number.
@@ -2655,7 +2711,7 @@ export function lowerArraySortCall(
? F64
: fnArg.type.ret;
const expected = funcOf(
- fnArg.type.params.map(() => elem),
+ fnArg.type.params.slice(0, 2).map(() => elem),
result,
);
if (!typeEquals(fnArg.type, expected)) fnArg = lowerer.coerceToExpected(fnArg, expected);
diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts
index 2a3ad64c0..e286a02b8 100644
--- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts
+++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts
@@ -56,6 +56,8 @@ import {
import {
requireProducedArrayElement,
lowerArrayCallback,
+ lowerArrayCallbackAnswering,
+ unitOnlyArrayElement,
callbackArrayElement,
} from "./callback-arguments.js";
import { classGeneratorIterable } from "../class-iterables.js";
@@ -1002,6 +1004,8 @@ export function lowerArrayFromCall(
args[1]!,
[input.element],
arrayOf(input.element),
+ false,
+ unitOnlyArrayElement(lowerer, call) ?? undefined,
);
if (callback.arity > 2)
lowerer.noLowering("Array.from mapper with an array parameter", args[1]!);
@@ -1123,7 +1127,14 @@ export function lowerArrayFromCall(
if (paramSymbol) lowerer.runtimeOptionalBindingTypes.set(paramSymbol, valueT);
let callback: ReturnType;
try {
- callback = lowerArrayCallback(lowerer, mapper, [valueT], arrT);
+ callback = lowerArrayCallback(
+ lowerer,
+ mapper,
+ [valueT],
+ arrT,
+ false,
+ unitOnlyArrayElement(lowerer, call) ?? undefined,
+ );
} finally {
if (paramSymbol) {
if (previous === undefined) lowerer.runtimeOptionalBindingTypes.delete(paramSymbol);
@@ -1162,7 +1173,14 @@ export function lowerArrayFromCall(
// mapper that writes the source changes later reads, as in Node),
// so this is a loop over the typed array, not a toArray snapshot.
if (src.type.kind === "bytes" && args.length === 2) {
- const callback = lowerArrayCallback(lowerer, args[1]!, [F64], arrayOf(F64));
+ const callback = lowerArrayCallback(
+ lowerer,
+ args[1]!,
+ [F64],
+ arrayOf(F64),
+ false,
+ unitOnlyArrayElement(lowerer, call) ?? undefined,
+ );
if (callback.arity > 2)
lowerer.noLowering("Array.from mapper with an array parameter", args[1]!);
const fnRet = callback.fnArg.type.ret;
@@ -1245,7 +1263,7 @@ export function lowerArrayFromCall(
);
}
if (n.type.kind !== "f64") lowerer.badType(args[0]!, lowerer.typeOf(args[0]!));
- const fnArg = lowerer.lowerExpr(args[1]!);
+ const fnArg = lowerArrayCallbackAnswering(lowerer, args[1]!, unitOnlyArrayElement(lowerer, call));
// The mapper may declare any prefix of (v, i): v is the checker's own
// `unknown` (Node passes undefined there — the dyn undefined singleton
// here), i the index. The result type must be a legal array element.
diff --git a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts
index 3b504861a..80b34bd86 100644
--- a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts
+++ b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts
@@ -306,6 +306,42 @@ function readsParameter(argNode: ts.Expression, index: number): boolean {
);
}
+/** The result array's element when it is the unit-only union (`undefined[]`,
+ * the sparse-literal/`Array.from(..., () => undefined)` shape): a callback
+ * that answers `undefined` stores a PRESENT unit element, so the producer
+ * gives the callback that union as its contextual return. The standalone
+ * `undefined` in return position otherwise maps to the void convention,
+ * whose answer is erased before it could be stored. Null for other elements. */
+export function unitOnlyArrayElement(lowerer: Lowerer, call: ts.CallExpression): IrType | null {
+ const result = lowerer.mapTypeOf(lowerer.typeOf(call));
+ if (result?.kind !== "array") return null;
+ const elem = result.elem;
+ if (isUnitType(elem)) return elem;
+ return elem.kind === "union" &&
+ lowerer.unions.get(elem.unionId)?.arms.every((arm) => isUnitType(arm)) === true
+ ? elem
+ : null;
+}
+
+/** Lower a callback whose answer becomes an array element, with the result
+ * element as its contextual return when that element is the unit-only union
+ * (see unitOnlyArrayElement). Other producers lower the callback directly. */
+export function lowerArrayCallbackAnswering(
+ lowerer: Lowerer,
+ fnNode: ts.Expression,
+ expected: IrType | null,
+): IrExpr {
+ if (expected === null) return lowerer.lowerExpr(fnNode);
+ const previous = lowerer.contextualFunctionReturns.get(fnNode);
+ lowerer.contextualFunctionReturns.set(fnNode, expected);
+ try {
+ return lowerer.lowerExpr(fnNode);
+ } finally {
+ if (previous === undefined) lowerer.contextualFunctionReturns.delete(fnNode);
+ else lowerer.contextualFunctionReturns.set(fnNode, previous);
+ }
+}
+
/** Preserve the checker-selected payload ABI when a callback's runtime
* return gained only undefined. The array state byte carries that value. */
export function callbackArrayElement(
diff --git a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts
index ba8fb9d53..421d703d1 100644
--- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts
+++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts
@@ -163,7 +163,13 @@ function conversionBody(
target.indexValue
)
return null;
- if (source.fields.length !== target.fields.length) return null;
+ // A written predicate usually names FEWER members than the receiver's
+ // element has (`(c): c is { text: string } => ...` over a block with a
+ // `textSignature` and a `type`): the target's members are a subset of
+ // the source's, each read and converted, and the copy keeps the
+ // source's keys in the source's order. A member the source lacks has
+ // no slot to read, so it stays refused.
+ if (target.fields.length > source.fields.length) return null;
const fields: { name: string; value: IrExpr }[] = [];
for (const field of target.fields) {
const own = source.fields.find((f) => f.name === field.name);
@@ -216,7 +222,13 @@ function keepAbsence(
}
/** A union value to one of its arms, or to a union of some of its arms:
- * each arm the target holds returns, any other arm throws. */
+ * each arm the target holds returns, any other arm throws.
+ *
+ * A written predicate may name a structural type that is no arm at all
+ * (`(c): c is { text: string; type: string } => ...` over the block union
+ * above, whose text arm also carries `textSignature`): when no arm matches
+ * exactly, each arm the target can be read from converts by its own shape,
+ * tag by tag, and an arm with no such read throws. */
function unionConversion(
lowerer: Lowerer,
input: IrExpr,
@@ -279,7 +291,10 @@ function unionConversion(
// Every arm the target names must be one of the union's own arms, or a
// record target converts from at least one arm. A record target that is
// itself an arm keeps that arm's elements as they are, and the arms with
- // its fields convert.
+ // its fields convert. Any other structural target that is no arm converts
+ // from each arm it can be read from.
+ if (kept === 0 && recordArmConversions === 0 && to.kind !== "union")
+ return structuralUnionConversion(lowerer, input, unionId, to, typeText, loc);
if (kept === 0 ? recordArmConversions === 0 : kept !== targetArms.length) return null;
body.push(
typeError(
@@ -289,3 +304,42 @@ function unionConversion(
);
return body;
}
+
+/** A union value to a structural type that is no arm: each arm the target
+ * can be read from returns its converted value, any other arm throws. */
+function structuralUnionConversion(
+ lowerer: Lowerer,
+ input: IrExpr,
+ unionId: string,
+ to: IrType,
+ typeText: string,
+ loc: SrcLoc,
+): IrStmt[] | null {
+ const source = lowerer.unions.get(unionId);
+ if (!source) return null;
+ const body: IrStmt[] = [];
+ let kept = 0;
+ for (let tag = 0; tag < source.arms.length; tag++) {
+ const arm = source.arms[tag]!;
+ if (isUnitType(arm)) continue;
+ const payload: IrExpr = { kind: "unionNarrow", unionId, tag, value: input, type: arm, loc };
+ const value = converted(lowerer, payload, arm, to, loc);
+ if (value === null) continue;
+ kept++;
+ body.push({
+ kind: "if",
+ cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc },
+ then: [{ kind: "return", value, loc }],
+ else_: null,
+ loc,
+ });
+ }
+ if (kept === 0) return null;
+ body.push(
+ typeError(
+ `a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`,
+ loc,
+ ),
+ );
+ return body;
+}
diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts
index 92a724b6b..ad8f019b9 100644
--- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts
+++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts
@@ -1,3 +1,4 @@
+import { lowerWrapperToObject, programCreatesWrappers } from "../primitive-wrappers.js";
import { keyedHelperName } from "../stable-names.js";
import { nullProtoRecordShape, recordNullProtoTest } from "../null-prototype.js";
import { countedFor, numLit, varRef } from "../../../ir/build.js";
@@ -50,6 +51,13 @@ export function lowerObjectConstructor(
}
if (args.length !== 1 || ts.isSpreadElement(args[0]!)) return null;
const value = lowerer.lowerExpr(args[0]!);
+ // ToObject of a value that may be a primitive, once the program creates
+ // wrapper objects (programCreatesWrappers counts this very call).
+ if (
+ ["dyn", "string", "f64", "bool"].includes(value.type.kind) &&
+ (lowerer.createsWrappers ??= programCreatesWrappers(lowerer))
+ )
+ return lowerWrapperToObject(lowerer, lowerer.coerceToExpected(value, DYN), loc);
if (value.kind === "dynObjLit" || value.kind === "dynArrLit") return value;
if (
[
diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts
index e20604f92..48ea9c490 100644
--- a/packages/compiler/src/frontend/lowering/expressions/binary.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts
@@ -1,4 +1,5 @@
/* Binary operators: arithmetic, comparisons and equality, logical and nullish pairs, and string conversion for concatenation. */
+import { lowerStaticPrivateAssignment } from "../private-statics.js";
import { keyedHelperName } from "../stable-names.js";
import * as ts from "../../ts7/adapter.js";
import {
@@ -61,7 +62,7 @@ import { classToStringConversion, classUnionToString } from "../class-to-primiti
import { isPerfEntryType, perfEntryTag } from "../builtins/performance.js";
import { isX509Type, x509ToString } from "../builtins/x509.js";
import { guardNullProto, nullProtoRecordShape } from "../null-prototype.js";
-import { numLit, varRef } from "../../../ir/build.js";
+import { boolLit, numLit, varRef } from "../../../ir/build.js";
import { lowerUrlAssignment } from "../builtins/url.js";
import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js";
import { lowerShortCircuitAssignment } from "./nullish-assignment.js";
@@ -81,7 +82,7 @@ import {
runtimeConstructorReceiver,
} from "../builtins/runtime-constructor.js";
import { tryLowerIndexedComparison } from "../indexed-comparison.js";
-import { lowerUnionEquality, tagEqualityMayMissAlias } from "../strict-equality.js";
+import { lowerUnionEquality, sameNamespace, tagEqualityMayMissAlias } from "../strict-equality.js";
import { withUnitArm } from "../../type-mapper.js";
import { npmStaticPackageOfPath } from "../../npm-static.js";
import { lowerWebAssemblyTypeof } from "../builtins/webassembly.js";
@@ -632,7 +633,11 @@ export function lowerNativeFunctionAssignment(
const classType = storedClassValueType(lowerer, target.expression);
const staticClass =
classType?.kind === "classval" ? lowerer.classes.get(classType.className) : undefined;
- if (staticClass && hasRuntimeStatics(staticClass)) {
+ if (
+ staticClass &&
+ hasRuntimeStatics(staticClass) &&
+ !(ts.isPropertyAccessExpression(target) && ts.isPrivateIdentifier(target.name))
+ ) {
return lowerDynMemberAssignment(
lowerer,
expr,
@@ -1110,6 +1115,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
if (op === ts.SyntaxKind.EqualsToken) {
const prototypeWrite = lowerClassPrototypeComputedAssignment(lowerer, expr);
if (prototypeWrite) return prototypeWrite;
+ const staticPrivateWrite = lowerStaticPrivateAssignment(lowerer, expr);
+ if (staticPrivateWrite) return staticPrivateWrite;
const callableWrite = lowerNativeFunctionAssignment(lowerer, expr);
if (callableWrite) return callableWrite;
const dereferenced = lowerDereferencedElementAssignment(lowerer, expr);
@@ -1656,9 +1663,11 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
const shape = type.kind === "record" ? lowerer.shapes.get(type.shapeId) : undefined;
return !!shape && (shape.fields.length > 0 || shape.indexValue !== undefined);
};
+ // A builtin function boxes as its checked twin, the value its
+ // module handle answers, so the two compare equal.
const boxed: IrExpr = {
kind: "dynFrom",
- value: scalarSide,
+ value: lowerer.checkedBuiltinValue(scalarSide),
type: DYN,
loc: scalarSide.loc,
...(reference.kind === "record" ||
@@ -1677,13 +1686,16 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
loc,
};
}
- // EventEmitter or a stream class against an untyped value in
- // JavaScript: the untyped value can hold the class's untyped
- // constructor (coerceToExpected), which compares by identity.
+ // EventEmitter, a stream class or a node:path family against an
+ // untyped value in JavaScript: the untyped value can hold the class's
+ // untyped constructor or the module object (coerceToExpected), which
+ // compare by identity.
if (
dynSide.type.kind === "dyn" &&
isJsSourceFile(expr.getSourceFile()) &&
- ((scalarSide.type.kind === "moduleNs" && scalarSide.type.moduleId === "builtin:events") ||
+ ((scalarSide.type.kind === "moduleNs" &&
+ (scalarSide.type.moduleId === "builtin:events" ||
+ scalarSide.type.moduleId.startsWith("builtin:path"))) ||
(scalarSide.kind === "classRef" && builtinClassKind(scalarSide.className) !== null))
) {
const boxed = lowerer.coerceToExpected(scalarSide, DYN);
@@ -2627,6 +2639,8 @@ export function lowerStrictEquality(
};
}
if (idLeft.type.kind === "moduleNs" && idRight.type.kind === "moduleNs") {
+ if (idLeft.kind === "moduleNsRef" && idRight.kind === "moduleNsRef")
+ return boolLit(sameNamespace(lowerer, idLeft.moduleId, idRight.moduleId) !== negated, loc);
return {
kind: "bin",
op: negated ? "!==" : "===",
@@ -3007,6 +3021,44 @@ function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrE
loc,
};
}
+ // `u || d` whose checker result is one of u's own arms while u keeps
+ // arms flow narrowing ruled out (`typeof o === "string" ? ... : o ||
+ // {}` leaves the string arm in the stored union). Test u in its own
+ // union and extract the result arm through the checked narrowing: a
+ // value of a ruled-out arm traps rather than masquerading as the
+ // result type.
+ if (op === ts.SyntaxKind.BarBarToken && left.type.kind === "union") {
+ const def = lowerer.unions.get(left.type.unionId);
+ if (
+ target !== null &&
+ def !== undefined &&
+ def.arms.some(isUnitType) &&
+ lowerer.armTag(left.type.unionId, target) >= 0
+ ) {
+ const helper = lowerer.narrowedArmHelper(left.type.unionId, target, loc);
+ if (helper) {
+ lowerer.requireTruthyUnion(left.type.unionId, expr);
+ const stmts: IrStmt[] = [];
+ let stable: IrExpr = left;
+ if (!isSafeToRepeat(left)) {
+ const local = lowerer.declareHiddenLocal("%or", left.type);
+ stmts.push({ kind: "varDecl", localId: local.id, init: left, loc });
+ stable = varRef(local.id, left.type, loc);
+ }
+ const result: IrExpr = {
+ kind: "ternary",
+ cond: lowerer.ensureBool(stable, expr.left),
+ then: { kind: "call", callee: helper, args: [stable], type: target, loc },
+ else_: lowerer.lowerExprExpecting(expr.right, target),
+ type: target,
+ loc,
+ };
+ return stmts.length === 0
+ ? result
+ : { kind: "seqExpr", stmts, result, type: target, loc };
+ }
+ }
+ }
lowerer.unsupported(
"SC1090",
expr,
diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts
index 5cb9776f6..1ce873853 100644
--- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts
@@ -1534,6 +1534,20 @@ export function lowerRecordKeyRead(
recordKeyResultOk(lowerer, shape, DYN)
)
declared = DYN;
+ // A `keyof` read over a record that holds an 'unknown' member: the
+ // checker's answer collapses to 'unknown' (the union of every field
+ // type absorbs into it), so there is no typed join to dispatch into.
+ // Each declared field still reads through its checked-dynamic slot —
+ // the very value the checker's 'unknown' can hold — so dispatch per
+ // declared key and answer a checked-dynamic read. The key type is the
+ // record's own `keyof`, so the answer is present whenever the read is.
+ if (
+ !declared &&
+ (lowerer.typeOf(expr).flags & ts.TypeFlags.Unknown) !== 0 &&
+ !shape.indexValue &&
+ recordKeyResultOk(lowerer, shape, DYN)
+ )
+ declared = DYN;
if (!declared) {
lowerer.unsupported(
"SC1090",
diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts
index ebe7e2556..a790f57a6 100644
--- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts
@@ -1,5 +1,5 @@
/* Identifier references in expression position: lowerExprInner's identifier branch and its helpers. */
-import { globalMemberValue } from "../global-values.js";
+import { globalMemberValue, tsUntypedGlobalValue } from "../global-values.js";
import { predeclareForwardClass } from "../lower-stmts.js";
import { cjsMemberAliasValueOf } from "../cjs-exports-object.js";
import { builtinClassValue, builtinMemberClassKind } from "../builtins/builtin-class-dyn.js";
@@ -96,6 +96,15 @@ import { mixinFnOfCallee } from "../lower-mixins.js";
import { isNavigatorGlobal, navigatorValue } from "../builtins/navigator.js";
import { moduleFileName } from "../import-meta.js";
+/** A JavaScript binding with checked-dynamic storage because the program
+ * stores values outside its declared type (analyzeJsWidenedBindings): the
+ * checker's narrowing at a read does not describe the stored value. */
+function isJsWidenedBinding(lowerer: Lowerer, expr: ts.Identifier): boolean {
+ if (lowerer.jsWidenedBindings.size === 0) return false;
+ const symbol = lowerer.resolveValueSymbol(expr);
+ return symbol !== null && symbol !== undefined && lowerer.jsWidenedBindings.has(symbol);
+}
+
export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: SrcLoc): IrExpr {
if (lowerer.isStdlibGlobal(expr, "console")) {
return { kind: "libCall", fn: "console.native", args: [], type: DYN, loc };
@@ -286,6 +295,8 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
}
return { kind: "varRef", localId: local.id, type: local.type, loc };
}
+ if (local.type.kind === "dyn" && isJsWidenedBinding(lowerer, expr))
+ return { kind: "varRef", localId: local.id, type: local.type, loc };
const narrowed = lowerer.maybeNarrow(
{ kind: "varRef", localId: local.id, type: local.type, loc },
expr,
@@ -401,6 +412,7 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
if (use?.kind === "call") return runtimeOptionalReceiverRead(lowerer, expr, g, null, loc);
return ref;
}
+ if (g.type.kind === "dyn" && isJsWidenedBinding(lowerer, expr)) return ref;
return lowerer.maybeNarrow(ref, expr);
}
// `undefined` — tsc's intrinsic global (a local shadowing the name
@@ -825,6 +837,8 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
if (isJsSourceFile(ts.getSourceFileOfNode(expr))) {
return { kind: "strLit", value: `[builtin ${canonical}]`, type: STRING, loc };
}
+ const untyped = tsUntypedGlobalValue(lowerer, expr, canonical, loc);
+ if (untyped !== null) return untyped;
// The families with a WHY: each hint states what makes the
// surface genuinely non-static (or what to use instead).
const globalHints: Record = {
@@ -832,7 +846,7 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
"Proxy constructor values have no native lowering; use a direct new Proxy with checked-native plain objects",
Reflect:
"reflective property access has no static lowering — read and call members directly",
- Intl: 'native Intl supports default Unicode grapheme segmentation and the composed new Intl.NumberFormat("en-US").format(x); locale negotiation and other ICU-backed operations remain unsupported',
+ Intl: 'native Intl supports Intl.Segmenter with grapheme or word granularity and the composed new Intl.NumberFormat("en-US").format(x), x.toLocaleString("en-US") and x.toLocaleString(); formatter objects, locale negotiation and the other Intl constructors remain unsupported',
SharedArrayBuffer: "use direct new SharedArrayBuffer(length) for fixed shared storage",
ArrayBuffer:
"ArrayBuffer constructor values have no native lowering; use new ArrayBuffer(length) for fixed-length shared storage",
@@ -1041,6 +1055,27 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
);
}
}
+ // A `.js` READ of a name NOTHING declares. Node resolves such a name at
+ // run time: the access throws the catchable `ReferenceError: is not
+ // defined` (a `typeof` test lowers earlier, in expressions/typeof.ts, and
+ // answers "undefined" — exactly Node's rule), so the read compiles to that
+ // throw and never yields a value. The value is checked-dynamic (the
+ // checker's error-any), which every use site carries or fences — and the
+ // read always unwinds before any of them observes it. A WRITE to the same
+ // name is not a read: it reaches rejectUnresolved and its SC1091 story.
+ // Typed sources keep the compile error (preflight's JS relaxation is
+ // `isJsSourceFileName`-gated, so no TS file reaches this branch).
+ // In shorthand position (`{ x }`) `resolveValueSymbol` answers the
+ // PROPERTY symbol, so the declaration test there is the checker's
+ // shorthand-VALUE symbol.
+ const parent = expr.parent;
+ const undeclared =
+ parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === expr
+ ? lowerer.checker.getShorthandAssignmentValueSymbol(parent) === undefined
+ : lowerer.resolveValueSymbol(expr) === null;
+ if (undeclared && isJsSourceFile(ts.getSourceFileOfNode(expr))) {
+ return nsUndefRead(lowerer, expr.text, expr, contextualUndefReadType(lowerer, expr) ?? DYN);
+ }
// Preflight guarantees no unresolved identifiers; anything else here
// is a blocked declaration's binding (the SC2004 cascade) or a
// binding form we don't model yet.
diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts
index a3d2a5b11..7947f2830 100644
--- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts
@@ -47,6 +47,7 @@ import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.
import { builtinGlobalValue } from "./property-access.js";
import { holdsEvolvingArray } from "../lower-stmts.js";
import { builtinNamespaceValue } from "../builtin-namespaces.js";
+import { tsUntypedGlobalValue } from "../global-values.js";
/** `{ a: 1, b: "x" }` → recordLit. The record type comes from the
* contextual type when tsc has one (annotated declarations, arguments,
@@ -446,8 +447,11 @@ export function lowerDynObjectLiteral(
// A PURE member read a JS file cannot lower (a namespace object
// in an export aggregate): the slot takes a boxed fence closure —
// the diagnostics defer to the runtime-fence ledger, the object
- // builds, and only USING the member stops the run.
- const pureMember = ts.isIdentifier(valueExpr);
+ // builds, and only USING the member stops the run. A TypeScript
+ // source keeps the compile-time refusal: the closure would answer
+ // typeof "function" and undefined members where Node reads the
+ // value.
+ const pureMember = ts.isIdentifier(valueExpr) && isJsSourceFile(ts.getSourceFileOfNode(prop));
if (!(err instanceof PoisonError) || !pureMember) throw err;
const fence = lowerer.deferToRuntimeFence(propDiagsBefore, prop, {
kind: "closure",
@@ -2562,7 +2566,7 @@ export function lowerObjectLiteral(
) {
throw err;
}
- lowerer.runtimeFences.push(...lowerer.diags.splice(propDiagsBefore));
+ for (const d of lowerer.diags.splice(propDiagsBefore)) lowerer.runtimeFences.push(d);
droppedNames.add(name);
continue;
}
@@ -3540,7 +3544,8 @@ function shorthandBuiltinValue(
const name = ts.symbolName(symbol);
return (
(js ? builtinNamespaceValue(lowerer, name, locOf(prop.name)) : null) ??
- builtinGlobalValue(name, locOf(prop.name), js)
+ builtinGlobalValue(name, locOf(prop.name), js) ??
+ tsUntypedGlobalValue(lowerer, prop.name as ts.Identifier, name, locOf(prop.name))
);
}
diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts
index 104d85a80..717fd8c66 100644
--- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts
@@ -1,5 +1,4 @@
import { isNavigatorGlobal } from "../builtins/navigator.js";
-import { isSafeToDiscard } from "./evaluation-safety.js";
import { completeFuncValueArgs } from "../call-arguments.js";
import { dynUndefinedExpr } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
@@ -449,24 +448,20 @@ export function lowerOptionalChain(
// directly (no member dispatch exists to re-enter).
if (dotNode === expr && ts.isCallExpression(expr)) {
if (narrowed.kind !== "func") lowerer.badType(recvNode, lowerer.typeOf(recvNode));
- const params = narrowed.params;
- // Forwarded fixed tuples (`f?.(...args)`) complete into the slots.
- if (expr.arguments.nodes.some(ts.isSpreadElement)) {
- const completed = completeFuncValueArgs(lowerer, expr, narrowed, loc);
- const call: IrExpr = {
- kind: "callValue",
- callee: recvRef,
- args: completed,
- type: narrowed.ret,
- loc,
- };
- return lowerer.finishOptionalChain(expr, id, receiver, call, loc);
- }
- const args = expr.arguments.nodes.map((a, i) => lowerer.lowerExprExpecting(a, params[i]));
- // Effect-free surplus arguments (a dropped `void` slot) are ignored.
- while (narrowed.rest !== true && args.length > params.length && isSafeToDiscard(args.at(-1)!))
- args.pop();
- const body: IrExpr = { kind: "callValue", callee: recvRef, args, type: narrowed.ret, loc };
+ // The same completed function-value ABI an unguarded call uses: an
+ // omitted optional argument still reaches the callee as undefined
+ // (`cb?.onRetryFinished?.(false, attempt)` must see its third
+ // parameter), a forwarded tuple completes into the slots, and an
+ // effect-free surplus argument is ignored. The completion runs INSIDE
+ // the guard, so a nullish callee still evaluates no argument.
+ const completed = completeFuncValueArgs(lowerer, expr, narrowed, loc);
+ const body: IrExpr = {
+ kind: "callValue",
+ callee: recvRef,
+ args: completed,
+ type: narrowed.ret,
+ loc,
+ };
return lowerer.finishOptionalChain(expr, id, receiver, body, loc);
}
diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts
index 3bcaf1013..720d1f9e9 100644
--- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts
@@ -1,4 +1,5 @@
import { widensReducedRecords } from "../function-returns.js";
+import { lowerStaticPrivateRead } from "../private-statics.js";
import { widthNarrowHelper } from "../coercions/unions.js";
/* Property reads (`a.b`): lowerExprInner's property-access branch, intrinsic and union properties, field reads and targets, and match-group reads. */
import { jsSpecialGlobalValue } from "../global-values.js";
@@ -344,6 +345,12 @@ export function lowerPropertyAccessExpr(
): IrExpr {
const arrayClassConstructor = lowerArrayClassConstructorRead(lowerer, expr, loc);
if (arrayClassConstructor !== null) return arrayClassConstructor;
+ // `R.#x` for a static private on a checked-dynamic receiver: the brand
+ // check, then the native storage (private-statics.ts).
+ {
+ const staticPrivate = lowerStaticPrivateRead(lowerer, expr, loc);
+ if (staticPrivate) return staticPrivate;
+ }
// `value.#x` on a checked-dynamic value, or on a checked extraction of
// one: the field of the brand-checked receiver (checkedPrivateValue).
if (ts.isPrivateIdentifier(expr.name)) {
@@ -1617,6 +1624,19 @@ export function lowerPropertyAccessExpr(
}
const asserted = assertedMemberRead(lowerer, expr.expression, expr.name.text, recvLowered, loc);
if (asserted) return asserted;
+ // Lowering erases flow narrowing, so a checker-NARROWED receiver keeps
+ // the wider union it stores (`stored?.type` after the earlier arm
+ // returned, `(entry as { role: string }).role`): the checker's type
+ // names one record while the value still carries its siblings. Read the
+ // member off whichever arm the value holds — an arm that does not
+ // declare it answers undefined, exactly Node's get — and narrow the
+ // answer to the checker's type, which traps on an arm the checker's
+ // narrowing ruled out (types do not lie). Reaching this point means no
+ // lowering above claimed the read, so only refusals gain a lowering.
+ if (recvLowered.type.kind === "union") {
+ const read = lowerUnionValueRead(lowerer, expr, recvLowered, true);
+ if (read) return lowerer.maybeNarrow(read, expr);
+ }
// A checker-`any` receiver whose value is a FRESH typed result of an
// earlier step in the chain (`out.split(" ").length`, where split
// extracted the string and answered a native string[]): the member read
@@ -3532,9 +3552,21 @@ export function lowerUnionProperty(
expr: ts.PropertyAccessExpression,
): IrExpr | null {
if (lowerer.chainBlocked(expr)) return null;
- const receiverIr =
+ const spelled =
structuralReadType(lowerer, expr.expression) ??
lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
+ // Inside an optional-chain body the guarded receiver is checker-typed by
+ // its non-nullish narrowing — often one record of a discriminated union
+ // — while the bound chain value still carries the whole present union
+ // (`stored?.type` after an earlier `stored?.type === "oauth"` return
+ // narrowed `stored` to `ApiKeyCredential`, though the runtime-optional
+ // storage still holds `ApiKeyCredential | OAuthCredential`). The union
+ // reader dispatches on the runtime tag, so take the union the chain
+ // binding hands to `lowerExpr` below instead of the spelled narrowing.
+ const chainRecv = lowerer.chainRecvByNode.get(expr.expression);
+ const chainUnion =
+ spelled?.kind !== "union" && chainRecv?.type.kind === "union" ? chainRecv.type : null;
+ const receiverIr = chainUnion ?? spelled;
// The checker can rule the receiver UNREACHABLE and the assertion that
// escapes it (`const x: never = event; (event as any).type` — the
// `default:` branch of a switch that exhausts every arm) erases to
@@ -3568,6 +3600,16 @@ export function lowerUnionProperty(
// extract the instance and test its class, as typed slots do.
else value = narrowStoredClassValue(lowerer, value, receiverIr) ?? value;
}
+ // The spelled type is a narrowing, so every path below (dyn, record,
+ // object, array) was unreachable for this read before it took the stored
+ // union: answer it only when the union reader can, and otherwise hand the
+ // read back to the lowering the spelled type used to reach. A stored arm
+ // the checker ruled out that has no such member (`m1?.name` where the
+ // stored union still carries a `string` arm) must not turn an accepted
+ // program into a refusal.
+ if (chainUnion !== null) {
+ return value.type.kind === "union" ? lowerUnionValueRead(lowerer, expr, value) : null;
+ }
// A checker-union receiver whose VALUE lowered to a plain RECORD (the
// merged-signature fiction — `runner(cmd, args)` where runner joined
// a structural runner type with spawnSync's, and the local adopted
@@ -3642,8 +3684,33 @@ export function lowerUnionProperty(
"shared property access without a represented union receiver",
);
}
- const def = lowerer.unions.get(value.type.unionId);
- if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${value.type.unionId}`);
+ const read = lowerUnionValueRead(lowerer, expr, value);
+ if (read) return read;
+ lowerer.unsupported(
+ "SC1090",
+ expr,
+ `reading '${expr.name.text}' on a union-typed value (every arm must be an object/record ` +
+ `with a same-typed field '${expr.name.text}'; ` +
+ `${NARROW_FIRST})`,
+ );
+}
+
+/** The member read itself on a lowered UNION value: `lowerUnionProperty`
+ * after it has produced the value (retagged to the checker's union when
+ * the checker removed arms) and the dispatcher's narrowed-receiver
+ * fallback, which reaches the same read with the value a checker-narrowed
+ * receiver still stores. Returns null when no arm can answer — the caller
+ * owns the diagnostic. */
+export function lowerUnionValueRead(
+ lowerer: Lowerer,
+ expr: ts.PropertyAccessExpression,
+ value: IrExpr,
+ missingFieldAsUndefined = false,
+): IrExpr | null {
+ if (value.type.kind !== "union") return null;
+ const unionId = value.type.unionId;
+ const def = lowerer.unions.get(unionId);
+ if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`);
const field = expr.name.text;
if (field === "length") {
const presentArms = def.arms.filter((arm) => !isUnitType(arm));
@@ -3675,6 +3742,42 @@ export function lowerUnionProperty(
};
}
}
+ // A union of arrays and strings (`Model[] | ModelRef[]`,
+ // `string[] | Buffer[]`): every arm answers its own length, so the
+ // read dispatches on the tag. Node's `.length` is a number for both,
+ // and no arm can be absent here (a unit arm would have to throw on
+ // this read, which the checker's narrowing already excludes).
+ if (
+ def.arms.length > 1 &&
+ def.arms.every((arm) => arm.kind === "array" || arm.kind === "string")
+ ) {
+ return perArmUnionRead(
+ lowerer,
+ expr,
+ unionId,
+ value,
+ F64,
+ "%unionLength",
+ (arm, _tag, receiver) =>
+ arm.kind === "string"
+ ? {
+ kind: "strIntrinsic",
+ method: "length",
+ receiver,
+ args: [],
+ type: F64,
+ loc: locOf(expr),
+ }
+ : {
+ kind: "arrIntrinsic",
+ method: "length",
+ receiver,
+ args: [],
+ type: F64,
+ loc: locOf(expr),
+ },
+ );
+ }
}
const nonUnitArms = def.arms.filter((arm) => !isUnitType(arm));
if (nonUnitArms.length === 1 && nonUnitArms[0]?.kind === "object") {
@@ -3688,11 +3791,7 @@ export function lowerUnionProperty(
field,
);
if (receiver === null) {
- const helper = lowerer.narrowedArmHelper(
- value.type.unionId,
- objectType,
- locOf(expr.expression),
- );
+ const helper = lowerer.narrowedArmHelper(unionId, objectType, locOf(expr.expression));
if (helper !== null)
receiver = lowerer.checkedNarrowCall(helper, value, objectType, locOf(expr.expression));
}
@@ -3708,6 +3807,14 @@ export function lowerUnionProperty(
}
}
}
+ // A member the arms answer through an ACCESSOR (a getter inherited
+ // from a shared base class — `renderer.hasOverlayEntries` on
+ // `FullscreenRenderer | RegularRenderer`): there is no field slot
+ // `unionDisc` could read, so dispatch on the tag and call each arm's
+ // own getter. Fires only when some arm's answer is an accessor, so
+ // data-field unions keep their `unionDisc` lowering.
+ const accessorRead = lowerUnionAccessorRead(lowerer, expr, unionId, value);
+ if (accessorRead) return accessorRead;
let common: IrType | null = null;
for (const arm of def.arms) {
let ft: IrType | undefined;
@@ -3736,7 +3843,15 @@ export function lowerUnionProperty(
// arms): the JOIN path — `env.PORTLESS_PORT` on `ProcessEnv |
// Record`, the tail read of `loaded?.config.script`.
const key: IrExpr = { kind: "strLit", value: field, type: STRING, loc: locOf(expr.name) };
- const keyed = lowerUnionKeyedRead(lowerer, expr, value.type.unionId, value, key, field);
+ const keyed = lowerUnionKeyedRead(
+ lowerer,
+ expr,
+ unionId,
+ value,
+ key,
+ field,
+ missingFieldAsUndefined,
+ );
if (keyed) return keyed;
// Class arms declaring the field with compatible but different types
// (`T | undefined` beside `T`): per-arm slot reads widened to one type.
@@ -3763,12 +3878,178 @@ export function lowerUnionProperty(
const member = lowerIndexedUnionMember(lowerer, value, indexedArms, field, locOf(expr));
if (member) return member;
}
- lowerer.unsupported(
- "SC1090",
+ return null;
+}
+
+/** Evaluate `value` once into a hidden local and answer the read by
+ * dispatching on the union's tag: arm `tag` (narrowed from the local)
+ * answers `answer(arm, tag, narrowed)`. The first non-unit arm's answer
+ * is the fallback branch, which a representable union never reaches. */
+function perArmUnionRead(
+ lowerer: Lowerer,
+ expr: ts.Node,
+ unionId: string,
+ value: IrExpr,
+ type: IrType,
+ name: string,
+ answer: (arm: IrType, tag: number, receiver: IrExpr) => IrExpr,
+): IrExpr {
+ const loc = locOf(expr);
+ const def = lowerer.unions.get(unionId);
+ if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`);
+ const stable = lowerer.declareHiddenLocal(name, value.type);
+ const ref = (): IrExpr => ({ kind: "varRef", localId: stable.id, type: value.type, loc });
+ let result: IrExpr | null = null;
+ def.arms.forEach((arm, tag) => {
+ if (isUnitType(arm)) return;
+ const receiver: IrExpr = { kind: "unionNarrow", unionId, tag, value: ref(), type: arm, loc };
+ const read = answer(arm, tag, receiver);
+ result =
+ result === null
+ ? read
+ : {
+ kind: "ternary",
+ cond: {
+ kind: "unionIsTag",
+ unionId,
+ tag,
+ value: ref(),
+ negated: false,
+ type: BOOL,
+ loc,
+ },
+ then: read,
+ else_: result,
+ type,
+ loc,
+ };
+ });
+ if (result === null) throw new InternalCompilerError(`lowerer bug: no arm answers ${name}`);
+ return {
+ kind: "seqExpr",
+ stmts: [{ kind: "varDecl", localId: stable.id, init: value, loc }],
+ result,
+ type,
+ loc,
+ };
+}
+
+/** A member read the arms answer through an ACCESSOR — an inherited
+ * getter (`this.renderer.hasOverlayEntries`). `unionDisc` reads record
+ * and class FIELD slots, so a getter arm needs the tag dispatch: each
+ * arm calls its own getter (once per read, as Node's read does). Data
+ * arms in the same union read their slot. Every arm must answer one
+ * type; a unit arm or an arm without the member declines (the caller's
+ * keyed read and fences own those). Null when no arm answers through an
+ * accessor — plain field unions keep `unionDisc`. */
+function lowerUnionAccessorRead(
+ lowerer: Lowerer,
+ expr: ts.PropertyAccessExpression,
+ unionId: string,
+ value: IrExpr,
+): IrExpr | null {
+ const def = lowerer.unions.get(unionId);
+ if (!def) return null;
+ const field = expr.name.text;
+ const getterName = `get:${field}`;
+ let type: IrType | null = null;
+ let anyAccessor = false;
+ const arms: { tag: number; read: (receiver: IrExpr) => IrExpr }[] = [];
+ for (let tag = 0; tag < def.arms.length; tag++) {
+ const arm = def.arms[tag]!;
+ if (isUnitType(arm)) return null;
+ let answer: IrType;
+ let read: (receiver: IrExpr) => IrExpr;
+ if (arm.kind === "record") {
+ const shape = lowerer.shapes.get(arm.shapeId);
+ if (!shape || shape.tuple) return null;
+ const data = shape.fields.find((f) => f.name === field);
+ if (data) {
+ answer = data.type;
+ read = (receiver) => ({
+ kind: "recordGet",
+ obj: receiver,
+ shapeId: arm.shapeId,
+ field,
+ type: data.type,
+ loc: locOf(expr),
+ });
+ } else {
+ const getter = shape.fields.find((f) => f.name === getterName);
+ if (!getter || getter.type.kind !== "func") return null;
+ anyAccessor = true;
+ answer = getter.type.ret;
+ read = (receiver) =>
+ lowerer.coerceToExpected(
+ {
+ kind: "callValue",
+ callee: {
+ kind: "recordGet",
+ obj: receiver,
+ shapeId: arm.shapeId,
+ field: getterName,
+ type: getter.type,
+ loc: locOf(expr),
+ },
+ args: [],
+ type: getter.type.kind === "func" ? getter.type.ret : DYN,
+ loc: locOf(expr),
+ },
+ answer,
+ );
+ }
+ } else if (arm.kind === "object") {
+ const info = lowerer.classes.get(arm.className) ?? null;
+ const slot = info?.fields.get(field);
+ if (slot !== undefined) {
+ answer = slot;
+ read = (receiver) => ({
+ kind: "fieldGet",
+ obj: receiver,
+ className: arm.className,
+ field,
+ type: slot,
+ loc: locOf(expr),
+ });
+ } else {
+ const getter = lowerer.findMethodOn(info, getterName);
+ if (!getter || getter.sig.ret.kind === "void") return null;
+ anyAccessor = true;
+ answer = getter.sig.ret;
+ read = (receiver) =>
+ lowerer.fieldGetExpr(
+ {
+ container: "accessor",
+ obj: receiver,
+ className: arm.className,
+ field,
+ fieldType: getter.sig.ret,
+ },
+ locOf(expr),
+ expr,
+ );
+ }
+ } else {
+ return null;
+ }
+ if (answer.kind === "void" || isUnitType(answer)) return null;
+ if (type !== null && !typeEquals(type, answer)) return null;
+ type = answer;
+ arms.push({ tag, read });
+ }
+ if (!anyAccessor || type === null || arms.length < 2) return null;
+ return perArmUnionRead(
+ lowerer,
expr,
- `reading '${field}' on a union-typed value (every arm must be an object/record ` +
- `with a same-typed field '${field}'; ` +
- `${NARROW_FIRST})`,
+ unionId,
+ value,
+ type,
+ "%unionMember",
+ (_arm, tag, receiver) => {
+ const entry = arms.find((a) => a.tag === tag);
+ if (!entry) throw new InternalCompilerError("lowerer bug: accessor arm missing");
+ return entry.read(receiver);
+ },
);
}
@@ -4041,6 +4322,46 @@ function checkedDynamicValue(expr: IrExpr): IrExpr | null {
return expr.kind === "dynCheck" && expr.value.type.kind === "dyn" ? expr.value : null;
}
+/** A GENERIC receiver type: a type parameter, or a narrowing of one that
+ * intersects it (`"k" in options` refines a type variable to
+ * `T & Record<"k", unknown>`). Its stored layout is whatever the call site
+ * instantiated it with, and the CONSTRAINT's members are not necessarily
+ * stored there. */
+function typeParameterReceiver(t: ts.Type): boolean {
+ try {
+ if ((t.flags & ts.TypeFlags.TypeParameter) !== 0) return true;
+ return (
+ (t.flags & ts.TypeFlags.Intersection) !== 0 &&
+ ts.constituentTypes(t).some((part) => (part.flags & ts.TypeFlags.TypeParameter) !== 0)
+ );
+ } catch (e) {
+ if (ts.isUnreadableConstituentsPanic(e)) return false;
+ throw e;
+ }
+}
+
+/** Whether `access` is only READ (not the target of an assignment, a
+ * delete, or an increment). The left operand of `??`, `===` or `&&`, and
+ * the operand of `!`, `-` or `typeof`, are reads. */
+function readOnlyAccess(access: ts.PropertyAccessExpression): boolean {
+ const parent = access.parent;
+ if (parent === undefined) return true;
+ if (
+ ts.isBinaryExpression(parent) &&
+ parent.left === access &&
+ parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
+ parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
+ )
+ return false;
+ if (ts.isDeleteExpression(parent)) return false;
+ if (ts.isPostfixUnaryExpression(parent)) return false;
+ return (
+ !ts.isPrefixUnaryExpression(parent) ||
+ (parent.operator !== ts.SyntaxKind.PlusPlusToken &&
+ parent.operator !== ts.SyntaxKind.MinusMinusToken)
+ );
+}
+
export function fieldTarget(
lowerer: Lowerer,
access: ts.PropertyAccessExpression,
@@ -4167,6 +4488,55 @@ export function fieldTarget(
lowerer.lowerExpr(access.expression),
);
}
+ // A generic receiver's member the INSTANTIATION cannot store: the type
+ // parameter's constraint declares the member (so tsc admits the read),
+ // while the record the parameter was instantiated with has no such slot —
+ // a generic options bag read through its constraint (`options.sessionId`
+ // under an `"sessionId" in options` guard) at a call site whose options
+ // literal omits the optional member. Node reads the property off the
+ // object it actually has: undefined when the caller omitted it, the extra
+ // property when a JavaScript caller supplied one. So the read is the
+ // checked-dynamic keyed read of the stored record, entering the static
+ // code at the type the checker gave the read.
+ if (
+ !access.questionDotToken &&
+ readOnlyAccess(access) &&
+ typeParameterReceiver(lowerer.typeOf(access.expression))
+ ) {
+ const storedIr = stored ?? narrowed;
+ const arm = storedIr?.kind === "union" ? lowerer.stripUndefinedArm(storedIr) : storedIr;
+ const shape = arm?.kind === "record" ? lowerer.shapes.get(arm.shapeId) : undefined;
+ const admitted = lowerer.mapTypeOf(lowerer.typeOf(access));
+ if (
+ shape &&
+ !shape.tuple &&
+ !shape.indexValue &&
+ !shape.fields.some((f) => f.name === access.name.text) &&
+ admitted &&
+ admitted.kind !== "void"
+ ) {
+ let obj = lowerer.lowerExpr(access.expression);
+ if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) {
+ const present = lowerer.stripUndefinedArm(obj.type);
+ obj =
+ present.kind === "record"
+ ? (lowerer.runtimeOptionalPropertyReceiver(
+ access.expression,
+ obj,
+ present,
+ access.name.text,
+ ) ?? obj)
+ : lowerer.maybeNarrow(obj, access.expression);
+ }
+ if (obj.type.kind !== "record") return null;
+ return {
+ container: "dynamic",
+ obj: lowerer.coerceToExpected(obj, DYN),
+ field: access.name.text,
+ fieldType: admitted,
+ };
+ }
+ }
if (receiverIr?.kind === "record") {
const shape = lowerer.shapes.get(receiverIr.shapeId);
const fieldType = shape?.fields.find((f) => f.name === access.name.text)?.type;
diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts
index a47e24908..5c41aad52 100644
--- a/packages/compiler/src/frontend/lowering/expressions/relational.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts
@@ -1,4 +1,5 @@
/* instanceof and in, including private-name and runtime-key in. */
+import { programCreatesWrappers, WRAPPER_KIND_NUMBER } from "../primitive-wrappers.js";
import { lowerArrayClassInstanceOf } from "../builtins/array-classes.js";
import { dateRootOf } from "../builtins/date-classes.js";
import { keyedHelperName } from "../stable-names.js";
@@ -201,6 +202,23 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
// (typebox's IsBoolean/IsNumber/IsString guards). The operand still runs.
if (PRIMITIVE_WRAPPERS.some((name) => lowerer.isStdlibGlobal(expr.right, name))) {
const value = lowerer.lowerExpr(expr.left);
+ // In a program that creates wrappers, a checked-dynamic value may be
+ // one: the kind's prototype on its chain decides.
+ const wrapperKind = (["String", "Number", "Boolean"] as const).find((name) =>
+ lowerer.isStdlibGlobal(expr.right, name),
+ );
+ if (
+ wrapperKind &&
+ value.type.kind === "dyn" &&
+ (lowerer.createsWrappers ??= programCreatesWrappers(lowerer))
+ )
+ return {
+ kind: "libCall",
+ fn: "wrapper.instanceOf",
+ args: [value, { kind: "numLit", value: WRAPPER_KIND_NUMBER[wrapperKind], type: F64, loc }],
+ type: BOOL,
+ loc,
+ };
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: value, loc }],
diff --git a/packages/compiler/src/frontend/lowering/expressions/typeof.ts b/packages/compiler/src/frontend/lowering/expressions/typeof.ts
index 5f1e4a830..6a545ffcb 100644
--- a/packages/compiler/src/frontend/lowering/expressions/typeof.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/typeof.ts
@@ -493,6 +493,24 @@ export function lowerDynTypeofTest(
// preserving both operands' evaluation order without a narrowing test.
if (!ts.isStringLiteral(b)) continue;
const declared = lowerer.mapTypeOf(lowerer.typeOf(a.expression));
+ // `typeof undeclared === "..."` in a JavaScript source: nothing declares
+ // the name, so Node answers "undefined" without ever reading it — the
+ // test is that constant's comparison, never a read (a read throws).
+ {
+ let operand = a.expression;
+ while (ts.isParenthesizedExpression(operand)) operand = operand.expression;
+ if (
+ ts.isIdentifier(operand) &&
+ isJsSourceFile(ts.getSourceFileOfNode(operand)) &&
+ lowerer.resolveValueSymbol(operand) === null
+ )
+ return {
+ kind: "boolLit",
+ value: (b.text === "undefined") !== negated,
+ type: BOOL,
+ loc,
+ };
+ }
let value: IrExpr;
if (declared?.kind === "dyn") {
value = lowerer.lowerExpr(a.expression);
diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts
index 35d5d1a2e..4911a88d7 100644
--- a/packages/compiler/src/frontend/lowering/expressions/updates.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts
@@ -1,4 +1,6 @@
/* Increment, decrement and prefix unary operators, and compound field updates. */
+import { staticPrivateUpdateTarget } from "../private-statics.js";
+import { lowerCompoundValueToTarget } from "./binary.js";
import {
indexedUnionArms,
lowerIndexedUnionNumberRead,
@@ -269,6 +271,19 @@ export function lowerIncDec(
}
fenceNodeModuleMutation(lowerer, access, "assignment");
if (ts.isPropertyAccessExpression(access)) {
+ // `this.#x++` on a static private field (private-statics.ts): the
+ // brand check, then the field's native global.
+ const staticPrivate = staticPrivateUpdateTarget(lowerer, access);
+ if (staticPrivate) {
+ const update = lowerIncDecTarget(lowerer, expr, staticPrivate.target, prefix);
+ return {
+ kind: "seqExpr",
+ stmts: [staticPrivate.brand],
+ result: update,
+ type: update.type,
+ loc,
+ };
+ }
const target =
staticFieldWriteTarget(lowerer, access) ??
expandoWritableTarget(lowerer, access) ??
@@ -922,6 +937,27 @@ export function lowerFieldCompoundValue(
"compound assignment through 'super' (read and write separately)",
);
}
+ // `this.#x op= v` on a static private field (private-statics.ts): the
+ // brand check, then the field's native global read before the right side.
+ if (
+ ts.isPropertyAccessExpression(access) &&
+ rhsNode !== null &&
+ ts.isBinaryExpression(access.parent) &&
+ access.parent.left === access
+ ) {
+ const staticPrivate = staticPrivateUpdateTarget(lowerer, access);
+ if (staticPrivate) {
+ const { target } = staticPrivate;
+ const value = lowerCompoundValueToTarget(lowerer, access.parent, op, target);
+ return {
+ kind: "seqExpr",
+ stmts: [staticPrivate.brand],
+ result: { kind: "assignExpr", localId: target.id, value, type: target.type, loc },
+ type: target.type,
+ loc,
+ };
+ }
+ }
const body: IrStmt[] = [];
const save = (value: IrExpr, name: string): IrExpr => {
const local = lowerer.declareHiddenLocal(name, value.type);
diff --git a/packages/compiler/src/frontend/lowering/frozen-records.test.ts b/packages/compiler/src/frontend/lowering/frozen-records.test.ts
new file mode 100644
index 000000000..47a9d7c80
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/frozen-records.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from "vitest";
+import {
+ DYN,
+ STRING,
+ VOID,
+ type IrExpr,
+ type IrFunction,
+ type IrStmt,
+ type SrcLoc,
+ type IrType,
+} from "../../ir/ir.js";
+import { freezableTypes } from "./frozen-records.js";
+import type { Lowerer } from "./lowerer.js";
+
+const loc: SrcLoc = { file: "frozen.ts", start: 0, end: 1 };
+
+/** An untyped conversion of a `type`-typed value: a live-reference root. */
+function dynFrom(type: IrType, count: number): IrStmt[] {
+ const expr: IrExpr = {
+ kind: "dynFrom",
+ value: { kind: "varRef", localId: "s.0", type, loc },
+ type: DYN,
+ loc,
+ };
+ const body: IrStmt[] = [];
+ for (let i = 0; i < count; i++) body.push({ kind: "exprStmt", expr, loc });
+ return body;
+}
+
+function fn(body: IrStmt[]): IrFunction {
+ return { name: "f", params: [], returnType: VOID, locals: [], body, loc };
+}
+
+function stub(shapes: [string, unknown][] = []): Lowerer {
+ return {
+ untypedIntegrity: true,
+ classes: new Map(),
+ shapes: new Map(shapes),
+ unions: new Map(),
+ } as unknown as Lowerer;
+}
+
+describe("freezableTypes", () => {
+ test("seeds a program-sized live list without a spread argument limit", () => {
+ // A spread (`stack.push(...live)`) throws RangeError above roughly 100k
+ // arguments, which a large program's live-reference roots reach.
+ const roots = 200_000;
+ const { shapes, arrays } = freezableTypes(stub(), [fn(dynFrom(STRING, roots))]);
+ expect(shapes.size).toBe(0);
+ expect(arrays.size).toBe(0);
+ });
+
+ test("a live record type makes its shape freezable", () => {
+ const lowerer = stub([["r0", { id: "r0", fields: [] }]]);
+ const live = dynFrom({ kind: "record", shapeId: "r0" }, 1);
+ expect([...freezableTypes(lowerer, [fn(live)]).shapes]).toEqual(["r0"]);
+ });
+});
diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts
index 75056b3cc..2555dfc2a 100644
--- a/packages/compiler/src/frontend/lowering/frozen-records.ts
+++ b/packages/compiler/src/frontend/lowering/frozen-records.ts
@@ -114,7 +114,7 @@ function holdsAbsentState(e: IrExpr): boolean {
/** The record shapes and array types (typeKeys) whose values the program
* may freeze. */
-function freezableTypes(
+export function freezableTypes(
lowerer: Lowerer,
functions: IrFunction[],
): { shapes: Set; arrays: Set } {
@@ -141,16 +141,21 @@ function freezableTypes(
});
if (!lowerer.untypedIntegrity && !freezesUntyped) return { shapes, arrays };
const stack: IrType[] = [];
+ // Both seeds are program-sized (one entry per untyped conversion or check in
+ // every function), so they are pushed with loops: a spread argument list
+ // exceeds the engine's limit and throws RangeError on a large program.
// A record checked from an untyped value copies it, frozen when the value
// is (a fresh literal frozen in a JavaScript file included), so the shapes
// a check builds are freezable in a program that freezes untyped values.
- if (freezesUntyped) stack.push(...checked);
+ if (freezesUntyped) {
+ for (const type of checked) stack.push(type);
+ }
if (lowerer.untypedIntegrity) {
// An untyped reference to a class instance, or to a record or array
// boxed live, reaches the values its fields and elements hold as further
// live references. Class instances also cross into untyped code through
// compiled callbacks, so every class is a root.
- stack.push(...live);
+ for (const type of live) stack.push(type);
for (const info of lowerer.classes.values())
for (const field of info.def.fields) stack.push(field.type);
}
diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts
index 7378f26a7..0c09999c7 100644
--- a/packages/compiler/src/frontend/lowering/function-returns.ts
+++ b/packages/compiler/src/frontend/lowering/function-returns.ts
@@ -578,8 +578,8 @@ export function resolveInferredReturn(
// per-statement deferral, applied to one return of an instance).
const captured = lowerer.diags.splice(diagsBefore);
const ice = captured.filter((d) => d.code === "SC9001");
- if (ice.length > 0) lowerer.diags.push(...ice);
- lowerer.runtimeFences.push(...captured.filter((d) => d.code !== "SC9001"));
+ for (const d of ice) lowerer.diags.push(d);
+ for (const d of captured) if (d.code !== "SC9001") lowerer.runtimeFences.push(d);
const first = captured.find((d) => d.code !== "SC9001");
// Property writes, not literal element writes: the class method
// census reads `x["kind"] = …` as a possible write to every method
diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts
index 046b34008..4f49f98b0 100644
--- a/packages/compiler/src/frontend/lowering/generic-functions.ts
+++ b/packages/compiler/src/frontend/lowering/generic-functions.ts
@@ -48,6 +48,7 @@ import {
promiseCarriesDyn,
} from "./function-returns.js";
import { bindingNeverReassigned, stripValueWrappers } from "./binding-analysis.js";
+import { declareContextLocal } from "./function-context.js";
import {
extendInstantiationPath,
MAX_INSTANTIATION_RECURSION,
@@ -101,6 +102,21 @@ export interface GenericFnInfo {
* declaration makes a new function object in JavaScript, while the
* compiled instances are shared module functions, so only calls lower. */
functionScoped?: true;
+ /** The enclosing-frame values a function-scoped generic arrow reads.
+ * The instances are module functions, so each captured binding travels
+ * as a TRAILING parameter: the call sites (which sit in the declaring
+ * frame or in a closure over it) pass the value, in this order, and the
+ * instance body reads it through the same symbol. Only never-reassigned
+ * bindings qualify — a shared copy is exact exactly as long as nothing
+ * writes the original. */
+ captures?: FrameCapture[];
+ /** "env": some capture is reassigned, so a copy cannot stand in for it.
+ * The instances are then LIFTED functions that receive every capture
+ * through a closure environment (boxed, shared with the declaring frame
+ * and every ordinary closure over it), and each call site calls a fresh
+ * closure over its own boxes. Absent: captures travel as trailing
+ * parameters. */
+ captureMode?: "env";
/** The declaration of a generic arrow/function-expression binding. */
bindingDecl?: ts.VariableDeclaration;
/** A module-scope binding that code can use before its declaration
@@ -131,6 +147,19 @@ export interface GenericFnInfo {
nameReadParams?: Map;
}
+/** One enclosing-frame value a generic arrow/function-expression binding
+ * reads (GenericFnInfo.captures). `nameNode` is the binding's own
+ * declaration identifier: declaring a local for it binds the SYMBOL, so
+ * the instance body resolves the reference like any parameter. */
+export interface FrameCapture {
+ symbol: ts.Symbol;
+ nameNode: ts.Node;
+ name: string;
+ type: IrType;
+ /** Something in the file writes the binding (bindingNeverReassigned). */
+ reassigned: boolean;
+}
+
export interface GenericInstance {
path: InstantiationPath;
name: string;
@@ -285,6 +314,57 @@ export function lowerGenericCall(
? implicitCallInstance(lowerer, expr, info)
: genericCallInstance(lowerer, expr, info);
const args = lowerer.completeArgs(expr.arguments.nodes, instance.params, loc, expr);
+ if (info.captureMode === "env") {
+ // The environment form: a closure over this call site's boxes of the
+ // captured bindings (resolveKey threads them through any closures in
+ // between and boxes the origin), called at once. Arguments evaluate
+ // after the callee, as in JavaScript; building the closure only reads
+ // the boxes.
+ if (
+ instance.params.some(
+ (p) => p.mode === "rest" || p.mode === "dynRest" || p.mode === "arguments",
+ )
+ ) {
+ lowerer.unsupported(
+ "SC1090",
+ expr,
+ `generic arrow/function-expression bindings with rest parameters that write the frames of their enclosing functions ('${info.baseName}')`,
+ );
+ }
+ const sources = info.captures!.map((capture) => {
+ const local = lowerer.resolveKey(capture.symbol, expr);
+ if (local === null || !typeEquals(local.type, capture.type)) {
+ lowerer.unsupported(
+ "SC1090",
+ expr,
+ `generic arrow/function-expression bindings sharing '${capture.name}' with their enclosing function when its storage differs from its declared type ('${info.baseName}')`,
+ );
+ }
+ // An origin read from its own frame is not boxed by resolveKey; the
+ // closure needs the box (captureContextBinding's origin rule).
+ local.boxed = true;
+ return local.id;
+ });
+ const call: IrExpr & { kind: "callValue" } = {
+ kind: "callValue",
+ callee: {
+ kind: "closure",
+ fnName: instance.name,
+ captures: sources,
+ type: funcTypeFromParamShapes(instance.params, instance.returnType),
+ loc,
+ },
+ args,
+ type: instance.returnType,
+ loc,
+ };
+ return guardEarlyBindingCall(lowerer, info, expr, call);
+ }
+ // The enclosing-frame values follow the declared arguments, the order
+ // the instance takes them in (lowerGenericInstance).
+ for (const capture of info.captures ?? []) {
+ args.push(lowerer.lowerExprExpecting(capture.nameNode as ts.Expression, capture.type));
+ }
const call: IrExpr & { kind: "call" } = {
kind: "call",
callee: instance.name,
@@ -1202,7 +1282,10 @@ export function lowerGenericInstance(
let bodyReturn = isGenerator
? lowerer.genBodyReturnType(inst.returnType)
: lowerer.bodyReturnType(isAsync, inst.returnType);
- const fnCtx = newFnCtx(false, null, null, bodyReturn);
+ // The environment form lowers as a LIFTED function: its captures arrive
+ // in the closure environment, never as parameters.
+ const envForm = info.captureMode === "env";
+ const fnCtx = newFnCtx(envForm, null, null, bodyReturn);
fnCtx.isAsync = isAsync;
// Instances lower on demand, on top of whichever caller reached them.
fnCtx.thisBoundary = true;
@@ -1246,10 +1329,41 @@ export function lowerGenericInstance(
const thisLocal = lowerer.declareThis(thisType);
params.push({ localId: thisLocal.id, name: "this", type: thisType });
}
+ // Environment form: one boxed capture entry per captured binding, in
+ // FrameCapture order (the call sites' closure caps[] order), keyed by
+ // the binding's symbol so the body (parameter defaults included)
+ // reads and writes the shared box. Pre-registering them also keeps
+ // resolution from walking past this instance's frame.
+ if (envForm) {
+ for (const capture of info.captures!) {
+ const entry = declareContextLocal(
+ fnCtx,
+ capture.name,
+ capture.type,
+ capture.reassigned,
+ undefined,
+ undefined,
+ );
+ entry.boxed = true;
+ fnCtx.captureBySymbol.set(capture.symbol, entry);
+ fnCtx.captures!.push({ localId: entry.id, name: entry.name, type: entry.type });
+ }
+ }
+ // The enclosing-frame values travel as TRAILING parameters, after the
+ // declared ones: a call lowers its arguments first (JavaScript's
+ // order), then reads the captures. Binding each local to the captured
+ // symbol is what lets the body resolve the reference; the locals are
+ // bound before the declared parameters so that a default initializer
+ // reading a capture resolves it too.
+ const captureParams: IrParam[] = [];
+ for (const capture of envForm ? [] : (info.captures ?? [])) {
+ const local = lowerer.declareLocal(capture.nameNode, capture.name, capture.type, false);
+ captureParams.push({ localId: local.id, name: capture.name, type: capture.type });
+ }
// Default-param initializers lower per instance, with the bindings
// threaded — a default mentioning T resolves like any body expression.
const declared = lowerer.declareParams(decl.parameters.nodes, inst.params);
- params.push(...declared.params);
+ params.push(...declared.params, ...captureParams);
const body = [...receiverInit, ...declared.prologue];
const bodyBlock = blockBodyOf(decl);
if (bodyBlock) {
@@ -1304,6 +1418,7 @@ export function lowerGenericInstance(
params,
returnType: bodyReturn,
locals: lowerer.ctx.locals,
+ ...(envForm ? { captures: fnCtx.captures! } : {}),
body,
loc: locOf(decl),
};
@@ -2638,6 +2753,120 @@ function capturesEnclosingFrame(
return captures;
}
+/** The enclosing-frame VALUES `fn`, bound by `decl`, reads — in first-use
+ * order, the order the instances take them as trailing parameters. The
+ * shape is the one the instance body resolves: a binding declared by an
+ * enclosing function (a local, a parameter, a local function or class).
+ *
+ * Module-scope declarations and the binding itself are reachable from the
+ * module functions the instances compile to, so they are not captures; nor
+ * are type-only declarations, whose mapping is static. `this`, `super`,
+ * `arguments`, `new.target`, and an enclosing generic's type parameter
+ * have no value a parameter can stand in for and stay fenced. A
+ * reassigned binding is shared through the environment form instead of
+ * copied. */
+function enclosingFrameCaptures(
+ lowerer: Lowerer,
+ fn: ts.FunctionExpression | ts.ArrowFunction,
+ decl: ts.VariableDeclaration,
+ scope: ts.Node,
+ name: string,
+): FrameCapture[] {
+ const file = ts.getSourceFileOfNode(decl);
+ const self = lowerer.checker.getSymbolAtLocation(decl.name);
+ const frameFence = (): never =>
+ lowerer.unsupported(
+ "SC1090",
+ fn,
+ `generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of '${name}' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope)`,
+ );
+ const captures: FrameCapture[] = [];
+ const seen = new Set();
+ const scan = (n: ts.Node): void => {
+ if (
+ n.kind === ts.SyntaxKind.ThisKeyword ||
+ n.kind === ts.SyntaxKind.SuperKeyword ||
+ ts.isMetaProperty(n)
+ ) {
+ // Arrow bodies see the ENCLOSING this; function expressions
+ // rebind their own — but a bare `this` there is untyped JS
+ // dynamism either way. Reject both, cheaply and soundly.
+ frameFence();
+ }
+ if (ts.isIdentifier(n)) {
+ const s = lowerer.checker.getSymbolAtLocation(n);
+ if (s === undefined) {
+ if (n.text === "arguments") frameFence();
+ } else if (s !== self && !seen.has(s)) {
+ for (const d of s.declarations) {
+ if (ts.getSourceFileOfNode(d) !== file || (d.pos >= fn.pos && d.end <= fn.end)) continue;
+ let enclosing = false;
+ for (
+ let p: ts.Node | undefined = d.parent;
+ p !== undefined && !ts.isSourceFile(p);
+ p = p.parent
+ ) {
+ if (ts.isFunctionLike(p)) {
+ enclosing = true;
+ break;
+ }
+ }
+ if (!enclosing) continue;
+ // A type parameter (of the enclosing function or class) keys
+ // the enclosing instantiation; the instance would need that
+ // frame's type arguments, which this binding cannot carry.
+ if ((s.flags & ts.SymbolFlags.TypeParameter) !== 0) frameFence();
+ // Type-only declarations cost no runtime slot.
+ if ((s.flags & ts.SymbolFlags.Value) === 0) break;
+ // A trailing-parameter COPY is exact only while the original
+ // never changes; a binding written anywhere in the file puts the
+ // whole binding in the environment form (GenericFnInfo.captureMode).
+ const reassigned = !bindingNeverReassigned(lowerer, s, d);
+ // ...and exact only once the original is INITIALIZED. A capture
+ // declared after the binding could be read from a call that runs
+ // before its initializer — a temporal dead zone JavaScript would
+ // throw on. Declared before, the copy is always the initialized
+ // value: reaching the binding's own declaration ran it.
+ if (d.pos > decl.pos) frameFence();
+ // The declaration's own identifier (a binding element's for a
+ // destructured name): lowering it resolves the SYMBOL, so the
+ // instance body reads the parameter we declare for it.
+ const declaredName = (d as { name?: ts.Node }).name;
+ const nameNode =
+ declaredName !== undefined && ts.isIdentifier(declaredName) ? declaredName : d;
+ const type = lowerer.irTypeOf(nameNode);
+ captures.push({
+ symbol: s,
+ nameNode,
+ name: ts.isIdentifier(nameNode) ? nameNode.text : ts.symbolName(s),
+ type,
+ reassigned,
+ });
+ seen.add(s);
+ break;
+ }
+ }
+ }
+ ts.forEachChild(n, scan);
+ };
+ scan(scope);
+ // A capture's type is fixed when the info is registered, once per
+ // binding, while an enclosing generic function or class re-lowers its
+ // frame per instantiation: the second instantiation's value would have a
+ // different type than the parameter the shared instance declares.
+ // Generic enclosing scopes keep the fence; non-generic ones carry no
+ // type argument for the instance to miss.
+ if (captures.length > 0) {
+ for (let p: ts.Node | undefined = fn.parent; p && !ts.isSourceFile(p); p = p.parent) {
+ if (!ts.isFunctionLike(p) && !ts.isClassLikeDeclaration(p)) continue;
+ const typeParameters = (p as { typeParameters?: ts.NodeArray })
+ .typeParameters;
+ if (typeParameters !== undefined && typeParameters.nodes.length > 0) frameFence();
+ }
+ }
+ return captures;
+}
+
/** Registers (or returns) the GenericFnInfo of a qualifying implicit-any
* function-value binding — implicitLocalFnNodeOf's companion, the
* bindingGenericFnInfoOf shape: the info enters genericFnsBySymbol under
@@ -2848,22 +3077,22 @@ export function bindingGenericFnInfoOf(
if (existing) return existing;
const name = (decl.name as ts.Identifier).text;
if (fnNode.asteriskToken) lowerer.unsupported("SC1071", fnNode);
- // Inside a function, the compiled instances are module functions, so
- // the initializer may not use the enclosing frame.
+ // Inside a function, the compiled instances are module functions: the
+ // values the initializer reads from the enclosing frame travel as
+ // trailing parameters (FrameCapture), or through a closure environment
+ // when one of them is reassigned (captureMode). `this`, `arguments` and
+ // an enclosing generic's type parameters have no value to pass and stay
+ // fenced.
let functionScoped = false;
+ let captures: FrameCapture[] | undefined;
for (
let n: ts.Node | undefined = decl.parent;
n !== undefined && !ts.isSourceFile(n);
n = n.parent
) {
if (ts.isFunctionLike(n)) {
- if (capturesEnclosingFrame(lowerer, fnNode, decl, fnNode)) {
- lowerer.unsupported(
- "SC1090",
- fnNode,
- `generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of '${name}' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope)`,
- );
- }
+ const found = enclosingFrameCaptures(lowerer, fnNode, decl, fnNode, name);
+ if (found.length > 0) captures = found;
functionScoped = true;
break;
}
@@ -2912,6 +3141,8 @@ export function bindingGenericFnInfoOf(
typeParams,
instances: new Map(),
...(functionScoped ? { functionScoped: true as const } : {}),
+ ...(captures ? { captures } : {}),
+ ...(captures?.some((capture) => capture.reassigned) ? { captureMode: "env" as const } : {}),
bindingDecl: decl,
};
lowerer.bindingGenericFns.set(fnNode, info);
@@ -3095,7 +3326,7 @@ function guardEarlyBindingCall(
lowerer: Lowerer,
info: GenericFnInfo,
expr: ts.CallExpression,
- call: IrExpr & { kind: "call" },
+ call: IrExpr & { kind: "call" | "callValue" },
): IrExpr {
if (info.bindingDecl === undefined || runsAfterDeclaration(lowerer, info, expr)) return call;
const loc = call.loc;
diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts
index b5d5a7225..5b8d828e9 100644
--- a/packages/compiler/src/frontend/lowering/global-values.ts
+++ b/packages/compiler/src/frontend/lowering/global-values.ts
@@ -138,6 +138,55 @@ export function jsGlobalValueByName(
);
}
+/** Globals whose values a TypeScript read keeps refused: the Array
+ * constructor value has none of Array's static members (`o.Array.isArray`
+ * would read undefined), and the timer values lack the
+ * util.promisify.custom getter Node's util.inspect lists. */
+const UNTYPED_UNMODELED: ReadonlySet = new Set(["Array", "setTimeout", "setImmediate"]);
+
+/** The value a TypeScript source reads for the declared global `name` at
+ * `node` when the checker types the slot it flows into as any or unknown
+ * (`const o: any = { j: JSON }`, a call to a parameter typed unknown): the
+ * value a JavaScript source reads. Null in JavaScript sources, in typed
+ * slots, and for a global without a value, whose read keeps its SC2020
+ * refusal. Null for the primitive constructors too: a TypeScript read of
+ * String, Number, Boolean or BigInt is the closure with the typed
+ * signature (lowerIdentifierExpr), and `{ String }` must equal it. */
+export function tsUntypedGlobalValue(
+ lowerer: Lowerer,
+ node: ts.Expression,
+ name: string,
+ loc: SrcLoc,
+): IrExpr | null {
+ if (isJsSourceFile(ts.getSourceFileOfNode(node)) || UNTYPED_UNMODELED.has(name)) return null;
+ if (name === "String" || name === "Number" || name === "Boolean" || name === "BigInt")
+ return null;
+ if (!flowsIntoUntypedSlot(lowerer, node)) return null;
+ return jsGlobalValueByName(lowerer, name, loc);
+}
+
+/** Whether the checker types the slot `node` flows into as any or unknown.
+ * A member of an object or array literal whose own slot is any has no
+ * contextual type, so the literal's slot decides. */
+function flowsIntoUntypedSlot(lowerer: Lowerer, node: ts.Expression): boolean {
+ let value: ts.Expression = node;
+ for (;;) {
+ const slot = lowerer.checker.getContextualType(value);
+ if (slot !== undefined) return (slot.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0;
+ const parent = value.parent;
+ if (
+ (ts.isPropertyAssignment(parent) && parent.initializer === value) ||
+ ts.isShorthandPropertyAssignment(parent)
+ ) {
+ value = parent.parent as ts.ObjectLiteralExpression;
+ } else if (ts.isArrayLiteralExpression(parent) || ts.isParenthesizedExpression(parent)) {
+ value = parent;
+ } else {
+ return false;
+ }
+ }
+}
+
/** jsGlobalValueByName for the globals other than the interned builtin
* functions and constructors (builtinGlobalValue). */
export function jsSpecialGlobalValue(lowerer: Lowerer, name: string, loc: SrcLoc): IrExpr | null {
diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts
index 02faa7ade..704314eaf 100644
--- a/packages/compiler/src/frontend/lowering/lower-assert.ts
+++ b/packages/compiler/src/frontend/lowering/lower-assert.ts
@@ -1290,9 +1290,21 @@ function lowerAssertEqual(
?.arms.every((arm) => instanceType(arm) || isUnitType(arm)) ??
false))) &&
lowerer.dynConvertible(e.type);
- if (a.type.kind === "dyn" || b.type.kind === "dyn" || (instance(a) && instance(b))) {
+ if (
+ a.type.kind === "dyn" ||
+ b.type.kind === "dyn" ||
+ a.type.kind === "moduleNs" ||
+ b.type.kind === "moduleNs" ||
+ (instance(a) && instance(b))
+ ) {
const box = (e: IrExpr, node: ts.Expression): IrExpr => {
if (e.type.kind === "dyn") return e;
+ // A builtin module namespace crosses as its module object, whose
+ // identity is the module's (`assert.strictEqual(path, path.posix)`).
+ if (e.type.kind === "moduleNs") {
+ const value = lowerer.coerceToExpected(e, DYN);
+ if (value.type.kind === "dyn") return value;
+ }
const k = e.type.kind;
const ok =
e.kind === "unitLit" ||
diff --git a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts
index 1058b1c26..d1e7b963a 100644
--- a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts
+++ b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts
@@ -8,11 +8,19 @@ import {
UNDEFINED_T,
arrayOf,
type IrExpr,
+ type IrFunction,
type IrLibFn,
type IrStmt,
+ type IrType,
type SrcLoc,
+ typeEquals,
+ typeKey,
} from "../../ir/ir.js";
-import { strLit, varRef } from "../../ir/build.js";
+import { boolLit, dynUndefinedExpr, strLit, varRef } from "../../ir/build.js";
+import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
+import { isJsSourceFile } from "../program.js";
+import { funcTypeFromParamShapes, type ParamShape } from "./call-signatures.js";
+import { keyedHelperName } from "./stable-names.js";
import type { Lowerer } from "./lowerer.js";
import {
BUILTIN_MODULE_CONSTS,
@@ -22,6 +30,8 @@ import {
builtinModuleConstOf,
builtinModulesArrayLit,
stdlibGlobalNameOf,
+ type BuiltinModuleFn,
+ type BuiltinValueParam,
} from "./surfaces.js";
import { CRYPTO_CONSTANTS } from "./crypto-tables.js";
@@ -425,77 +435,35 @@ const MODULES = ["path/posix", "path/win32", "os", "worker_threads", "util/types
const box = (value: IrExpr): IrExpr =>
value.type.kind === "dyn" ? value : { kind: "dynFrom", value, type: DYN, loc: value.loc };
+/** `path.posix`, `path.win32` and the bare module as untyped values: the
+ * same module handle process.getBuiltinModule answers, so each family is
+ * one object (`require("path/posix") === require("path").posix`) whose
+ * members are the interned function values. */
export function pathModuleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr {
- // node:path's namespaces are module objects: the same handle
- // `process.getBuiltinModule("node:path")` answers. The handle caches
- // each member (so `path.join === path.join` and a mutation through one
- // spelling is visible through the other) and identifies the module, so
- // `posix === path.posix` holds — the object model the platform
- // namespaces need when they are VALUES (`const api = win32`).
return moduleValue(lowerer, module, loc);
}
-/** Node's own keys of the node:path module objects (path, path.posix and
- * path.win32), in order; `_makeLong` is the same function as
- * toNamespacedPath. */
-const PATH_MODULE_KEYS = [
- "resolve",
- "normalize",
- "isAbsolute",
- "join",
- "relative",
- "toNamespacedPath",
- "dirname",
- "basename",
- "extname",
- "format",
- "parse",
- "matchesGlob",
- "sep",
- "delimiter",
- "win32",
- "posix",
- "_makeLong",
-] as const;
-
-/** What a spread of a node:path namespace copies: the module object's own
- * members in Node's order, each read through the module value, so the copy
- * holds the same functions (`{ ...path }.join === path.join`). An ES module
+/** What a spread of a node:path namespace copies. A module object
+ * (`require("path")`, a default import, `posix`, `win32`) is spread as
+ * itself, so the copy holds its members in Node's order. An ES module
* namespace (`import * as path`) has its keys sorted and also `default`,
- * the module object. Members the module value does not answer (format and
- * parse have no function values) are left out, as they are of the value. */
+ * the module object; each member is read through the module value, so the
+ * copy holds the same functions (`{ ...path }.join === path.join`). */
export function pathModuleSpreadSource(
lowerer: Lowerer,
module: string,
namespace: boolean,
loc: SrcLoc,
): IrExpr {
+ if (!namespace) return pathModuleValue(lowerer, module, loc);
const local = lowerer.declareHiddenLocal("%pathModule", DYN);
const value = varRef(local.id, DYN, loc);
- const answered = (key: string): boolean =>
- key === "default" ||
- key === "posix" ||
- key === "win32" ||
- builtinModuleConstOf(lowerer, module, key) !== undefined ||
- lowerer.lowerBuiltinCallableValue(
- { module, member: key === "_makeLong" ? "toNamespacedPath" : key },
- loc,
- ) !== null;
- const keys = (namespace ? [...PATH_MODULE_KEYS, "default"].sort() : [...PATH_MODULE_KEYS]).filter(
- answered,
- );
- const fields = keys.map((key) => ({
+ const fields = [...PATH_MODULE_KEYS, "default"].sort().map((key) => ({
key: strLit(key, loc),
value:
key === "default"
? value
- : ({
- kind: "dynKeyGet",
- value,
- key: strLit(key === "_makeLong" ? "toNamespacedPath" : key, loc),
- type: DYN,
- loc,
- } as IrExpr),
+ : ({ kind: "dynKeyGet", value, key: strLit(key, loc), type: DYN, loc } as IrExpr),
}));
return {
kind: "seqExpr",
@@ -579,7 +547,7 @@ function moduleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr {
};
for (const member of Object.keys(builtinModuleFns()[module] ?? {})) {
const callable = lowerer.lowerBuiltinCallableValue({ module, member }, loc);
- if (callable) add(member, callable);
+ if (callable) add(member, lowerer.checkedBuiltinValue(callable));
}
for (const member of Object.keys(BUILTIN_MODULE_CONSTS[module] ?? {})) {
const worker = module === "worker_threads" ? lowerWorkerMetadata(member, loc) : null;
@@ -590,20 +558,27 @@ function moduleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr {
if (module.startsWith("path/")) {
add("posix", moduleValue(lowerer, "path/posix", loc));
add("win32", moduleValue(lowerer, "path/win32", loc));
+ // Node's deprecated alias of toNamespacedPath.
+ const alias = lowerer.lowerBuiltinCallableValue({ module, member: "toNamespacedPath" }, loc);
+ if (alias) add("_makeLong", lowerer.checkedBuiltinValue(alias));
}
if (module === "worker_threads") {
for (const member of ["isInternalThread", "parentPort", "workerData"])
add(member, lowerWorkerMetadata(member, loc)!);
}
+ // A path family is the whole of Node's object, so another key is
+ // absent there too.
body.push({
kind: "return",
- value: {
- kind: "libCall",
- fn: "process.builtinUnsupported",
- args: [strLit(module, loc), key],
- type: DYN,
- loc,
- },
+ value: module.startsWith("path/")
+ ? dynUndefinedExpr(loc)
+ : {
+ kind: "libCall",
+ fn: "process.builtinUnsupported",
+ args: [strLit(module, loc), key],
+ type: DYN,
+ loc,
+ },
loc,
});
lowerer.liftedFns.push({
@@ -622,6 +597,27 @@ function moduleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr {
type: { kind: "func", params: [STRING], ret: DYN },
loc,
};
+ // A path family is a plain object holding Node's members in Node's
+ // order, so reflection over it (Object.keys, Object.assign, spreads,
+ // own-property checks) sees them.
+ if (module.startsWith("path/")) {
+ return {
+ kind: "libCall",
+ fn: "process.builtinObject",
+ args: [
+ strLit(module, loc),
+ box(getter),
+ {
+ kind: "arrayLit",
+ elems: PATH_MODULE_KEYS.map((key) => strLit(key, loc)),
+ type: arrayOf(STRING),
+ loc,
+ },
+ ],
+ type: DYN,
+ loc,
+ };
+ }
return {
kind: "libCall",
fn: "process.builtinModule",
@@ -631,6 +627,27 @@ function moduleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr {
};
}
+/** Object.keys of Node's path.posix and path.win32. */
+const PATH_MODULE_KEYS = [
+ "resolve",
+ "normalize",
+ "isAbsolute",
+ "join",
+ "relative",
+ "toNamespacedPath",
+ "dirname",
+ "basename",
+ "extname",
+ "format",
+ "parse",
+ "matchesGlob",
+ "sep",
+ "delimiter",
+ "win32",
+ "posix",
+ "_makeLong",
+];
+
/** A first-class loader uses the same native functions as direct imports.
* Module handles cache values and preserve aliases without a JS engine. */
export function lowerObjectAssignValue(lowerer: Lowerer, loc: SrcLoc): IrExpr {
@@ -776,3 +793,257 @@ export function builtinConstantsObject(lowerer: Lowerer, loc: SrcLoc): IrExpr {
};
return { kind: "libCall", fn: "constants.object", args: [box(table)], type: DYN, loc };
}
+
+/** The untyped-caller twin of a builtin function value whose parameters
+ * carry Node's validator names (BuiltinValueParam.name): every argument
+ * arrives as a checked-dynamic value. With a `checked` entry point the
+ * arguments reach it as given, followed by its constants; otherwise string
+ * parameters pass through validateString in Node's order (`valueOrder`)
+ * before the direct call's entry point. One interned adapter per entry,
+ * so each read of the value is the same closure. */
+export function checkedNativeCallableValue(
+ lowerer: Lowerer,
+ fn: BuiltinModuleFn,
+ display: string,
+ loc: SrcLoc,
+): IrExpr {
+ const valueParams = fn.valueParams ?? [];
+ const callee = fn.checked?.fn ?? fn.valueFn ?? fn.fn;
+ const result = fn.checked ? (fn.checked.result ?? fn.result) : fn.result;
+ const raw = fn.checked !== undefined;
+ const shapes: ParamShape[] = valueParams.map((param): ParamShape => ({
+ mode: param.mode === "rest" ? "dynRest" : param.mode === "optional" ? "omittable" : "required",
+ type: DYN,
+ }));
+ const funcType = funcTypeFromParamShapes(shapes, result);
+ const key = `checked:${callee}:${JSON.stringify(fn.checked?.args ?? [])}:${typeKey(funcType)}`;
+ let fnName = lowerer.builtinCallableValueFns.get(key);
+ if (!fnName) {
+ fnName = keyedHelperName(lowerer, `%builtin.value.${callee}`, key);
+ lowerer.builtinCallableValueFns.set(key, fnName);
+ lowerer.builtinCallableValueNames.set(fnName, display.slice(display.lastIndexOf(".") + 1));
+ const params = valueParams.map((_, index) => ({
+ localId: `arg.${index}`,
+ name: `arg${index}`,
+ type: DYN,
+ }));
+ const locals = params.map((param) => ({
+ id: param.localId,
+ name: param.name,
+ type: param.type as IrType,
+ mutable: false,
+ }));
+ // validateString under Node's name; an omitted optional argument (or
+ // undefined) takes its default without a check, as Node's
+ // `if (suffix !== undefined)` does.
+ const checked = (param: BuiltinValueParam, index: number): IrExpr => {
+ const ref = varRef(`arg.${index}`, DYN, loc);
+ const name = strLit(param.name ?? `arg${index}`, loc);
+ if (param.mode === "rest") {
+ return {
+ kind: "libCall",
+ fn: "dyn.validateStrings",
+ args: [ref, name],
+ type: arrayOf(STRING),
+ loc,
+ };
+ }
+ const value: IrExpr = {
+ kind: "libCall",
+ fn: "dyn.validateString",
+ args: [ref, name],
+ type: STRING,
+ loc,
+ };
+ if (param.mode !== "optional" || typeof param.defaultValue !== "string") return value;
+ return {
+ kind: "ternary",
+ cond: { kind: "dynTest", test: "undefined", value: ref, type: BOOL, loc },
+ then: strLit(param.defaultValue, loc),
+ else_: value,
+ type: STRING,
+ loc,
+ };
+ };
+ const body: IrStmt[] = [];
+ const args: IrExpr[] = valueParams.map((param, index) =>
+ raw || param.type.kind === "dyn"
+ ? varRef(`arg.${index}`, DYN, loc)
+ : varRef(`checked.${index}`, param.mode === "rest" ? arrayOf(STRING) : STRING, loc),
+ );
+ // Validate in Node's order, then call with the arguments in place.
+ for (const index of fn.valueOrder ?? valueParams.map((_, i) => i)) {
+ const param = valueParams[index]!;
+ if (raw || param.type.kind === "dyn") continue;
+ const init = checked(param, index);
+ locals.push({
+ id: `checked.${index}`,
+ name: `checked${index}`,
+ type: init.type,
+ mutable: false,
+ });
+ body.push({ kind: "varDecl", localId: `checked.${index}`, init, loc });
+ }
+ const call: IrExpr = {
+ kind: "libCall",
+ fn: callee,
+ args: [...args, ...(fn.checked?.args ?? []).map((value) => boolLit(value, loc))],
+ type: result,
+ loc,
+ };
+ body.push(
+ result.kind === "void"
+ ? { kind: "exprStmt", expr: call, loc }
+ : { kind: "return", value: call, loc },
+ );
+ if (result.kind === "void") body.push({ kind: "return", value: null, loc });
+ lowerer.liftedFns.push({ name: fnName, params, returnType: result, locals, body, loc });
+ }
+ return { kind: "closure", fnName, captures: [], type: funcType, loc };
+}
+
+/** Every builtin function value that becomes untyped boxes as its checked
+ * twin, under the builtin's name and length, wherever the boxing happens: a
+ * module handle's member, an assertion's argument, or a read of a binding
+ * whose only stores are that builtin (`const join = path.join`): a const
+ * local, or a module-level binding, whose stores are all visible here.
+ * An untyped caller then holds the value that validates its arguments as
+ * Node does, and it compares equal to the same builtin read anywhere else.
+ * A binding read keeps its own evaluation, so its TDZ check still runs. */
+export function boxBuiltinValuesChecked(lowerer: Lowerer, functions: IrFunction[]): void {
+ const names = lowerer.builtinCallableValueNames;
+ if (names.size === 0) return;
+ const lengths = lowerer.builtinCallableValueLengths;
+ const builtin = (expr: IrExpr | null): expr is Extract =>
+ expr !== null &&
+ expr.kind === "closure" &&
+ expr.captures.length === 0 &&
+ (names.has(expr.fnName) || lowerer.builtinCheckedValues.has(expr.fnName));
+ // Bindings and the one builtin closure they hold, or null when a binding
+ // stores anything else. Locals are keyed by their function.
+ const globals = new Set(lowerer.globalsList.map((g) => g.id));
+ const bound = new Map | null>();
+ const note = (key: string, value: IrExpr | null): void => {
+ const previous = bound.get(key);
+ const next = builtin(value) ? value : null;
+ bound.set(
+ key,
+ previous === undefined || (previous !== null && next?.fnName === previous.fnName)
+ ? next
+ : null,
+ );
+ };
+ const localKey = (fn: IrFunction, id: string): string => `${fn.name}\u0000${id}`;
+ for (const fn of functions) {
+ const constLocals = new Set(fn.locals.filter((l) => !l.mutable).map((l) => l.id));
+ const keyOf = (id: string): string | null =>
+ constLocals.has(id) ? localKey(fn, id) : globals.has(id) ? id : null;
+ // Any other node naming a binding (a for-of target, a catch binding, an
+ // expression-level write) may store something else in it.
+ const other = (node: object): void => {
+ for (const field of ["localId", "catchLocalId"]) {
+ const id = (node as Record)[field];
+ const key = typeof id === "string" ? keyOf(id) : null;
+ if (key) note(key, null);
+ }
+ };
+ everyStmtList(fn.body, {
+ expr: (expr) => {
+ if (expr.kind !== "varRef") other(expr);
+ return true;
+ },
+ stmt: (stmt) => {
+ const key = stmt.kind === "varDecl" || stmt.kind === "assign" ? keyOf(stmt.localId) : null;
+ if (stmt.kind === "varDecl" && key) {
+ if (stmt.init) note(key, stmt.init);
+ } else if (stmt.kind === "assign" && key) note(key, stmt.value);
+ else other(stmt);
+ return true;
+ },
+ });
+ }
+ const lifted = lowerer.liftedFns.length;
+ for (const fn of functions) {
+ fn.body = transformStmtList(fn.body, {
+ stmt: (stmt) => stmt,
+ expr: (expr) => {
+ if (expr.kind !== "dynFrom") return expr;
+ const read = expr.value.kind === "varRef" ? expr.value : null;
+ const source = read
+ ? (bound.get(localKey(fn, read.localId)) ?? bound.get(read.localId) ?? null)
+ : builtin(expr.value)
+ ? expr.value
+ : null;
+ if (!source) return expr;
+ const value = lowerer.checkedBuiltinValue(source);
+ const fnName =
+ expr.fnName ??
+ names.get(source.fnName) ??
+ (value.kind === "closure" ? names.get(value.fnName) : undefined);
+ const fnLength =
+ expr.fnLength ??
+ lengths.get(source.fnName) ??
+ (value.kind === "closure" ? lengths.get(value.fnName) : undefined);
+ const boxed: IrExpr = {
+ ...expr,
+ value,
+ ...(fnName !== undefined ? { fnName } : {}),
+ ...(fnLength !== undefined ? { fnLength } : {}),
+ };
+ if (!read) return boxed;
+ return {
+ kind: "seqExpr",
+ stmts: [{ kind: "exprStmt", expr: read, loc: read.loc }],
+ result: boxed,
+ type: DYN,
+ loc: expr.loc,
+ };
+ },
+ });
+ }
+ functions.push(...lowerer.liftedFns.slice(lifted));
+}
+
+/** A JavaScript call of a builtin with named value parameters whose
+ * arguments are not the native parameters' types or omit one
+ * (`path.matchesGlob(123, glob)`, `path.toNamespacedPath()`): the call goes
+ * through the checked
+ * adapter (checkedNativeCallableValue), which applies Node's validation and
+ * answers the value Node answers. Null for every other call, which keeps
+ * the direct lowering. */
+export function jsCheckedBuiltinCall(
+ lowerer: Lowerer,
+ call: ts.CallExpression,
+ fn: BuiltinModuleFn,
+ display: string,
+ loc: SrcLoc,
+): IrExpr | null {
+ const params = fn.valueParams;
+ if (!params?.some((param) => param.name !== undefined)) return null;
+ if (params.some((param) => param.mode === "rest")) return null;
+ if (!isJsSourceFile(ts.getSourceFileOfNode(call))) return null;
+ const args = call.arguments.nodes;
+ if (args.length > params.length || args.some((arg) => ts.isSpreadElement(arg))) return null;
+ // An omitted required argument is undefined to Node's validation too.
+ const required = params.filter((param) => param.mode === "required").length;
+ const mismatch =
+ args.length < required ||
+ args.some((arg, index) => {
+ const want = params[index]!.type;
+ if (want.kind === "dyn") return false;
+ const type = lowerer.mapTypeOf(lowerer.typeOf(arg));
+ return type === null || !typeEquals(type, want);
+ });
+ if (!mismatch) return null;
+ const adapter = checkedNativeCallableValue(lowerer, fn, display, loc);
+ if (adapter.kind !== "closure") return null;
+ return {
+ kind: "call",
+ callee: adapter.fnName,
+ args: params.map((_, index) =>
+ index < args.length ? lowerer.lowerExprExpecting(args[index]!, DYN) : dynUndefinedExpr(loc),
+ ),
+ type: fn.checked?.result ?? fn.result,
+ loc,
+ };
+}
diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts
index e9d79213c..4f420bdb3 100644
--- a/packages/compiler/src/frontend/lowering/lower-calls.ts
+++ b/packages/compiler/src/frontend/lowering/lower-calls.ts
@@ -1,3 +1,4 @@
+import { programCreatesWrappers } from "./primitive-wrappers.js";
import {
arrayClassMemberOwner,
arrayClassReceiver,
@@ -5,6 +6,7 @@ import {
} from "./builtins/array-classes.js";
import { structuralCallInstance } from "./structural-instances.js";
import { checkedPrivateMethodReceiver, privateNameClass } from "./private-brands.js";
+import { lowerStaticPrivateCall } from "./private-statics.js";
import { assertFunctionAliasOf } from "./lower-assert.js";
import { keyedHelperName } from "./stable-names.js";
import { perKeyOrder } from "./key-order-witness.js";
@@ -256,7 +258,12 @@ import {
nsUndefRead,
} from "./lower-namespaces.js";
import { expandoMemberRead } from "./lower-expando.js";
-import { fenceNodeModuleMutationCall, lowerRequireCacheKeys } from "./lower-node-module.js";
+import {
+ fenceNodeModuleMutationCall,
+ isCjsRequireGlobal,
+ lowerRequireCacheKeys,
+} from "./lower-node-module.js";
+import { pathModuleValue } from "./lower-builtin-values.js";
import {
defaultAfterUndefined,
lowerOptionalArgument,
@@ -468,6 +475,22 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
const inlineValue = lowerInlineRequireValue(lowerer, expr);
if (inlineValue) return inlineValue;
}
+ // `require("path/posix")` of a builtin as a value: the module's
+ // namespace, as a `const posix = require("path/posix")` binding is.
+ if (
+ ts.isIdentifier(expr.expression) &&
+ expr.expression.text === "require" &&
+ isCjsRequireGlobal(lowerer, expr.expression)
+ ) {
+ const module = lowerer.builtinNamespaceModuleOf(expr);
+ // The platform namespaces are the path module objects.
+ if (module === "path/posix" || module === "path/win32")
+ return pathModuleValue(lowerer, module, loc);
+ if (module !== null && module !== "constants") {
+ const moduleId = `builtin:${module}`;
+ return { kind: "moduleNsRef", moduleId, type: { kind: "moduleNs", moduleId }, loc };
+ }
+ }
// A call whose chain ROOTS at an ambient-undefined name (`declare
// const value: Y | undefined; value?.foo("a")`, `declare function
@@ -669,6 +692,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
ts.isPrivateIdentifier(expr.expression.name) &&
!expr.expression.questionDotToken
) {
+ const staticPrivate = lowerStaticPrivateCall(lowerer, expr, expr.expression);
+ if (staticPrivate) return staticPrivate;
const privateCall = lowerCheckedPrivateMethodCall(lowerer, expr, expr.expression);
if (privateCall) return privateCall;
}
@@ -2124,7 +2149,7 @@ function callableInstanceStringCall(
/** A call through an evaluated callee value: checked-dynamic callees,
* function unions, typed-rest and runtime-arity closures. */
-function lowerCallThroughValue(
+export function lowerCallThroughValue(
lowerer: Lowerer,
expr: ts.CallExpression,
callee: IrExpr,
@@ -4045,7 +4070,9 @@ function lowerDynReceiverMethodCall(
// else calls its member as Node does.
if (
(checkerUntyped ||
- (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) &&
+ (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 ||
+ // A receiver of the boxed String type may be a wrapper object.
+ (lowerer.createsWrappers ??= programCreatesWrappers(lowerer))) &&
!call.questionDotToken &&
!access.questionDotToken &&
!call.arguments.nodes.some(ts.isSpreadElement)
@@ -4289,7 +4316,24 @@ function lowerStringOrMemberCall(
const loc = locOf(call);
const receiverLocal = lowerer.declareHiddenLocal("%dynCallReceiver", DYN);
const receiver = varRef(receiverLocal.id, DYN, loc);
- const isString: IrExpr = { kind: "dynTest", test: "string", value: receiver, type: BOOL, loc };
+ const nameLit: IrExpr = { kind: "strLit", value: access.name.text, type: STRING, loc };
+ const receiverIsString: IrExpr = {
+ kind: "dynTest",
+ test: "string",
+ value: receiver,
+ type: BOOL,
+ loc,
+ };
+ // In a program that creates wrapper objects, a receiver whose member is
+ // the builtin String.prototype method runs the string lowering on
+ // ToString of the receiver, after the arguments, as the builtin does.
+ // The member is read once: the check inspects the value the call read.
+ const wrappers = (lowerer.createsWrappers ??= programCreatesWrappers(lowerer));
+ const builtinLocal = wrappers ? lowerer.declareHiddenLocal("%dynCallStringMethod", BOOL) : null;
+ const isString: IrExpr = builtinLocal
+ ? { kind: "varRef", localId: builtinLocal.id, type: BOOL, loc }
+ : receiverIsString;
+ const stringLocal = wrappers ? lowerer.declareHiddenLocal("%dynCallString", DYN) : null;
const calleeLocal = lowerer.declareHiddenLocal("%dynCallCallee", DYN);
const callee = varRef(calleeLocal.id, DYN, loc);
const stmts: IrStmt[] = [
@@ -4299,7 +4343,7 @@ function lowerStringOrMemberCall(
localId: calleeLocal.id,
init: {
kind: "ternary",
- cond: isString,
+ cond: receiverIsString,
then: dynUndefinedExpr(loc),
else_: {
kind: "dynKeyGet",
@@ -4313,16 +4357,53 @@ function lowerStringOrMemberCall(
},
loc,
},
+ ...(builtinLocal
+ ? [
+ {
+ kind: "varDecl" as const,
+ localId: builtinLocal.id,
+ init: {
+ kind: "logical" as const,
+ op: "||" as const,
+ left: receiverIsString,
+ right: {
+ kind: "libCall" as const,
+ fn: "wrapper.isStringMethod" as const,
+ args: [callee, nameLit],
+ type: BOOL,
+ loc,
+ },
+ type: BOOL,
+ loc,
+ },
+ loc,
+ },
+ ]
+ : []),
];
const args = call.arguments.nodes.map((node) => {
- const value = lowerer.lowerExpr(node);
+ const lowered = lowerer.lowerExpr(node);
+ // A unit-typed argument (`f()` of a void function, a literal undefined
+ // or null) has no local storage of its own: hold it as the dynamic
+ // value both branches receive.
+ const value =
+ lowered.type.kind === "void" ||
+ lowered.type.kind === "undefinedT" ||
+ lowered.type.kind === "nullT"
+ ? lowerer.coerceToExpected(lowered, DYN)
+ : lowered;
const local = lowerer.declareHiddenLocal("%dynCallArg", value.type);
stmts.push({ kind: "varDecl", localId: local.id, init: value, loc: value.loc });
return { node, value: varRef(local.id, value.type, value.loc) };
});
// String.prototype methods ignore arguments past the ones they read.
const arity = DYN_STRING_ONLY_ARITY[access.name.text] ?? call.arguments.nodes.length;
- const stringReceiver = (): IrExpr => ({ kind: "dynCheck", value: receiver, type: STRING, loc });
+ const stringReceiver = (): IrExpr => ({
+ kind: "dynCheck",
+ value: stringLocal ? varRef(stringLocal.id, DYN, loc) : receiver,
+ type: STRING,
+ loc,
+ });
const lowerString = (): IrExpr | null =>
(call.arguments.nodes.length <= arity
? lowerRegexMethodCall(lowerer, call, access, stringReceiver)
@@ -4351,7 +4432,35 @@ function lowerStringOrMemberCall(
kind: "ternary",
cond: isString,
then: stringCall
- ? lowerer.coerceToExpected(stringCall, DYN)
+ ? stringLocal
+ ? {
+ kind: "seqExpr",
+ stmts: [
+ {
+ kind: "varDecl",
+ localId: stringLocal.id,
+ init: {
+ kind: "ternary",
+ cond: receiverIsString,
+ then: receiver,
+ else_: {
+ kind: "libCall",
+ fn: "wrapper.thisString",
+ args: [receiver, nameLit],
+ type: DYN,
+ loc,
+ },
+ type: DYN,
+ loc,
+ },
+ loc,
+ },
+ ],
+ result: lowerer.coerceToExpected(stringCall, DYN),
+ type: DYN,
+ loc,
+ }
+ : lowerer.coerceToExpected(stringCall, DYN)
: nodeThrowExpr(
0,
"SC1090",
@@ -5496,24 +5605,60 @@ function lowerPrimitiveProtoCall(
}
// Number.prototype.toLocaleString("en-US") — the spec makes it
// NumberFormat(locale).format(this), so the en-US embedded formatter
- // answers exactly. The unlowered forms fence by NAME: no locale (the
- // host environment's default, which a compiled binary cannot carry),
- // other locales (ICU data the binary does not embed), options bags.
+ // answers exactly. Without a locale (or with `undefined`) the DEFAULT
+ // locale answers, over the ICU data scr_intl.c loads; other locales and
+ // options bags stay fenced.
if (name === "toLocaleString" && recvKind === "f64") {
- if (call.arguments.nodes.length === 0) {
- lowerer.noLowering(
- "Number.prototype.toLocaleString without a locale",
- call,
- "the default locale is the host environment's, which a compiled binary cannot carry — " +
- 'pass it explicitly: x.toLocaleString("en-US")',
- );
+ // Exactly undefined (or void): what the absent form means. A union that
+ // merely includes undefined may hold a locale at run time and falls
+ // through to the refusal below.
+ const isUndefined = (node: ts.Expression): boolean => {
+ const flags = lowerer.typeOf(node).flags;
+ return flags === ts.TypeFlags.Undefined || flags === ts.TypeFlags.Void;
+ };
+ const defaultLocale =
+ call.arguments.nodes.length === 0 ||
+ (call.arguments.nodes.length === 1 &&
+ !ts.isSpreadElement(call.arguments.nodes[0]!) &&
+ isUndefined(call.arguments.nodes[0]!));
+ if (defaultLocale) {
+ const operand = lowerer.lowerExpr(recv);
+ if (operand.type.kind !== "f64") return null;
+ const stmts: IrStmt[] = [];
+ let formatted: IrExpr = operand;
+ if (call.arguments.nodes.length === 1) {
+ // An explicit undefined may be any expression that evaluates to it
+ // (a call, a variable), so it still runs, after the receiver and
+ // before the formatting: the receiver's value is read first, so an
+ // argument that writes it does not change the number formatted. Its
+ // unit value has no standalone representation, so it is coerced
+ // into the dynamic slot every argument occupies.
+ const receiver = lowerer.declareHiddenLocal("%localeReceiver", F64);
+ stmts.push({ kind: "varDecl", localId: receiver.id, init: operand, loc });
+ formatted = varRef(receiver.id, F64, loc);
+ const arg = call.arguments.nodes[0]!;
+ const value = lowerer.lowerExpr(arg);
+ stmts.push({
+ kind: "exprStmt",
+ expr: value.type.kind === "void" ? value : lowerer.coerceInto(arg, value, DYN),
+ loc,
+ });
+ }
+ const call_: IrExpr = {
+ kind: "libCall",
+ fn: "icu.numFormatDefault",
+ args: [formatted],
+ type: STRING,
+ loc,
+ };
+ return stmts.length ? { kind: "seqExpr", stmts, result: call_, type: STRING, loc } : call_;
}
if (call.arguments.nodes.length > 1) {
lowerer.noLowering(
"Number.prototype.toLocaleString with an options bag",
call,
- "the embedded data covers DEFAULT options only (decimal notation, up to 3 fraction " +
- 'digits, grouping) — x.toLocaleString("en-US")',
+ "what lowers is the DEFAULT options shape (decimal notation, up to 3 fraction digits, " +
+ "grouping) at the default locale or at en-US — omit the options bag",
);
}
const locNode = call.arguments.nodes[0]!;
@@ -5527,8 +5672,8 @@ function lowerPrimitiveProtoCall(
? `Number.prototype.toLocaleString at locale "${locNode.text}"`
: "Number.prototype.toLocaleString with a non-literal locale",
locNode,
- '"en-US" (Node\'s default-build locale) is the one locale whose data the runtime embeds — ' +
- "everything else is ICU data the binary does not carry",
+ "what lowers is the default locale (no argument) and the composed en-US form — " +
+ "other locales are ICU data the binary does not negotiate yet",
);
}
const operand = lowerer.lowerExpr(recv);
diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts
index 4c8743d32..6fc6020ec 100644
--- a/packages/compiler/src/frontend/lowering/lower-classes.ts
+++ b/packages/compiler/src/frontend/lowering/lower-classes.ts
@@ -1,3 +1,4 @@
+import { lowerWrapperNew } from "./primitive-wrappers.js";
import { lowerAsyncLocalStorageOptions } from "./builtins/async-context.js";
import { shapeOverlayAnnotated } from "../shape-inference/index.js";
import {
@@ -2452,9 +2453,15 @@ export function collectClassShapeInner(
(modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword,
),
)) &&
+ // A private static ACCESSOR has no native storage yet, and a private
+ // static method that reads `arguments` would lose the arguments past
+ // its parameters when called through its value (`this.#m` returned by
+ // a getter): keep such a class on the static path, whose public
+ // static accessors refuse.
!decl.members.nodes.some(
(member) =>
- member.name &&
+ (ts.isAccessor(member) ||
+ (ts.isMethodDeclaration(member) && bodyReadsArguments(member))) &&
ts.isPrivateIdentifier(member.name) &&
ts
.getModifiers(member)
@@ -2523,8 +2530,22 @@ export function collectClassShapeInner(
.getModifiers(member)
?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword))
) {
- runtimeStatics.push(member);
- continue;
+ // A private static of a declaration-level class keeps its native
+ // storage (the %C.static:#m function, the %g.s.C.#x global): the
+ // constructor object never carries a property for it, and every
+ // access brand-checks its receiver (private-statics.ts). A field
+ // also stays in runtimeStatics, so its initializer runs in source
+ // order with the constructor's other initializers.
+ const privateStatic =
+ checkedStatics &&
+ !functionLocalClass(decl) &&
+ (member as { name?: ts.PropertyName }).name !== undefined &&
+ ts.isPrivateIdentifier((member as { name: ts.PropertyName }).name);
+ if (!privateStatic) {
+ runtimeStatics.push(member);
+ continue;
+ }
+ if (ts.isPropertyDeclaration(member)) runtimeStatics.push(member);
}
if (
functionLocalClass(decl) &&
@@ -5139,11 +5160,15 @@ function lowerStaticFieldInitsInner(lowerer: Lowerer, info: ClassInfo): IrStmt[]
| { pos: number; kind: "field"; f: ClassInfo["staticFields"][number] }
| { pos: number; kind: "block"; b: ts.ClassStaticBlockDeclaration };
const items: Item[] = [
- ...info.staticFields.map((f): Item => ({
- pos: ts.tokenStartOfNode(f.initializer),
- kind: "field",
- f,
- })),
+ // A runtime-statics class initializes its private static fields with
+ // its other initializers (initializeRuntimeStatics).
+ ...info.staticFields
+ .filter((f) => !(info.runtimeStatics && f.name.startsWith("#")))
+ .map((f): Item => ({
+ pos: ts.tokenStartOfNode(f.initializer),
+ kind: "field",
+ f,
+ })),
...(info.staticBlocks ?? []).map((b): Item => ({
pos: ts.tokenStartOfNode(b),
kind: "block",
@@ -5848,7 +5873,7 @@ export function lowerClassStaticRead(
!(ts.isCallExpression(site.parent) && site.parent.expression === site)
)
lowerer.noLowering(`Date.${name} read through a class that extends Date`, site);
- if (hasRuntimeStatics(info) && !found?.field)
+ if (hasRuntimeStatics(info) && !found?.field && !name.startsWith("#"))
return {
kind: "dynKeyGet",
value: lowerer.coerceToExpected(classValueRef(lowerer, info, receiver), DYN),
@@ -6746,7 +6771,9 @@ function staticCallOn(
if (date) return date;
}
if (
- (hasRuntimeStatics(info) && !findStaticOn(lowerer, info, access.name.text)?.field) ||
+ (hasRuntimeStatics(info) &&
+ !access.name.text.startsWith("#") &&
+ !findStaticOn(lowerer, info, access.name.text)?.field) ||
(info.callableBase &&
!findStaticOn(lowerer, info, access.name.text) &&
!findGenericStaticOn(lowerer, info, access.name.text))
@@ -7201,7 +7228,9 @@ export function lowerClassValueProperty(
if (info?.def.runtime && member !== "name") return null;
if (
info &&
- ((hasRuntimeStatics(info) && !findStaticOn(lowerer, info, member)?.field) ||
+ ((hasRuntimeStatics(info) &&
+ !member.startsWith("#") &&
+ !findStaticOn(lowerer, info, member)?.field) ||
(info.callableBase && !findStaticOn(lowerer, info, member)))
)
return {
@@ -10075,6 +10104,21 @@ function lowerProgramClassNew(
});
}
+/** The supported map layout a contextual type pins for `new Map()`: the type
+ * itself, or the single map arm of a union — a nullable annotation such as
+ * `Map | undefined`, or `ReadonlyMap | undefined`, where a Map
+ * value can only inhabit the map arm and the no-argument constructor gives
+ * the checker nothing to infer the type arguments from. */
+function contextualMapLayout(lowerer: Lowerer, ctx: ts.Type): IrType | null {
+ const mapped = lowerer.mapTypeOf(ctx);
+ if (mapped?.kind === "map") return mapped;
+ if (mapped?.kind !== "union") return null;
+ const arms = (lowerer.unions.get(mapped.unionId)?.arms ?? []).filter(
+ (arm): arm is IrType & { kind: "map" } => arm.kind === "map",
+ );
+ return arms.length === 1 ? arms[0]! : null;
+}
+
function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType | null {
const parent = expr.parent;
if (
@@ -10193,6 +10237,8 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
return lowerWithInlineRequireInit(lowerer, req, () => lowerNew(lowerer, expr));
}
}
+ const wrapper = lowerWrapperNew(lowerer, expr);
+ if (wrapper) return wrapper;
const worker = lowerWorkerNew(lowerer, expr);
if (worker) return worker;
const readlineUi = lowerReadlineUiNew(lowerer, expr) ?? lowerReadlineInterfaceNew(lowerer, expr);
@@ -10276,8 +10322,15 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
lowerer.isStdlibGlobal(expr.expression.expression, "Intl")
) {
const args = expr.arguments?.nodes ?? [];
- const undefinedArg = (arg: ts.Expression): boolean =>
- (lowerer.typeOf(arg).flags & ts.TypeFlags.Undefined) !== 0;
+ // An argument whose value IS undefined: exactly undefined, or void. A
+ // union that merely includes undefined may hold a locale or an options
+ // object at run time, and the constructor cannot inspect either — such
+ // an argument is refused below rather than silently read as the default
+ // locale or the default options.
+ const undefinedArg = (arg: ts.Expression): boolean => {
+ const flags = lowerer.typeOf(arg).flags;
+ return flags === ts.TypeFlags.Undefined || flags === ts.TypeFlags.Void;
+ };
// A literal language tag: Unicode grapheme clusters have no locale
// tailoring. Only tags of the form language[-script][-region] are
// accepted; each is well-formed, so Node constructs the segmenter
@@ -10292,13 +10345,14 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
"omit the locale, pass undefined, or pass one language tag as a string literal",
);
}
- // JavaScript dependencies may construct word and sentence segmenters
- // that they use only on some paths (pi-tui's shared word segmenter):
- // the construction compiles and their segment() throws SC2020.
- // TypeScript sources keep the build-time refusal.
+ // Word segmentation lowers through ICU (scr_intl.c). Sentence
+ // segmentation does not: JavaScript dependencies may construct sentence
+ // segmenters they use only on some paths, so the construction compiles
+ // and their segment() throws SC2020; TypeScript sources keep the
+ // build-time refusal.
const granularities = isJsSourceFile(ts.getSourceFileOfNode(expr))
? ["grapheme", "word", "sentence"]
- : ["grapheme"];
+ : ["grapheme", "word"];
const options = args[1];
let granularity = "grapheme";
if (options && !undefinedArg(options)) {
@@ -10308,7 +10362,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
lowerer.noLowering(
"Intl.Segmenter options",
options,
- 'the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported',
+ 'the native segmenter supports default options, { granularity: "grapheme" } and { granularity: "word" }; sentence segmentation remains unsupported',
);
for (const property of options.properties.nodes) {
if (
@@ -10321,7 +10375,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
lowerer.noLowering(
"Intl.Segmenter options",
options,
- 'the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported',
+ 'the native segmenter supports default options, { granularity: "grapheme" } and { granularity: "word" }; sentence segmentation remains unsupported',
);
}
granularity = property.initializer.text;
@@ -10336,16 +10390,32 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
loc: locOf(arg),
};
});
+ // Grapheme segmentation is the engine-free table-driven segmenter
+ // (scr_grapheme.c). NOTE: once ICU is established everywhere, this fast
+ // path is deleted in favor of ICU's character iterator (design I4).
let result: IrExpr = { kind: "libCall", fn: "intl.segmenterNew", args: [], type: DYN, loc };
- // A word or sentence segmenter carries its segment() refusal, which
- // coverage lists as deferred to runtime.
- if (granularity !== "grapheme") {
+ if (granularity === "word") {
+ // ICU's word iterator at the requested locale; a literal language tag
+ // is the locale, and the empty string means the default locale (what
+ // Node negotiates when the argument is absent or undefined).
+ const tag = args[0] !== undefined && ts.isStringLiteralLike(args[0]) ? args[0].text : "";
+ result = {
+ kind: "libCall",
+ fn: "icu.segmenterWord",
+ args: [strLit(tag, loc)],
+ type: DYN,
+ loc,
+ };
+ }
+ // A sentence segmenter carries its segment() refusal, which coverage
+ // lists as deferred to runtime.
+ if (granularity === "sentence") {
const surface = `Intl.Segmenter segmentation with granularity '${granularity}'`;
lowerer.runtimeFences.push(
noLoweringDiag(
surface,
loc,
- "only grapheme segmenters segment; word and sentence segmenters construct",
+ "only grapheme and word segmenters segment; sentence segmenters construct",
),
);
const line = ts.getLineAndCharacterOfPosition(ts.getSourceFileOfNode(expr), loc.start).line;
@@ -11068,10 +11138,10 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
mapped = lowerer.mapTypeOf(inferred);
} else if (mapped?.kind !== "map" || contextualSeed) {
const ctx = lowerer.checker.getContextualType(expr);
- const ctxMapped = ctx ? lowerer.mapTypeOf(ctx) : null;
- if (ctx && ctxMapped?.kind === "map") {
+ const ctxMap = ctx ? contextualMapLayout(lowerer, ctx) : null;
+ if (ctx && ctxMap !== null) {
tsType = ctx;
- mapped = ctxMapped;
+ mapped = ctxMap;
}
}
// `new Map(source)` the checker types `Map` because the
diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts
index f44a3d6d8..92cc7f23d 100644
--- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts
+++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts
@@ -49,6 +49,7 @@ import type { Lowerer } from "./lowerer.js";
import { dynFallbackType, newFnCtx } from "./lowerer.js";
import type { ClassInfo } from "./lower-classes.js";
import { isJsSourceFile, locOf } from "../program.js";
+import { bodyReadsArguments } from "../arguments-usage.js";
import {
arrayOf,
BOOL,
@@ -339,11 +340,13 @@ function emitterEvents(lowerer: Lowerer): Map {
const cbCt = lowerer.typeOf(node.arguments.nodes[1]);
const cbT =
lowerer.mapTypeOf(cbCt) ?? dynFallbackType(lowerer, node.arguments.nodes[1], cbCt);
+ const pack = readsArgumentsPack(lowerer, node.arguments.nodes[1]);
// A TypeScript listener with no parameters is typed too: it
// registers without the adapter, so an untyped emit reaches it
// only through the event's fixed ABI. A JavaScript function
// without parameters can still read `arguments`.
if (
+ !pack &&
cbT?.kind !== "dyn" &&
(cbT?.kind !== "func" ||
(cbT.params.length === 0 && !isJsSourceFile(sf)) ||
@@ -358,7 +361,8 @@ function emitterEvents(lowerer: Lowerer): Map {
// undefined (exactly JS's extra-parameter semantics).
if (
cbT?.kind === "dyn" ||
- (cbT?.kind === "func" && cbT.params.some((p) => p.kind === "dyn"))
+ (cbT?.kind === "func" && cbT.params.some((p) => p.kind === "dyn")) ||
+ pack
) {
const sig = sigOf(name);
sig.hasListener = true;
@@ -714,6 +718,26 @@ function tupleOf(
return sig.tuple;
}
+/** True when the listener is a function whose body reads `arguments`: it
+ * lowers with an arguments-pack ABI and takes every emitted argument,
+ * which the typed path's call with a prefix of the event's tuple would not
+ * pass, so it registers as a checked-dynamic listener. */
+function readsArgumentsPack(lowerer: Lowerer, node: ts.Expression): boolean {
+ let declaration: ts.Node | undefined;
+ try {
+ declaration = lowerer.checker.getCallSignatures(lowerer.typeOf(node))[0]?.declaration;
+ } catch {
+ return false;
+ }
+ return (
+ declaration !== undefined &&
+ ts.isFunctionLike(declaration) &&
+ !ts.isArrowFunction(declaration) &&
+ isJsSourceFile(ts.getSourceFileOfNode(declaration)) &&
+ bodyReadsArguments(declaration)
+ );
+}
+
/** How a listener argument is dyn-flavored: "dyn" for a checked-dynamic
* VALUE (a mustCall wrapper, a listener that rode an untyped binding),
* "func" for a function whose parameters include dyn (the JS lane's
@@ -734,6 +758,7 @@ function dynListenerFlavor(lowerer: Lowerer, node: ts.Expression): "dyn" | "func
if (t?.kind === "dyn") return "dyn";
if (t?.kind === "func") {
if (t.params.some((p) => p.kind === "dyn")) return "func";
+ if (readsArgumentsPack(lowerer, node)) return "func";
// The inferred signature of a wrapper can omit its runtime arguments
// pack. Register the checked callable produced by the factory itself.
return tryLowerExpression(lowerer, node)?.type.kind === "dyn" ? "dyn" : null;
@@ -2532,6 +2557,43 @@ export function lowerEventsOnce(lowerer: Lowerer, call: ts.CallExpression, loc:
const arrayT = arrayOf(DYN);
const resultT: IrType = { kind: "promise", inner: arrayT };
let receiver = lowerer.lowerExpr(args[0]!);
+ // events.once(process.stdout, "drain"): the next 'drain' of a stream
+ // whose writes are synchronous never comes (Node emits it only after a
+ // write returned false), so the promise stays pending. A pending promise
+ // holds nothing: the process exits when the loop is empty, as in Node.
+ {
+ let target = args[0]!;
+ while (ts.isParenthesizedExpression(target)) target = target.expression;
+ const stream = ts.isPropertyAccessExpression(target)
+ ? lowerer.stdlibGlobalMember(target, "process")
+ : null;
+ const nameT = lowerer.typeOf(args[1]!);
+ if (
+ (stream === "stdout" || stream === "stderr") &&
+ nameT.isStringLiteralType() &&
+ nameT.value === "drain" &&
+ args.length === 2
+ ) {
+ const pending: IrExpr = {
+ kind: "libCall",
+ fn: "events.pendingForever",
+ args: [],
+ type: resultT,
+ loc,
+ };
+ let name = args[1]!;
+ while (ts.isParenthesizedExpression(name)) name = name.expression;
+ if (ts.isStringLiteralLike(name)) return pending;
+ // A name expression typed "drain" still runs (`pick()`, `(log(), "drain")`).
+ return {
+ kind: "seqExpr",
+ stmts: [{ kind: "exprStmt", expr: lowerer.lowerExpr(args[1]!), loc }],
+ result: pending,
+ type: resultT,
+ loc,
+ };
+ }
+ }
// A checked-dynamic NodeJS stream slot (dyn-streams.ts): a native stream
// unboxes by identity; any other value throws the not-supported Error.
if (
diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts
index f82728c6e..00299ce58 100644
--- a/packages/compiler/src/frontend/lowering/lower-modules.ts
+++ b/packages/compiler/src/frontend/lowering/lower-modules.ts
@@ -1189,8 +1189,8 @@ function deferJsCollectionDiags(lowerer: Lowerer, sf: ts.SourceFile, diagsBefore
if (!isJsSourceFile(sf)) return;
const captured = lowerer.diags.splice(diagsBefore);
const ice = captured.filter((d) => d.code === "SC9001");
- if (ice.length > 0) lowerer.diags.push(...ice);
- lowerer.runtimeFences.push(...captured.filter((d) => d.code !== "SC9001"));
+ for (const d of ice) lowerer.diags.push(d);
+ for (const d of captured) if (d.code !== "SC9001") lowerer.runtimeFences.push(d);
}
/** A scalar-literal expression a single-value `module.exports =` can carry
diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts
index 46354dedc..dc49bb99f 100644
--- a/packages/compiler/src/frontend/lowering/lower-node-island.ts
+++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts
@@ -20,7 +20,7 @@ import { readFileSync, realpathSync } from "node:fs";
import { resolveFrom } from "../../node-islands/resolve.js";
import { dirname, isAbsolute, join } from "node:path";
import * as ts from "../ts7/adapter.js";
-import { locOf } from "../program.js";
+import { isJsSourceFile, locOf } from "../program.js";
import type { NodeApiMember } from "../node-api-import.js";
import { PoisonError, newFnCtx, type Lowerer } from "./lowerer.js";
import {
@@ -379,6 +379,17 @@ function deferIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr
};
}
+/** Whether `node` runs while its module evaluates: no enclosing function,
+ * method, accessor, constructor or class member initializer. */
+function runsAtModuleEvaluation(node: ts.Node): boolean {
+ for (let n = node.parent; n !== undefined; n = n.parent) {
+ if (ts.isSourceFile(n)) return true;
+ if (ts.isFunctionLike(n) || ts.isClassStaticBlockDeclaration(n) || ts.isPropertyDeclaration(n))
+ return false;
+ }
+ return false;
+}
+
/** A static import (or re-export) of an island package: loads the module
* in the importer's %init prelude. Returns false when `spec` is not an
* island module. */
@@ -510,8 +521,22 @@ function islandBindingOf(lowerer: Lowerer, ident: ts.Identifier): IslandBinding
const name = decl.propertyName?.text ?? decl.name.text;
binding = name === "default" ? "default" : { name };
} else {
+ // `const { name } = require("pkg")`: a member of module.exports, read
+ // at each use (shortcut: not copied once at the declaration).
+ const property = requiredPropertyOf(decl);
+ if (property !== null && loadsAtStatement(property.declaration)) {
+ const spec = requireInitSpecOf(lowerer, property.declaration);
+ if (spec === null) return null;
+ const file = ts.getSourceFileOfNode(decl).fileName;
+ const path = islandModuleFile(islands, file, spec, "require");
+ const module = path === null ? undefined : lowerer.islandModules.get(path);
+ return module === undefined ? null : { kind: "named", module, name: property.name };
+ }
// `const x = require("pkg")` (a CommonJS module of a native package
- // requiring an island package): x is module.exports.
+ // requiring an island package): x is module.exports. Only at the
+ // module's top level: a function's own binding is a local.
+ const statement = decl.parent?.parent;
+ if (statement === undefined || !ts.isSourceFile(statement.parent)) return null;
const spec = requireSpecOf(lowerer, decl);
if (spec === null) return null;
const path = islandModuleFile(islands, ts.getSourceFileOfNode(decl).fileName, spec, "require");
@@ -717,8 +742,41 @@ function undeclaredHandleType(lowerer: Lowerer, type: ts.Type): boolean {
return containerType(lowerer, type);
}
+/** Builtin classes whose native representation is not a JavaScript object
+ * (URLSearchParams, Headers, Request, …): an island instance of one has no
+ * native counterpart to convert into, so it stays a handle. */
+const BUILTIN_HANDLE_CLASSES: ReadonlySet = new Set([
+ "URL",
+ "URLSearchParams",
+ "Headers",
+ "Request",
+ "Response",
+ "FormData",
+ "Blob",
+ "File",
+ "AbortController",
+ "AbortSignal",
+ "ReadableStream",
+ "WritableStream",
+ "TransformStream",
+ "TextEncoder",
+ "TextDecoder",
+]);
+
+/** Whether `type` is one of BUILTIN_HANDLE_CLASSES as declared by the
+ * standard library or @types/node (not a program class of the same name). */
+function builtinHandleClass(type: ts.Type): boolean {
+ const symbol = type.getSymbol();
+ if (symbol === undefined || !BUILTIN_HANDLE_CLASSES.has(String(symbol.escapedName))) return false;
+ return (
+ symbol.declarations.length > 0 &&
+ symbol.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile)
+ );
+}
+
/** Arrays, tuples, Maps and Sets (every arm of a union, nullish arms
- * aside): the object types an island hands back without declaring them. */
+ * aside), and the builtin classes of builtinHandleClass: the object types
+ * an island hands back without declaring them. */
function containerType(lowerer: Lowerer, type: ts.Type): boolean {
if (type.isUnionType())
return ts
@@ -731,7 +789,7 @@ function containerType(lowerer: Lowerer, type: ts.Type): boolean {
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
if (lowerer.checker.isArrayType(type) || lowerer.checker.isTupleType(type)) return true;
const name = type.getSymbol()?.escapedName;
- return name === "Map" || name === "Set";
+ return name === "Map" || name === "Set" || builtinHandleClass(type);
}
/** A member read, call, index or `new` whose receiver or callee is an
@@ -1675,6 +1733,20 @@ function islandReceiver(
return { value: lowerer.coerceToExpected(lowerer.lowerExpr(receiver), FOREIGN) };
}
+/** Whether `require(spec)` in `file` loads an island module. */
+export function isIslandRequireSpec(lowerer: Lowerer, file: string, spec: string): boolean {
+ const islands = lowerer.nodeIslands;
+ return islands !== null && islandModuleFile(islands, file, spec, "require") !== null;
+}
+
+/** `require("")` in a CommonJS module, at a call no other
+ * lowering claimed (inside a function, or as a subexpression): the island
+ * module loads at the call. Null when the package is not an island. */
+export function lowerIslandRequireCall(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null {
+ if (lowerer.nodeIslands === null) return null;
+ return lowerIslandLoadCall(lowerer, call, locOf(call)) ?? null;
+}
+
/** `require("pkg")` (the global require, anywhere) and `import("pkg")` of
* an island module: load it at the call. Undefined: not such a call. */
function lowerIslandLoadCall(
@@ -1707,7 +1779,14 @@ function lowerIslandLoadCall(
loc,
};
}
- return fromIsland(lowerer, loadIslandModule(lowerer, path, loc), expr);
+ // A require() the module's own evaluation runs (not inside a function or
+ // class body) loads the island exactly when a static import would: it is
+ // deferred the same way (scope-B NI-7d), so a native package's top-level
+ // `const x = require("")` does not start Node at boot.
+ const load = runsAtModuleEvaluation(expr)
+ ? deferIslandModule(lowerer, path, loc)
+ : loadIslandModule(lowerer, path, loc);
+ return fromIsland(lowerer, load, expr);
}
/* ── native classes extending island classes (scope-B NI-5b) ───────────
@@ -2014,15 +2093,86 @@ function plainLiteral(node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpressi
/** The specifier of `x = require("spec")` (a variable declaration binding
* an identifier to a literal require), or null. */
function requireSpecOf(lowerer: Lowerer, decl: ts.Node): string | null {
- if (
- !ts.isVariableDeclaration(decl) ||
- !ts.isIdentifier(decl.name) ||
- decl.initializer === undefined
- )
- return null;
+ if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) return null;
// Only a const keeps the module: a let or var can be reassigned.
const list = decl.parent;
if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return null;
+ return requireInitSpecOf(lowerer, decl);
+}
+
+/** The property a destructured CommonJS require binds: `name` in
+ * `const { name } = require("spec")` or `const { name: local } = …`, with
+ * the declaration. Rest elements, defaults and nested patterns are null. */
+function requiredPropertyOf(
+ decl: ts.Node,
+): { name: string; declaration: ts.VariableDeclaration } | null {
+ if (!ts.isBindingElement(decl) || decl.dotDotDotToken || decl.initializer) return null;
+ if (!ts.isIdentifier(decl.name)) return null;
+ const pattern = decl.parent;
+ if (pattern === undefined || !ts.isObjectBindingPattern(pattern)) return null;
+ const declaration = pattern.parent;
+ if (declaration === undefined || !ts.isVariableDeclaration(declaration)) return null;
+ const key = decl.propertyName;
+ const name =
+ key === undefined
+ ? decl.name.text
+ : ts.isIdentifier(key) || ts.isStringLiteral(key)
+ ? key.text
+ : null;
+ return name === null ? null : { name, declaration };
+}
+
+/** Whether every element of `const { … } = require("")` binds a
+ * plain member (requiredPropertyOf): only then do the names read the
+ * module global; other patterns keep the ordinary lowering. */
+function islandPatternBindable(pattern: ts.ObjectBindingPattern): boolean {
+ return pattern.elements.nodes.every((e) => requiredPropertyOf(e) !== null);
+}
+
+/** Whether the top-level declaration `decl` of a JavaScript module loads
+ * its island module at the statement (lowerIslandRequireAtStatement)
+ * rather than in the module's %init prelude: `const` declarations binding
+ * an identifier or a plain object pattern. Reads before the statement are
+ * TDZ errors in Node, so the binding is never observed unassigned. `let`
+ * and `var` bindings can be reassigned: they keep the ordinary lowering. */
+function loadsAtStatement(decl: ts.VariableDeclaration): boolean {
+ const list = decl.parent;
+ if (list === undefined || !ts.isVariableDeclarationList(list)) return false;
+ if ((list.flags & ts.NodeFlags.Const) === 0) return false;
+ if (!isJsSourceFile(ts.getSourceFileOfNode(decl))) return false;
+ if (ts.isIdentifier(decl.name)) return true;
+ return ts.isObjectBindingPattern(decl.name) && islandPatternBindable(decl.name);
+}
+
+/** The top-level statement `const x = require("")` or `const { a,
+ * b: c } = require("")` of a JavaScript module: assigns the island
+ * module (deferred like any top-level require) to its module global at the
+ * statement's position, so writes before it (`process.env.NODE_ENV = …`)
+ * are visible to the module's evaluation, as in Node; `x` is the module,
+ * `a` and `c` read its members at each use (shortcut: not copied once at
+ * the declaration). Null for any other declaration. */
+export function lowerIslandRequireAtStatement(
+ lowerer: Lowerer,
+ decl: ts.VariableDeclaration,
+): IrStmt | null {
+ const islands = lowerer.nodeIslands;
+ if (islands === null || !loadsAtStatement(decl)) return null;
+ const spec = requireInitSpecOf(lowerer, decl);
+ if (spec === null) return null;
+ const path = islandModuleFile(islands, ts.getSourceFileOfNode(decl).fileName, spec, "require");
+ const module = path === null ? undefined : lowerer.islandModules.get(path);
+ if (path === null || module === undefined) return null;
+ const loc = locOf(decl);
+ const load = runsAtModuleEvaluation(decl)
+ ? deferIslandModule(lowerer, path, loc)
+ : loadIslandModule(lowerer, path, loc);
+ return { kind: "assign", localId: module.global.id, value: load, loc };
+}
+
+/** The specifier of a variable declaration initialized with a literal
+ * `require("spec")`, whatever its binding name or pattern, or null. */
+function requireInitSpecOf(lowerer: Lowerer, decl: ts.VariableDeclaration): string | null {
+ if (decl.initializer === undefined) return null;
let init: ts.Expression = decl.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
if (
@@ -2046,13 +2196,27 @@ function requireSpecOf(lowerer: Lowerer, decl: ts.Node): string | null {
/** Top-level `const x = require("pkg")` statements of island packages in a
* compiled module (a native package requiring an island package): load the
* island module in the module's %init prelude (shortcut: before the body,
- * not at the statement). */
+ * not at the statement; JavaScript `const` declarations load at the
+ * statement instead, loadsAtStatement), deferred exactly like a static import (scope-B
+ * NI-7d: the build decides from the probe, stubs stand in until first use). */
export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void {
const islands = lowerer.nodeIslands;
if (islands === null) return;
for (const stmt of sf.statements.nodes) {
if (!ts.isVariableStatement(stmt)) continue;
for (const decl of stmt.declarationList.declarations.nodes) {
+ // JavaScript `const` requires: the module global exists from
+ // here on; the statement itself loads the module at its position
+ // (lowerIslandRequireAtStatement).
+ if (loadsAtStatement(decl)) {
+ const spec = requireInitSpecOf(lowerer, decl);
+ const path = spec === null ? null : islandModuleFile(islands, sf.fileName, spec, "require");
+ if (path !== null) {
+ lowerer.usesNodeIsland = true;
+ islandModuleGlobal(lowerer, path);
+ }
+ continue;
+ }
const spec = requireSpecOf(lowerer, decl);
if (spec === null) continue;
const path = islandModuleFile(islands, sf.fileName, spec, "require");
@@ -2065,7 +2229,7 @@ export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void
actions.push({
kind: "assign",
localId: binding.global.id,
- value: loadIslandModule(lowerer, path, loc),
+ value: deferIslandModule(lowerer, path, loc),
loc,
});
}
diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts
index 44f2b7370..f1db4bcf1 100644
--- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts
+++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts
@@ -1425,7 +1425,7 @@ export function fenceStaticReadableStreamMember(
lowerer.noLowering(
`ReadableStream.${member} in a static build`,
access,
- "the native static ReadableStream surface is locked plus cancel() and getReader(); values()/[Symbol.asyncIterator]() compile when consumed directly by for await, while stored iterator handles remain unsupported",
+ "ReadableStream's locked compiles as a read, and getReader(), cancel(), tee(), pipeTo() and pipeThrough() as direct calls; values()/[Symbol.asyncIterator]() compile when consumed directly by for await, while method values and stored iterator handles remain unsupported",
sym,
);
}
@@ -1965,8 +1965,9 @@ export function lowerStaticReadableStreamNew(
/** `new TransformStream()`: the identity transform, a native handle whose
* readable side is a native ReadableStream and whose writable side is the
- * runtime's WritableStream. A transformer or queuing strategies have no
- * native lowering yet and keep the SC2020 refusal. */
+ * runtime's WritableStream. A TransformStream with a transformer or
+ * queuing strategies is built with the web stream classes instead
+ * (web-streams-dyn.ts, lowerWebStreamNew). */
export function lowerStaticTransformStreamNew(
lowerer: Lowerer,
expr: ts.NewExpression,
diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts
index 377b08917..8231365b1 100644
--- a/packages/compiler/src/frontend/lowering/lower-stmts.ts
+++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts
@@ -1,8 +1,15 @@
+import { lowerStaticPrivateAssignment } from "./private-statics.js";
import type { DiagnosticCode } from "../../diagnostics/codes.js";
import { keepsInitializerLayout, notePreservedLayout } from "./structural-instances.js";
import { jsGlobalValueByName } from "./global-values.js";
import { lowerTupleAliasDecl, stripTypeWrappers } from "./typed-arguments.js";
-import { islandHandleBinding, lowerIslandExpr, lowerIslandSubclass } from "./lower-node-island.js";
+import {
+ islandHandleBinding,
+ lowerIslandExpr,
+ isIslandRequireSpec,
+ lowerIslandRequireAtStatement,
+ lowerIslandSubclass,
+} from "./lower-node-island.js";
import { isIslandSubclassDecl } from "../../node-islands/boundary.js";
import {
allBytesArms,
@@ -25,7 +32,7 @@ import {
matchAllRowsReadByElements,
storedMatchAllRowsReadByElements,
} from "./containers/match-row-uses.js";
-import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../ir/build.js";
+import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js";
import { runtimeKeyPatternRead } from "./expressions/keyed-field-targets.js";
import { declaresHoistedBinding, staticCondition } from "./static-typeof.js";
import { lowerCollectionSpread } from "./containers/collection-methods.js";
@@ -961,8 +968,8 @@ function lowerOneStatement(
if (deferFences && lowerer.diagSink === null && lowerer.diags.length > diagsBefore) {
const flushed = lowerer.diags.splice(diagsBefore);
const ice = flushed.filter((d) => d.code === "SC9001");
- lowerer.diags.push(...ice);
- lowerer.runtimeFences.push(...flushed.filter((d) => d.code !== "SC9001"));
+ for (const d of ice) lowerer.diags.push(d);
+ for (const d of flushed) if (d.code !== "SC9001") lowerer.runtimeFences.push(d);
}
}
@@ -1222,7 +1229,10 @@ export function predeclareForwardFnDecl(lowerer: Lowerer, symbol: ts.Symbol): bo
entry.out.push(lowerer.lowerNestedFunctionDecl(decl));
} catch (e) {
entry.ctx.hoistedFnDecls.delete(decl);
- if (previousSink !== null) previousSink.push(...lowerer.diags.splice(diagsBefore));
+ if (previousSink !== null) {
+ const flushed = lowerer.diags.splice(diagsBefore);
+ for (const d of flushed) previousSink.push(d);
+ }
throw e;
} finally {
lowerer.diagSink = previousSink;
@@ -2177,6 +2187,10 @@ function lowerVarDeclarator(
const spec = requireSpecOf(decl.initializer)!;
if (lowerer.externalTypes.has(spec)) lowerer.externalHostFence(spec, decl, false);
if (ts.isSourceFile(stmt.parent)) {
+ // `const x = require("")` and `const { name } = …`: the
+ // island module loads here, at the statement (lower-node-island.ts).
+ const island = lowerIslandRequireAtStatement(lowerer, decl);
+ if (island !== null) return [island];
const init = lowerer.requireInitStmt(spec, decl);
const out: IrStmt[] = init ? [init] : [];
// Nested patterns destructure the named export's value right after
@@ -2201,6 +2215,12 @@ function lowerVarDeclarator(
// same routing top-level requires use, and no statement remains.
// Relative requires keep the fence: their module-init-at-position
// semantics needs storage this model doesn't represent.
+ // A nested require of an island package is an ordinary binding of the
+ // island module, loaded at the statement (lower-node-island.ts).
+ if (isIslandRequireSpec(lowerer, ts.getSourceFileOfNode(decl).fileName, spec)) {
+ const lowered = lowerer.lowerVarDecl(decl, isLet);
+ return lowered ? [lowered] : [];
+ }
if (canonicalBuiltinModule(spec) !== null) {
return canonicalBuiltinModule(spec) === "punycode"
? [lowerer.punycodeRequireWarning(decl)]
@@ -2549,13 +2569,26 @@ export function lowerDestructuringDecl(
}
}
const out: IrStmt[] = [];
+ // V8's destructuring TypeError spells STATIC sources: an identifier
+ // source reads " is not iterable", an identifier-callee call
+ // " is not a function or its return value is not iterable";
+ // everything else (property reads, method calls, parameters) gets the
+ // runtime kind wording — exactly V8's CallPrinter behavior. The DYN
+ // pattern arm and the unit arms of a narrowed union consume this.
+ const src = decl.initializer!;
+ const dynSpell = ts.isIdentifier(src)
+ ? `${src.text} is not iterable`
+ : ts.isCallExpression(src) && ts.isIdentifier(src.expression)
+ ? `${src.expression.text} is not a function or its return value is not iterable`
+ : undefined;
// JS: a union source with exactly ONE destructurable arm beside unit
// arms (`nameAndArgs.match(re)` — `RegExpMatchArray | null`, the
// always-matches idiom) destructures through a VALIDATED narrow: the
- // unit arms throw at runtime exactly where Node's TypeError would
- // (the message is the fence's — a unit value here is the failure
- // path either way). TypeScript keeps the fence: narrowing first is
- // the annotated fix.
+ // unit arms throw Node's TypeError, through the checks a dyn source
+ // takes (RequireObjectCoercible for an object pattern, GetIterator for
+ // an array pattern). An object pattern whose source has no V8
+ // spelling keeps the fence. TypeScript keeps the fence: narrowing
+ // first is the annotated fix.
if (isJsSourceFile(ts.getSourceFileOfNode(decl)) && init.type.kind === "union") {
const def = lowerer.unions.get(init.type.unionId);
const dataArms = (def?.arms ?? []).filter((a) => !isUnitType(a));
@@ -2568,9 +2601,63 @@ export function lowerDestructuringDecl(
(arm.kind === "array" || arm.kind === "record")
) {
const tag = def.arms.indexOf(arm);
+ const unionId = init.type.unionId;
const utmp = lowerer.declareHiddenLocal("%destrU", init.type);
out.push({ kind: "varDecl", localId: utmp.id, init, loc });
const uref: IrExpr = { kind: "varRef", localId: utmp.id, type: init.type, loc };
+ // The failure branch holds one of the unit arms: the check reads it
+ // as the dyn null or undefined it is, so the data arm needs no dyn
+ // form (a record holding a generator has none).
+ const unitValue = (): IrExpr => {
+ const unitOf = (t: IrType): IrExpr =>
+ lowerer.coerceToExpected(
+ { kind: "unitLit", unit: t.kind === "nullT" ? "null" : "undefined", type: t, loc },
+ DYN,
+ );
+ const units = def.arms.filter(isUnitType);
+ const last = units[units.length - 1]!;
+ return units.slice(0, -1).reduceRight(
+ (rest, t) => ({
+ kind: "ternary",
+ cond: {
+ kind: "unionIsTag",
+ unionId,
+ tag: def.arms.indexOf(t),
+ negated: false,
+ value: uref,
+ type: BOOL,
+ loc,
+ },
+ then: unitOf(t),
+ else_: rest,
+ type: DYN,
+ loc,
+ }),
+ unitOf(last),
+ );
+ };
+ const spelling = destrSpellingOf(src);
+ const check: IrExpr | null = ts.isArrayBindingPattern(decl.name)
+ ? {
+ kind: "dynIterN",
+ value: unitValue(),
+ count: decl.name.elements.nodes.length,
+ ...(dynSpell ? { notIterableMessage: dynSpell } : {}),
+ type: DYN,
+ loc,
+ }
+ : firstReadsWithDefault(decl.name as ts.ObjectBindingPattern)
+ ? firstKeyRead(lowerer, decl.name as ts.ObjectBindingPattern, unitValue, loc)
+ : spelling !== null
+ ? {
+ kind: "dynDestrCheck",
+ value: unitValue(),
+ spelling,
+ ...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern),
+ type: DYN,
+ loc,
+ }
+ : null;
out.push({
kind: "if",
cond: {
@@ -2583,6 +2670,7 @@ export function lowerDestructuringDecl(
loc,
},
then: [
+ ...(check !== null ? [{ kind: "exprStmt" as const, expr: check, loc }] : []),
{
kind: "runtimeFence",
code: "SC1031",
@@ -2610,22 +2698,25 @@ export function lowerDestructuringDecl(
// value: RequireObjectCoercible with V8's own TypeError ("Cannot
// destructure property 'a' of 'f(...)' as it is null.") for the source
// spellings the assignment form reproduces; other sources are refused,
- // as in the assignment form. JavaScript files keep their existing
- // per-read errors, except that a runtime computed first key checks the
- // source first: Node throws before it evaluates any key, and V8's bare
- // message names the source.
- const jsRuntimeKeyCheck =
+ // as in the assignment form. A JavaScript object pattern over a dyn
+ // source checks the source first in the same way when V8 can spell it,
+ // so a null or undefined source throws Node's message before any key
+ // is read or evaluated; other sources keep their per-read errors. V8
+ // skips the check when the first element has a default and a static
+ // key: the read of that key throws ("Cannot read properties of null
+ // (reading 'a')"), and so does the lowering's.
+ const jsSourceCheck =
init.type.kind === "dyn" &&
ts.isObjectBindingPattern(decl.name) &&
isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
- (decl.name.elements.nodes.length === 0 || startsWithRuntimeKey(lowerer, decl.name)) &&
destrSpellingOf(decl.initializer) !== null;
if (
- jsRuntimeKeyCheck ||
- (init.type.kind === "dyn" &&
- ts.isObjectBindingPattern(decl.name) &&
- !isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
- lowerer.anyOrigin(decl.initializer))
+ (jsSourceCheck ||
+ (init.type.kind === "dyn" &&
+ ts.isObjectBindingPattern(decl.name) &&
+ !isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
+ lowerer.anyOrigin(decl.initializer))) &&
+ !firstReadsWithDefault(decl.name as ts.ObjectBindingPattern)
) {
const spelling = destrSpellingOf(decl.initializer);
if (spelling === null) {
@@ -2635,42 +2726,19 @@ export function lowerDestructuringDecl(
"destructuring declaration from an `any` source whose expression spelling is unsupported (use a variable, ordinary property chain, or ordinary call)",
);
}
- const first = (decl.name as ts.ObjectBindingPattern).elements.nodes[0];
- const firstKey =
- first === undefined || first.dotDotDotToken !== undefined
- ? undefined
- : (first.propertyName ?? first.name);
- // V8 names the first property when its key is an identifier, a string
- // or a number; a computed first key takes the bare (source-only) form.
- const firstProp =
- firstKey === undefined || ts.isComputedPropertyName(firstKey)
- ? undefined
- : (patternKeyNameOf(lowerer, firstKey as ts.PropertyName) ?? undefined);
out.push({
kind: "exprStmt",
expr: {
kind: "dynDestrCheck",
value: { kind: "varRef", localId: tmp.id, type: init.type, loc },
spelling,
- ...(firstProp !== undefined ? { firstProp } : {}),
+ ...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern),
type: init.type,
loc,
},
loc,
});
}
- // V8's destructuring TypeError spells STATIC sources: an identifier
- // source reads " is not iterable", an identifier-callee call
- // " is not a function or its return value is not iterable";
- // everything else (property reads, method calls, parameters) gets the
- // runtime kind wording — exactly V8's CallPrinter behavior. Only the
- // DYN pattern arm consumes this.
- const src = decl.initializer!;
- const dynSpell = ts.isIdentifier(src)
- ? `${src.text} is not iterable`
- : ts.isCallExpression(src) && ts.isIdentifier(src.expression)
- ? `${src.expression.text} is not a function or its return value is not iterable`
- : undefined;
lowerer.lowerBindingPattern(
decl.name as ts.ArrayBindingPattern | ts.ObjectBindingPattern,
() => ({ kind: "varRef", localId: tmp.id, type: init.type, loc }),
@@ -6494,6 +6562,12 @@ export function lowerVarDecl(
type = init.type;
notePreservedLayout(lowerer, decl);
}
+ // A JavaScript binding assigned values outside its declared type
+ // (analyzeJsWidenedBindings in lowerer.ts) holds them as checked-dynamic.
+ {
+ const symbol = lowerer.resolveValueSymbol(decl.name);
+ if (symbol && lowerer.jsWidenedBindings.has(symbol)) type = DYN;
+ }
// A typed-arguments function expression (typed-arguments.ts): the
// checker types the binding variadic, but its value has the fixed
// signature every use asserts.
@@ -8664,6 +8738,9 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm
if (urlWrite) return { kind: "exprStmt", expr: urlWrite, loc: locOf(expr) };
}
if (opKind === ts.SyntaxKind.EqualsToken) {
+ const staticPrivateWrite = lowerStaticPrivateAssignment(lowerer, expr);
+ if (staticPrivateWrite)
+ return { kind: "exprStmt", expr: staticPrivateWrite, loc: locOf(expr) };
const callableWrite = lowerNativeFunctionAssignment(lowerer, expr);
if (callableWrite) return { kind: "exprStmt", expr: callableWrite, loc: locOf(expr) };
// Expando function members (`foo.bar = 12`, `foo[SYM] = v` on a
@@ -9261,6 +9338,39 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm
};
}
}
+ // process.stdout.write = fn (and stderr): replace the stream object's
+ // write slot. Every later write to the stream — console output, typed
+ // write calls (programPatchesStdio), runtime writers — goes through it.
+ if (ts.isPropertyAccessExpression(expr.left) && expr.left.name.text === "write") {
+ const recv = expr.left.expression;
+ const stream = ts.isPropertyAccessExpression(recv)
+ ? lowerer.stdlibGlobalMember(recv, "process")
+ : null;
+ if (stream === "stdout" || stream === "stderr") {
+ const loc = locOf(expr);
+ return {
+ kind: "exprStmt",
+ expr: {
+ kind: "libCall",
+ fn: "dyn.keySet",
+ args: [
+ {
+ kind: "libCall",
+ fn: "process.stdioSlot",
+ args: [numLit(stream === "stdout" ? 1 : 2, loc)],
+ type: DYN,
+ loc,
+ },
+ strLit("write", locOf(expr.left.name)),
+ lowerer.lowerExprExpecting(expr.right, DYN),
+ ],
+ type: VOID,
+ loc,
+ },
+ loc,
+ };
+ }
+ }
if (!ts.isIdentifier(expr.left)) {
lowerer.unsupported("SC1090", expr.left, "assignment to non-variables");
}
@@ -9589,6 +9699,59 @@ export function lowerDestructuringAssign(
return { kind: "block", body: parts.stmts, loc };
}
+/** An object pattern whose first element has a default and a static key
+ * (`{ a = 1 }`, `{ a: b = 1 }`): V8 reads that key without checking the
+ * source first, so a null or undefined source throws the read's TypeError. */
+function firstReadsWithDefault(pattern: ts.ObjectBindingPattern): boolean {
+ const first = pattern.elements.nodes[0];
+ return (
+ first !== undefined &&
+ first.dotDotDotToken === undefined &&
+ first.initializer !== undefined &&
+ !(first.propertyName !== undefined && ts.isComputedPropertyName(first.propertyName))
+ );
+}
+
+/** The read of the first key of a pattern that `firstReadsWithDefault`
+ * accepts, over a dyn source: on null or undefined it throws the read's
+ * TypeError, as V8 does. Null when the key has no static name. */
+function firstKeyRead(
+ lowerer: Lowerer,
+ pattern: ts.ObjectBindingPattern,
+ source: () => IrExpr,
+ loc: SrcLoc,
+): IrExpr | null {
+ const { firstProp } = firstPatternProp(lowerer, pattern);
+ if (firstProp === undefined) return null;
+ return {
+ kind: "dynKeyGet",
+ key: { kind: "strLit", value: firstProp, type: STRING, loc },
+ value: source(),
+ type: DYN,
+ loc,
+ };
+}
+
+/** The first property V8 names in an object pattern's destructuring
+ * TypeError ("Cannot destructure property 'a' of ..."): an identifier,
+ * string or numeric first key. A computed first key, a rest element or
+ * an empty pattern takes the bare (source-only) form. */
+function firstPatternProp(
+ lowerer: Lowerer,
+ pattern: ts.ObjectBindingPattern,
+): { firstProp?: string } {
+ const first = pattern.elements.nodes[0];
+ const firstKey =
+ first === undefined || first.dotDotDotToken !== undefined
+ ? undefined
+ : (first.propertyName ?? first.name);
+ const firstProp =
+ firstKey === undefined || ts.isComputedPropertyName(firstKey)
+ ? undefined
+ : (patternKeyNameOf(lowerer, firstKey as ts.PropertyName) ?? undefined);
+ return firstProp !== undefined ? { firstProp } : {};
+}
+
/** V8's spelling of a destructuring RHS in its TypeError message
* ("Cannot destructure 'a' as it is undefined.") — plain identifiers
* spell their name (parens unwrap), `this`, property chains and
@@ -12983,7 +13146,61 @@ function lowerForIn(lowerer: Lowerer, stmt: ts.ForInStatement): IrStmt {
receiver = lowerer.coerceToExpected(receiver, DYN);
}
}
+ return lowerForInReceiver(lowerer, stmt, labels, receiver);
+}
+
+/** for-in over the lowered `receiver` of `stmt`. */
+function lowerForInReceiver(
+ lowerer: Lowerer,
+ stmt: ts.ForInStatement,
+ labels: string[] | undefined,
+ receiver: IrExpr,
+): IrStmt {
+ const loc = locOf(stmt);
const recvT = receiver.type;
+ // A receiver that may be null or undefined: for-in visits nothing for
+ // either, and walks a present value as its own type. The receiver is
+ // evaluated once.
+ if (recvT.kind === "union") {
+ const arms = lowerer.unions.get(recvT.unionId)?.arms ?? [];
+ const present = arms.filter((arm) => arm.kind !== "undefinedT" && arm.kind !== "nullT");
+ if (present.length === 1 && present.length < arms.length) {
+ const arm = present[0]!;
+ const tag = lowerer.armTag(recvT.unionId, arm);
+ const recv = lowerer.declareHiddenLocal("%innullable", recvT);
+ const ref = varRef(recv.id, recvT, loc);
+ const narrowed: IrExpr = {
+ kind: "unionNarrow",
+ unionId: recvT.unionId,
+ tag,
+ value: ref,
+ type: arm,
+ loc,
+ };
+ return {
+ kind: "block",
+ body: [
+ { kind: "varDecl", localId: recv.id, init: receiver, loc },
+ {
+ kind: "if",
+ cond: {
+ kind: "unionIsTag",
+ unionId: recvT.unionId,
+ tag,
+ negated: false,
+ value: ref,
+ type: BOOL,
+ loc,
+ },
+ then: [lowerForInReceiver(lowerer, stmt, labels, narrowed)],
+ else_: null,
+ loc,
+ },
+ ],
+ loc,
+ };
+ }
+ }
if (recvT.kind === "dyn") {
const recv = lowerer.declareHiddenLocal("%indyn", DYN);
const ref: IrExpr = { kind: "varRef", localId: recv.id, type: DYN, loc };
diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts
index d8c370511..5da73ed0a 100644
--- a/packages/compiler/src/frontend/lowering/lower-stream.ts
+++ b/packages/compiler/src/frontend/lowering/lower-stream.ts
@@ -6964,18 +6964,45 @@ export function lowerStreamMethodCall(
const kind = chunkKind(args[0]!);
if (kind === "null") lowerer.noLowering("write(null)", call, "end() finishes a writable");
const lowerWriteCb = (cbArg: ts.Expression): IrExpr => {
- const cb = callbackDeclaresParameters(cbArg)
- ? null
- : lowerer.lowerExprExpecting(cbArg, funcOf([], VOID));
- if (cb === null || cb.type.kind !== "func" || cb.type.params.length > 0) {
+ // The stream ignores what the callback returns (`() => count++`, a
+ // stored function returning a value): the value is discarded.
+ const raw = lowerer.lowerExpr(cbArg);
+ const cb =
+ raw.type.kind === "func" && raw.type.ret.kind !== "void" && raw.type.params.length <= 1
+ ? lowerer.coerceToExpected(raw, funcOf(raw.type.params, VOID))
+ : raw;
+ if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) {
lowerer.noLowering(
- "write completion callbacks taking the error argument",
+ "write completion callbacks of this shape",
cbArg,
- "() => void callbacks are supported (listen for 'error' to observe failures)",
+ "callbacks take () or (error?: Error | null) and return nothing",
);
}
- return cb!;
+ if (cb.type.params.length === 0) return cb;
+ const param = cb.type.params[0]!;
+ const def = param.kind === "union" ? lowerer.unions.get(param.unionId) : undefined;
+ const ok =
+ param.kind === "dyn" ||
+ (!!def &&
+ def.arms.some((a) => a.kind === "nullT") &&
+ def.arms.some((a) => a.kind === "object" && a.className === "%Error") &&
+ def.arms.every(
+ (a) =>
+ a.kind === "nullT" ||
+ a.kind === "undefinedT" ||
+ (a.kind === "object" && a.className === "%Error"),
+ ));
+ if (!ok) {
+ lowerer.unsupported(
+ "SC1090",
+ cbArg,
+ `write completion callbacks whose parameter is not 'Error | null' (got '${lowerer.fmt(param)}')`,
+ );
+ }
+ return cb;
};
+ const takesError = (cb: IrExpr): boolean =>
+ cb.type.kind === "func" && cb.type.params.length === 1;
// write(chunk, encoding[, cb]), and write(value, cb): the encoding
// argument reaches an object-mode side's _write as passed, null when
// the callback took its place (a byte-mode side encodes a string
@@ -6988,7 +7015,19 @@ export function lowerStreamMethodCall(
const chunk = lowerer.lowerExprExpecting(args[0]!, DYN);
const tail: IrExpr[] = [];
if (encArg !== undefined) tail.push(lowerer.lowerExprExpecting(encArg, DYN));
- if (cbArg !== undefined) tail.push(lowerWriteCb(cbArg));
+ if (cbArg !== undefined) {
+ const cb = lowerWriteCb(cbArg);
+ if (takesError(cb)) {
+ return {
+ kind: "libCall",
+ fn: "writable.writeEcb",
+ args: [receiver, numLit(2, loc), chunk, tail[0] ?? dynUndefinedExpr(loc), cb],
+ type: BOOL,
+ loc,
+ };
+ }
+ tail.push(cb);
+ }
const flags = (encArg !== undefined ? 1 : 0) | (cbArg !== undefined ? 2 : 0);
return {
kind: "libCall",
@@ -7055,6 +7094,15 @@ export function lowerStreamMethodCall(
const fn = kind === "bytes" ? "writable.write" : "writable.writeStr";
if (cbArg !== undefined) {
const cb = lowerWriteCb(cbArg);
+ if (takesError(cb)) {
+ return {
+ kind: "libCall",
+ fn: "writable.writeEcb",
+ args: [receiver, numLit(kind === "bytes" ? 0 : 1, loc), chunk, dynUndefinedExpr(loc), cb],
+ type: BOOL,
+ loc,
+ };
+ }
return { kind: "libCall", fn, args: [receiver, chunk, cb], type: BOOL, loc };
}
return { kind: "libCall", fn, args: [receiver, chunk], type: BOOL, loc };
diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index b928055eb..ef9d5c31c 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -1,3 +1,4 @@
+import { programCreatesWrappers, routeWrapperConsumers } from "./primitive-wrappers.js";
import type { DiagnosticCode } from "../../diagnostics/codes.js";
import { markInferredFactChecks, shapeOverlayFileNames } from "../shape-inference/index.js";
import { npmStaticPackageOfPath } from "../npm-static.js";
@@ -30,7 +31,9 @@ import {
irDataTable,
journalDump,
journalMode,
+ HeldTables,
lowerWaveRecorded,
+ type WaveReuse,
mapTable,
setTable,
waveStats,
@@ -41,6 +44,7 @@ import {
type UnitEvents,
type WaveHost,
} from "./unit-journal.js";
+import { UnitReuse, unitReuseVerify, verifyStoredUnit, type UnitStore } from "./unit-reuse.js";
import {
moduleNamespaceDynValue,
moduleNamespaceObject,
@@ -100,6 +104,8 @@ import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions.
import {
isOptionalProcessStreamProperty,
isOptionalProcessVersionsRead,
+ programPatchesStdio,
+ type StdioPatchSite,
} from "./builtins/process.js";
import { isSettledIteratorValueRead } from "./lower-generators.js";
import { isRebindableFunctionSymbol } from "./rebindable-functions.js";
@@ -179,7 +185,9 @@ import {
} from "./coercions/unions.js";
import { assertedConversion } from "./coercions/asserted.js";
import {
+ boxBuiltinValuesChecked,
lowerUtilTypeValue,
+ checkedNativeCallableValue,
pathModuleValue,
utilTypesModuleValue,
} from "./lower-builtin-values.js";
@@ -358,6 +366,7 @@ import {
overridesDtsPath,
npmStaticDepSf7,
requireSpecOf,
+ isJsWriteTarget7,
resolveImport,
workspacePackageOfPath,
type CycleEdge,
@@ -953,6 +962,11 @@ export interface LowerResult {
dynamicFunctions?: DynamicFunction[];
/** LowerOptions.tierReport: the report-only tier assignment. */
tiers?: TierReport;
+ /** The program replaces a process output stream's write method, with the
+ * site: the executable planner refuses the combination with Node islands
+ * (SC8020), whose process objects would not see the patch. Present only
+ * when the build has islands. */
+ stdioPatches?: StdioPatchSite[];
}
export interface LowerOptions {
@@ -961,6 +975,9 @@ export interface LowerOptions {
nodeIslands?: NodeIslandSet | null;
/** Parser/checker services owned by the load; required for npm graph analysis. */
frontendServices?: FrontendServices | undefined;
+ /** Where reachable emit stores its units' records and finds an earlier
+ * build's (unit-reuse.ts). */
+ unitStore?: UnitStore | null;
/** The project's tsconfig sets `checkJs` (see isCheckedJsSourceFile). */
projectCheckJs?: boolean;
/** Coverage: additionally lower the unreached remainder (bodies nothing
@@ -1360,7 +1377,7 @@ function lowerToIrWithIslands(
});
for (const d of extraModuleCycleDiags) reachableEmit.pushDiag(d);
for (const d of ffiValidation.diagnostics) reachableEmit.pushDiag(d);
- const emitted = reachableEmit.emitReachable(options.libRoots);
+ const emitted = reachableEmit.emitReachable(options.libRoots, options.unitStore ?? null);
let { reachable } = emitted;
let result = emitted.result;
let resultLowerer = reachableEmit;
@@ -2671,6 +2688,12 @@ export class Lowerer {
streamPropMutatedSyms: Set | null = null;
/** Whether some source reads process.exitCode (processReadsExitCode). */
readsExitCode: boolean | null = null;
+ /** Whether some source replaces a process output stream's write method
+ * (programPatchesStdio in builtins/process.ts). */
+ patchesStdio: boolean | null = null;
+ /** The write-replacement site programPatchesStdio found: the executable
+ * planner refuses it beside Node islands (SC8020). */
+ readonly stdioPatchSites: StdioPatchSite[] = [];
requestInitPropMutatedSyms: Set | null = null;
emitterEventTable: Map | null = null;
emitterComputedPatterns: ComputedEventPattern[] = [];
@@ -2726,6 +2749,10 @@ export class Lowerer {
* prepass. This includes explicit annotations: unchecked TS annotations do
* not prove that an array property read produced a value. */
readonly runtimeOptionalBindingTypes = new Map();
+ /** JavaScript bindings assigned values outside their declared type
+ * (analyzeJsWidenedBindings): checked-dynamic storage, and reads keep the
+ * stored value instead of narrowing it to the checker's type. */
+ readonly jsWidenedBindings = new Set();
/** A pipeline() function stage's `source` parameter: the IR type of what
* the pipeline passes it (the previous stage as an async iterable), set
* while the stage lowers. Parameter typing and `for await` heads read
@@ -3485,6 +3512,9 @@ export class Lowerer {
* interned here so references under one source-world ABI are the SAME
* zero-capture closure (see primitiveCtorClosure). */
readonly primitiveCtorFns = new Map();
+ /** Whether a compiled source creates primitive wrapper objects
+ * (programCreatesWrappers), computed on first use. */
+ createsWrappers: boolean | undefined = undefined;
/** Builtin-module FUNCTIONS admitted as values by their surface-table
* entries. The resolved runtime function plus ABI is the identity key,
* so named imports, namespace reads, CommonJS bindings, and alias chains
@@ -3495,6 +3525,12 @@ export class Lowerer {
/** Builtin function values whose `length` differs from their adapter's
* parameter count (BuiltinModuleFn.valueLength), by adapter name. */
readonly builtinCallableValueLengths = new Map();
+ /** Typed builtin adapters with a checked twin (checkedBuiltinValue): the
+ * builtin, its display name and the location the twin is lowered at. */
+ readonly builtinCheckedValues = new Map<
+ string,
+ { fn: BuiltinModuleFn; display: string; loc: SrcLoc }
+ >();
/** Optional-chain lowering state. While a chain body lowers, the guarded
* receiver NODE reads as a chainRecv (typed by the narrowed arm) instead
* of re-lowering, its checker type reads non-nullish (typeOf), and the
@@ -3859,6 +3895,9 @@ export class Lowerer {
/** web-streams-dyn.ts's answer: whether a JavaScript source names a web
* stream class; undefined until asked. */
namesWebStreamClass: boolean | undefined = undefined;
+ /** web-streams-dyn.ts's answer: whether a TypeScript source uses a web
+ * stream operation the native streams lack; undefined until asked. */
+ typedWebStreams: boolean | undefined = undefined;
/** The first lifted function of each name (liftedFnNamed), for the
* liftedFns indexed so far. liftedFns only grows. */
private readonly liftedFnsByName = new Map();
@@ -4196,6 +4235,7 @@ export class Lowerer {
mapTable("builtinCallableValueFns", this.builtinCallableValueFns),
mapTable("builtinCallableValueNames", this.builtinCallableValueNames),
countTable("builtinCallableValueLengths", this.builtinCallableValueLengths),
+ irDataTable("builtinCheckedValues", this.builtinCheckedValues),
mapTable("regionRunHelpers", this.regionRunHelpers),
mapTable("internTableHelpers", this.internTableHelpers),
functionTable(
@@ -4474,6 +4514,7 @@ export class Lowerer {
// caches made when first needed
"nanCodedReads",
"mentionsProcessVersions",
+ "createsWrappers",
"stringIndexBoundsCache",
"functionLocalSubclasses",
"socketEncodingReachable",
@@ -4481,8 +4522,13 @@ export class Lowerer {
"unixListenReachable",
"streamPropMutatedSyms",
"readsExitCode",
+ "patchesStdio",
+ "stdioPatchSites",
"requestInitPropMutatedSyms",
"classProtocols",
+ // The whole-program prepass analyzeJsWidenedBindings computes this set
+ // before lowering begins: a pure function of the program's modules.
+ "jsWidenedBindings",
"cjsSplitRequireBindings",
"cjsWholeExportSymbols",
"cjsLiveMemberExports",
@@ -4493,6 +4539,7 @@ export class Lowerer {
"sloppyThisGlobalReads",
"globalLookupNames",
"namesWebStreamClass",
+ "typedWebStreams",
"timeValueDateDecisions",
"timeValueDateWrites",
"dynamicOverloadAnswers",
@@ -5134,6 +5181,7 @@ export class Lowerer {
mixinIntersectionInstance: (widened) => mixinIntersectionInstanceType(this, widened),
classImplementedProtocol: (type) => this.classImplementedProtocol(type),
isStdlibFile: (sf) => this.isStdlibFile(sf),
+ primitiveWrappers: () => (this.createsWrappers ??= programCreatesWrappers(this)),
isNpmFile: (sf) => this.isNpmFile(sf),
isExperimentalFile: (sf) => isExperimentalDeclarationFile(sf, this.experimentalFiles),
// The declaration-typed boundary's generated types (decl-projection.ts)
@@ -6602,6 +6650,49 @@ export class Lowerer {
}
}
+ /** A JavaScript `let`/`var` binding assigned a value its declared type
+ * does not admit (`let t = parseInt(s); if (isNaN(t)) t = undefined`):
+ * TypeScript types the binding from its initializer and reports the
+ * assignment (TS2322), which JavaScript files relax, so the binding takes
+ * checked-dynamic storage and holds whatever the program stores. JSDoc
+ * type annotations keep their declared storage. */
+ analyzeJsWidenedBindings(parts: FileParts[]): void {
+ for (const { sf } of parts) {
+ if (!isJsSourceFileName(sf.fileName) || sf.isDeclarationFile) continue;
+ ts.walkPreorder(sf, (node) => {
+ if (
+ !ts.isBinaryExpression(node) ||
+ node.operatorToken.kind !== ts.SyntaxKind.EqualsToken ||
+ !ts.isIdentifier(node.left)
+ )
+ return undefined;
+ const symbol = this.resolveValueSymbol(node.left);
+ if (!symbol || this.runtimeOptionalBindingTypes.has(symbol)) return undefined;
+ const decl = symbol.valueDeclaration;
+ if (
+ decl === undefined ||
+ !ts.isVariableDeclaration(decl) ||
+ !ts.isIdentifier(decl.name) ||
+ !isJsSourceFileName(ts.getSourceFileOfNode(decl).fileName) ||
+ hasJsTypeAnnotation(decl) ||
+ decl.parent === undefined ||
+ !ts.isVariableDeclarationList(decl.parent) ||
+ (decl.parent.flags & ts.NodeFlags.Const) !== 0
+ )
+ return undefined;
+ const declared = this.checker.getTypeOfSymbol(symbol);
+ if (declared.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return undefined;
+ const assigned = this.checker.getTypeAtLocation(node.right);
+ if (this.checker.isTypeAssignableTo(assigned, declared)) return undefined;
+ this.runtimeOptionalBindingTypes.set(symbol, DYN);
+ this.jsWidenedBindings.add(symbol);
+ const global = this.globalsBySymbol.get(symbol);
+ if (global) global.type = DYN;
+ return undefined;
+ });
+ }
+ }
+
analyzeRuntimeOptionalArrayReads(parts: FileParts[]): void {
const provenance = RuntimeOptionalProvenance.fromEnvironment();
// The site whose value the current promotion follows (debug only).
@@ -9470,6 +9561,7 @@ export class Lowerer {
this.analyzeRuntimeOptionalArrayReads(parts);
this.analyzeDateJsonResults(parts);
this.analyzeGuardedRecordReadGlobals(parts);
+ this.analyzeJsWidenedBindings(parts);
});
// Decorated classes analyze AFTER the whole collection pass: a
// decorator's return type may name a subclass declared below the
@@ -9624,9 +9716,9 @@ export class Lowerer {
// array-HOF loop functions, which ride the same list), and the
// implicit-any instances lowered eagerly at their first call sites.
this.settleSharedHelperLocations([...functions, ...this.liftedFns, ...this.implicitFns]);
- functions.push(...this.liftedFns);
- functions.push(...this.implicitFns);
- functions.push(...runtimeClassConstructs(this, functions));
+ for (const fn of this.liftedFns) functions.push(fn);
+ for (const fn of this.implicitFns) functions.push(fn);
+ for (const fn of runtimeClassConstructs(this, functions)) functions.push(fn);
if (this.remainder) {
// Deferred collection diagnostics nothing flushed — declarations no
@@ -9652,9 +9744,11 @@ export class Lowerer {
/** Final retention, pruning, and module assembly shared by ordinary emit
* and the retained reachability worklist. */
finishModule(functions: IrFunction[]): LowerResult {
+ boxBuiltinValuesChecked(this, functions);
if (this.diags.length === 0) fuseArrayPipelines(this, functions);
finalizeClassMethodValues(this, functions);
finalizePrivateBrands(this, functions);
+ routeWrapperConsumers(this, functions);
trackClassFieldCreation(this, functions);
boxClassInstancesExactly(this, functions);
addUntypedIntegrityWords(this);
@@ -9808,11 +9902,20 @@ export class Lowerer {
// rewrite keeps every dynamic operation dynamic (each dyn operator
// becomes its js twin), so coverage's dynamic statements are unchanged.
if (module && this.dynamicFunctions.length > 0) promoteJsvals(module);
+ // A program that replaces a process output stream's write method beside
+ // Node islands is refused by the executable planner (SC8020): an
+ // island's process.stdout is its own object. Only island builds pay for
+ // the whole-program scan.
+ const stdioPatches =
+ this.nodeIslands !== null && programPatchesStdio(this)
+ ? [...this.stdioPatchSites]
+ : undefined;
return {
module,
diagnostics: this.diags,
runtimeFences: this.runtimeFences,
stats: this.stats,
+ ...(stdioPatches !== undefined && stdioPatches.length > 0 ? { stdioPatches } : {}),
...(this.dynamicFunctions.length > 0 ? { dynamicFunctions: this.dynamicFunctions } : {}),
...(this.statsByFile.size > 0 ? { statsByFile: this.statsByFile } : {}),
...(this.provenanceElided.length > 0 ? { provenanceElided: this.provenanceElided } : {}),
@@ -9912,9 +10015,11 @@ export class Lowerer {
private reportBlockedBody(decl: ts.FunctionDeclaration): void {
const diags = blockedBodyDiagnostics(this, decl);
const symbol = declSymbolOf(this, decl);
- if (symbol && this.alreadyFlushed.has(symbol))
- this.reachedBlockedBodyDiagnostics.push(...diags);
- else for (const d of diags) this.pushDiag(d);
+ if (symbol && this.alreadyFlushed.has(symbol)) {
+ for (const d of diags) this.reachedBlockedBodyDiagnostics.push(d);
+ } else {
+ for (const d of diags) this.pushDiag(d);
+ }
}
/** Whether run() counts a signature-blocked declaration in
@@ -9940,7 +10045,10 @@ export class Lowerer {
* demand-driven) and lifted lambdas lower inline with their enclosing
* body; both fire edges through the same hooks and are not units
* themselves. */
- emitReachable(extraRoots?: readonly string[]): { reachable: Set; result: LowerResult } {
+ emitReachable(
+ extraRoots?: readonly string[],
+ unitStore: UnitStore | null = null,
+ ): { reachable: Set; result: LowerResult } {
// Frontend passes below rewrite function bodies through the IR mapper
// (caught-value and class dispatch); key-order notes follow the record
// literals they rebuild until the witness pass has read them.
@@ -9987,6 +10095,7 @@ export class Lowerer {
this.analyzeRuntimeOptionalArrayReads(parts);
this.analyzeDateJsonResults(parts);
this.analyzeGuardedRecordReadGlobals(parts);
+ this.analyzeJsWidenedBindings(parts);
});
// Decorated classes analyze post-collection here too: the %init seeds
// lower the decoration calls, whose edges (decorator bodies, construct
@@ -9998,12 +10107,16 @@ export class Lowerer {
// Every lowerable body, by emitted-function name. The names double as
// retained-function keys and are deterministic by construction
// (qualified declaration names).
+ // `source` is the declaration whose text a unit lowers and `owners` the
+ // declarations around it (unit-reuse.ts).
const units = new Map<
string,
{
order: number;
roots: readonly ts.Node[];
lower: () => IrFunction | null;
+ source?: ts.Node | undefined;
+ owners?: readonly ts.Node[];
}
>();
// Roots are collected with plain pushes: spread arguments into a rest
@@ -10047,6 +10160,7 @@ export class Lowerer {
order: unitOrder++,
roots: functionRoots(decl),
lower: () => this.lowerFunction(decl),
+ source: decl,
});
}
}
@@ -10067,6 +10181,7 @@ export class Lowerer {
roots: classCtorRoots(info),
lower: () =>
info.localClass && !info.localClass.ready ? null : this.lowerClassCtor(info),
+ source: info.decl ?? undefined,
});
// The descriptor helpers of a class that extends Array, reached
// from its constructor.
@@ -10075,11 +10190,13 @@ export class Lowerer {
order: unitOrder++,
roots: [],
lower: () => arrayClassFieldsFn(this, info),
+ source: info.decl ?? undefined,
});
units.set(`%${cName}.species`, {
order: unitOrder++,
roots: [],
lower: () => arrayClassSpeciesFn(this, info),
+ source: info.decl ?? undefined,
});
}
for (const { mName, member } of this.classMethodMembers(info)) {
@@ -10090,6 +10207,8 @@ export class Lowerer {
info.localClass && !info.localClass.ready
? null
: this.lowerClassMethodMember(info, member),
+ source: member,
+ owners: info.decl ? [info.decl] : [],
});
}
for (const prop of info.throwingSetters) {
@@ -10097,6 +10216,7 @@ export class Lowerer {
order: unitOrder++,
roots: [],
lower: () => this.throwingSetterFn(info, prop),
+ source: info.decl ?? undefined,
});
}
}
@@ -10106,6 +10226,8 @@ export class Lowerer {
order: unitOrder++,
roots: functionRoots(entry.member),
lower: () => lowerStaticMethod(this, info, name),
+ source: entry.member,
+ owners: info.decl ? [info.decl] : [],
});
}
}
@@ -10420,17 +10542,94 @@ export class Lowerer {
),
);
};
- const recorded = journalMode() === "serial" ? waveStats() : null;
- const host = recorded === null ? null : this.waveHost(loweredUnits, lowerUnitBody);
+ // With a unit store, every unit is recorded as in the serial mode, and
+ // a unit applies the record an earlier build stored for it where that
+ // record still holds (unit-reuse.ts).
+ const reuse =
+ unitStore === null
+ ? null
+ : UnitReuse.open(
+ unitStore,
+ this.program,
+ parts.map((fp) => fp.sf),
+ {
+ facts: (declaration) => this.declarationFacts(declaration),
+ exportsOf: (specifier) => {
+ const symbol = this.checker.getSymbolAtLocation(specifier);
+ if (!symbol) return null;
+ return this.checker
+ .getExportsOfModule(symbol)
+ .map((exported) => ts.symbolName(exported))
+ .sort()
+ .join("\n");
+ },
+ isLocal: (identifier, container) => {
+ const declarations = this.checker.getSymbolAtLocation(identifier)?.declarations;
+ if (declarations === undefined || declarations.length === 0) return false;
+ const sf = ts.getSourceFileOfNode(container);
+ return declarations.every(
+ (declaration) =>
+ ts.getSourceFileOfNode(declaration) === sf &&
+ declaration.pos >= container.pos &&
+ declaration.end <= container.end,
+ );
+ },
+ },
+ );
+ const recorded =
+ journalMode() === "serial" || (reuse !== null && reuse.records) ? waveStats() : null;
+ const host =
+ recorded === null
+ ? null
+ : this.waveHost(loweredUnits, lowerUnitBody, (name) => {
+ const priority = metadataPriority.get(name)!;
+ return { owner: demandOwner(priority), priority };
+ });
const dump = recorded === null ? null : journalDump();
+ const fileOfUnit = (name: string): string | null => {
+ const source = units.get(name)?.source;
+ return source === undefined ? null : ts.getSourceFileOfNode(source).fileName;
+ };
+ const waveReuse: WaveReuse | null =
+ reuse === null || !reuse.records
+ ? null
+ : {
+ verify: unitReuseVerify(),
+ stored: (name) => {
+ // A unit without a declared rank takes the next expression
+ // rank when it lowers, which applying a record would not
+ // advance.
+ const unit = units.get(name);
+ const file = fileOfUnit(name);
+ if (unit === undefined || file === null || !metadataPriority.has(name)) return null;
+ return reuse.candidate(name, file, unit.source ?? null, unit.owners ?? []);
+ },
+ check: (name, stored, pending, unit) =>
+ verifyStoredUnit(name, stored, pending, unit.record, unit.reasons),
+ note: (name, record, applied, files) => {
+ const file = fileOfUnit(name);
+ if (file !== null) reuse.note(name, file, record, applied, files);
+ },
+ };
const recordLines: string[] = [];
const drainUnits = (): void => {
+ // The waves of one drain have only checker prefetches between them, so
+ // they share the content their units start from (HeldTables).
+ const held =
+ host !== null && recorded !== null ? new HeldTables(host.tables, recorded) : null;
while (queue.length > 0) {
const wave = queue.splice(0);
shuffle?.(wave);
this.checker.prefetchRoots(wave.flatMap((name) => units.get(name)!.roots));
if (host !== null && recorded !== null) {
- lowerWaveRecorded(host, wave, recorded, dump === null ? null : recordLines);
+ lowerWaveRecorded(
+ host,
+ wave,
+ recorded,
+ dump === null ? null : recordLines,
+ waveReuse,
+ held,
+ );
continue;
}
for (const name of wave) {
@@ -10447,6 +10646,7 @@ export class Lowerer {
}
}
}
+ held?.check();
};
drainUnits();
this.restoreGenericInstanceOrder();
@@ -10491,6 +10691,13 @@ export class Lowerer {
}
classDispatch.finalize(this);
if (dump !== null) writeRecords(dump, recordLines);
+ if (reuse !== null) {
+ reuse.finish();
+ if (process.env["SCRIPTC_TIMING"] === "1")
+ process.stderr.write(
+ `scriptc lowering ${JSON.stringify({ phase: "unit-reuse", ...reuse.stats })}\n`,
+ );
+ }
if (recorded !== null && process.env["SCRIPTC_TIMING"] === "1")
process.stderr.write(
`scriptc lowering ${JSON.stringify({ phase: "unit-records", ...recorded })}\n`,
@@ -10525,7 +10732,7 @@ export class Lowerer {
...byName(this.liftedFns),
...byName(this.implicitFns),
];
- functions.push(...runtimeClassConstructs(this, functions));
+ for (const fn of runtimeClassConstructs(this, functions)) functions.push(fn);
if (this.regexGroupResults) for (const fn of functions) foldRegexSpanReads(this, fn);
// The worklist can leave a virtual site with a single implementation.
// Devirtualize after construction/override reachability has settled.
@@ -10539,11 +10746,57 @@ export class Lowerer {
return { reachable, result: this.finishModule(functions) };
}
+ /** What the steps before the worklist settled for a top-level
+ * declaration, as text without source positions (unit-reuse.ts): a
+ * function's signature, a class's layout and the signatures of its
+ * methods and statics, and the globals of a variable statement. */
+ private declarationFacts(declaration: ts.Node): string {
+ const positionFree = (key: string, value: unknown): unknown =>
+ key === "loc" || key === "scope" ? undefined : value;
+ if (ts.isFunctionDeclaration(declaration)) {
+ const symbol = declSymbolOf(this, declaration);
+ const sig = symbol ? this.fnSigsBySymbol.get(symbol) : undefined;
+ return sig === undefined ? "" : JSON.stringify(sig, positionFree);
+ }
+ if (ts.isClassDeclaration(declaration)) {
+ const info = this.classes.get(this.classNamer(declaration));
+ if (info === undefined) return "";
+ const staticMethods: { name: string; params: ParamShape[]; ret: IrType }[] = [];
+ if (info.staticMethods)
+ for (const [name, method] of info.staticMethods)
+ staticMethods.push({ name, params: method.params, ret: method.ret });
+ return JSON.stringify(
+ {
+ def: info.def,
+ fields: [...info.fields],
+ methods: [...info.methods],
+ statics: info.staticFields.map((field) => ({
+ name: field.name,
+ type: field.type,
+ runtimeOptional: field.runtimeOptional ?? false,
+ globalId: field.globalId,
+ readonly: field.readonly,
+ })),
+ staticMethods,
+ },
+ positionFree,
+ );
+ }
+ if (ts.isVariableStatement(declaration)) {
+ const globals: (IrGlobal | null)[] = [];
+ for (const decl of declaration.declarationList.declarations.nodes)
+ globals.push(this.globalsByDeclNode.get(decl) ?? null);
+ return JSON.stringify(globals, positionFree);
+ }
+ return "";
+ }
+
/** The lowerer's side of a recorded wave (unit-journal.ts).
* `lowerUnitBody` lowers a unit as reachable emit does. */
private waveHost(
loweredUnits: Map,
lowerUnitBody: (name: string) => IrFunction | null,
+ ownerOf: (name: string) => { owner: GenericDemandOwner; priority: readonly [number, number] },
): WaveHost {
const scalars = this.journalScalars();
const flags = new Map(scalars.map((scalar) => [scalar.name, scalar]));
@@ -10574,6 +10827,35 @@ export class Lowerer {
return undefined;
}
};
+ // Makes a recorded call again (resolves has answered true for it).
+ const replay = (event: JournalEvent): void => {
+ switch (event.kind) {
+ case "edge":
+ this.noteEdge(event.name);
+ break;
+ case "superConstructorEdge":
+ this.noteSuperConstructorEdge(event.name);
+ break;
+ case "virtualEdge":
+ this.noteVirtualEdge(classNamed(event.name)!, event.detail!);
+ break;
+ case "functionDemand":
+ this.noteGenericInstanceDemand(instanceNamed(event.name)!);
+ break;
+ case "classDemand":
+ this.noteGenericClassInstanceDemand(classNamed(event.name)!);
+ break;
+ case "statement":
+ this.countStatement(statementNode(event)!, event.detail ?? undefined);
+ break;
+ case "failedStatement":
+ this.countStatementFailed(statementNode(event)!, event.detail ?? undefined);
+ break;
+ case "flag":
+ flags.get(event.name)!.set!();
+ break;
+ }
+ };
return {
tables: this.journalTables(),
scalars,
@@ -10614,6 +10896,15 @@ export class Lowerer {
return true;
}
},
+ keep: (name, fn, events) => {
+ if (fn) loweredUnits.set(name, fn);
+ const { owner, priority } = ownerOf(name);
+ this.withGenericDemandOwner(owner, () =>
+ this.shapes.withDeclaredOrderPriority(priority, () => {
+ for (const event of events) replay(event);
+ }),
+ );
+ },
};
}
@@ -10730,10 +11021,10 @@ export class Lowerer {
}
const captured = this.diags.splice(diagsBefore);
if (captured.some((d) => d.code === "SC9001")) {
- this.diags.push(...captured);
+ for (const d of captured) this.diags.push(d);
return null;
}
- this.runtimeFences.push(...captured);
+ for (const d of captured) this.runtimeFences.push(d);
const fence = this.runtimeFenceStmt(captured[0], node, target.fallback, target.bareMessage);
const loc = locOf(node);
@@ -11022,7 +11313,7 @@ export class Lowerer {
.slice(diagsBefore)
.filter((d) => d.rejects !== undefined || this.loweredOnceDiags.has(d));
this.diags.length = diagsBefore;
- this.diags.push(...kept);
+ for (const d of kept) this.diags.push(d);
}
/** A unit that still fails after its retries commits its first
@@ -11035,7 +11326,8 @@ export class Lowerer {
d.rejects !== undefined || this.loweredOnceDiags.has(d);
const kept = this.diags.slice(diagsBefore).filter(carried);
this.diags.length = diagsBefore;
- this.diags.push(...first.filter((d) => !carried(d)), ...kept);
+ for (const d of first) if (!carried(d)) this.diags.push(d);
+ for (const d of kept) this.diags.push(d);
}
/** Lowers one unit whose body-level poison skips it (a generic
@@ -11390,9 +11682,11 @@ export class Lowerer {
// pointed reason the identifier/constructor chokepoints teach.
if (stdlibNominal) {
const intlHint =
- "Intl formatter values have no representation — the COMPOSED en-US forms lower: " +
- 'new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options; ' +
- "the rest is ICU locale data the binary does not carry";
+ "Intl formatter values have no representation — the COMPOSED forms lower: " +
+ 'new Intl.NumberFormat("en-US").format(x), x.toLocaleString("en-US") and ' +
+ "x.toLocaleString() with default options, d.toLocaleString(), d.toLocaleTimeString(), " +
+ "and Intl.Segmenter with grapheme or word granularity; the formatter objects " +
+ "themselves (locale negotiation, resolvedOptions, options bags) remain unsupported";
const typeHints: Record = {
ArrayBuffer:
"fixed ArrayBuffer storage and typed-array views are supported; resizable and detached storage are not modeled",
@@ -11958,9 +12252,28 @@ export class Lowerer {
if (dyn) return dyn;
this.badType(node, t);
}
+ // A JavaScript binding whose inferred type holds a union of functions
+ // and arrays (a test table of `[fn, cases]` rows, or one row
+ // destructured): the checked-dynamic kind dynFallbackType gives the
+ // unmappable spellings of such tables, so the rows keep their functions'
+ // names and the destructured items their values.
+ if (isJsSourceFile(ts.getSourceFileOfNode(node)) && this.mixesFunctionsWithArrays(mapped))
+ return DYN;
return jsOpenObjectType(node, mapped, this.shapes, this.unions);
}
+ /** Whether `type`, or an array element type inside it, is a union with
+ * both a function arm and an array arm. */
+ private mixesFunctionsWithArrays(type: IrType): boolean {
+ if (type.kind === "array") return this.mixesFunctionsWithArrays(type.elem);
+ if (type.kind !== "union") return false;
+ const arms = this.unions.get(type.unionId)?.arms ?? [];
+ return (
+ (arms.some((arm) => arm.kind === "func") && arms.some((arm) => arm.kind === "array")) ||
+ arms.some((arm) => arm.kind === "array" && this.mixesFunctionsWithArrays(arm))
+ );
+ }
+
/** Exact-shape enforcement (SC2002). Records are monomorphic structs, so
* everywhere a value flows into a typed slot (call arg, initializer,
* assignment, field, return) the shapes must MATCH — or width-coerce:
@@ -12373,6 +12686,30 @@ export class Lowerer {
);
if (boxed) return boxed;
}
+ // A node:path family is its module object (pathModuleValue), the
+ // bare module the target platform's family.
+ if (
+ expr.type.kind === "moduleNs" &&
+ (expr.type.moduleId === "builtin:path" ||
+ expr.type.moduleId === "builtin:path/posix" ||
+ expr.type.moduleId === "builtin:path/win32")
+ ) {
+ const family =
+ expr.type.moduleId !== "builtin:path"
+ ? expr.type.moduleId.slice("builtin:".length)
+ : builtinModuleConstOf(this, "path", "sep") === "\\"
+ ? "path/win32"
+ : "path/posix";
+ const value = pathModuleValue(this, family, expr.loc);
+ if (expr.kind === "moduleNsRef") return value;
+ return {
+ kind: "seqExpr",
+ stmts: [{ kind: "exprStmt", expr, loc: expr.loc }],
+ result: value,
+ type: DYN,
+ loc: expr.loc,
+ };
+ }
if (
expr.type.kind === "moduleNs" &&
expr.type.moduleId === "builtin:events" &&
@@ -12418,7 +12755,7 @@ export class Lowerer {
(this.unions.get(expr.type.unionId)?.arms.some(liveReference) ?? false));
return {
kind: "dynFrom",
- value: expr,
+ value: this.checkedBuiltinValue(expr),
type: DYN,
loc: expr.loc,
...(liveRef ? { liveRef: true as const } : {}),
@@ -13894,6 +14231,19 @@ export class Lowerer {
);
}
+ /** Whether `node`'s TS type is a `Promise` (the throw-only async
+ * function's declared result): the promise can never settle, so the value
+ * it would resolve to is unreachable. The IR keeps `Promise` as a
+ * promise with a void payload, exactly like `Promise`, so the TS
+ * payload type is what separates the two. */
+ private promisePayloadIsNever(node: ts.Expression): boolean {
+ const t = this.typeOf(node);
+ if ((t.flags & ts.TypeFlags.Object) === 0) return false;
+ if (((t as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) === 0) return false;
+ const args = this.checker.getTypeArguments(t as ts.TypeReference);
+ return args.length === 1 && (args[0]!.flags & ts.TypeFlags.Never) !== 0;
+ }
+
/** The value of `return ` (already lowered untyped) in an async or
* void context: a promise flattens through the returned-promise await,
* everything else converts into the declared result. */
@@ -13901,6 +14251,15 @@ export class Lowerer {
if (expected.kind === "void" && e.kind === "unitLit") return null;
if (this.ctx.isAsync && e.type.kind === "promise" && expected.kind !== "promise") {
const awaited = this.returnedPromiseAwait(e);
+ // A `Promise` never settles, so the awaited value is
+ // unreachable: it fills any result shape instead of failing the
+ // void-to-record conversion.
+ if (
+ expected.kind !== "void" &&
+ e.type.inner.kind === "void" &&
+ this.promisePayloadIsNever(node)
+ )
+ return this.divergentValue(awaited, expected);
return this.coerceInto(node, awaited, expected);
}
return this.coerceInto(node, e, expected);
@@ -15624,6 +15983,18 @@ export class Lowerer {
node: ts.Node,
fallback: string,
): never {
+ // A `.js` WRITE to a name nothing declares. Sloppy mode resolves it by
+ // creating a property on the global object — process-wide state a native
+ // binary has no object for, so those programs run in a Node island
+ // (plan D-5). Until the write is routed there it is refused by name
+ // rather than by the generic no-lowering text.
+ if (symbol === null && isJsWriteTarget7(node) && isJsSourceFile(ts.getSourceFileOfNode(node))) {
+ this.unsupported(
+ "SC1091",
+ node,
+ `writing the undeclared name '${name}' in a JavaScript source`,
+ );
+ }
if (this.isBlockedBinding(symbol)) {
this.pushDiag(blockedBindingUseDiag(name, locOf(node)));
throw new PoisonError();
@@ -17003,6 +17374,7 @@ export class Lowerer {
lowerNativeCallableValue(fn: BuiltinModuleFn, display: string, loc: SrcLoc): IrExpr | null {
const valueParams = fn.valueParams;
if (!valueParams) return null;
+ if (fn.checked?.only) return checkedNativeCallableValue(this, fn, display, loc);
const shapes: ParamShape[] = valueParams.map((param): ParamShape => {
if (param.mode === "rest") return { mode: "rest", type: arrayOf(param.type) };
if (param.mode === "optional") {
@@ -17115,9 +17487,24 @@ export class Lowerer {
loc,
});
}
+ if (valueParams.some((param) => param.name !== undefined)) {
+ this.builtinCheckedValues.set(fnName, { fn, display, loc });
+ }
return { kind: "closure", fnName, captures: [], type: funcType, loc };
}
+ /** A builtin function value about to become untyped: its checked twin
+ * when the builtin names its parameters (checkedNativeCallableValue), so
+ * untyped callers meet Node's argument validation. Typed slots keep the
+ * typed adapter. */
+ checkedBuiltinValue(expr: IrExpr): IrExpr {
+ const checked =
+ expr.kind === "closure" ? this.builtinCheckedValues.get(expr.fnName) : undefined;
+ return checked
+ ? checkedNativeCallableValue(this, checked.fn, checked.display, checked.loc)
+ : expr;
+ }
+
/** The name and length a builtin function's adapter boxes under: the ones
* Node's function carries (`[Function: randomUUID]`). Empty for any other
* value. */
diff --git a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts
index 405d2af5f..b7542aae3 100644
--- a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts
+++ b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts
@@ -22,6 +22,20 @@ export function lowerModuleNamespaceElement(
): IrExpr | null {
const type = lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
if (type?.kind !== "moduleNs") return null;
+ // A builtin namespace with a module object (`path[platform]`): the keyed
+ // read of that object.
+ if (type.moduleId.startsWith("builtin:")) {
+ const receiver = lowerer.coerceToExpected(lowerer.lowerExpr(expr.expression), DYN);
+ if (receiver.type.kind !== "dyn") return null;
+ const key = lowerer.lowerExpr(expr.argumentExpression);
+ return {
+ kind: "dynKeyGet",
+ key: key.type.kind === "string" ? key : lowerer.coerceInto(expr.argumentExpression, key, DYN),
+ value: receiver,
+ type: DYN,
+ loc: locOf(expr),
+ };
+ }
const source = lowerer.sourceFileOfModuleNamespace(type);
if (!source) return null;
const loc = locOf(expr);
diff --git a/packages/compiler/src/frontend/lowering/primitive-wrappers.ts b/packages/compiler/src/frontend/lowering/primitive-wrappers.ts
new file mode 100644
index 000000000..7f9ed51d0
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/primitive-wrappers.ts
@@ -0,0 +1,240 @@
+/* Primitive wrapper objects (`new String(s)`, `new Number(n)`, `new
+ * Boolean(b)`, `Object(primitive)`) in JavaScript sources. A wrapper is an
+ * ordinary dynamic object (scr_js_wrapper.c): its prototype is the
+ * wrapper unit's String/Number/Boolean prototype object, whose valueOf,
+ * toString and methods read the [[PrimitiveValue]] slot, and a String
+ * wrapper owns its index properties and `length`. Every generic dynamic
+ * operation (ToPrimitive, property reads, method calls, key listing,
+ * identity, typeof) already handles that shape; this module builds the
+ * values and routes the few consumers that must see the slot itself. */
+import * as ts from "../ts7/adapter.js";
+import {
+ BOOL,
+ DYN,
+ F64,
+ STRING,
+ type IrExpr,
+ type IrFunction,
+ type IrStmt,
+ type SrcLoc,
+} from "../../ir/ir.js";
+import { transformStmtList } from "../../ir/traverse.js";
+import { isJsSourceFile, locOf } from "../program.js";
+import type { Lowerer } from "./lowerer.js";
+import { primitiveCtorClosure } from "./lower-exprs.js";
+
+export type WrapperKind = "String" | "Number" | "Boolean";
+
+/** The runtime's kind numbers (scr_js_wrapper.c SCR_JS_WRAPPER_*). */
+export const WRAPPER_KIND_NUMBER: Record = {
+ String: 0,
+ Number: 1,
+ Boolean: 2,
+};
+
+/** Whether any compiled source creates a primitive wrapper: a `new` of the
+ * stdlib String, Number or Boolean constructor (with or without an
+ * argument: `new String()` is a wrapper over "", `new Number()` over 0 and
+ * `new Boolean()` over false), or a call of the stdlib Object with an
+ * argument. `new Object()` without one is a fresh plain object, so it is
+ * not a wrapper. Until a program has one, no value is a wrapper, so the
+ * boxed interface types keep their primitive mapping and every fold that
+ * relies on it stays. */
+export function programCreatesWrappers(lowerer: Lowerer): boolean {
+ for (const sf of lowerer.program.getSourceFiles()) {
+ if (sf.isDeclarationFile || !isJsSourceFile(sf)) continue;
+ const text = sf.text;
+ if (
+ !text.includes("String") &&
+ !text.includes("Number") &&
+ !text.includes("Boolean") &&
+ !text.includes("Object")
+ )
+ continue;
+ let found = false;
+ const visit = (node: ts.Node): void => {
+ if (found) return;
+ if (
+ (ts.isNewExpression(node) || ts.isCallExpression(node)) &&
+ ts.isIdentifier(node.expression)
+ ) {
+ const name = node.expression.text;
+ // A `new` of a boxed constructor is a wrapper even without an
+ // argument; Object is a wrapper site only when it converts a value.
+ const wrapperNew =
+ ts.isNewExpression(node) &&
+ (name === "String" || name === "Number" || name === "Boolean");
+ const toObject = name === "Object" && (node.arguments?.nodes.length ?? 0) > 0;
+ if ((wrapperNew || toObject) && lowerer.isStdlibGlobal(node.expression, name)) {
+ found = true;
+ return;
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sf);
+ if (found) return true;
+ }
+ return false;
+}
+
+/** `new String(x)` / `new Number(x)` / `new Boolean(x)` in a JavaScript
+ * source: the wrapper over ToString/ToNumber/ToBoolean of the argument
+ * (the runtime converts, with the object hooks), or over "", 0, false
+ * without one. Null outside JavaScript sources and for other callees. */
+export function lowerWrapperNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null {
+ if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return null;
+ if (!ts.isIdentifier(expr.expression)) return null;
+ const kind = expr.expression.text;
+ if (kind !== "String" && kind !== "Number" && kind !== "Boolean") return null;
+ if (!lowerer.isStdlibGlobal(expr.expression, kind)) return null;
+ const args = expr.arguments?.nodes ?? [];
+ if (args.some(ts.isSpreadElement)) return null;
+ const loc = locOf(expr);
+ // Arguments past the first are evaluated and ignored.
+ const value: IrExpr =
+ args.length > 0
+ ? lowerer.lowerExprExpecting(args[0]!, DYN)
+ : lowerer.coerceToExpected(
+ kind === "String"
+ ? { kind: "strLit", value: "", type: STRING, loc }
+ : kind === "Number"
+ ? { kind: "numLit", value: 0, type: F64, loc }
+ : { kind: "boolLit", value: false, type: BOOL, loc },
+ DYN,
+ );
+ const rest = args.slice(1).map((arg) => ({
+ kind: "exprStmt" as const,
+ expr: lowerer.lowerExpr(arg),
+ loc: locOf(arg),
+ }));
+ const created: IrExpr = {
+ kind: "libCall",
+ fn: "wrapper.new",
+ args: [
+ { kind: "numLit", value: WRAPPER_KIND_NUMBER[kind], type: F64, loc },
+ value,
+ ...wrapperConstructors(lowerer, loc),
+ ],
+ type: DYN,
+ loc,
+ };
+ if (rest.length === 0) return created;
+ const local = lowerer.declareHiddenLocal("%wrapperArg", DYN);
+ return {
+ kind: "seqExpr",
+ stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }, ...rest],
+ result: {
+ ...created,
+ args: [
+ created.args[0]!,
+ { kind: "varRef", localId: local.id, type: DYN, loc },
+ ...created.args.slice(2),
+ ],
+ },
+ type: DYN,
+ loc,
+ };
+}
+
+/** The String, Number and Boolean constructor values, for the prototypes'
+ * `constructor` property (the interned closures `x === String` compares). */
+export function wrapperConstructors(lowerer: Lowerer, loc: SrcLoc): IrExpr[] {
+ return (["String", "Number", "Boolean"] as const).map((name) =>
+ lowerer.coerceToExpected(primitiveCtorClosure(lowerer, name, loc, true), DYN),
+ );
+}
+
+/** Consumers that walk a dynamic value's whole graph and must see the
+ * [[PrimitiveValue]] slot (JSON's unwrapping, util.inspect's `[String:
+ * 'x']`, structuredClone's wrapper clone, deep equality's primitive
+ * compare). Until they know the slot, a wrapper reaching one refuses: each
+ * dynamic argument passes through wrapper.guard, which throws the
+ * not-supported Error when the value's own data graph holds a wrapper. */
+const GUARDED_CONSUMERS: ReadonlySet = new Set([
+ "json.stringifyValue",
+ "json.stringifyReplacer",
+ "util.isDeepStrictEqual",
+ "dyn.structuredClone",
+ "assert.looseDeepDyn",
+ "assert.partialDeepDyn",
+ "console.dirDyn",
+ "insp.format",
+ "insp.formatPrimitives",
+ "insp.formatList",
+ "insp.formatWithOptions",
+ "insp.inspect",
+ "insp.dynS",
+ "insp.dynFormatS",
+]);
+
+/** util.types brands that read the [[PrimitiveValue]] slot. */
+const WRAPPER_TYPE_PROBES: ReadonlySet = new Set([
+ "isStringObject",
+ "isNumberObject",
+ "isBooleanObject",
+ "isBoxedPrimitive",
+]);
+
+/** The consumer as the refusal names it. */
+function consumerName(fn: string): string {
+ if (fn.startsWith("json.")) return "JSON.stringify";
+ if (fn === "dyn.structuredClone") return "structuredClone";
+ if (fn === "util.isDeepStrictEqual") return "util.isDeepStrictEqual";
+ if (fn.startsWith("assert.")) return "assert's deep equality";
+ return "console.log/util.inspect";
+}
+
+function guarded(arg: IrExpr, fn: string): IrExpr {
+ const consumer = consumerName(fn);
+ if (arg.type.kind !== "dyn" && !(arg.type.kind === "array" && arg.type.elem.kind === "dyn"))
+ return arg;
+ return {
+ kind: "libCall",
+ fn: arg.type.kind === "dyn" ? "wrapper.guard" : "wrapper.guardList",
+ args: [arg, { kind: "strLit", value: consumer, type: STRING, loc: arg.loc }],
+ type: arg.type,
+ loc: arg.loc,
+ };
+}
+
+/** In a program that creates wrappers, route the consumers that read
+ * internal slots: Object.prototype.toString's tag and the util.types
+ * brands answer from the slot (wrapper.objectTag, wrapper.typeIs), and the
+ * graph-walking consumers above guard their dynamic arguments. Programs
+ * without a wrapper site keep every node unchanged. */
+export function routeWrapperConsumers(lowerer: Lowerer, functions: IrFunction[]): void {
+ if (!(lowerer.createsWrappers ??= programCreatesWrappers(lowerer))) return;
+ const transform = {
+ expr: (expr: IrExpr): IrExpr => {
+ if (expr.kind === "libCall") {
+ if (expr.fn === "dyn.objectTag") return { ...expr, fn: "wrapper.objectTag" };
+ if (
+ expr.fn === "util.typeIs" &&
+ expr.args[1]?.kind === "strLit" &&
+ WRAPPER_TYPE_PROBES.has(expr.args[1].value)
+ )
+ return { ...expr, fn: "wrapper.typeIs" };
+ if (GUARDED_CONSUMERS.has(expr.fn))
+ return { ...expr, args: expr.args.map((arg) => guarded(arg, expr.fn)) };
+ }
+ if (
+ expr.kind === "intrinsic" &&
+ (expr.name === "console.log" || expr.name === "console.error")
+ )
+ return { ...expr, args: expr.args.map((arg) => guarded(arg, expr.name)) };
+ return expr;
+ },
+ stmt: (stmt: IrStmt): IrStmt => stmt,
+ };
+ for (const fn of functions) fn.body = transformStmtList(fn.body, transform);
+}
+
+/** `Object(x)` / `new Object(x)` over a checked-dynamic `x` in a program
+ * that creates wrappers: ToObject (wrapper.toObject), a wrapper for a
+ * primitive, the value itself for an object, a fresh object for null and
+ * undefined. */
+export function lowerWrapperToObject(lowerer: Lowerer, value: IrExpr, loc: SrcLoc): IrExpr {
+ const ctors = wrapperConstructors(lowerer, loc);
+ return { kind: "libCall", fn: "wrapper.toObject", args: [value, ...ctors], type: DYN, loc };
+}
diff --git a/packages/compiler/src/frontend/lowering/private-statics.ts b/packages/compiler/src/frontend/lowering/private-statics.ts
new file mode 100644
index 000000000..b8823a523
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/private-statics.ts
@@ -0,0 +1,365 @@
+import * as ts from "../ts7/adapter.js";
+import {
+ BOOL,
+ DYN,
+ VOID,
+ type IrExpr,
+ type IrStmt,
+ type IrType,
+ type SrcLoc,
+} from "../../ir/ir.js";
+import { helperLoc, strLit, varRef } from "../../ir/build.js";
+import { locOf } from "../program.js";
+import { hasStaticThis } from "./static-this.js";
+import type { Lowerer } from "./lowerer.js";
+import { lowerClassStaticRead, lowerStaticMethodCall, type ClassInfo } from "./lower-classes.js";
+import { tryLowerExpression } from "./expressions/try-lower-expression.js";
+import { lowerCallThroughValue } from "./lower-calls.js";
+
+/** How a static private name is used: a field read or write, or a method
+ * (or accessor) read or call. V8 words each failure differently. */
+export type StaticPrivateUse = "read" | "write" | "method";
+
+/** The declaration-level class whose body declares `priv` as a STATIC
+ * member (the nearest enclosing class declaring the name, as JavaScript
+ * scoping resolves it). Null for an instance private, a function-local or
+ * generic class, or a class that was not collected. */
+export function staticPrivateOwner(lowerer: Lowerer, priv: ts.PrivateIdentifier): ClassInfo | null {
+ for (let node: ts.Node | undefined = priv.parent; node; node = node.parent) {
+ if (!ts.isClassDeclaration(node) && !ts.isClassExpression(node)) continue;
+ const member = node.members.nodes.find((candidate) => {
+ const name = (candidate as { name?: ts.PropertyName }).name;
+ return name !== undefined && ts.isPrivateIdentifier(name) && name.text === priv.text;
+ });
+ if (!member) continue;
+ const isStatic =
+ ts.canHaveModifiers(member) &&
+ ts.getModifiers(member)?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true;
+ if (!isStatic) return null;
+ for (const info of lowerer.classes.values()) {
+ if (info.decl !== node) continue;
+ if (info.localClass || info.generic || info.genericInstance) return null;
+ return info;
+ }
+ return null;
+ }
+ return null;
+}
+
+/** V8's TypeError text for a static private `name` of `info` used on a
+ * receiver that is not the declaring class object. */
+function brandMessage(info: ClassInfo, use: StaticPrivateUse, name: string): string {
+ if (use === "read")
+ return `Cannot read private member ${name} from an object whose class did not declare it`;
+ if (use === "write")
+ return `Cannot write private member ${name} to an object whose class did not declare it`;
+ // V8 names the class by its own identifier; an anonymous class
+ // expression prints ".".
+ return `Receiver must be class ${info.decl?.name?.text ?? "."}`;
+}
+
+/** The brand check of a static private: JavaScript installs static private
+ * elements on the declaring class object alone, so the receiver must BE
+ * that object (a subclass, an object inheriting from it, or a primitive
+ * throws). An expression statement calling the lifted helper
+ * `%private.static.