Merge main

This commit is contained in:
Chris Tate
2026-10-29 21:25:26 -05:00
416 changed files with 20020 additions and 1305 deletions
+98 -25
View File
@@ -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 }}"
+1
View File
@@ -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
+2 -2
View File
@@ -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
+2
View File
@@ -100,6 +100,8 @@ title: "Limitations"
<include>../limitations/builtin-apis/file-streams.mdx</include>
<include>../limitations/builtin-apis/file-watching.mdx</include>
<include>../limitations/builtin-apis/crypto.mdx</include>
<include>../limitations/builtin-apis/assertions.mdx</include>
@@ -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.
@@ -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`.
@@ -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.
+2 -2
View File
@@ -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.
@@ -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`.
@@ -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"
}
},
{
@@ -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"
},
+2 -1
View File
@@ -485,6 +485,7 @@
{ "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"] }
]
}
+143 -24
View File
@@ -252,15 +252,20 @@ struct UnnameStructTypes : PassInfoMixin<UnnameStructTypes> {
// 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<uint64_t> Annotated{0};
std::atomic<uint64_t> Mismatched{0};
std::atomic<uint64_t> Missing{0};
std::atomic<uint64_t> Recovered{0};
std::atomic<uint64_t> Unknown{0};
std::atomic<uint64_t> AnnotatedCounts{0};
std::atomic<uint64_t> 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<StringRef, 4> Parts;
SmallVector<StringRef, 6> 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<Function *> Mismatched;
std::vector<Function *> 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<uint64_t> &profileCounts(const std::string &Path) {
static std::once_flag Once;
static StringMap<uint64_t> Counts;
std::call_once(Once, [&Path] {
Expected<std::unique_ptr<IndexedInstrProfReader>> 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<uint64_t> &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<std::string> Annotated;
std::vector<std::string> Mismatched;
private:
StringMap<std::string> 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<std::string> &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<const Module *>(&IR))
for (const Function &F : **M) {
std::optional<Function::ProfileCount> Entry = F.getEntryCount();
@@ -714,13 +810,21 @@ emitModule(Module &M, TargetMachine &Machine, OptimizationLevel Level,
StringRef OutputPath, std::vector<SmallString<256>> &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<ProfileMatchHandler>(Match));
std::make_unique<ProfileMatchHandler>(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<EmitFailure> 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<std::string> MismatchedNames;
if (Annotating)
Context.setDiagnosticHandler(
std::make_unique<ProfileMatchHandler>(PartitionMatch));
Context.setDiagnosticHandler(std::make_unique<ProfileMatchHandler>(
PartitionMatch, &MismatchedNames));
Expected<std::unique_ptr<Module>> 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<EmitFailure>
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<ProfileMatchHandler>(Match));
std::make_unique<ProfileMatchHandler>(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<EmitFailure> Failure =
verify(M, "post_optimization_verification_failed"))
return Failure;
@@ -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<std::mutex> Guard(Lock);
Records.emplace(F.getName().str(), std::move(Text));
}
void BlockRecorder::add(const Function &F) {
std::optional<Function::ProfileCount> Entry = F.getEntryCount();
if (!Entry || Entry->isSynthetic())
+2
View File
@@ -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<std::string> write(llvm::StringRef Path);
+1
View File
@@ -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",
+5 -2
View File
@@ -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");
+45 -5
View File
@@ -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<T> = (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<string>): FSWatcher;
export function watch(
path: string,
options: WatchOptions,
listener?: (eventType: string) => void,
listener?: WatchListener<string>,
): 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"`. */
+24 -7
View File
@@ -1115,9 +1115,9 @@ declare var ReadableStream: {
from(iterable: string): ReadableStream<string>;
readonly prototype: ReadableStream<unknown>;
};
/* 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<W = unknown> {
readonly closed: Promise<void>;
readonly desiredSize: number | null;
@@ -1133,14 +1133,25 @@ interface WritableStream<W = unknown> {
close(): Promise<void>;
getWriter(): WritableStreamDefaultWriter<W>;
}
/* 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<I = unknown, O = unknown> {
readonly readable: ReadableStream<O>;
readonly writable: WritableStream<I>;
}
interface Transformer<I = unknown, O = unknown> {
start?(controller: TransformStreamDefaultController<O>): unknown;
transform?(chunk: I, controller: TransformStreamDefaultController<O>): unknown;
flush?(controller: TransformStreamDefaultController<O>): unknown;
cancel?(reason?: unknown): unknown;
}
declare var TransformStream: {
new <I = unknown, O = I>(): TransformStream<I, O>;
new <I = unknown, O = I>(
transformer?: Transformer<I, O>,
writableStrategy?: QueuingStrategy<I>,
readableStrategy?: QueuingStrategy<O>,
): TransformStream<I, O>;
readonly prototype: TransformStream<unknown, unknown>;
};
/* The other web stream interfaces. JavaScript sources construct and read
@@ -1158,8 +1169,14 @@ interface WritableStreamDefaultController {
readonly signal: AbortSignal;
error(reason?: unknown): void;
}
interface UnderlyingSink<W = unknown> {
start?(controller: WritableStreamDefaultController): unknown;
write?(chunk: W, controller: WritableStreamDefaultController): unknown;
close?(): unknown;
abort?(reason?: unknown): unknown;
}
declare var WritableStream: {
new <W = unknown>(sink?: object, strategy?: QueuingStrategy<W>): WritableStream<W>;
new <W = unknown>(sink?: UnderlyingSink<W>, strategy?: QueuingStrategy<W>): WritableStream<W>;
readonly prototype: WritableStream<unknown>;
};
declare var WritableStreamDefaultWriter: {
@@ -67,6 +67,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,
@@ -56,6 +56,7 @@ export async function compilePackedLibrary(
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
@@ -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,
+155 -121
View File
@@ -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,139 @@ 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)`,
` 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${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",
sz,
[` %kf = sitofp i64 %ki to double`, ` %kb = bitcast double %kf to i64`],
"%kf",
),
];
}
/** 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 +1537,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 +1711,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
@@ -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,
@@ -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);
@@ -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" }),
);
@@ -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 };
@@ -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.
@@ -876,6 +876,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
@@ -906,6 +910,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
@@ -1055,6 +1060,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`);
@@ -261,6 +261,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"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,6 +306,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"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",
@@ -441,6 +444,12 @@ export const LIB_FN_SYMS: Record<string, string> = {
"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",
@@ -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<IrStmt>;
/** 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<LoopState>(() => ({
@@ -73,4 +93,5 @@ export const LOOPS = defineModuleState<LoopState>(() => ({
bytesLoopEntries: null,
bytesLoopFunction: null,
provenCountedGuards: new Set<IrStmt>(),
inlinedLookups: false,
}));
@@ -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`);
@@ -198,6 +216,7 @@ test("a key with an integer view tests the direct index on the integer", () => {
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("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.
@@ -220,6 +239,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;
@@ -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;
}
@@ -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<string> {
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+:/);
});
@@ -563,6 +563,9 @@ function helperProfileMatch(match: NativeProfileMatch): Record<string, number> {
missing: match.missing,
recovered: match.recovered,
unknown_partitions: match.unknownPartitions,
...(match.annotatedCounts === undefined || match.mismatchedCounts === undefined
? {}
: { annotated_counts: match.annotatedCounts, mismatched_counts: match.mismatchedCounts }),
};
}
@@ -120,6 +120,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;
@@ -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();
@@ -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;
}
@@ -258,6 +258,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. */
@@ -174,9 +174,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 +186,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,
]
: [],
@@ -344,6 +346,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"),
@@ -423,6 +428,8 @@ const RUNTIME_DEFINES: readonly {
{ 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 },
];
@@ -104,7 +104,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
// 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"] : []),
@@ -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<string, string>();
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);
});
@@ -2,9 +2,11 @@
* 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 } from "node:crypto";
import {
closeSync,
constants,
@@ -13,6 +15,8 @@ import {
mkdirSync,
openSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeSync,
type Stats,
@@ -239,21 +243,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<StoredVerification> | 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<string, VerifiedArtifact>,
newlyVerified: VerifiedArtifact[],
records: readonly Map<string, VerifiedArtifact>[],
verified: VerifiedArtifact[],
): string {
const source = join(root, artifact.path);
const destination = join(stage, artifact.path);
@@ -276,26 +312,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 +380,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 +401,78 @@ 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<string, VerifiedArtifact>): 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 };
}
/** 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<string, VerifiedArtifact> {
const verified = new Map<string, VerifiedArtifact>();
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. */
}
}
}
@@ -120,6 +120,7 @@ const features = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
@@ -62,6 +62,7 @@ const BASE: NativeLinkFeatures = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
@@ -63,6 +63,7 @@ export async function warmNativeCaches(
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
+28 -23
View File
@@ -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<string>;
readableStreamCalls: ReadonlySet<string>;
/** 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<string>;
}
@@ -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;
}
@@ -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.",
];
@@ -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",
@@ -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
@@ -687,6 +691,11 @@ export const UNSUPPORTED: Record<string, UnsupportedEntry> = {
// 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",
@@ -60,6 +60,7 @@ const native: EarlyExecutableNativeFeatures = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
@@ -111,6 +111,7 @@ export interface EarlyExecutableNativeFeatures {
dgramDyn: boolean;
textCodecDyn: boolean;
watch: boolean;
watchFile: boolean;
foreignFfi: boolean;
nodeTest: boolean;
tls: boolean;
@@ -299,6 +300,7 @@ const BOOLEAN_NATIVE_KEYS = [
"dgramDyn",
"textCodecDyn",
"watch",
"watchFile",
"foreignFfi",
"nodeTest",
"tls",
+9 -1
View File
@@ -20,6 +20,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 +43,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 +55,7 @@ export function prepareExecutableModule(
timing: CompilationTiming = () => {},
lowerIslands: ReadonlyMap<string, IslandPackage> = 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 +119,7 @@ export function prepareExecutableModule(
timing,
grown,
retryFences,
unitStore,
);
}
return fail(fe.preflight);
@@ -127,6 +132,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 +171,7 @@ export function prepareExecutableModule(
timing,
grown,
retryFences,
unitStore,
);
}
// A failed program whose compiled packages need the runtime or a
@@ -267,6 +274,7 @@ export function prepareExecutableModule(
timing,
islands,
false,
unitStore,
);
if (islanded.ok) return islanded;
firstError ??= islanded.diagnostics[0] ?? null;
@@ -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);
});
@@ -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<string, ts.Node[]>();
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<string>();
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<string>): 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;
}
@@ -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);
});
@@ -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. */
@@ -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);
@@ -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),
);
}
@@ -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);
}
@@ -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({
@@ -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":
@@ -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<string> = new Set([
"ReadableStream",
"WritableStream",
"TransformStream",
]);
const WEB_VIEW_CALLS: ReadonlySet<string> = 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. */
@@ -1062,6 +1062,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) };
}
}
@@ -61,7 +61,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 +81,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";
@@ -1656,9 +1656,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 +1679,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 +2632,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 ? "!==" : "===",
@@ -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";
@@ -825,6 +825,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<string, string | undefined> = {
@@ -1041,6 +1043,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: <name> 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.
@@ -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",
@@ -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))
);
}
@@ -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);
}
@@ -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);
@@ -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<number, ts.Symbol>;
}
/** 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<ts.Symbol>();
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<ts.TypeParameterDeclaration> })
.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;
@@ -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<string> = 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 {
@@ -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" ||
@@ -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<IrExpr, { kind: "closure" }> =>
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<string, Extract<IrExpr, { kind: "closure" }> | 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<string, unknown>)[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,
};
}
@@ -256,7 +256,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 +473,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
@@ -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<string, EventSig> {
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<string, EventSig> {
// 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;
@@ -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. */
@@ -1707,7 +1718,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("<island>")` 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) ───────────
@@ -2046,7 +2064,8 @@ 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), 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;
@@ -2065,7 +2084,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,
});
}
@@ -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,
@@ -2549,13 +2549,26 @@ export function lowerDestructuringDecl(
}
}
const out: IrStmt[] = [];
// V8's destructuring TypeError spells STATIC sources: an identifier
// source reads "<name> is not iterable", an identifier-callee call
// "<name> 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 +2581,61 @@ 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<IrExpr>(
(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,
}
: 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 +2648,7 @@ export function lowerDestructuringDecl(
loc,
},
then: [
...(check !== null ? [{ kind: "exprStmt" as const, expr: check, loc }] : []),
{
kind: "runtimeFence",
code: "SC1031",
@@ -2635,42 +2701,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 "<name> is not iterable", an identifier-callee call
// "<name> 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 }),
@@ -9589,6 +9632,26 @@ export function lowerDestructuringAssign(
return { kind: "block", body: parts.stmts, 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 +13046,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 };
@@ -30,7 +30,9 @@ import {
irDataTable,
journalDump,
journalMode,
HeldTables,
lowerWaveRecorded,
type WaveReuse,
mapTable,
setTable,
waveStats,
@@ -41,6 +43,7 @@ import {
type UnitEvents,
type WaveHost,
} from "./unit-journal.js";
import { UnitReuse, unitReuseVerify, verifyStoredUnit, type UnitStore } from "./unit-reuse.js";
import {
moduleNamespaceDynValue,
moduleNamespaceObject,
@@ -179,7 +182,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 +363,7 @@ import {
overridesDtsPath,
npmStaticDepSf7,
requireSpecOf,
isJsWriteTarget7,
resolveImport,
workspacePackageOfPath,
type CycleEdge,
@@ -961,6 +967,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 +1369,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;
@@ -3495,6 +3504,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<string, number>();
/** 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 +3874,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<string, IrFunction>();
@@ -4196,6 +4214,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(
@@ -4493,6 +4512,7 @@ export class Lowerer {
"sloppyThisGlobalReads",
"globalLookupNames",
"namesWebStreamClass",
"typedWebStreams",
"timeValueDateDecisions",
"timeValueDateWrites",
"dynamicOverloadAnswers",
@@ -9652,6 +9672,7 @@ 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);
@@ -9940,7 +9961,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<string>; result: LowerResult } {
emitReachable(
extraRoots?: readonly string[],
unitStore: UnitStore | null = null,
): { reachable: Set<string>; 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.
@@ -9998,12 +10022,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 +10075,7 @@ export class Lowerer {
order: unitOrder++,
roots: functionRoots(decl),
lower: () => this.lowerFunction(decl),
source: decl,
});
}
}
@@ -10067,6 +10096,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 +10105,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 +10122,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 +10131,7 @@ export class Lowerer {
order: unitOrder++,
roots: [],
lower: () => this.throwingSetterFn(info, prop),
source: info.decl ?? undefined,
});
}
}
@@ -10106,6 +10141,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 +10457,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 +10561,7 @@ export class Lowerer {
}
}
}
held?.check();
};
drainUnits();
this.restoreGenericInstanceOrder();
@@ -10491,6 +10606,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`,
@@ -10539,11 +10661,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<string, IrFunction>,
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 +10742,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 +10811,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);
}),
);
},
};
}
@@ -11958,9 +12164,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 +12598,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 +12667,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 } : {}),
@@ -15624,6 +15873,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 +17264,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 +17377,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. */
@@ -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);
@@ -12,6 +12,7 @@ import {
} from "../../ir/ir.js";
import type { Lowerer } from "./lowerer.js";
import { unionComparison } from "./union-comparison.js";
import { builtinModuleConstOf } from "./surfaces.js";
const IDENTITY_KINDS: ReadonlySet<IrType["kind"]> = new Set([
"array",
@@ -29,6 +30,19 @@ const IDENTITY_KINDS: ReadonlySet<IrType["kind"]> = new Set([
"moduleNs",
]);
/** Whether two namespace references are the same module object: the bare
* node:path namespace IS its target platform's family (`path ===
* path.posix` on POSIX targets); other namespaces are their module. */
export function sameNamespace(lowerer: Lowerer, a: string, b: string): boolean {
const family = (id: string): string =>
id !== "builtin:path"
? id
: builtinModuleConstOf(lowerer, "path", "sep") === "\\"
? "builtin:path/win32"
: "builtin:path/posix";
return family(a) === family(b);
}
function atomicEquality(
lowerer: Lowerer,
left: IrExpr,
@@ -41,6 +55,8 @@ function atomicEquality(
if ([a.kind, b.kind].some((kind) => kind === "dyn" || kind === "caught" || kind === "void"))
return null;
if (isUnitType(a) || isUnitType(b)) return boolLit(a.kind === b.kind, loc);
if (left.kind === "moduleNsRef" && right.kind === "moduleNsRef")
return boolLit(sameNamespace(lowerer, left.moduleId, right.moduleId), loc);
if (a.kind === "object" && b.kind === "object" && !typeEquals(a, b)) {
if (lowerer.isSubclassOf(a.className, b.className)) left = lowerer.upcastTo(left, b.className);
else if (lowerer.isSubclassOf(b.className, a.className))
@@ -443,6 +443,13 @@ export const FS_WATCH_DOCUMENTED_OPTIONS: ReadonlySet<string> = new Set([
"signal",
]);
/** fs.watchFile's documented option keys. */
export const FS_WATCH_FILE_DOCUMENTED_OPTIONS: ReadonlySet<string> = new Set([
"interval",
"persistent",
"bigint",
]);
/** fs.readdirSync's documented option keys. */
export const FS_READDIR_DOCUMENTED_OPTIONS: ReadonlySet<string> = new Set([
"encoding",
@@ -823,15 +830,28 @@ export interface BuiltinModuleFn {
/** The function value's `length` when it is not the count of its
* required value parameters (`Math.max`'s 2 over its rest pack). */
valueLength?: number;
/** The order Node validates named value parameters in, when it is not
* the parameter order (basename checks its suffix first). */
valueOrder?: readonly number[];
/** The runtime entry point an untyped caller's adapter calls with every
* argument as given, when it validates them itself (resolve's last-first
* walk) or the value has no typed form (`only`: parse's record result,
* format's untyped pathObject). `args` are constants passed after the
* arguments (the win32 flag of an entry both path families share). */
checked?: { fn: IrLibFn; args: readonly boolean[]; result?: IrType; only?: true };
/** The function value's `name` when it is not the member's
* (`process.cwd` is Node's `wrappedCwd`). */
valueName?: string;
}
/** `name` is the argument name Node's validator reports
* (ERR_INVALID_ARG_TYPE's 'The "path" argument ...'): the adapter accepts
* any value there and validates it, so a string parameter arrives checked
* and a dyn one reaches its valueFn as given. */
export type BuiltinValueParam =
| { mode: "required"; type: IrType }
| { mode: "optional"; type: IrType; defaultValue: string | number | undefined }
| { mode: "rest"; type: IrType };
| { mode: "required"; type: IrType; name?: string }
| { mode: "optional"; type: IrType; defaultValue: string | number | undefined; name?: string }
| { mode: "rest"; type: IrType; name?: string };
/** The common first-class shape for builtin functions whose supported
* source signature is already exactly the fixed native ABI. Keeping the
@@ -933,26 +953,27 @@ const PATH_MODULE_FNS: Record<string, BuiltinModuleFn | undefined> = {
params: [STRING],
result: STRING,
variadicPack: true,
valueParams: [{ mode: "rest", type: STRING }],
valueParams: [{ mode: "rest", type: STRING, name: "path" }],
},
resolve: {
fn: "path.resolve",
params: [STRING],
result: STRING,
variadicPack: true,
valueParams: [{ mode: "rest", type: STRING }],
valueParams: [{ mode: "rest", type: STRING, name: "paths" }],
checked: { fn: "path.resolveValue", args: [false] },
},
normalize: {
fn: "path.normalize",
params: [STRING],
result: STRING,
valueParams: [{ mode: "required", type: STRING }],
valueParams: [{ mode: "required", type: STRING, name: "path" }],
},
dirname: {
fn: "path.dirname",
params: [STRING],
result: STRING,
valueParams: [{ mode: "required", type: STRING }],
valueParams: [{ mode: "required", type: STRING, name: "path" }],
},
basename: {
fn: "path.basename",
@@ -960,48 +981,65 @@ const PATH_MODULE_FNS: Record<string, BuiltinModuleFn | undefined> = {
result: STRING,
defaults: [""],
valueParams: [
{ mode: "required", type: STRING },
{ mode: "optional", type: STRING, defaultValue: "" },
{ mode: "required", type: STRING, name: "path" },
{ mode: "optional", type: STRING, defaultValue: "", name: "suffix" },
],
valueOrder: [1, 0],
},
extname: {
fn: "path.extname",
params: [STRING],
result: STRING,
valueParams: [{ mode: "required", type: STRING }],
valueParams: [{ mode: "required", type: STRING, name: "path" }],
},
isAbsolute: {
fn: "path.isAbsolute",
params: [STRING],
result: BOOL,
valueParams: [{ mode: "required", type: STRING }],
valueParams: [{ mode: "required", type: STRING, name: "path" }],
},
relative: {
fn: "path.relative",
params: [STRING, STRING],
result: STRING,
valueParams: [
{ mode: "required", type: STRING },
{ mode: "required", type: STRING },
{ mode: "required", type: STRING, name: "from" },
{ mode: "required", type: STRING, name: "to" },
],
},
toNamespacedPath: {
fn: "path.toNamespacedPath",
params: [STRING],
result: STRING,
valueParams: [{ mode: "required", type: STRING }],
valueParams: [{ mode: "required", type: STRING, name: "path" }],
checked: { fn: "path.toNamespacedValue", args: [false], result: DYN },
},
matchesGlob: {
fn: "path.matchesGlob",
params: [STRING, STRING],
result: BOOL,
valueParams: exactValueParams(STRING, STRING),
valueParams: [
{ mode: "required", type: STRING, name: "path" },
{ mode: "required", type: STRING, name: "pattern" },
],
},
// Special-cased (lowerPathParseCall / lowerPathFormatCall): the
// ParsedPath record and the pathObject argument follow the call site's
// mapped shapes.
parse: { fn: "path.parse", params: [STRING], result: VOID },
format: { fn: "path.format", params: [], result: STRING },
parse: {
fn: "path.parse",
params: [STRING],
result: VOID,
valueParams: [{ mode: "required", type: STRING, name: "path" }],
checked: { fn: "path.parseValue", args: [false], result: DYN, only: true },
},
format: {
fn: "path.format",
params: [],
result: STRING,
valueParams: [{ mode: "required", type: DYN, name: "pathObject" }],
checked: { fn: "path.formatValue", args: [false], result: DYN, only: true },
},
};
/** The win32 twins (scr_path.c's exact port of Node-v24 path.win32):
@@ -1013,26 +1051,27 @@ const PATH_WIN32_MODULE_FNS: Record<string, BuiltinModuleFn | undefined> = {
params: [STRING],
result: STRING,
variadicPack: true,
valueParams: [{ mode: "rest", type: STRING }],
valueParams: [{ mode: "rest", type: STRING, name: "path" }],
},
resolve: {
fn: "path.win32Resolve",
params: [STRING],
result: STRING,
variadicPack: true,
valueParams: [{ mode: "rest", type: STRING }],
valueParams: [{ mode: "rest", type: STRING, name: "paths" }],
checked: { fn: "path.resolveValue", args: [true] },
},
normalize: {
fn: "path.win32Normalize",
params: [STRING],
result: STRING,
valueParams: [{ mode: "required", type: STRING }],
valueParams: [{ mode: "required", type: STRING, name: "path" }],
},
dirname: {
fn: "path.win32Dirname",
params: [STRING],
result: STRING,
valueParams: [{ mode: "required", type: STRING }],
valueParams: [{ mode: "required", type: STRING, name: "path" }],
},
basename: {
fn: "path.win32Basename",
@@ -1040,48 +1079,65 @@ const PATH_WIN32_MODULE_FNS: Record<string, BuiltinModuleFn | undefined> = {
result: STRING,
defaults: [""],
valueParams: [
{ mode: "required", type: STRING },
{ mode: "optional", type: STRING, defaultValue: "" },
{ mode: "required", type: STRING, name: "path" },
{ mode: "optional", type: STRING, defaultValue: "", name: "suffix" },
],
valueOrder: [1, 0],
},
extname: {
fn: "path.win32Extname",
params: [STRING],
result: STRING,
valueParams: [{ mode: "required", type: STRING }],
valueParams: [{ mode: "required", type: STRING, name: "path" }],
},
isAbsolute: {
fn: "path.win32IsAbsolute",
params: [STRING],
result: BOOL,
valueParams: [{ mode: "required", type: STRING }],
valueParams: [{ mode: "required", type: STRING, name: "path" }],
},
relative: {
fn: "path.win32Relative",
params: [STRING, STRING],
result: STRING,
valueParams: [
{ mode: "required", type: STRING },
{ mode: "required", type: STRING },
{ mode: "required", type: STRING, name: "from" },
{ mode: "required", type: STRING, name: "to" },
],
},
toNamespacedPath: {
fn: "path.win32ToNamespacedPath",
params: [STRING],
result: STRING,
valueParams: [{ mode: "required", type: STRING }],
valueParams: [{ mode: "required", type: STRING, name: "path" }],
checked: { fn: "path.toNamespacedValue", args: [true], result: DYN },
},
matchesGlob: {
fn: "path.win32MatchesGlob",
params: [STRING, STRING],
result: BOOL,
valueParams: exactValueParams(STRING, STRING),
valueParams: [
{ mode: "required", type: STRING, name: "path" },
{ mode: "required", type: STRING, name: "pattern" },
],
},
// Special-cased (lowerPathParseCall / lowerPathFormatCall): the
// ParsedPath record and the pathObject argument follow the call site's
// mapped shapes.
parse: { fn: "path.win32Parse", params: [STRING], result: VOID },
format: { fn: "path.win32Format", params: [], result: STRING },
parse: {
fn: "path.win32Parse",
params: [STRING],
result: VOID,
valueParams: [{ mode: "required", type: STRING, name: "path" }],
checked: { fn: "path.parseValue", args: [true], result: DYN, only: true },
},
format: {
fn: "path.win32Format",
params: [],
result: STRING,
valueParams: [{ mode: "required", type: DYN, name: "pathObject" }],
checked: { fn: "path.formatValue", args: [true], result: DYN, only: true },
},
};
/** util.types's own keys in Node's order (Node 24.22.0). The table is the
@@ -2318,7 +2374,7 @@ export const BUILTIN_MODULE_FN_ALIASES: Record<
},
os: {
// lowerOsUserInfoCall assembles the record from scalar libCalls.
userInfo: ["os.userHomedir", "os.userShell"],
userInfo: ["os.userHomedir", "os.userShell", "os.userUid", "os.userGid"],
// lowerOsCpusCall reads the cpusTake snapshot through its accessors.
cpus: ["os.cpusModel", "os.cpusField"],
},
@@ -2436,6 +2492,22 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] {
fns: ["fs.callbackValue", "fs.callbackCall", "fs.callbackGlob"],
note: "native filesystem callbacks, including stored platform adapter functions",
},
// fs.watchFile/fs.unwatchFile lower in lowerFsWatchFileCall, ahead of
// the module table.
{
id: "node-builtin.fs.watchFile",
kind: "node-builtin",
name: "fs.watchFile",
fns: ["fs.watchFile"],
note: "stat polling of a resolved path with Stats listeners, in statement position; bigint: true is refused",
},
{
id: "node-builtin.fs.unwatchFile",
kind: "node-builtin",
name: "fs.unwatchFile",
fns: ["fs.unwatchFile"],
note: "removes one listener, or every listener, from a resolved path's poller",
},
// ── the Date slice (lowerDateCall/lowerNew): statics, construction,
// stored read-only values, calendar getters, and ISO formatting.
{
@@ -2730,7 +2802,12 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] {
id: "node-builtin.process.getBuiltinModule",
kind: "node-builtin",
name: "process.getBuiltinModule",
fns: ["process.builtinId", "process.builtinModule", "process.builtinUnsupported"],
fns: [
"process.builtinId",
"process.builtinModule",
"process.builtinObject",
"process.builtinUnsupported",
],
note: "native path and os export subsets plus current-thread worker_threads metadata; other modules and exports throw SC2020",
},
{
@@ -4,6 +4,7 @@ import { Lowerer } from "./lowerer.js";
import {
checkedTable,
contentDigest,
decodeRecordFunction,
encodeUnitFunction,
flagScalar,
functionTable,
@@ -12,7 +13,9 @@ import {
recordUnit,
setTable,
waveStats,
type UnitRecord,
type WaveHost,
type WaveReuse,
} from "./unit-journal.js";
import type { IrFunction } from "../../ir/ir.js";
@@ -225,6 +228,7 @@ function host(lower: (name: string, state: HostState) => IrFunction | null): {
},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
},
};
}
@@ -276,7 +280,10 @@ test("a unit's record lists its events, flags and added entries", () => {
["flag", "flag"],
]);
expect(record!.tables).toEqual({ helpers: [`"key.a"\n"%h.a"`] });
expect(JSON.parse(JSON.stringify(record))).toEqual(record);
// The function is in the binary encoding, and the rest is JSON data.
const { fn: bytes, ...rest } = record!;
expect(bytes).toBeInstanceOf(Uint8Array);
expect(JSON.parse(JSON.stringify(rest))).toEqual(rest);
});
test("a unit that changes an entry it did not add has no record", () => {
@@ -303,6 +310,7 @@ test("a recorded wave throws when a unit changes a held function in place", () =
store: () => {},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
};
lowerWaveRecorded(subject, ["a"], waveStats());
expect(() => lowerWaveRecorded(subject, ["a", "b"], waveStats())).toThrow(
@@ -310,6 +318,47 @@ test("a recorded wave throws when a unit changes a held function in place", () =
);
});
test("a recorded wave holds the entries an applied record adds after its first lowering unit", () => {
// `first` lowers, which takes the wave's content; `a` applies a stored
// record that adds `%added`; `b` changes `%added` in place without naming
// it, which the end of the wave must see.
const functions: IrFunction[] = [];
const subject: WaveHost = {
tables: [functionTable("lifted", functions, (name) => functions.some((f) => f.name === name))],
scalars: [],
lowerRecorded: (name) => {
if (name === "b")
functions
.find((f) => f.name === "%added")!
.locals.push({ id: "x.0", name: "x", type: { kind: "f64" } } as never);
return { fn: fn(`%${name}`), poisoned: false };
},
store: () => {},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
};
const stored: UnitRecord = {
name: "a",
fn: null,
poisoned: false,
events: [],
tables: { lifted: [encodeUnitFunction(fn("%added"))!] },
reads: {},
};
const reuse: WaveReuse = {
stored: (name) => (name === "a" ? { record: stored, fn: () => null } : null),
verify: false,
check: () => {},
note: () => {},
};
const stats = waveStats();
expect(() => lowerWaveRecorded(subject, ["first", "a", "b"], stats, null, reuse)).toThrow(
"a unit changed lifted entry %added in place without naming it",
);
expect(stats.applied).toBe(1);
});
/** A wave host over one function table holding `%shared` and one checked
* list, whose units change `%shared` in place and name it in their IR. */
function sharedHost(grows: (name: string) => boolean): { host: WaveHost; checked: string[] } {
@@ -334,6 +383,7 @@ function sharedHost(grows: (name: string) => boolean): { host: WaveHost; checked
store: () => {},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
},
};
}
@@ -352,6 +402,40 @@ test("a unit that changes a function it names in place has no record", () => {
expect(stats).toMatchObject({ recorded: 0, unrecorded: 1, reasons: { "lifted:changed": 1 } });
});
test("a record's binary function names the held functions its strings name", () => {
const functions = [fn("%shared")];
const loc = { file: "b.ts", start: 0, end: 0 };
const subject: WaveHost = {
tables: [functionTable("lifted", functions, (name) => functions.some((f) => f.name === name))],
scalars: [],
lowerRecorded: () => ({
fn: {
...fn("%a"),
body: [
{
kind: "exprStmt",
expr: { kind: "call", callee: "%shared", args: [], type: { kind: "f64" }, loc },
loc,
},
] as never,
},
poisoned: false,
}),
store: () => {},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
};
const unit = recordUnit(subject, "a");
expect(unit.record!.fn).toBeInstanceOf(Uint8Array);
expect(unit.record!.reads).toEqual({
lifted: [["%shared", contentDigest(encodeUnitFunction(functions[0]!)!)]],
});
// The files of the function's source positions, for reuse to check.
expect([...unit.files!].sort()).toEqual(["a.ts", "b.ts"]);
expect(decodeRecordFunction(unit.record!.fn!)).toEqual(unit.fn);
});
test("a record lists the held functions it names, with their digests", () => {
const functions = [fn("%shared"), fn("%unused")];
const subject: WaveHost = {
@@ -373,6 +457,7 @@ test("a record lists the held functions it names, with their digests", () => {
store: () => {},
statementCounts: () => ({ total: 0, failed: 0, other: 0 }),
resolves: () => true,
keep: () => {},
};
const { record } = recordUnit(subject, "a");
// The function the unit added is in its entries, not its reads.
@@ -4,8 +4,9 @@
* unit's lowering reached: the unit's IR, the entries it added to the
* tables that collect lowering results (Lowerer.journalTables), and the
* calls it made into reachability, generic demand and statement counting
* (JournalEvent). Records hold only strings, numbers and names, so they can
* be written to a file and read by another process or another build.
* (JournalEvent). Records hold only the function's bytes (or its JSON),
* strings, numbers and names, so they can be written to a file and read by
* another process or another build.
*
* A unit has no record when its lowering changed state that no record
* carries: a table that is not appendable grew, a field changed, an entry
@@ -23,20 +24,59 @@
import { appendFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { InternalCompilerError } from "../../errors.js";
import { decodeFunctionBinary, encodeFunctionStrings, functionDigest } from "../../ir/codec.js";
import type { IrFunction } from "../../ir/ir.js";
import { deserializeFunction, encodeFunction } from "../../ir/serialize.js";
/** A function as records hold it, or null when it does not encode. Every
* function a record holds goes through this pair. */
/** A function as table entries hold it, or null when it does not encode.
* Every function an entry holds goes through this pair. */
export function encodeUnitFunction(fn: IrFunction): string | null {
return encodeFunction(fn) ?? null;
}
/** A function from its record encoding (encodeUnitFunction). */
/** A function from its entry encoding (encodeUnitFunction). */
export function decodeUnitFunction(text: string): IrFunction {
return deserializeFunction(text);
}
/** A unit's function as its record holds it: the binary IR encoding, or
* JSON (encodeUnitFunction) when an object in it would not read back with
* its own key order, which frontend passes and the IR's text depend on. */
export type RecordFunction = string | Uint8Array;
/** `fn` as a record holds it, or null when it does not encode. A binary
* encoding comes with its strings and the files its fields named `file`
* hold (encodeFunctionStrings). */
export function encodeRecordFunction(fn: IrFunction): {
readonly fn: RecordFunction;
readonly strings: readonly string[] | null;
readonly files: readonly string[] | null;
} | null {
const binary = encodeFunctionStrings(fn);
if (binary !== null) return { fn: binary.bytes, strings: binary.strings, files: binary.files };
const text = encodeUnitFunction(fn);
return text === null ? null : { fn: text, strings: null, files: null };
}
/** A function from its record encoding (encodeRecordFunction). */
export function decodeRecordFunction(fn: RecordFunction): IrFunction {
return typeof fn === "string"
? decodeUnitFunction(fn)
: decodeFunctionBinary(Buffer.from(fn.buffer, fn.byteOffset, fn.byteLength));
}
/** `record` as a JSON line, with a binary function in base64, marked when
* it was `applied` from an earlier build's store. */
export function recordLine(record: UnitRecord, applied = false): string {
const fn =
record.fn === null || typeof record.fn === "string"
? record.fn
: Buffer.from(record.fn.buffer, record.fn.byteOffset, record.fn.byteLength).toString(
"base64",
);
return JSON.stringify(applied ? { ...record, fn, applied: true } : { ...record, fn });
}
/** A call a unit made while it lowered, which applying its record makes
* again. */
export interface JournalEvent {
@@ -62,9 +102,9 @@ export interface JournalEvent {
/** One unit's record. */
export interface UnitRecord {
readonly name: string;
/** The unit's function in the compact IR encoding, or null when the unit
/** The unit's function (encodeRecordFunction), or null when the unit
* lowered to nothing. */
readonly fn: string | null;
readonly fn: RecordFunction | null;
/** True when the unit's lowering ended in a poison: its diagnostics and
* the edges it fired before poisoning stay, and it has no function. */
readonly poisoned: boolean;
@@ -91,6 +131,12 @@ export interface JournalTable {
readonly entries?: (from: number) => string[] | null;
/** Adds the encoded entries whose keys this process's table lacks. */
readonly apply?: (entries: readonly string[]) => void;
/** Whether every encoded entry whose key this process's table holds
* holds the same value there. */
readonly agrees?: (entries: readonly string[]) => boolean;
/** Whether this process's table holds an entry under the encoded entry's
* key. */
readonly holds?: (entry: string) => boolean;
/** A digest of the content of the entry under `key`, when the table
* holds one before position `before`; null otherwise. */
readonly digest?: (key: string, before: number) => string | null;
@@ -125,10 +171,20 @@ export function contentDigest(text: string): string {
return `${text.length}:${(a >>> 0).toString(16)}${(b >>> 0).toString(16)}`;
}
/** The names quoted in JSON `texts`, unescaped only as far as their
* delimiting quotes: the strings that start with `%` (helper, lifted and
* IR data names) and union ids (`u` and 12 hex digits), which the tables
* a unit reads and changes are keyed by. */
/** Adds to `out` the names among `strings`: the strings that start with `%`
* (helper, lifted and IR data names) and union ids (`u` and 12 hex digits),
* which the tables a unit reads and changes are keyed by. */
export function namedStrings(strings: Iterable<string>, out: Set<string>): void {
for (const text of strings) {
const first = text.charCodeAt(0);
if (first === 37 /* % */) out.add(text);
else if (first === 117 /* u */ && text.length === 13 && /^u[0-9a-f]{12}$/.test(text))
out.add(text);
}
}
/** The names (namedStrings) quoted in JSON `texts`, unescaped only as far
* as their delimiting quotes. */
export function quotedStrings(texts: readonly string[]): Set<string> {
const out = new Set<string>();
const hex = (char: number): boolean => (char >= 48 && char <= 57) || (char >= 97 && char <= 102);
@@ -225,6 +281,10 @@ export interface WaveHost {
statementCounts(): { total: number; failed: number; other: number };
/** Whether `event` names something this process holds. */
resolves(event: JournalEvent): boolean;
/** Keeps a unit's function from its record and makes the record's events
* again, under the unit's generic demand owner and declared-order
* priority. */
keep(name: string, fn: IrFunction | null, events: readonly JournalEvent[]): void;
}
/** Whether reachable emit records its units (SCRIPTC_LOWER_JOURNAL=serial,
@@ -259,10 +319,69 @@ export interface WaveStats {
unrecorded: number;
/** Per table, field or check, the units it left without a record. */
reasons: Record<string, number>;
/** Units whose stored record applied in place of lowering them
* (unit-reuse.ts). */
applied: number;
/** Time taking and checking the tables' contents, finding stored records,
* applying them, and lowering and recording units, in milliseconds. */
holdMs: number;
storedMs: number;
applyMs: number;
recordMs: number;
/** Of recordMs, the time the units' lowering took, and the time a
* recorded unit's results took to be replaced by its record's. */
lowerMs: number;
replaceMs: number;
}
export function waveStats(): WaveStats {
return { waves: 0, units: 0, recorded: 0, unrecorded: 0, reasons: {} };
return {
waves: 0,
units: 0,
recorded: 0,
unrecorded: 0,
reasons: {},
applied: 0,
holdMs: 0,
storedMs: 0,
applyMs: 0,
recordMs: 0,
lowerMs: 0,
replaceMs: 0,
};
}
/** A wave's use of the records an earlier build stored (unit-reuse.ts). */
/** A record an earlier build stored, with its function, which the store
* can keep apart from the record (`record.fn` is null then). */
export interface StoredRecord {
readonly record: UnitRecord;
fn(): IrFunction | null;
}
export interface WaveReuse {
/** The stored record of `name`, when the unit can apply it. */
stored(name: string): StoredRecord | null;
/** Whether units with a stored record lower and are checked against it
* instead (SCRIPTC_INCR_VERIFY=1). */
readonly verify: boolean;
/** Checks the record a unit lowered to against its stored one, whose
* entries the tables did not hold yet at the unit's turn are `pending`. */
check(
name: string,
stored: StoredRecord,
pending: Readonly<Record<string, readonly string[]>>,
unit: RecordedUnit,
): void;
/** Notes the record a unit lowered to, with the files its binary
* function names (RecordedUnit.files), or the stored record it applied
* (`files` null). */
note(
name: string,
record: UnitRecord | null,
applied: boolean,
files: readonly string[] | null,
): void;
}
/** What lowering one unit with its events recording produced. */
@@ -275,6 +394,11 @@ export interface RecordedUnit {
readonly fn: IrFunction | null;
/** Each table's size before the unit. */
readonly before: readonly number[];
/** The time the unit's lowering took. */
readonly lowerMs: number;
/** The files the fields named `file` in the record's function hold, when
* the function is binary. */
readonly files: readonly string[] | null;
}
/** Lowers `name` with its events recording, and makes its record. The unit
@@ -297,7 +421,9 @@ export function recordUnit(
const marks = host.scalars.map((scalar) => scalar.mark());
const counts = host.statementCounts();
const recorder = new UnitEvents();
const lowering = performance.now();
const { fn, poisoned } = host.lowerRecorded(name, recorder);
const lowerMs = performance.now() - lowering;
const reasons: string[] = [];
const grown: number[] = [];
tables.forEach((table, index) => {
@@ -322,8 +448,8 @@ export function recordUnit(
if (now.total - counts.total !== statements || now.failed - counts.failed !== failed)
reasons.push("statements");
if (now.other !== counts.other) reasons.push("diagnostics");
const text = fn === null ? null : encodeUnitFunction(fn);
if (fn !== null && text === null) reasons.push("encoding");
const encoded = fn === null ? null : encodeRecordFunction(fn);
if (fn !== null && encoded === null) reasons.push("encoding");
const added: Record<string, readonly string[]> = {};
if (reasons.length === 0)
for (const index of grown) {
@@ -335,13 +461,14 @@ export function recordUnit(
// A unit uses the entries its IR and new entries name: it reads them, and
// a unit that changes one in place changes one it names.
const texts: string[] = [];
if (text !== null) texts.push(text);
if (typeof encoded?.fn === "string") texts.push(encoded.fn);
for (const index of grown) {
const table = tables[index]!;
const entries = added[table.name] ?? table.entries!(before[index]!);
if (entries !== null) for (const entry of entries) texts.push(entry);
}
const names = quotedStrings(texts);
if (encoded?.strings != null) namedStrings(encoded.strings, names);
if (own !== null) {
if (reasons.length === 0)
tables.forEach((table, index) => {
@@ -369,36 +496,72 @@ export function recordUnit(
}
if (read.length > 0) reads[table.name] = read;
});
if (reasons.length > 0) return { record: null, reasons, fn, before };
if (reasons.length > 0) return { record: null, reasons, fn, before, lowerMs, files: null };
return {
record: { name, fn: text, poisoned, events, tables: added, reads },
record: { name, fn: encoded?.fn ?? null, poisoned, events, tables: added, reads },
reasons,
fn,
before,
lowerMs,
files: encoded?.files ?? null,
};
}
/** Lowers `wave` with every unit recorded. A unit with a record keeps the
* results its record carries: the entries it added are taken out of their
* tables and added again from the record, and its function is the one
* decoded from the record. A unit without one keeps what lowering made.
/** Lowers `wave` with every unit recorded. Without `reuse`, a unit with a
* record keeps the results its record carries: the entries it added are
* taken out of their tables and added again from the record, and its
* function is the one decoded from the record. Otherwise, and for a unit
* without a record, a unit keeps what lowering made.
* Throws an InternalCompilerError when a unit changed the content of an
* entry a table held when the unit started (JournalTable.contents), which
* no record carries.
* `lines`, when given, receives each unit's record as a JSON line. */
* `lines`, when given, receives each unit's record as a JSON line. A unit
* whose stored record `reuse` gives applies it, and lowers when it does not
* apply. With `held`, the wave continues the content the waves before it
* left (HeldTables), and the check waits for HeldTables.check. */
export function lowerWaveRecorded(
host: WaveHost,
wave: readonly string[],
stats: WaveStats,
lines: string[] | null = null,
reuse: WaveReuse | null = null,
held: HeldTables | null = null,
): void {
stats.waves++;
const tables = host.tables;
// Units keep the content of the entries that tables held when they
// started: a record carries a unit's new entries, not changes to others.
const contents = tables.map((table) => (table.contents === undefined ? null : table.contents()));
const contents = held ?? new HeldTables(tables, stats);
let started = 0;
for (const name of wave) {
const unit = recordUnit(host, name, contents);
started = performance.now();
let stored = reuse?.stored(name) ?? null;
stats.storedMs += performance.now() - started;
let pending: Readonly<Record<string, readonly string[]>> = {};
if (stored !== null && reuse!.verify) {
// Verify mode lowers the units whose stored record would apply and
// checks the record they lower to.
if (recordApplies(host, stored.record).length === 0)
pending = pendingEntries(host, stored.record);
else stored = null;
} else if (stored !== null) {
started = performance.now();
const reasons = applyRecord(host, stored.record, stored.fn);
stats.applyMs += performance.now() - started;
if (reasons.length === 0) {
stats.units++;
stats.recorded++;
stats.applied++;
contents.extendTaken();
lines?.push(recordLine(stored.record, true));
reuse!.note(name, stored.record, true, null);
continue;
}
for (const reason of reasons)
stats.reasons[`stored:${reason}`] = (stats.reasons[`stored:${reason}`] ?? 0) + 1;
}
started = performance.now();
const unit = recordUnit(host, name, contents.take());
stats.recordMs += performance.now() - started;
stats.lowerMs += unit.lowerMs;
stats.units++;
const record = unit.record;
if (record === null) {
@@ -406,43 +569,174 @@ export function lowerWaveRecorded(
stats.unrecorded++;
for (const reason of unit.reasons) stats.reasons[reason] = (stats.reasons[reason] ?? 0) + 1;
host.store(name, unit.fn);
if (stored !== null && reuse!.verify) reuse!.check(name, stored, pending, unit);
reuse?.note(name, null, false, null);
continue;
}
lines?.push(JSON.stringify(record));
lines?.push(recordLine(record));
stats.recorded++;
tables.forEach((table, index) => {
const entries = record.tables[table.name];
if (entries === undefined) return;
table.truncate!(unit.before[index]!);
table.apply!(entries);
});
host.store(name, record.fn === null ? null : decodeUnitFunction(record.fn));
// The serial mode replaces a unit's results by its record's, so that the
// build's output is what applying the records gives. Reuse keeps what
// lowering made: its records apply in later builds, which verify mode
// and the edit-sequence tests check.
let fn = unit.fn;
if (reuse === null) {
const replacing = performance.now();
tables.forEach((table, index) => {
const entries = record.tables[table.name];
if (entries === undefined) return;
table.truncate!(unit.before[index]!);
table.apply!(entries);
});
fn = record.fn === null ? null : decodeRecordFunction(record.fn);
stats.replaceMs += performance.now() - replacing;
}
host.store(name, fn);
if (stored !== null && reuse!.verify) reuse!.check(name, stored, pending, unit);
reuse?.note(name, record, false, unit.files);
}
tables.forEach((table, index) => {
const key = contents[index]?.changed() ?? null;
if (key !== null)
throw new InternalCompilerError(
`a unit changed ${table.name} entry ${key} in place without naming it, which no unit record carries`,
if (held === null) contents.check();
}
/** The content of the tables' entries that recorded units start from
* (JournalTable.contents): units keep the content of the entries the
* tables held when they started, since a record carries a unit's new
* entries, not changes to others. Applying a stored record only adds
* entries, so the content is taken when the first unit lowers, and not at
* all when none does. Consecutive waves with nothing but checker prefetches
* between them share one: the content a wave leaves, as the check finds it,
* is the content the next one starts with. */
export class HeldTables {
private contents: (HeldContents | null)[] | null = null;
constructor(
private readonly tables: readonly JournalTable[],
private readonly stats: WaveStats,
) {}
/** The held content, taken now when it was not yet. */
take(): (HeldContents | null)[] {
if (this.contents === null) {
const taken = performance.now();
this.contents = this.tables.map((table) =>
table.contents === undefined ? null : table.contents(),
);
});
this.stats.holdMs += performance.now() - taken;
}
return this.contents;
}
/** Adds the entries past the held ones, when the content is taken. */
extendTaken(): void {
if (this.contents !== null) for (const entry of this.contents) entry?.extend();
}
/** Throws an InternalCompilerError when an entry a table held changed in
* place since the content was taken, other than by the units that keep
* their changes (HeldContents.refresh). */
check(): void {
const contents = this.contents;
if (contents === null) return;
const started = performance.now();
this.tables.forEach((table, index) => {
const key = contents[index]?.changed() ?? null;
if (key !== null)
throw new InternalCompilerError(
`a unit changed ${table.name} entry ${key} in place without naming it, which no unit record carries`,
);
});
this.stats.holdMs += performance.now() - started;
}
}
/** Applies `record` in place of lowering its unit, at the unit's turn, or
* returns why the unit has to lower instead. A record applies when every
* entry it adds agrees with what the tables hold, it names only known
* tables, every event resolves and every entry it reads is held with the
* same content. Applying adds its entries, keeps its function (`stored`'s,
* when the store keeps it apart from the record) and makes its events
* again. */
export function applyRecord(
host: WaveHost,
record: UnitRecord,
stored?: () => IrFunction | null,
): string[] {
const reasons = recordApplies(host, record);
if (reasons.length > 0) return reasons;
for (const table of host.tables) {
const entries = record.tables[table.name];
if (entries !== undefined) table.apply!(entries);
}
const fn = record.poisoned
? null
: stored !== undefined
? stored()
: record.fn === null
? null
: decodeRecordFunction(record.fn);
host.keep(record.name, fn, record.events);
return [];
}
/** The entries `record` adds that the tables do not hold yet, by table
* name: what applying it would add at its unit's turn. */
export function pendingEntries(
host: WaveHost,
record: UnitRecord,
): Record<string, readonly string[]> {
const out: Record<string, readonly string[]> = {};
for (const table of host.tables) {
const entries = record.tables[table.name];
if (entries === undefined) continue;
const pending = entries.filter((entry) => table.holds?.(entry) !== true);
if (pending.length > 0) out[table.name] = pending;
}
return out;
}
/** Why `record` does not apply at its unit's turn (applyRecord), or no
* reasons when it does. */
export function recordApplies(host: WaveHost, record: UnitRecord): string[] {
const tables = host.tables;
for (const table of tables) {
const entries = record.tables[table.name];
if (entries === undefined) continue;
if (table.agrees === undefined || table.apply === undefined || !table.agrees(entries))
return [`disagrees:${table.name}`];
}
for (const table of Object.keys(record.tables))
if (!tables.some((held) => held.name === table)) return [`unknown table:${table}`];
for (const event of record.events) if (!host.resolves(event)) return [`event:${event.kind}`];
for (const table of tables) {
const reads = record.reads[table.name];
if (reads === undefined) continue;
if (table.digest === undefined) return [`unknown read:${table.name}`];
const size = table.size();
for (const [key, digest] of reads)
if (table.digest(key, size) !== digest) return [`read:${table.name}`];
}
for (const table of Object.keys(record.reads))
if (!tables.some((held) => held.name === table)) return [`unknown read:${table}`];
return [];
}
/** The content of a keyed table's entries (JournalTable.contents): each
* entry's encoded value, and the order of the keys, since a table only adds
* entries at the end. An entry is found by its key, or by the name its value
* holds (`valueName`): a unit names the helper it got, not the key it was
* looked up by. `text` answers undefined for a key the table does not
* entry's content (`text`: its encoded value, or the value itself when it
* is a string or a number), and the order of the keys, since a table only
* adds entries at the end. An entry is found by its key, or by the name its
* value holds (`valueName`): a unit names the helper it got, not the key it
* was looked up by. `text` answers undefined for a key the table does not
* hold. */
function keyedContents(
size: () => number,
keys: () => Iterable<string>,
text: (key: string) => string | null | undefined,
text: (key: string) => string | number | null | undefined,
valueName: (key: string) => string | null,
): HeldContents {
const texts = new Map<string, string | null>();
const texts = new Map<string, string | number | null>();
let order: string[] = [];
const byName = new Map<string, string>();
const differs = (key: string): boolean => text(key) !== texts.get(key);
const differs = (key: string): boolean => !Object.is(text(key), texts.get(key));
const keyOf = (name: string): string | undefined => (texts.has(name) ? name : byName.get(name));
const held: HeldContents = {
extend: () => {
@@ -499,6 +793,7 @@ export function mapTable(name: string, map: Map<string, string>): JournalTable {
(value) => JSON.stringify(value),
(text) => JSON.parse(text) as string,
(value) => value,
(value) => value,
);
}
@@ -509,6 +804,8 @@ export function countTable(name: string, map: Map<string, number>): JournalTable
map,
(value) => String(value),
(text) => Number(text),
null,
(value) => value,
);
}
@@ -530,13 +827,17 @@ export function irDataTable<V>(name: string, map: Map<string, V>): JournalTable
/** An appendable table over a Map from strings to values that `encode`
* writes, or answers null for, and `decode` reads back. An entry is its key
* as a JSON string, a line break and its value: a JSON string holds no line
* break. A value that does not encode leaves its unit without a record. */
* break. A value that does not encode leaves its unit without a record.
* `content` is what held contents compare (JournalTable.contents): the
* encoding, or the value itself for a table of strings or numbers, which
* only change by being replaced. */
export function codecTable<V>(
name: string,
map: Map<string, V>,
encode: (value: V) => string | null,
decode: (text: string) => V,
nameOf: ((value: V) => string) | null = null,
content: (value: V) => string | number | null = encode,
): JournalTable {
return {
name,
@@ -552,6 +853,13 @@ export function codecTable<V>(
}
return out;
},
agrees: (entries) =>
entries.every((entry) => {
const split = entry.indexOf("\n");
const held = map.get(JSON.parse(entry.slice(0, split)) as string);
return held === undefined || encode(held) === entry.slice(split + 1);
}),
holds: (entry) => map.has(JSON.parse(entry.slice(0, entry.indexOf("\n"))) as string),
apply: (entries) => {
for (const entry of entries) {
const split = entry.indexOf("\n");
@@ -569,7 +877,7 @@ export function codecTable<V>(
() => map.keys(),
(key) => {
const value = map.get(key);
return value === undefined ? undefined : encode(value);
return value === undefined ? undefined : content(value);
},
(key) => {
const value = map.get(key);
@@ -585,6 +893,8 @@ export function setTable(name: string, set: Set<string>): JournalTable {
name,
size: () => set.size,
entries: (from) => [...set].slice(from),
agrees: () => true,
holds: (entry) => set.has(entry),
apply: (entries) => {
for (const entry of entries) set.add(entry);
},
@@ -652,6 +962,14 @@ export function functionTable(
}
return out;
},
agrees: (entries) =>
entries.every((entry) => {
const fn = decodeUnitFunction(entry);
if (!has(fn.name)) return true;
const index = indexOf(fn.name);
return index !== undefined && encodeUnitFunction(functions[index]!) === entry;
}),
holds: (entry) => has(decodeUnitFunction(entry).name),
apply: (entries) => {
for (const entry of entries) {
const fn = decodeUnitFunction(entry);
@@ -663,16 +981,17 @@ export function functionTable(
while (functions.length > from) functions.pop();
changed();
},
// Held functions are their content digests (heldFunction).
contents: () => {
const texts: (string | null)[] = [];
const indexes = new Map<string, number>();
const differs = (index: number): boolean =>
index < functions.length && encodeUnitFunction(functions[index]!) !== texts[index];
index < functions.length && heldFunction(functions[index]!) !== texts[index];
const held: HeldContents = {
extend: () => {
for (let index = texts.length; index < functions.length; index++) {
const fn = functions[index]!;
texts.push(encodeUnitFunction(fn));
texts.push(heldFunction(fn));
indexes.set(fn.name, index);
}
},
@@ -692,7 +1011,7 @@ export function functionTable(
for (const key of keys) {
const index = indexes.get(key);
if (index !== undefined && index < functions.length)
texts[index] = encodeUnitFunction(functions[index]!);
texts[index] = heldFunction(functions[index]!);
}
},
};
@@ -702,6 +1021,17 @@ export function functionTable(
};
}
/** The content a function table holds of `fn` (HeldContents): its digest
* (functionDigest), or its entry encoding when the binary encoder cannot
* write it. */
function heldFunction(fn: IrFunction): string | null {
try {
return functionDigest(fn);
} catch {
return encodeUnitFunction(fn);
}
}
/** A table whose growth leaves the unit without a record. */
export function checkedTable(name: string, size: () => number): JournalTable {
return { name, size };
File diff suppressed because it is too large Load Diff
+112
View File
@@ -136,6 +136,7 @@ import {
JS_RELAXED_TSC_CODES,
} from "./tsc-codes.js";
import { trackedFileExists, trackedReadFile, trackedRealpath } from "./input-tracker.js";
import { earlyErrorDiagnostics7 } from "./early-errors.js";
import { forkTargetPaths } from "./fork-target.js";
import { workerTargetPaths } from "./worker-target.js";
import {
@@ -2761,6 +2762,107 @@ function isAssignmentTarget7(node: ts.Node): boolean {
);
}
/** True when the identifier is a reference position that WRITES: an
* assignment (or compound-assignment) target, a `++`/`--` operand, a
* for-in/for-of head, or an element of a destructuring pattern — through
* parentheses and pattern containers, exactly the shapes a sloppy-mode
* implicit global can be introduced by. */
export function isJsWriteTarget7(node: ts.Node): boolean {
let cur: ts.Node = node;
for (;;) {
const parent: ts.Node | undefined = cur.parent;
if (parent === undefined) return false;
if (ts.isParenthesizedExpression(parent)) {
cur = parent;
continue;
}
if (ts.isBinaryExpression(parent))
return (
parent.left === cur &&
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
);
if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent))
return (
parent.operand === cur &&
(parent.operator === ts.SyntaxKind.PlusPlusToken ||
parent.operator === ts.SyntaxKind.MinusMinusToken)
);
if (ts.isForInStatement(parent) || ts.isForOfStatement(parent))
return parent.initializer === cur;
if (ts.isArrayLiteralExpression(parent) || ts.isObjectLiteralExpression(parent)) {
cur = parent;
continue;
}
if (
(ts.isShorthandPropertyAssignment(parent) && parent.name === cur) ||
(ts.isPropertyAssignment(parent) && parent.initializer === cur) ||
((ts.isSpreadElement(parent) || ts.isSpreadAssignment(parent)) && parent.expression === cur)
) {
cur = parent;
continue;
}
return false;
}
}
/** Nothing binds `node`: the tsc had no symbol, and a shorthand pattern's
* name has no VALUE binding either (its property symbol is the target, not a
* declaration of the name being written). */
function undeclaredJsName7(
checker: ReturnType<ts.Program["getTypeChecker"]>,
node: ts.Identifier,
): boolean {
const parent = node.parent;
if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === node)
return checker.getShorthandAssignmentValueSymbol(parent) === undefined;
return checker.getSymbolAtLocation(node) === undefined;
}
/** A `.js` WRITE to a name nothing declares (plan D-2/D-5). Sloppy mode
* resolves such a write by creating a property on Node's global object —
* process-wide state a native binary has no object for — so the program is
* refused at build time, under SC1091, whose text names the Node-island
* route those semantics need. A READ of the same name is admitted instead:
* it throws Node's own catchable `ReferenceError` at the access
* (lowering/expressions/identifiers.ts). TypeScript input never reaches
* either path — an undeclared name is a compile error there. */
function jsUndeclaredWriteDiags(
program: ts.Program,
files: readonly ts.SourceFile[],
): ScrDiagnostic[] {
const diags: ScrDiagnostic[] = [];
const checker = program.getTypeChecker();
for (const sf of files) {
if (!isJsSourceFileName(sf.fileName)) continue;
const stack: ts.Node[] = [sf];
while (stack.length > 0) {
const node = stack.pop()!;
// An empty name is a parse-recovery artifact (`@import x = require(…)`
// in a JSDoc prologue leaves one behind), never a reference a program
// writes; the syntax error that created it is already reported.
if (
ts.isIdentifier(node) &&
node.text !== "" &&
isJsWriteTarget7(node) &&
undeclaredJsName7(checker, node)
)
diags.push(
unsupportedDiag(
"SC1091",
{ file: sf.fileName, start: node.getStart(sf), end: node.end },
`writing the undeclared name '${node.text}' in a JavaScript source`,
),
);
ts.forEachChild(node, (child) => {
stack.push(child);
return undefined;
});
}
}
return diags;
}
/** True when `node` is inside the try block of a try statement in the same
* function (a catch clause or finally block does not count). A class field
* initializer runs when the class is instantiated, so it is a boundary too. */
@@ -6592,6 +6694,11 @@ function preflight7(load: LoadResult): {
};
visit(entry);
// ECMAScript's sloppy-mode WRITE to an undeclared name, which the JS
// relaxation above admits for READS (plan D-2): refused by name, with the
// island route in the text (SC1091).
for (const d of jsUndeclaredWriteDiags(program, order)) diags.push(d);
// The startup crash Node's RESOLUTION phase reports: modules resolve
// their request specifiers in source order, children linking depth-first
// as each resolves (ModuleJob.syncLink) — a preorder walk over the
@@ -6683,6 +6790,11 @@ function preflight7(load: LoadResult): {
esmNamedImportLinkCheck(program, entry, order, diags),
);
// ECMAScript early errors the TypeScript checker does not model (plan
// §7.7 D-4): a JavaScript source the spec makes a SyntaxError fails here,
// before any lowering, exactly where a tsc parse error fails.
diags.push(...earlyErrorDiagnostics7(order, (sf) => isNodeEsmFile7(sf, program)));
return {
diags,
moduleOrder: order,
@@ -36,6 +36,21 @@ export const ADOPTED_OPTIONS = [
"reactNamespace",
] as const;
/* ── The early-error boundary (read this before relaxing a code) ────────
*
* Some of the codes below do double duty: TypeScript reports them for a
* JavaScript construct that ECMAScript ALSO makes a SyntaxError (a
* redeclared block binding, a declaration in a statement-only position).
* Relaxing such a code removes that construct's only rejection, so a
* parse-negative program would start compiling and running.
*
* frontend/early-errors.ts owns those rejections now, under their own
* preflight codes — 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). A relaxation that needs one of those rules to keep firing must
* land with the matching rule in that pass, not rely on this table. */
export const JS_RELAXED_TSC_CODES: ReadonlySet<number> = new Set([
// implicit any (params, variables, elements, this, returns, rest,
// construct signatures, index reads over shapes with no index
@@ -109,6 +124,16 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet<number> = new Set([
2488,
2721,
2722,
// the async-iterator protocol demand (2504: "must have a
// '[Symbol.asyncIterator]()' method that returns an async iterator").
// Whether a value is async-iterable is a runtime fact of untyped JS, exactly
// like the sync demand (2488) relaxed above: `for await` over a non-iterable
// value and `yield*` in an async generator both lower through the runtime
// protocol openers, which read the method, call it, and throw TypeError at
// the site when it is missing, uncallable, or returns a non-object — the
// same protocol failure Node reports. 2505 is the sync sibling on the typed
// lowering lanes and stays enforced.
2504,
// an UNUSED @ts-expect-error: the directive annotated an error visible
// under the project's OWN tsconfig world — scriptc's world deliberately
// differs (adopted options, hidden declaration twins), and an unused
@@ -150,6 +175,20 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet<number> = new Set([
// ride the checked-dynamic tree; unsupported operations report a compile-time diagnostic).
18046,
2571,
// UNDECLARED NAMES in a JavaScript source: Node resolves them at run time,
// never at compile time — a READ throws the catchable
// `ReferenceError: <name> is not defined` at the access (`typeof` of one
// answers "undefined"), and a sloppy-mode WRITE creates a global. The
// lowerer turns the read into `scr_undef_global_read` (exact Node text and
// behavior) and refuses the write with SC1091, which names the Node-island
// route the sloppy semantics need (the plan's D-2/D-5). TypeScript input
// keeps the compile error: an undeclared name there is a bug, not a
// runtime path. 2552 is 2304's did-you-mean spelling, and 18004 is the
// same unresolved name in SHORTHAND position (`{ x }`), which the checker
// reports against the property instead of the reference.
2304,
2552,
18004,
]);
/* Arithmetic on an `any` operand (TS2362/2363/2365 fire when the other
+51 -1
View File
@@ -1,6 +1,6 @@
import { InternalCompilerError } from "../errors.js";
import { keyedHelperName, type KeyedNameOwner } from "./lowering/stable-names.js";
import type { HeldContents, JournalTable } from "./lowering/unit-journal.js";
import { contentDigest, type HeldContents, type JournalTable } from "./lowering/unit-journal.js";
import { mockCallRecordType, mockContextType } from "./mock-shapes.js";
import * as ts from "./ts7/adapter.js";
import { bodyReadsArguments } from "./arguments-usage.js";
@@ -220,8 +220,10 @@ export const FETCH_AMBIENT_TYPES = [
"ReadableStreamReadValueResult",
"ReadableStreamReadDoneResult",
"TransformStream",
"TransformStreamDefaultController",
"WritableStream",
"WritableStreamDefaultWriter",
"WritableStreamDefaultController",
"Blob",
"File",
"BlobPropertyBag",
@@ -716,9 +718,39 @@ export class UnionRegistry {
* the key its arms give. A unit that adds a recursive union changes
* journalSizes, so the unions past a unit's start are interned ones. */
journalTable(): JournalTable {
// The position of each union by id, for the unions indexed so far.
let indexes = new Map<string, number>();
let indexed = 0;
const indexOf = (id: string): number | undefined => {
if (indexed > this.unions.length) {
indexes = new Map();
indexed = 0;
}
for (; indexed < this.unions.length; indexed++)
indexes.set(this.unions[indexed]!.id, indexed);
const index = indexes.get(id);
return index !== undefined && this.unions[index]?.id === id ? index : undefined;
};
return {
name: "unions",
size: () => this.unions.length,
digest: (key, before) => {
if (!this.byId.has(key)) return null;
let index = indexOf(key);
if (index === undefined) {
// A list that was cut and grown again holds other unions at the
// positions indexed before.
indexes = new Map();
indexed = 0;
index = indexOf(key);
}
if (index === undefined || index >= before) return null;
return contentDigest(JSON.stringify(this.unions[index]!));
},
holds: (entry) => {
const def = JSON.parse(entry) as IrUnionDef;
return this.byKey.has(unionKey(def.arms, def.discriminant));
},
entries: (from) => {
const out: string[] = [];
for (const def of this.unions.slice(from)) {
@@ -729,6 +761,12 @@ export class UnionRegistry {
}
return out;
},
agrees: (entries) =>
entries.every((entry) => {
const def = JSON.parse(entry) as IrUnionDef;
const held = this.byKey.get(unionKey(def.arms, def.discriminant));
return held === undefined ? !this.byId.has(def.id) : held === def.id;
}),
apply: (entries) => {
for (const entry of entries) {
const def = JSON.parse(entry) as IrUnionDef;
@@ -5469,6 +5507,18 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
) {
return DYN;
}
// A module namespace beside another value in one slot (`[path.posix,
// path.win32]`, a `['posix', path.posix]` table row): a namespace has
// no union representation, and as a value it is the runtime's module
// object, so the slot is checked-dynamic. A namespace or null keeps
// its union (a probed process.getBuiltinModule result).
if (
arms.some((a) => a.kind === "moduleNs") &&
arms.filter((a) => !isUnitType(a)).length > 1 &&
!unions.recursivePending(widened)
) {
return DYN;
}
// SEVERAL PROMISE ARMS are ONE: `Promise<A> | Promise<B>` is
// `Promise<A | B>`. A promise arm's tag IS the `instanceof Promise`
// test, so JavaScript cannot tell the arms apart at run time (both
+40
View File
@@ -23,6 +23,11 @@ import { checkerCacheIdentity } from "./frontend/ts7/checker-backend.js";
import { setLibDiagnosticsStore } from "./frontend/ts7/lib-diagnostics.js";
import { analyzeWithVerdictCache, fileVerdictStore } from "./coverage/verdict-cache.js";
import { coverageCompilerIdentity } from "./coverage/verdict-startup.js";
import {
directoryUnitStore,
unitReuseEnabled,
type UnitStore,
} from "./frontend/lowering/unit-reuse.js";
import { commentOnlyReplay } from "./coverage/verdict-semantic.js";
import { llvmRefusalDiag, targetRefusalDiag } from "./backend/target-diagnostics.js";
import type {
@@ -748,6 +753,7 @@ async function compileExecutableNative(
dgramDyn: features.dgramDyn,
textCodecDyn: features.textCodecDyn,
watch: features.watch,
watchFile: features.watchFile,
foreignFfi: features.foreignFfi,
...(features.workers ? { workers: true } : {}),
...(features.youngNursery ? { youngNursery: true } : {}),
@@ -916,6 +922,37 @@ function runtimePackDiagnostic(error: RuntimePackError, entryPath: string): ScrD
);
}
/** The unit store of a Node-hosted build (unit-reuse.ts), keyed by the
* compiler, the entry, the options that reach lowering and the target
* platform. */
async function nodeUnitStore(
entryPath: string,
opts: CompileRequestOptions,
ffi: FfiProfile | null,
buildPlatform: string,
): Promise<UnitStore | null> {
if (!unitReuseEnabled() || provenanceSources() !== null) return null;
const root = await prepareBuildCacheRoot(buildCacheRoot());
if (root === null) return null;
const directory = join(root, "units-v1");
const implementation = await coverageCompilerIdentity(directory);
if (implementation === null) return null;
const { outPath: _outPath, outDir: _outDir, ...lowering } = opts;
const key = createHash("sha256")
.update(
JSON.stringify({
schema: 1,
entry: resolve(entryPath),
options: lowering,
ffi,
platform: buildPlatform,
compiler: implementation.digest,
}),
)
.digest("hex");
return directoryUnitStore(directory, key);
}
interface PreparedExecutable {
llvmSource: LlvmInput;
/** The kept LLVM text beside the executable, installed when the build ends. */
@@ -947,6 +984,9 @@ async function prepareExecutableInput(
buildPlatform,
nodeFrontend,
timing,
new Map(),
true,
await nodeUnitStore(entryPath, opts, ffi, buildPlatform),
);
if (!prepared.ok) return prepared;
const { mod, sourceTexts } = prepared;
+1
View File
@@ -28,6 +28,7 @@ export {
moduleUsesQs,
moduleUsesParseArgs,
moduleUsesFsWatch,
moduleUsesFsWatchFile,
moduleUsesNodeTest,
moduleUsesDgram,
moduleUsesHttpServer,
@@ -131,6 +131,11 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"buffer.byteLenValue",
"buffer.allocValue",
"fs.realpathValue",
"dyn.validateString",
"dyn.validateStrings",
"path.resolveValue",
"path.parseValue",
"path.formatValue",
"bytes.construct",
"arrayBuffer.new",
"sharedArrayBuffer.new",
@@ -723,6 +728,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"dyn.keySetComputed",
"process.builtinId",
"process.builtinModule",
"process.builtinObject",
"process.builtinUnsupported",
"module.requireComputed",
"module.resolveComputed",
@@ -1088,6 +1094,10 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"fs.closeSync",
"fs.watch",
"fs.watchCb",
"watcher.on",
"watcher.off",
"fs.watchFile",
"fs.unwatchFile",
"crypto.x509Fingerprint",
"crypto.x509FingerprintStr",
"crypto.x509ValidFrom",
+35 -6
View File
@@ -806,17 +806,28 @@ function buildLibFnSignatures() {
* value: path and options arrive unchecked; the result is a string or a
* Buffer by the options' encoding. */
"fs.realpathValue": { argTypes: [DYN, DYN, BOOL], result: DYN },
// Builtin functions read as values: validateString under Node's argument
// name, and node:path's value adapters (scr_builtin_values.c).
"dyn.validateString": { argTypes: [DYN, STRING], result: STRING },
"dyn.validateStrings": { argTypes: [DYN, STRING], result: arrayOf(STRING) },
"path.resolveValue": { argTypes: [DYN, BOOL], result: STRING },
"path.parseValue": { argTypes: [DYN, BOOL], result: DYN },
"path.formatValue": { argTypes: [DYN, BOOL], result: DYN },
"path.toNamespacedValue": { argTypes: [DYN, BOOL], result: DYN },
/** Native realpath with syscall "realpath" on failure. +1 fresh string. */
"fs.realpathNativeSync": { argTypes: [STRING], result: STRING },
/** The os.userInfo() field trio (uv_os_get_passwd's slices): pw_name,
* pw_shell, pw_dir — the PASSWD homedir, not os.homedir's $HOME-first
* cascade (Node's own split). The frontend assembles the UserInfo
* record from these plus getuid/getgid. +1 fresh strings; a passwd
* lookup failure aborts (Node throws a system error there — no
* compiled program path reaches it for the running uid). */
/** The os.userInfo() fields (uv_os_get_passwd's slices of the passwd
* entry for the effective uid): pw_name, pw_shell, pw_dir — the PASSWD
* homedir, not os.homedir's $HOME-first cascade (Node's own split) —
* and pw_uid, pw_gid (-1 on Windows). The frontend assembles the
* UserInfo record from these. +1 fresh strings; a passwd lookup
* failure aborts (Node throws a system error there — no compiled
* program path reaches it for the running uid). */
"os.userName": { argTypes: [], result: STRING },
"os.userShell": { argTypes: [], result: STRING },
"os.userHomedir": { argTypes: [], result: STRING },
"os.userUid": { argTypes: [], result: F64 },
"os.userGid": { argTypes: [], result: F64 },
"os.tmpdir": { argTypes: [], result: STRING },
// Result is the call site's Dict<NetworkInterfaceInfo[]> record (VOID is
// the dgram.address sentinel — the libCall case checks the structure).
@@ -1308,6 +1319,23 @@ function buildLibFnSignatures() {
"fs.watch": { argTypes: [STRING], result: FSWATCHER_T },
"fs.watchCb": { argTypes: [STRING, null], result: FSWATCHER_T },
"watcher.close": { argTypes: [FSWATCHER_T], result: VOID },
// FSWatcher's EventEmitter surface: (receiver, event code, once|prepend
// flags, listener) for on/once/addListener/prependListener/
// prependOnceListener, and (receiver, event code, listener) for
// off/removeListener. The listener's func type is program-dependent;
// the null slot pins the arity and the receiver.
"watcher.on": { argTypes: [FSWATCHER_T, F64, F64, null], result: VOID },
"watcher.off": { argTypes: [FSWATCHER_T, F64, null], result: VOID },
/** fs.watchFile(path, interval, persistent, listener) → void
* (scr_watch_file.c — its own gated unit; moduleUsesFsWatchFile is the
* switch, and scr_watch_file_install() is emitted with it). One
* libuv-shaped stat poller per RESOLVED path, shared by every listener
* on it; the listener's (current, previous) Stats func type is
* program-dependent, so the slot pins the arity. Statement position
* only (Node returns a StatWatcher). fs.unwatchFile(path[, listener])
* takes a borrowed listener; the null slot is the absent one. */
"fs.watchFile": { argTypes: [STRING, F64, BOOL, null], result: VOID },
"fs.unwatchFile": { argTypes: [STRING, null], result: VOID },
/** The composed `new crypto.X509Certificate(data).fingerprint` read
* (scr_lib.c — no certificate handle exists): the SHA-1 of the DER,
* uppercase colon-separated, over PEM or raw-DER Buffer input; other
@@ -3672,6 +3700,7 @@ function buildLibFnSignatures() {
"process.versions": { argTypes: [], result: DYN },
"process.builtinId": { argTypes: [DYN, arrayOf(STRING)], result: STRING },
"process.builtinModule": { argTypes: [STRING, DYN], result: DYN },
"process.builtinObject": { argTypes: [STRING, DYN, arrayOf(STRING)], result: DYN },
"process.builtinUnsupported": { argTypes: [STRING, STRING], result: DYN },
/** A module namespace object as a checked-dynamic value
* (scr_module_namespace_value): the module id, its sorted export names
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
import { mkdtemp, readFile, rm } 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 {
decodeFunctionBinary,
decodeModuleBinary,
encodeFunctionBinary,
encodeFunctionInOrder,
encodeFunctionStrings,
encodeModuleBinary,
functionDigest,
} from "./codec.js";
import { IR_CODEC_LAYOUT } from "./codec.generated.js";
import { F64, STRING, VOID, type IrFunction, type IrModule } from "./ir.js";
import { deserializeModuleBytes } from "./serialize.js";
// Programs with many node kinds: classes, closures, async, generators,
// dynamic values, streams, records, regexes, lone surrogates and BigIntStats.
const PROGRAMS = [
"1003-json-parse-unions.ts",
"114-live-record-delete.ts",
"1200-regex-test-basics.ts",
"1631-inspect-arrays.ts",
"4900-console-instances.ts",
"4991-lone-surrogate-halves.ts",
"5612-readable-from-iterables.cjs",
"7018-bigint-stats.ts",
"7110-als-completion-callbacks.ts",
"7321-speculative-loop-bail-sites.ts",
];
/** Whether `a` and `b` hold the same data: the same keys with defined
* values, in any order, and numbers equal by Object.is. */
function sameData(a: unknown, b: unknown): boolean {
if (typeof a === "number" && typeof b === "number") return Object.is(a, b);
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return a === b;
if (Array.isArray(a) !== Array.isArray(b)) return false;
const record = (value: object) => value as Record<string, unknown>;
const keys = (value: object) =>
Object.keys(value)
.filter((key) => record(value)[key] !== undefined)
.sort();
const ka = keys(a);
const kb = keys(b);
return (
ka.length === kb.length &&
ka.every((key, index) => key === kb[index] && sameData(record(a)[key], record(b)[key]))
);
}
test.each(PROGRAMS)("%s round-trips through the binary encoding", async (program) => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-codec-"));
try {
const outPath = join(dir, "main.ir.json");
const entry = join(import.meta.dirname, "../../../../tests/corpus", program);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const mod = deserializeModule(await readFile(outPath, "utf8"));
const bytes = encodeModuleBinary(mod);
const back = decodeModuleBinary(bytes);
expect(sameData(mod, back)).toBe(true);
expect(Buffer.compare(encodeModuleBinary(back), bytes)).toBe(0);
// The JSON reader takes the binary encoding too.
expect(sameData(deserializeModuleBytes(bytes), mod)).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
const loc = { file: "/src/main.ts", start: 0, end: 1 };
const fn = (body: IrFunction["body"]): IrFunction => ({
name: "%main",
params: [],
locals: [],
body,
returnType: VOID,
loc,
});
test("numbers keep NaN, the infinities, -0 and integers outside int32", () => {
const values = [
NaN,
Infinity,
-Infinity,
-0,
0,
1,
-1,
2 ** 31,
-(2 ** 31) - 1,
2 ** 53,
0.1,
-1.5e300,
];
const source = fn(
values.map((value) => ({
kind: "exprStmt",
expr: { kind: "numLit", value, type: F64, loc },
loc,
})),
);
const back = decodeFunctionBinary(encodeFunctionBinary(source));
const read = back.body.map((s) =>
s.kind === "exprStmt" && s.expr.kind === "numLit" ? s.expr.value : null,
);
expect(read.length).toBe(values.length);
read.forEach((value, index) => expect(Object.is(value, values[index])).toBe(true));
});
test("strings keep lone surrogates and repeat through the string table", () => {
const texts = ["plain", "\ud83d", "a\udc00b", "😀", "plain", "é"];
const source = fn(
texts.map((value) => ({
kind: "exprStmt",
expr: { kind: "strLit", value, type: STRING, loc },
loc,
})),
);
const back = decodeFunctionBinary(encodeFunctionBinary(source));
expect(
back.body.map((s) => (s.kind === "exprStmt" && s.expr.kind === "strLit" ? s.expr.value : null)),
).toEqual(texts);
});
test("absent optional fields stay absent", () => {
const source: IrModule = {
irVersion: 17,
sourceFile: "/src/main.ts",
functions: [fn([])],
entry: "%main",
};
const back = decodeModuleBinary(encodeModuleBinary(source));
expect(Object.keys(back).sort()).toEqual(["entry", "functions", "irVersion", "sourceFile"]);
expect(Object.keys(back.functions[0]!).sort()).toEqual(Object.keys(source.functions[0]!).sort());
});
test("a function whose objects follow the declared field order reads back with its key order", () => {
// As lowering builds functions: the source name after the name, and the
// async flag set after the literal.
const source: IrFunction = {
name: "%f",
sourceName: "f",
ownsPrototype: true,
params: [],
returnType: VOID,
locals: [{ id: "x.0", name: "x", type: F64, mutable: true }],
body: [{ kind: "return", value: null, loc }],
loc,
};
source.async = true;
const bytes = encodeFunctionInOrder(source);
expect(bytes).not.toBeNull();
const back = decodeFunctionBinary(bytes!);
expect(JSON.stringify(back)).toBe(JSON.stringify(source));
expect(Object.keys(back)).toEqual(Object.keys(source));
});
test("a function with an object out of the declared field order has no in-order encoding", () => {
const source = fn([{ kind: "return", value: null, loc }]);
// `loc` first: spreading the rest keeps its place.
const { loc: first, ...rest } = source;
const shuffled = { loc: first, ...rest } as IrFunction;
expect(encodeFunctionInOrder(shuffled)).toBeNull();
// An object with a key its type does not declare.
expect(encodeFunctionInOrder({ ...source, extra: 1 } as IrFunction)).toBeNull();
// The helper above builds `locals` before `returnType`.
expect(encodeFunctionInOrder(source)).toBeNull();
const ordered: IrFunction = {
name: source.name,
params: [],
returnType: VOID,
locals: [],
body: source.body,
loc,
};
expect(encodeFunctionInOrder(ordered)).not.toBeNull();
});
test("a node whose kind its union does not declare is refused", () => {
const source = fn([{ kind: "noSuchStatement", loc } as never]);
expect(encodeFunctionInOrder(source)).toBeNull();
expect(() => encodeFunctionBinary(source)).toThrow(/cannot encode a IrStmt kind noSuchStatement/);
});
test("an encoding lists its strings and the files its fields named file hold", () => {
const other = { file: "/src/other.ts", start: 2, end: 3 };
const source: IrFunction = {
name: "%f",
params: [],
returnType: VOID,
locals: [],
body: [
{
kind: "exprStmt",
expr: { kind: "strLit", value: "%named", type: STRING, loc: other },
loc,
},
],
loc,
};
const encoded = encodeFunctionStrings(source)!;
expect(encoded.strings).toEqual(expect.arrayContaining(["%f", "%named", "/src/main.ts"]));
expect([...encoded.files].sort()).toEqual(["/src/main.ts", "/src/other.ts"]);
expect(Buffer.compare(encoded.bytes, encodeFunctionInOrder(source)!)).toBe(0);
});
test("a function's digest follows its content and not its key order", () => {
const source = fn([{ kind: "return", value: null, loc }]);
const digest = functionDigest(source);
expect(digest).toMatch(/^\d+:[0-9a-f]{16}$/);
expect(functionDigest(fn([{ kind: "return", value: null, loc }]))).toBe(digest);
// The same fields in another order.
const { loc: first, ...rest } = source;
expect(functionDigest({ loc: first, ...rest } as IrFunction)).toBe(digest);
// A change in place.
source.locals.push({ id: "x.0", name: "x", type: F64, mutable: true });
expect(functionDigest(source)).not.toBe(digest);
// A node the encoder refuses is covered by the function's JSON text.
const refused = fn([{ kind: "noSuchStatement", loc } as never]);
const text = functionDigest(refused);
expect(text).toMatch(/^json:/);
(refused.body[0] as unknown as { extra: number }).extra = 1;
expect(functionDigest(refused)).not.toBe(text);
});
test("bytes another layout wrote are refused", () => {
const bytes = Buffer.from(encodeFunctionBinary(fn([])));
// The layout digest follows the magic as the first new string.
const at = bytes.indexOf(Buffer.from(IR_CODEC_LAYOUT));
bytes[at] = bytes[at] === 0x30 ? 0x31 : 0x30;
expect(() => decodeFunctionBinary(bytes)).toThrow(/layout/);
expect(() => decodeFunctionBinary(Buffer.from("{}"))).toThrow(/not an IR encoding/);
});
+376
View File
@@ -0,0 +1,376 @@
/* The IR's binary encoding: the fields of every node and record, by name in
* the order ir.ts declares them (codec.generated.ts), written without field
* names or JSON structure.
*
* - A length, a union's member or arm index and a string reference are
* unsigned varints (7 bits per byte, low bits first).
* - A number is one byte, 0 for an integer in the int32 range other than
* -0, followed by its zigzag varint, or 1 followed by its IEEE 754 bytes
* (little-endian), which keep NaN, the infinities and -0.
* - A string is a varint: 0 for a new string, which also gives it the next
* index in the string table, otherwise one more than the index of a
* string the encoding already has. A new string's bytes follow, after a
* varint of twice their count: plus one when they are UTF-16LE code units
* (a string with a lone surrogate, which UTF-8 cannot spell), else UTF-8.
* - A boolean is one byte.
* - A field that can be absent or null starts with a byte: 0 absent, then 1
* for null and 2 for a value when both are possible, otherwise 1 for a
* value.
*
* An encoding starts with MAGIC and IR_CODEC_LAYOUT, a digest of the
* generated coders, so a reader refuses bytes that other coders wrote. Decoded objects carry their fields in
* declaration order, with absent optional fields left out; an object read
* back has the key order of the object written when IrWriter.inOrder held. */
import type { IrFunction, IrModule } from "./ir.js";
import {
IR_CODEC_LAYOUT,
decodeIrFunction,
decodeIrModule,
encodeIrFunction,
encodeIrModule,
} from "./codec.generated.js";
/** The first bytes of an encoding: "SCIR". */
const MAGIC = [0x53, 0x43, 0x49, 0x52];
/** Whether `value` has a surrogate code unit without its pair. */
function loneSurrogate(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const unit = value.charCodeAt(index);
if (unit < 0xd800 || unit > 0xdfff) continue;
const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0;
if (unit > 0xdbff || next < 0xdc00 || next > 0xdfff) return true;
index++;
}
return false;
}
export class IrWriter {
private bytes = Buffer.allocUnsafe(1 << 16);
private size = 0;
private readonly strings = new Map<string, number>();
/** The values of the string fields named `file` written so far. */
readonly files = new Set<string>();
private readonly scratch = Buffer.allocUnsafe(8);
/** Whether every object written so far reads back with its own key order
* (`keys`). False once one held a key its type does not declare, or its
* keys in another order than the declaration's. */
inOrder = true;
/** Notes an object's own keys against its type's declared fields. The
* decoder builds an object with its fields in declaration order, so an
* object whose keys come in another order, or include one the type does
* not declare, does not read back as it was. */
keys(own: readonly string[], declared: readonly string[]): void {
if (!this.inOrder) return;
let at = 0;
for (const key of own) {
while (at < declared.length && declared[at] !== key) at++;
if (at === declared.length) {
this.inOrder = false;
return;
}
at++;
}
}
private reserve(count: number): void {
if (this.size + count <= this.bytes.length) return;
let capacity = this.bytes.length * 2;
while (capacity < this.size + count) capacity *= 2;
const grown = Buffer.allocUnsafe(capacity);
this.bytes.copy(grown, 0, 0, this.size);
this.bytes = grown;
}
byte(value: number): void {
this.reserve(1);
this.bytes[this.size++] = value;
}
/** A non-negative integer below 2^53. */
length(value: number): void {
this.reserve(8);
while (value >= 0x80) {
this.bytes[this.size++] = (value % 0x80) + 0x80;
value = Math.floor(value / 0x80);
}
this.bytes[this.size++] = value;
}
bool(value: boolean): void {
this.byte(value ? 1 : 0);
}
num(value: number): void {
if (
Number.isInteger(value) &&
value >= -0x80000000 &&
value <= 0x7fffffff &&
!Object.is(value, -0)
) {
this.byte(0);
this.length(value < 0 ? -value * 2 - 1 : value * 2);
return;
}
this.byte(1);
this.scratch.writeDoubleLE(value, 0);
this.reserve(8);
this.scratch.copy(this.bytes, this.size, 0, 8);
this.size += 8;
}
str(value: string): void {
const known = this.strings.get(value);
if (known !== undefined) {
this.length(known + 1);
return;
}
this.strings.set(value, this.strings.size);
this.length(0);
const utf8 = Buffer.byteLength(value, "utf8");
// UTF-8 cannot spell a lone surrogate, so a string with one is written
// as its UTF-16 code units.
const units = utf8 !== value.length && loneSurrogate(value);
const count = units ? value.length * 2 : utf8;
this.length(count * 2 + (units ? 1 : 0));
this.reserve(count);
if (units) this.bytes.write(value, this.size, count, "utf16le");
else this.bytes.write(value, this.size, count, "utf8");
this.size += count;
}
/** What the encoding could not write, such as a node whose kind its
* union does not declare, or null. Such an encoding does not decode. */
refused: string | null = null;
/** Notes that `what` could not be written (`refused`). */
refuse(what: string): void {
this.inOrder = false;
this.refused ??= what;
}
/** A string field named `file`: a source file the encoding names. */
file(value: string): void {
this.files.add(value);
this.str(value);
}
/** Raw bytes, after their count. */
blob(bytes: Uint8Array): void {
this.length(bytes.length);
this.reserve(bytes.length);
Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).copy(this.bytes, this.size);
this.size += bytes.length;
}
/** The strings written so far, each once, in the order first written. */
written(): IterableIterator<string> {
return this.strings.keys();
}
/** Starts a new encoding in the same buffer. */
reset(): void {
this.size = 0;
this.strings.clear();
this.files.clear();
this.inOrder = true;
this.refused = null;
}
/** The bytes written so far. Throws when the encoding refused a value. */
finish(): Buffer {
if (this.refused !== null) throw new Error(`IR codec: cannot encode a ${this.refused}`);
return this.bytes.subarray(0, this.size);
}
}
export class IrReader {
private offset = 0;
private readonly strings: string[] = [];
/** The last presence byte `tag` read. */
lastTag = 0;
constructor(private readonly bytes: Buffer) {}
private fail(what: string): never {
throw new Error(`IR codec: ${what} at byte ${this.offset}`);
}
byte(): number {
if (this.offset >= this.bytes.length) this.fail("unexpected end");
return this.bytes[this.offset++]!;
}
length(): number {
let value = 0;
let scale = 1;
for (;;) {
const byte = this.byte();
value += (byte % 0x80) * scale;
if (byte < 0x80) return value;
scale *= 0x80;
if (scale > 2 ** 56) this.fail("varint too long");
}
}
bool(): boolean {
return this.byte() !== 0;
}
num(): number {
const tag = this.byte();
if (tag === 0) {
const zigzag = this.length();
return zigzag % 2 === 0 ? zigzag / 2 : -(zigzag + 1) / 2;
}
if (tag !== 1) this.fail("invalid number tag");
if (this.offset + 8 > this.bytes.length) this.fail("unexpected end");
const value = this.bytes.readDoubleLE(this.offset);
this.offset += 8;
return value;
}
str(): string {
const reference = this.length();
if (reference > 0) {
const known = this.strings[reference - 1];
if (known === undefined) this.fail("invalid string reference");
return known;
}
const spelled = this.length();
const count = Math.floor(spelled / 2);
if (this.offset + count > this.bytes.length) this.fail("unexpected end");
const value =
spelled % 2 === 0
? this.bytes.toString("utf8", this.offset, this.offset + count)
: this.bytes.toString("utf16le", this.offset, this.offset + count);
this.offset += count;
this.strings.push(value);
return value;
}
/** Raw bytes blob wrote, as a view of the bytes read. */
blob(): Buffer {
const count = this.length();
if (this.offset + count > this.bytes.length) this.fail("unexpected end");
const out = this.bytes.subarray(this.offset, this.offset + count);
this.offset += count;
return out;
}
/** A presence byte below `limit`, kept in lastTag. */
tag(limit: number, where: string): number {
const tag = this.byte();
if (tag >= limit) this.fail(`invalid presence byte for ${where}`);
this.lastTag = tag;
return tag;
}
/** Whether every byte was read. */
done(): boolean {
return this.offset === this.bytes.length;
}
}
function header(w: IrWriter): void {
for (const byte of MAGIC) w.byte(byte);
w.str(IR_CODEC_LAYOUT);
}
function readHeader(r: IrReader): void {
for (const byte of MAGIC) if (r.byte() !== byte) throw new Error("IR codec: not an IR encoding");
const layout = r.str();
if (layout !== IR_CODEC_LAYOUT)
throw new Error(
`IR codec: the encoding has layout ${layout}, this compiler reads ${IR_CODEC_LAYOUT}`,
);
}
/** Whether `bytes` start like an encoding this module writes. */
export function isIrEncoding(bytes: Uint8Array): boolean {
return MAGIC.every((byte, index) => bytes[index] === byte);
}
export function encodeModuleBinary(mod: IrModule): Buffer {
const w = new IrWriter();
header(w);
encodeIrModule(w, mod);
return w.finish();
}
export function decodeModuleBinary(bytes: Buffer): IrModule {
const r = new IrReader(bytes);
readHeader(r);
const mod = decodeIrModule(r);
if (!r.done()) throw new Error("IR codec: bytes after the module");
return mod;
}
export function encodeFunctionBinary(fn: IrFunction): Buffer {
const w = new IrWriter();
header(w);
encodeIrFunction(w, fn);
return w.finish();
}
/** The writer encodeFunctionInOrder reuses: its result is a copy. */
const inOrderWriter = new IrWriter();
/** `fn`'s encoding when it reads back as `fn` is, every object with its
* own key order (IrWriter.inOrder), or null. */
export function encodeFunctionInOrder(fn: IrFunction): Buffer | null {
return encodeFunctionStrings(fn)?.bytes ?? null;
}
/** encodeFunctionInOrder's encoding of `fn`, with the strings it holds,
* each once, and the files its fields named `file` hold (IrWriter.file), or
* null. */
export function encodeFunctionStrings(fn: IrFunction): {
readonly bytes: Buffer;
readonly strings: readonly string[];
readonly files: readonly string[];
} | null {
const w = inOrderWriter;
w.reset();
header(w);
encodeIrFunction(w, fn);
// An encoding that refused a value is not in order either.
return w.inOrder
? { bytes: Buffer.from(w.finish()), strings: [...w.written()], files: [...w.files] }
: null;
}
/** A 64-bit digest of `fn`'s content, the same for functions whose fields
* hold the same values in any key order, with the byte count of the
* content it covers. It covers the binary encoding, or the JSON text when
* the encoder refuses a value (IrWriter.refuse). */
export function functionDigest(fn: IrFunction): string {
const w = inOrderWriter;
w.reset();
encodeIrFunction(w, fn);
if (w.refused === null) return bytesDigest(w.finish());
return `json:${bytesDigest(Buffer.from(JSON.stringify(fn), "utf8"))}`;
}
/** Two 32-bit multiply-xor lanes over `bytes`, FNV-1a's and one with
* another offset and MurmurHash2's multiplier, and the byte count. */
function bytesDigest(bytes: Uint8Array): string {
let a = 0x811c9dc5;
let b = 0x050c5d1f;
for (let i = 0; i < bytes.length; i++) {
const byte = bytes[i]!;
a = Math.imul(a ^ byte, 0x01000193);
b = Math.imul(b ^ byte, 0x5bd1e995);
}
const hex = (lane: number): string => (lane >>> 0).toString(16).padStart(8, "0");
return `${bytes.length}:${hex(a)}${hex(b)}`;
}
export function decodeFunctionBinary(bytes: Buffer): IrFunction {
const r = new IrReader(bytes);
readHeader(r);
const fn = decodeIrFunction(r);
if (!r.done()) throw new Error("IR codec: bytes after the function");
return fn;
}
+17 -17
View File
@@ -802,14 +802,14 @@ export function walkExprFields(node: IrExpr, walker: IrFieldWalker, skipType: bo
case "classRef": {
walker.string(node.kind);
walker.string(node.className);
if (!skipType) {
walkTypeFields(node.type, walker, skipType);
}
if (node.captures !== undefined && node.captures !== null) {
for (const item6 of node.captures) {
walker.string(item6);
}
}
if (!skipType) {
walkTypeFields(node.type, walker, skipType);
}
return;
}
case "closure": {
@@ -890,12 +890,12 @@ export function walkExprFields(node: IrExpr, walker: IrFieldWalker, skipType: bo
case "dynFrom": {
walker.string(node.kind);
walkExprFields(node.value, walker, skipType);
if (node.fnName !== undefined && node.fnName !== null) {
walker.string(node.fnName);
}
if (!skipType) {
walkTypeFields(node.type, walker, skipType);
}
if (node.fnName !== undefined && node.fnName !== null) {
walker.string(node.fnName);
}
return;
}
case "dynHasKey": {
@@ -1635,14 +1635,14 @@ export function walkStmtFields(node: IrStmt, walker: IrFieldWalker, skipType: bo
}
case "block": {
walker.string(node.kind);
for (const item30 of node.body) {
walkStmtFields(item30, walker, skipType);
}
if (node.labels !== undefined && node.labels !== null) {
for (const item31 of node.labels) {
walker.string(item31);
for (const item30 of node.labels) {
walker.string(item30);
}
}
for (const item31 of node.body) {
walkStmtFields(item31, walker, skipType);
}
return;
}
case "break": {
@@ -2111,7 +2111,7 @@ export type IrFieldsCurrent = [
Unread<ValuesNotRead<Extract<IrExpr, { kind: "caughtTest" }>["negated"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "caughtToDyn" }>, "kind" | "value" | "type" | "loc">>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "chainRecv" }>, "kind" | "id" | "type" | "loc">>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "classRef" }>, "kind" | "className" | "captures" | "type" | "loc">>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "classRef" }>, "kind" | "className" | "type" | "loc" | "captures">>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "closure" }>, "kind" | "fnName" | "captures" | "adapts" | "type" | "loc">>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "closure" }>["adapts"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "downcast" }>, "kind" | "value" | "type" | "loc">>,
@@ -2125,9 +2125,9 @@ export type IrFieldsCurrent = [
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynCheck" }>["exact"]>>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynCheck" }>["origin"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynDestrCheck" }>, "kind" | "value" | "spelling" | "firstProp" | "type" | "loc">>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynFrom" }>, "kind" | "value" | "fnName" | "fnLength" | "liveRef" | "type" | "loc">>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynFrom" }>["fnLength"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynFrom" }>, "kind" | "value" | "type" | "loc" | "liveRef" | "fnName" | "fnLength">>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynFrom" }>["liveRef"]>>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynFrom" }>["fnLength"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynHasKey" }>, "kind" | "key" | "negated" | "value" | "type" | "loc">>,
Unread<ValuesNotRead<Extract<IrExpr, { kind: "dynHasKey" }>["negated"]>>,
Unread<FieldsNotRead<Extract<IrExpr, { kind: "dynInvoke" }>, "kind" | "recv" | "method" | "calleeName" | "calleeNameValue" | "args" | "type" | "loc">>,
@@ -2240,7 +2240,7 @@ export type IrFieldsCurrent = [
Unread<FieldsNotRead<Extract<IrStmt, { kind: "arraySetUndefined" }>, "kind" | "arr" | "index" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "assign" }>, "kind" | "localId" | "value" | "initializes" | "loc">>,
Unread<ValuesNotRead<Extract<IrStmt, { kind: "assign" }>["initializes"]>>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "block" }>, "kind" | "body" | "labels" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "block" }>, "kind" | "labels" | "body" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "break" }>, "kind" | "label" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "bytesSet" }>, "kind" | "arr" | "index" | "value" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "continue" }>, "kind" | "label" | "loc">>,
@@ -2264,9 +2264,9 @@ export type IrFieldsCurrent = [
Unread<FieldsNotRead<Extract<NonNullable<Extract<NonNullable<Extract<IrStmt, { kind: "switch" }>["cases"]>, readonly unknown[]>[number]>, object>, "test" | "body">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "switch" }>, "kind" | "disc" | "cases" | "labels" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "throw" }>, "kind" | "value" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "tryCatch" }>, "kind" | "tryBody" | "catchBody" | "catchLocalId" | "finallyBody" | "suppressFinallyErrors" | "dropFinallyErrors" | "loc">>,
Unread<ValuesNotRead<Extract<IrStmt, { kind: "tryCatch" }>["suppressFinallyErrors"]>>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "tryCatch" }>, "kind" | "tryBody" | "catchBody" | "catchLocalId" | "dropFinallyErrors" | "finallyBody" | "suppressFinallyErrors" | "loc">>,
Unread<ValuesNotRead<Extract<IrStmt, { kind: "tryCatch" }>["dropFinallyErrors"]>>,
Unread<ValuesNotRead<Extract<IrStmt, { kind: "tryCatch" }>["suppressFinallyErrors"]>>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "varDecl" }>, "kind" | "localId" | "init" | "loc">>,
Unread<FieldsNotRead<Extract<IrStmt, { kind: "while" }>, "kind" | "cond" | "body" | "labels" | "loc">>,
Unread<FieldsNotRead<Extract<IrType, { kind: "array" }>, "kind" | "elem" | "optionalElements">>,
+42 -42
View File
@@ -1964,14 +1964,10 @@ export interface IrFunction {
/** Original TS name (mangling is a backend concern). Lifted lambdas get
* synthetic '%'-prefixed names ('%' can't appear in a TS identifier). */
name: string;
/** Generated reflection dispatch contains speculative source-member edges.
* Target-only host operations behind those edges retain runtime refusals. */
speculativeDispatch?: boolean;
/** A read helper the backend can answer directly for dyn arrays and live
* array capsules, calling the helper only when that read has no answer.
* `index`: a computed read `(value, key)` whose callers pass a number
* converted to a string as the key. */
fastRead?: "index";
/** Source function name for native error stack frames. */
sourceName?: string;
/** Ordinary authored function declarations/expressions own a prototype. */
ownsPrototype?: true;
params: IrParam[];
returnType: IrType;
/** All locals including params, pre-collected and scope-flat: ids are
@@ -1986,22 +1982,29 @@ export interface IrFunction {
* their first (`this`) parameter. The slot indexes the class object's
* capture array; no closure parameter is added to the method ABI. */
classCaptures?: (IrParam & { slot: number })[];
body: IrStmt[];
loc: SrcLoc;
/** The function's execution tier (ir/tiers.ts). Absent means static: the
* function may use dynamic values only through boundary operations
* (dynFrom, dynCheck, kind tests) and moves, which the validator enforces
* (invariant I1). "dynamic" admits the dynamic operation vocabulary
* (dynKeyGet, dynCall, dyn.* builtins, ...). Lowering assigns the field
* from the body (assignLegacyTiers); stage S2 of the two-tier design
* keeps it out of serialized IR, so loaders derive it again. */
tier?: "dynamic";
/** Generated reflection dispatch contains speculative source-member edges.
* Target-only host operations behind those edges retain runtime refusals. */
speculativeDispatch?: boolean;
/** A read helper the backend can answer directly for dyn arrays and live
* array capsules, calling the helper only when that read has no answer.
* `index`: a computed read `(value, key)` whose callers pass a number
* converted to a string as the key. */
fastRead?: "index";
/** Async: the body runs on a fiber; `returnType` is the INNER type T (a
* `return v` fulfills with v) while ordinary call sites receive
* Promise<T>. With `generator` present, call sites receive the lazy
* async-generator object instead. */
async?: true;
/** Async module initializers only: a module-global Promise<T> slot where
* the spawn wrapper caches its first evaluation promise. Every later
* static/dynamic import receives that same promise, including while the
* first evaluation is suspended. */
asyncCacheGlobal?: string;
/** Async cyclic module initializers only: the SCC's shared Promise<T>
* slot. Eager recursive spawning writes member promises from the inside
* out, so the member that actually initiated evaluation writes last and
* becomes the runtime cycle root. Dynamic imports wait on this shared
* completion verdict instead of a build-time-selected member. */
asyncCycleCacheGlobal?: string;
/** Generator (`function*` / `async function*`): the body runs on a fiber created SUSPENDED
* (nothing runs until the first `.next()`; backends may instead compile a
* synchronous body as a stackless coroutine with the same protocol); `returnType` is the
@@ -2030,20 +2033,17 @@ export interface IrFunction {
* that generator's tag. */
inheritsTag?: true;
};
/** Source function name for native error stack frames. */
sourceName?: string;
/** Ordinary authored function declarations/expressions own a prototype. */
ownsPrototype?: true;
/** The function's execution tier (ir/tiers.ts). Absent means static: the
* function may use dynamic values only through boundary operations
* (dynFrom, dynCheck, kind tests) and moves, which the validator enforces
* (invariant I1). "dynamic" admits the dynamic operation vocabulary
* (dynKeyGet, dynCall, dyn.* builtins, ...). Lowering assigns the field
* from the body (assignLegacyTiers); stage S2 of the two-tier design
* keeps it out of serialized IR, so loaders derive it again. */
tier?: "dynamic";
body: IrStmt[];
loc: SrcLoc;
/** Async module initializers only: a module-global Promise<T> slot where
* the spawn wrapper caches its first evaluation promise. Every later
* static/dynamic import receives that same promise, including while the
* first evaluation is suspended. */
asyncCacheGlobal?: string;
/** Async cyclic module initializers only: the SCC's shared Promise<T>
* slot. Eager recursive spawning writes member promises from the inside
* out, so the member that actually initiated evaluation writes last and
* becomes the runtime cycle root. Dynamic imports wait on this shared
* completion verdict instead of a build-time-selected member. */
asyncCycleCacheGlobal?: string;
}
export interface IrParam {
@@ -2280,7 +2280,7 @@ export type IrStmt =
/** A bare lexical block `{ ... }` — its own scope. `labels` makes it a
* labeled-break target (`lbl: { ... break lbl; ... }` and the wrapper
* the frontend puts around labeled non-loop statements). */
| { kind: "block"; body: IrStmt[]; labels?: string[]; loc: SrcLoc }
| { kind: "block"; labels?: string[]; body: IrStmt[]; loc: SrcLoc }
/** `throw v`. Any non-void value type can be thrown; ownership of a
* refcounted value MOVES into the runtime's exception cell. Terminates
* the path like `return`: control unwinds to the innermost enclosing
@@ -2324,15 +2324,15 @@ export type IrStmt =
tryBody: IrStmt[];
catchBody: IrStmt[] | null;
catchLocalId: string | null;
finallyBody: IrStmt[] | null;
/** Resource-management cleanup: if the guarded body and cleanup both
* throw, raise a SuppressedError instead of replacing the first error. */
suppressFinallyErrors?: true;
/** Iterator closing: if the guarded body threw and the cleanup throws
* too, the cleanup's error is dropped and the first one propagates.
* After a generator .return() the cleanup's error replaces it, and on
* normal exits it propagates. */
dropFinallyErrors?: true;
finallyBody: IrStmt[] | null;
/** Resource-management cleanup: if the guarded body and cleanup both
* throw, raise a SuppressedError instead of replacing the first error. */
suppressFinallyErrors?: true;
loc: SrcLoc;
};
@@ -3341,7 +3341,7 @@ export type IrExpr =
* every classRef, so a value's construct thunk always has a constructor
* to call; backends emit class objects (and thunks) for exactly the
* classes some classRef in the module names. */
| { kind: "classRef"; className: string; captures?: string[]; type: IrType; loc: SrcLoc }
| { kind: "classRef"; className: string; type: IrType; loc: SrcLoc; captures?: string[] }
/** `new X(args)` through a class VALUE: call the class object's
* construct thunk. `callee` is classval-typed; args are completed
* against `%<callee.className>.constructor`'s ABI — sound because every
@@ -3513,13 +3513,13 @@ export type IrExpr =
| {
kind: "dynFrom";
value: IrExpr;
type: IrType;
loc: SrcLoc;
liveRef?: true;
fnName?: string;
/** A boxed function's `length` when it differs from its parameter
* count (builtin function values). */
fnLength?: number;
liveRef?: true;
type: IrType;
loc: SrcLoc;
}
/** CALLING a dyn value — `fn(a, b)` where fn is checked-dynamic (an
* implicit-any JS binding, a dyn record member, a dynKeyGet result).
+58 -2
View File
@@ -27,6 +27,8 @@ const RECEIVER_FREE_EXPRS = new Set<IrExpr["kind"]>([
"strCmp",
"nullish",
"orDefault",
"optChain",
"chainRecv",
"unionWrap",
"unionRetype",
"unionNarrow",
@@ -35,9 +37,12 @@ const RECEIVER_FREE_EXPRS = new Set<IrExpr["kind"]>([
"unionDisc",
"upcast",
"downcast",
"dynFrom",
"instanceOf",
"fieldGet",
"fieldAbsent",
"recordGet",
"recordHas",
"recordLit",
"recordClone",
"arrayGet",
@@ -58,6 +63,9 @@ const RECEIVER_FREE_EXPRS = new Set<IrExpr["kind"]>([
"setIntrinsic",
"mapNew",
"setNew",
"bytesIntrinsic",
"bytesNew",
"intrinsic",
]);
const RECEIVER_FREE_STMTS = new Set<IrStmt["kind"]>([
@@ -69,6 +77,7 @@ const RECEIVER_FREE_STMTS = new Set<IrStmt["kind"]>([
"while",
"doWhile",
"for",
"forOf",
"switch",
"block",
"break",
@@ -78,6 +87,7 @@ const RECEIVER_FREE_STMTS = new Set<IrStmt["kind"]>([
"arraySet",
"arraySetUndefined",
"arraySetLength",
"bytesSet",
"throw",
"rethrow",
"tryCatch",
@@ -101,7 +111,10 @@ const TRIVIALLY_RECEIVER_FREE = new Set<string>([
kind !== "arrIntrinsic" &&
kind !== "strIntrinsic" &&
kind !== "mapIntrinsic" &&
kind !== "setIntrinsic",
kind !== "setIntrinsic" &&
kind !== "bytesIntrinsic" &&
kind !== "bytesNew" &&
kind !== "intrinsic",
),
]);
@@ -114,8 +127,44 @@ const RECEIVER_FREE_LIB_FNS = new Set<IrLibFn>([
"math.minArr",
"error.new",
"error.nodeThrow",
"perf.now",
"atomics.op",
]);
/** Intrinsics that only check a flag or switch the current lifetime
* region: the module binding check (which throws a fixed ReferenceError)
* and the LifetimeRegion operations. */
const RECEIVER_FREE_INTRINSICS = new Set<string>([
"module.tdzCheck",
"region.create",
"region.enter",
"region.leave",
"region.release",
]);
function primitiveOperand(e: IrExpr): boolean {
const kind = e.type.kind;
return kind === "string" || kind === "f64" || kind === "bool";
}
/** A dynamic value boxed from a primitive, which converts to a string
* without user code. */
function primitiveDynOperand(e: IrExpr): boolean {
if (e.kind !== "dynFrom") return false;
const kind = e.value.type.kind;
return kind === "string" || kind === "f64" || kind === "bool" || kind === "undefinedT";
}
/** Whether a library call runs no user code: scalar Math, the members
* above, `new Error(message, options)` over a primitive message without an
* options object, and string and number members over primitive operands. */
function receiverFreeLibCall(e: Extract<IrExpr, { kind: "libCall" }>): boolean {
if (isBorrowSafeMath(e.fn) || RECEIVER_FREE_LIB_FNS.has(e.fn)) return true;
if (e.fn === "error.newOptions") return e.args.every(primitiveDynOperand);
if (e.fn.startsWith("string.") || e.fn.startsWith("num.")) return e.args.every(primitiveOperand);
return false;
}
/** Typed container and string operations. Their runtime paths compare and
* copy values but never call back into compiled code, as long as no
* operand is a function or a checked value. */
@@ -128,7 +177,12 @@ function dataIntrinsicOperands(e: IrExpr): IrExpr[] | null {
case "strIntrinsic":
case "mapIntrinsic":
case "setIntrinsic":
case "bytesIntrinsic":
return [e.receiver, ...e.args];
// A typed source is copied element by element; a dynamic one may be an
// iterable whose iterator is user code.
case "bytesNew":
return e.source === null ? [] : [e.source];
case "mapNew":
case "setNew":
return [];
@@ -210,7 +264,9 @@ export class AmbientReceiverReaders {
return targets.length > 0 && targets.every(edge);
}
case "libCall":
return isBorrowSafeMath(expr.fn) || RECEIVER_FREE_LIB_FNS.has(expr.fn);
return receiverFreeLibCall(expr);
case "intrinsic":
return RECEIVER_FREE_INTRINSICS.has(expr.name);
case "toString":
return !opaqueOperand(expr.operand);
default: {
@@ -1026,6 +1026,12 @@ const RUNTIME_FEATURE_RULES = {
"buffer.installValue",
"buffer.staticValue",
"buffer.prototypeMember",
"dyn.validateString",
"dyn.validateStrings",
"path.resolveValue",
"path.parseValue",
"path.formatValue",
"path.toNamespacedValue",
],
}),
/** The stream unit emits through the emitter unit, also when the
@@ -1075,6 +1081,9 @@ const RUNTIME_FEATURE_RULES = {
fetch: rule({ prefixes: ["fetch."] }),
fileHandle: rule({ types: ["fileHandle"] }),
fsWatch: rule({ prefixes: ["fs.watch", "watcher."], types: ["fsWatcher"] }),
/** fs.watchFile / fs.unwatchFile (scr_watch_file.c): the stat-polling
* half of the watching surface, gated on its own unit. */
fsWatchFile: rule({ fns: ["fs.watchFile", "fs.unwatchFile"] }),
/** A socket 'lookup' listener: the C main installs the net unit's
* lookup records (scr_net_lookup_install). */
netLookup: rule({ fns: ["net.sockOnLookup"] }),
@@ -1166,6 +1175,9 @@ const RUNTIME_FEATURE_RULES = {
...UTIL_CALLABLE_LIB_FNS,
"rl.terminal",
"str.localeCase",
// win32.relative compares with String.prototype.toLowerCase's
// Unicode case mapping.
"path.win32Relative",
],
prefixes: URL_PREFIXES,
expr: (node, _tables) =>
@@ -1293,6 +1305,7 @@ function noRuntimeFeatures(): RuntimeFeatures {
fetch: false,
fileHandle: false,
fsWatch: false,
fsWatchFile: false,
http2: false,
http: false,
inspect: false,
@@ -1638,6 +1651,15 @@ export function moduleUsesFsWatch(mod: IrModule): boolean {
return scanRuntimeFeatures(mod, "fsWatch").fsWatch;
}
/** True when the module contains an fs.watchFile/fs.unwatchFile libCall —
* the link switch that pulls scr_watch_file.c into the binary and has the
* emitted main call scr_watch_file_install (the moduleUsesFsWatch
* precedent, one unit of its own). Programs that never poll pay zero bytes
* and keep their exact link line. */
export function moduleUsesFsWatchFile(mod: IrModule): boolean {
return scanRuntimeFeatures(mod, "fsWatchFile").fsWatchFile;
}
/** True when the module contains any test.* libCall or a testCtx handle
* type — the link switch that pulls scr_test.c into the binary and has
* the emitted main return scr_test_exit_code() after the loop drains
@@ -162,9 +162,24 @@ test("jams a constant-count loop whose inner loop exits once its accumulator is
expect(breaks).toBe(1);
const last = inner.body[inner.body.length - 1]!;
expect(last.kind).toBe("if");
// Each copy keeps its own accumulator and lanes; `post` runs in order.
// It is tested every fourth iteration: `(j & 3) === 3 && ...`.
if (last.kind !== "if" || last.cond.kind !== "logical") throw new Error("expected an && exit");
const fourth = last.cond.left;
expect(fourth.kind === "bin" && fourth.op === "===" && fourth.left.kind === "bin").toBe(true);
if (fourth.kind === "bin" && fourth.left.kind === "bin") {
expect(fourth.left.op).toBe("&");
expect(fourth.left.left.kind === "varRef" && fourth.left.left.localId).toBe("j");
}
// The copies take turns statement by statement.
const firstDecls = inner.body.slice(0, 4);
expect(firstDecls.every((s) => s.kind === "varDecl")).toBe(true);
expect(new Set(firstDecls.map((s) => (s.kind === "varDecl" ? s.localId : ""))).size).toBe(4);
// Each copy keeps its own accumulator and lanes; each local's copies sit
// next to each other; `post` runs in order.
const added = out.functions[0]!.locals.filter((local) => local.id.startsWith("%jam."));
expect(added.filter((local) => local.name === "%jam.b").length).toBe(4);
for (let at = 0; at < added.length; at += 4)
expect(new Set(added.slice(at, at + 4).map((local) => local.name)).size).toBe(1);
const block = body[1]!;
expect(block.kind).toBe("block");
if (block.kind !== "block") return;
@@ -172,6 +187,27 @@ test("jams a constant-count loop whose inner loop exits once its accumulator is
expect(posts.every((s) => s.kind === "assign" && s.localId === "res")).toBe(true);
});
test("tests the exit every iteration unless the inner loop starts at an int32 literal", () => {
const exitTest = (innerStart: IrExpr): IrExpr => {
const out = jamSaturatingLoops(moduleOf(nest({ innerStart })));
const inner = loops(out.functions[0]!.body)[0]!;
const last = inner.body[inner.body.length - 1]!;
if (last.kind !== "if") throw new Error("expected an exit test");
return last.cond;
};
const everyFourth = (cond: IrExpr): boolean => {
if (cond.kind !== "logical" || cond.left.kind !== "bin") return false;
const low = cond.left.left;
return low.kind === "bin" && low.op === "&";
};
expect(everyFourth(exitTest(num(-5)))).toBe(true);
expect(everyFourth(exitTest(num(0x7fffffff)))).toBe(true);
// `j` may be NaN, an infinity or a value `j + 1` leaves unchanged.
expect(everyFourth(exitTest(ref("start")))).toBe(false);
expect(everyFourth(exitTest(num(2 ** 31)))).toBe(false);
expect(everyFourth(exitTest(num(-(2 ** 31) - 1)))).toBe(false);
});
test("keeps loops whose exits or values do not allow running iterations together", () => {
const reject = (body: IrStmt[]): void => {
const mod = moduleOf(body);

Some files were not shown because too many files have changed in this diff Show More